results.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import * as React from 'react';
  2. import * as ReactRouter from 'react-router';
  3. import styled from '@emotion/styled';
  4. import * as Sentry from '@sentry/react';
  5. import {Location} from 'history';
  6. import isEqual from 'lodash/isEqual';
  7. import omit from 'lodash/omit';
  8. import {fetchTotalCount} from 'app/actionCreators/events';
  9. import {fetchProjectsCount} from 'app/actionCreators/projects';
  10. import {loadOrganizationTags} from 'app/actionCreators/tags';
  11. import {Client} from 'app/api';
  12. import Alert from 'app/components/alert';
  13. import AsyncComponent from 'app/components/asyncComponent';
  14. import Confirm from 'app/components/confirm';
  15. import {CreateAlertFromViewButton} from 'app/components/createAlertButton';
  16. import SearchBar from 'app/components/events/searchBar';
  17. import * as Layout from 'app/components/layouts/thirds';
  18. import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
  19. import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
  20. import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
  21. import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
  22. import {MAX_QUERY_LENGTH} from 'app/constants';
  23. import {IconFlag} from 'app/icons';
  24. import {t, tct} from 'app/locale';
  25. import {PageContent} from 'app/styles/organization';
  26. import space from 'app/styles/space';
  27. import {GlobalSelection, Organization, SavedQuery} from 'app/types';
  28. import {generateQueryWithTag} from 'app/utils';
  29. import {trackAnalyticsEvent} from 'app/utils/analytics';
  30. import EventView, {isAPIPayloadSimilar} from 'app/utils/discover/eventView';
  31. import {generateAggregateFields} from 'app/utils/discover/fields';
  32. import localStorage from 'app/utils/localStorage';
  33. import {decodeScalar} from 'app/utils/queryString';
  34. import withApi from 'app/utils/withApi';
  35. import withGlobalSelection from 'app/utils/withGlobalSelection';
  36. import withOrganization from 'app/utils/withOrganization';
  37. import {addRoutePerformanceContext} from '../performance/utils';
  38. import {DEFAULT_EVENT_VIEW} from './data';
  39. import ResultsChart from './resultsChart';
  40. import ResultsHeader from './resultsHeader';
  41. import Table from './table';
  42. import Tags from './tags';
  43. import {generateTitle} from './utils';
  44. type Props = {
  45. api: Client;
  46. router: ReactRouter.InjectedRouter;
  47. location: Location;
  48. organization: Organization;
  49. selection: GlobalSelection;
  50. savedQuery?: SavedQuery;
  51. loading: boolean;
  52. };
  53. type State = {
  54. eventView: EventView;
  55. error: string;
  56. errorCode: number;
  57. totalValues: null | number;
  58. showTags: boolean;
  59. needConfirmation: boolean;
  60. confirmedQuery: boolean;
  61. incompatibleAlertNotice: React.ReactNode;
  62. };
  63. const SHOW_TAGS_STORAGE_KEY = 'discover2:show-tags';
  64. function readShowTagsState() {
  65. const value = localStorage.getItem(SHOW_TAGS_STORAGE_KEY);
  66. return value === '1';
  67. }
  68. class Results extends React.Component<Props, State> {
  69. static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State): State {
  70. if (nextProps.savedQuery || !nextProps.loading) {
  71. const eventView = EventView.fromSavedQueryOrLocation(
  72. nextProps.savedQuery,
  73. nextProps.location
  74. );
  75. return {...prevState, eventView};
  76. }
  77. return prevState;
  78. }
  79. state: State = {
  80. eventView: EventView.fromSavedQueryOrLocation(
  81. this.props.savedQuery,
  82. this.props.location
  83. ),
  84. error: '',
  85. errorCode: 200,
  86. totalValues: null,
  87. showTags: readShowTagsState(),
  88. needConfirmation: false,
  89. confirmedQuery: false,
  90. incompatibleAlertNotice: null,
  91. };
  92. componentDidMount() {
  93. const {api, organization, selection} = this.props;
  94. loadOrganizationTags(api, organization.slug, selection);
  95. addRoutePerformanceContext(selection);
  96. this.checkEventView();
  97. this.canLoadEvents();
  98. }
  99. componentDidUpdate(prevProps: Props, prevState: State) {
  100. const {api, location, organization, selection} = this.props;
  101. const {eventView, confirmedQuery} = this.state;
  102. this.checkEventView();
  103. const currentQuery = eventView.getEventsAPIPayload(location);
  104. const prevQuery = prevState.eventView.getEventsAPIPayload(prevProps.location);
  105. if (
  106. !isAPIPayloadSimilar(currentQuery, prevQuery) ||
  107. this.hasChartParametersChanged(prevState.eventView, eventView)
  108. ) {
  109. api.clear();
  110. this.canLoadEvents();
  111. }
  112. if (
  113. !isEqual(prevProps.selection.datetime, selection.datetime) ||
  114. !isEqual(prevProps.selection.projects, selection.projects)
  115. ) {
  116. loadOrganizationTags(api, organization.slug, selection);
  117. addRoutePerformanceContext(selection);
  118. }
  119. if (prevState.confirmedQuery !== confirmedQuery) this.fetchTotalCount();
  120. }
  121. hasChartParametersChanged(prevEventView: EventView, eventView: EventView) {
  122. const prevYAxisValue = prevEventView.getYAxis();
  123. const yAxisValue = eventView.getYAxis();
  124. if (prevYAxisValue !== yAxisValue) {
  125. return true;
  126. }
  127. const prevDisplay = prevEventView.getDisplayMode();
  128. const display = eventView.getDisplayMode();
  129. return prevDisplay !== display;
  130. }
  131. canLoadEvents = async () => {
  132. const {api, location, organization} = this.props;
  133. const {eventView} = this.state;
  134. let needConfirmation = false;
  135. let confirmedQuery = true;
  136. const currentQuery = eventView.getEventsAPIPayload(location);
  137. const duration = eventView.getDays();
  138. if (duration > 30 && currentQuery.project) {
  139. let projectLength = currentQuery.project.length;
  140. if (
  141. projectLength === 0 ||
  142. (projectLength === 1 && currentQuery.project[0] === '-1')
  143. ) {
  144. try {
  145. const results = await fetchProjectsCount(api, organization.slug);
  146. if (projectLength === 0) projectLength = results.myProjects;
  147. else projectLength = results.allProjects;
  148. } catch (err) {
  149. // do nothing, so the length is 0 or 1 and the query is assumed safe
  150. }
  151. }
  152. if (projectLength > 10) {
  153. needConfirmation = true;
  154. confirmedQuery = false;
  155. }
  156. }
  157. // Once confirmed, a change of project or datetime will happen before this can set it to false,
  158. // this means a query will still happen even if the new conditions need confirmation
  159. // using a state callback to return this to false
  160. this.setState({needConfirmation, confirmedQuery}, () => {
  161. this.setState({confirmedQuery: false});
  162. });
  163. if (needConfirmation) {
  164. this.openConfirm();
  165. }
  166. };
  167. openConfirm = () => {};
  168. setOpenFunction = ({open}) => {
  169. this.openConfirm = open;
  170. return null;
  171. };
  172. handleConfirmed = async () => {
  173. this.setState({needConfirmation: false, confirmedQuery: true}, () => {
  174. this.setState({confirmedQuery: false});
  175. });
  176. };
  177. handleCancelled = () => {
  178. this.setState({needConfirmation: false, confirmedQuery: false});
  179. };
  180. async fetchTotalCount() {
  181. const {api, organization, location} = this.props;
  182. const {eventView, confirmedQuery} = this.state;
  183. if (confirmedQuery === false || !eventView.isValid()) {
  184. return;
  185. }
  186. try {
  187. const totals = await fetchTotalCount(
  188. api,
  189. organization.slug,
  190. eventView.getEventsAPIPayload(location)
  191. );
  192. this.setState({totalValues: totals});
  193. } catch (err) {
  194. Sentry.captureException(err);
  195. }
  196. }
  197. checkEventView() {
  198. const {eventView} = this.state;
  199. const {loading} = this.props;
  200. if (eventView.isValid() || loading) {
  201. return;
  202. }
  203. // If the view is not valid, redirect to a known valid state.
  204. const {location, organization, selection} = this.props;
  205. const nextEventView = EventView.fromNewQueryWithLocation(
  206. DEFAULT_EVENT_VIEW,
  207. location
  208. );
  209. if (nextEventView.project.length === 0 && selection.projects) {
  210. nextEventView.project = selection.projects;
  211. }
  212. if (location.query?.query) {
  213. nextEventView.query = decodeScalar(location.query.query, '');
  214. }
  215. ReactRouter.browserHistory.replace(
  216. nextEventView.getResultsViewUrlTarget(organization.slug)
  217. );
  218. }
  219. handleChangeShowTags = () => {
  220. const {organization} = this.props;
  221. trackAnalyticsEvent({
  222. eventKey: 'discover_v2.results.toggle_tag_facets',
  223. eventName: 'Discoverv2: Toggle Tag Facets',
  224. organization_id: parseInt(organization.id, 10),
  225. });
  226. this.setState(state => {
  227. const newValue = !state.showTags;
  228. localStorage.setItem(SHOW_TAGS_STORAGE_KEY, newValue ? '1' : '0');
  229. return {...state, showTags: newValue};
  230. });
  231. };
  232. handleSearch = (query: string) => {
  233. const {router, location} = this.props;
  234. const queryParams = getParams({
  235. ...(location.query || {}),
  236. query,
  237. });
  238. // do not propagate pagination when making a new search
  239. const searchQueryParams = omit(queryParams, 'cursor');
  240. router.push({
  241. pathname: location.pathname,
  242. query: searchQueryParams,
  243. });
  244. };
  245. handleYAxisChange = (value: string) => {
  246. const {router, location} = this.props;
  247. const newQuery = {
  248. ...location.query,
  249. yAxis: value,
  250. };
  251. router.push({
  252. pathname: location.pathname,
  253. query: newQuery,
  254. });
  255. // Treat axis changing like the user already confirmed the query
  256. if (!this.state.needConfirmation) {
  257. this.handleConfirmed();
  258. }
  259. trackAnalyticsEvent({
  260. eventKey: 'discover_v2.y_axis_change',
  261. eventName: "Discoverv2: Change chart's y axis",
  262. organization_id: parseInt(this.props.organization.id, 10),
  263. y_axis_value: value,
  264. });
  265. };
  266. handleDisplayChange = (value: string) => {
  267. const {router, location} = this.props;
  268. const newQuery = {
  269. ...location.query,
  270. display: value,
  271. };
  272. router.push({
  273. pathname: location.pathname,
  274. query: newQuery,
  275. });
  276. // Treat display changing like the user already confirmed the query
  277. if (!this.state.needConfirmation) {
  278. this.handleConfirmed();
  279. }
  280. };
  281. getDocumentTitle(): string {
  282. const {organization} = this.props;
  283. const {eventView} = this.state;
  284. if (!eventView) {
  285. return '';
  286. }
  287. return generateTitle({eventView, organization});
  288. }
  289. renderTagsTable() {
  290. const {organization, location} = this.props;
  291. const {eventView, totalValues, confirmedQuery} = this.state;
  292. return (
  293. <Layout.Side>
  294. <Tags
  295. generateUrl={this.generateTagUrl}
  296. totalValues={totalValues}
  297. eventView={eventView}
  298. organization={organization}
  299. location={location}
  300. confirmedQuery={confirmedQuery}
  301. />
  302. </Layout.Side>
  303. );
  304. }
  305. generateTagUrl = (key: string, value: string) => {
  306. const {organization} = this.props;
  307. const {eventView} = this.state;
  308. const url = eventView.getResultsViewUrlTarget(organization.slug);
  309. url.query = generateQueryWithTag(url.query, {
  310. key,
  311. value,
  312. });
  313. return url;
  314. };
  315. handleIncompatibleQuery: React.ComponentProps<
  316. typeof CreateAlertFromViewButton
  317. >['onIncompatibleQuery'] = (incompatibleAlertNoticeFn, errors) => {
  318. const {organization} = this.props;
  319. const {eventView} = this.state;
  320. trackAnalyticsEvent({
  321. eventKey: 'discover_v2.create_alert_clicked',
  322. eventName: 'Discoverv2: Create alert clicked',
  323. status: 'error',
  324. query: eventView.query,
  325. errors,
  326. organization_id: organization.id,
  327. url: window.location.href,
  328. });
  329. const incompatibleAlertNotice = incompatibleAlertNoticeFn(() =>
  330. this.setState({incompatibleAlertNotice: null})
  331. );
  332. this.setState({incompatibleAlertNotice});
  333. };
  334. renderError(error: string) {
  335. if (!error) {
  336. return null;
  337. }
  338. return (
  339. <Alert type="error" icon={<IconFlag size="md" />}>
  340. {error}
  341. </Alert>
  342. );
  343. }
  344. setError = (error: string, errorCode: number) => {
  345. this.setState({error, errorCode});
  346. };
  347. render() {
  348. const {organization, location, router} = this.props;
  349. const {
  350. eventView,
  351. error,
  352. errorCode,
  353. totalValues,
  354. showTags,
  355. incompatibleAlertNotice,
  356. confirmedQuery,
  357. } = this.state;
  358. const fields = eventView.hasAggregateField()
  359. ? generateAggregateFields(organization, eventView.fields)
  360. : eventView.fields;
  361. const query = eventView.query;
  362. const title = this.getDocumentTitle();
  363. return (
  364. <SentryDocumentTitle title={title} orgSlug={organization.slug}>
  365. <StyledPageContent>
  366. <LightWeightNoProjectMessage organization={organization}>
  367. <ResultsHeader
  368. errorCode={errorCode}
  369. organization={organization}
  370. location={location}
  371. eventView={eventView}
  372. onIncompatibleAlertQuery={this.handleIncompatibleQuery}
  373. />
  374. <Layout.Body>
  375. {incompatibleAlertNotice && <Top fullWidth>{incompatibleAlertNotice}</Top>}
  376. <Top fullWidth>
  377. {this.renderError(error)}
  378. <StyledSearchBar
  379. organization={organization}
  380. projectIds={eventView.project}
  381. query={query}
  382. fields={fields}
  383. onSearch={this.handleSearch}
  384. maxQueryLength={MAX_QUERY_LENGTH}
  385. />
  386. <ResultsChart
  387. router={router}
  388. organization={organization}
  389. eventView={eventView}
  390. location={location}
  391. onAxisChange={this.handleYAxisChange}
  392. onDisplayChange={this.handleDisplayChange}
  393. total={totalValues}
  394. confirmedQuery={confirmedQuery}
  395. />
  396. </Top>
  397. <Layout.Main fullWidth={!showTags}>
  398. <Table
  399. organization={organization}
  400. eventView={eventView}
  401. location={location}
  402. title={title}
  403. setError={this.setError}
  404. onChangeShowTags={this.handleChangeShowTags}
  405. showTags={showTags}
  406. confirmedQuery={confirmedQuery}
  407. />
  408. </Layout.Main>
  409. {showTags ? this.renderTagsTable() : null}
  410. <Confirm
  411. priority="primary"
  412. header={<strong>{t('May lead to thumb twiddling')}</strong>}
  413. confirmText={t('Do it')}
  414. cancelText={t('Nevermind')}
  415. onConfirm={this.handleConfirmed}
  416. onCancel={this.handleCancelled}
  417. message={
  418. <p>
  419. {tct(
  420. `You've created a query that will search for events made
  421. [dayLimit:over more than 30 days] for [projectLimit:more than 10 projects].
  422. A lot has happened during that time, so this might take awhile.
  423. Are you sure you want to do this?`,
  424. {
  425. dayLimit: <strong />,
  426. projectLimit: <strong />,
  427. }
  428. )}
  429. </p>
  430. }
  431. >
  432. {this.setOpenFunction}
  433. </Confirm>
  434. </Layout.Body>
  435. </LightWeightNoProjectMessage>
  436. </StyledPageContent>
  437. </SentryDocumentTitle>
  438. );
  439. }
  440. }
  441. export const StyledPageContent = styled(PageContent)`
  442. padding: 0;
  443. `;
  444. export const StyledSearchBar = styled(SearchBar)`
  445. margin-bottom: ${space(2)};
  446. `;
  447. export const Top = styled(Layout.Main)`
  448. flex-grow: 0;
  449. `;
  450. type SavedQueryState = AsyncComponent['state'] & {
  451. savedQuery?: SavedQuery | null;
  452. };
  453. class SavedQueryAPI extends AsyncComponent<Props, SavedQueryState> {
  454. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  455. const {organization, location} = this.props;
  456. if (location.query.id) {
  457. return [
  458. [
  459. 'savedQuery',
  460. `/organizations/${organization.slug}/discover/saved/${location.query.id}/`,
  461. ],
  462. ];
  463. }
  464. return [];
  465. }
  466. renderLoading() {
  467. return this.renderBody();
  468. }
  469. renderBody(): React.ReactNode {
  470. const {savedQuery, loading} = this.state;
  471. return (
  472. <Results {...this.props} savedQuery={savedQuery ?? undefined} loading={loading} />
  473. );
  474. }
  475. }
  476. function ResultsContainer(props: Props) {
  477. /**
  478. * Block `<Results>` from mounting until GSH is ready since there are API
  479. * requests being performed on mount.
  480. *
  481. * Also, we skip loading last used projects if you have multiple projects feature as
  482. * you no longer need to enforce a project if it is empty. We assume an empty project is
  483. * the desired behavior because saved queries can contain a project filter.
  484. */
  485. return (
  486. <GlobalSelectionHeader
  487. skipLoadLastUsed={props.organization.features.includes('global-views')}
  488. >
  489. <SavedQueryAPI {...props} />
  490. </GlobalSelectionHeader>
  491. );
  492. }
  493. export default withApi(withOrganization(withGlobalSelection(ResultsContainer)));