results.tsx 17 KB

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