results.tsx 18 KB

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