index.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. import {Fragment, PureComponent} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {FocusScope} from '@react-aria/focus';
  5. import {AnimatePresence} from 'framer-motion';
  6. import type {Location} from 'history';
  7. import isEqual from 'lodash/isEqual';
  8. import type {Client} from 'sentry/api';
  9. import Feature from 'sentry/components/acl/feature';
  10. import FeatureDisabled from 'sentry/components/acl/featureDisabled';
  11. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  12. import Banner from 'sentry/components/banner';
  13. import {Button, LinkButton} from 'sentry/components/button';
  14. import ButtonBar from 'sentry/components/buttonBar';
  15. import {CreateAlertFromViewButton} from 'sentry/components/createAlertButton';
  16. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  17. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  18. import {Hovercard} from 'sentry/components/hovercard';
  19. import InputControl from 'sentry/components/input';
  20. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  21. import {IconBookmark, IconDelete, IconEllipsis, IconStar} from 'sentry/icons';
  22. import {t} from 'sentry/locale';
  23. import {space} from 'sentry/styles/space';
  24. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  25. import type {Organization, SavedQuery} from 'sentry/types/organization';
  26. import type {Project} from 'sentry/types/project';
  27. import {defined} from 'sentry/utils';
  28. import {trackAnalytics} from 'sentry/utils/analytics';
  29. import {browserHistory} from 'sentry/utils/browserHistory';
  30. import EventView from 'sentry/utils/discover/eventView';
  31. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  32. import {getDiscoverQueriesUrl} from 'sentry/utils/discover/urls';
  33. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  34. import useOverlay from 'sentry/utils/useOverlay';
  35. import withApi from 'sentry/utils/withApi';
  36. import withProjects from 'sentry/utils/withProjects';
  37. import {DashboardWidgetSource} from 'sentry/views/dashboards/types';
  38. import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
  39. import {
  40. handleAddQueryToDashboard,
  41. SAVED_QUERY_DATASET_TO_WIDGET_TYPE,
  42. } from 'sentry/views/discover/utils';
  43. import {DEFAULT_EVENT_VIEW} from '../data';
  44. import {
  45. getDatasetFromLocationOrSavedQueryDataset,
  46. getSavedQueryDataset,
  47. handleCreateQuery,
  48. handleDeleteQuery,
  49. handleResetHomepageQuery,
  50. handleUpdateHomepageQuery,
  51. handleUpdateQuery,
  52. } from './utils';
  53. const renderDisabled = (p: any) => (
  54. <Hovercard
  55. body={
  56. <FeatureDisabled
  57. features={p.features}
  58. hideHelpToggle
  59. message={t('Discover queries are disabled')}
  60. featureName={t('Discover queries')}
  61. />
  62. }
  63. >
  64. {p.children(p)}
  65. </Hovercard>
  66. );
  67. type SaveAsDropdownProps = {
  68. disabled: boolean;
  69. modifiedHandleCreateQuery: (e: React.MouseEvent<Element>) => void;
  70. onChangeInput: (e: React.FormEvent<HTMLInputElement>) => void;
  71. queryName: string;
  72. };
  73. function SaveAsDropdown({
  74. queryName,
  75. disabled,
  76. onChangeInput,
  77. modifiedHandleCreateQuery,
  78. }: SaveAsDropdownProps) {
  79. const {isOpen, triggerProps, overlayProps, arrowProps} = useOverlay();
  80. const theme = useTheme();
  81. return (
  82. <div>
  83. <Button
  84. {...triggerProps}
  85. size="sm"
  86. icon={<IconStar />}
  87. aria-label={t('Save as')}
  88. disabled={disabled}
  89. >
  90. {`${t('Save as')}\u2026`}
  91. </Button>
  92. <AnimatePresence>
  93. {isOpen && (
  94. <FocusScope contain restoreFocus autoFocus>
  95. <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
  96. <StyledOverlay arrowProps={arrowProps} animated>
  97. <SaveAsInput
  98. type="text"
  99. name="query_name"
  100. placeholder={t('Display name')}
  101. value={queryName || ''}
  102. onChange={onChangeInput}
  103. disabled={disabled}
  104. />
  105. <SaveAsButton
  106. onClick={modifiedHandleCreateQuery}
  107. priority="primary"
  108. disabled={disabled || !queryName}
  109. >
  110. {t('Save for Org')}
  111. </SaveAsButton>
  112. </StyledOverlay>
  113. </PositionWrapper>
  114. </FocusScope>
  115. )}
  116. </AnimatePresence>
  117. </div>
  118. );
  119. }
  120. type DefaultProps = {
  121. disabled: boolean;
  122. };
  123. type Props = DefaultProps & {
  124. api: Client;
  125. eventView: EventView;
  126. /**
  127. * DO NOT USE `Location` TO GENERATE `EventView` IN THIS COMPONENT.
  128. *
  129. * In this component, state is generated from EventView and SavedQueriesStore.
  130. * Using Location to rebuild EventView will break the tests. `Location` is
  131. * passed down only because it is needed for navigation.
  132. */
  133. location: Location;
  134. organization: Organization;
  135. projects: Project[];
  136. queryDataLoading: boolean;
  137. router: InjectedRouter;
  138. savedQuery: SavedQuery | undefined;
  139. setHomepageQuery: (homepageQuery?: SavedQuery) => void;
  140. setSavedQuery: (savedQuery: SavedQuery) => void;
  141. updateCallback: () => void;
  142. yAxis: string[];
  143. homepageQuery?: SavedQuery;
  144. isHomepage?: boolean;
  145. };
  146. type State = {
  147. isEditingQuery: boolean;
  148. isNewQuery: boolean;
  149. queryName: string;
  150. };
  151. class SavedQueryButtonGroup extends PureComponent<Props, State> {
  152. static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State): State {
  153. const {eventView: nextEventView, savedQuery, queryDataLoading, yAxis} = nextProps;
  154. // For a new unsaved query
  155. if (!savedQuery) {
  156. return {
  157. isNewQuery: true,
  158. isEditingQuery: false,
  159. queryName: prevState.queryName || '',
  160. };
  161. }
  162. if (queryDataLoading) {
  163. return prevState;
  164. }
  165. const savedEventView = EventView.fromSavedQuery(savedQuery);
  166. // Switching from a SavedQuery to another SavedQuery
  167. if (savedEventView.id !== nextEventView.id) {
  168. return {
  169. isNewQuery: false,
  170. isEditingQuery: false,
  171. queryName: '',
  172. };
  173. }
  174. // For modifying a SavedQuery
  175. const isEqualQuery = nextEventView.isEqualTo(savedEventView);
  176. // undefined saved yAxis defaults to count() and string values are converted to array
  177. const isEqualYAxis = isEqual(
  178. yAxis,
  179. !savedQuery.yAxis
  180. ? ['count()']
  181. : typeof savedQuery.yAxis === 'string'
  182. ? [savedQuery.yAxis]
  183. : savedQuery.yAxis
  184. );
  185. return {
  186. isNewQuery: false,
  187. isEditingQuery: !isEqualQuery || !isEqualYAxis,
  188. // HACK(leedongwei): See comment at SavedQueryButtonGroup.onFocusInput
  189. queryName: prevState.queryName || '',
  190. };
  191. }
  192. /**
  193. * Stop propagation for the input and container so people can interact with
  194. * the inputs in the dropdown.
  195. */
  196. static stopEventPropagation = (event: React.MouseEvent) => {
  197. const capturedElements = ['LI', 'INPUT'];
  198. if (
  199. event.target instanceof Element &&
  200. capturedElements.includes(event.target.nodeName)
  201. ) {
  202. event.preventDefault();
  203. event.stopPropagation();
  204. }
  205. };
  206. static defaultProps: DefaultProps = {
  207. disabled: false,
  208. };
  209. state: State = {
  210. isNewQuery: true,
  211. isEditingQuery: false,
  212. queryName: '',
  213. };
  214. onChangeInput = (event: React.FormEvent<HTMLInputElement>) => {
  215. const target = event.target as HTMLInputElement;
  216. this.setState({queryName: target.value});
  217. };
  218. /**
  219. * There are two ways to create a query
  220. * 1) Creating a query from scratch and saving it
  221. * 2) Modifying an existing query and saving it
  222. */
  223. handleCreateQuery = (event: React.MouseEvent<Element>) => {
  224. event.preventDefault();
  225. event.stopPropagation();
  226. const {api, organization, eventView, yAxis} = this.props;
  227. if (!this.state.queryName) {
  228. return;
  229. }
  230. const nextEventView = eventView.clone();
  231. nextEventView.name = this.state.queryName;
  232. // Checks if "Save as" button is clicked from a clean state, or it is
  233. // clicked while modifying an existing query
  234. const isNewQuery = !eventView.id;
  235. handleCreateQuery(api, organization, nextEventView, yAxis, isNewQuery).then(
  236. (savedQuery: SavedQuery) => {
  237. const view = EventView.fromSavedQuery(savedQuery);
  238. Banner.dismiss('discover');
  239. this.setState({queryName: ''});
  240. browserHistory.push(normalizeUrl(view.getResultsViewUrlTarget(organization)));
  241. }
  242. );
  243. };
  244. handleUpdateQuery = (event: React.MouseEvent<Element>) => {
  245. event.preventDefault();
  246. event.stopPropagation();
  247. const {api, organization, eventView, updateCallback, yAxis, setSavedQuery} =
  248. this.props;
  249. handleUpdateQuery(api, organization, eventView, yAxis).then(
  250. (savedQuery: SavedQuery) => {
  251. const view = EventView.fromSavedQuery(savedQuery);
  252. setSavedQuery(savedQuery);
  253. this.setState({queryName: ''});
  254. browserHistory.push(view.getResultsViewShortUrlTarget(organization));
  255. updateCallback();
  256. }
  257. );
  258. };
  259. handleDeleteQuery = (event?: React.MouseEvent<Element>) => {
  260. event?.preventDefault();
  261. event?.stopPropagation();
  262. const {api, organization, eventView} = this.props;
  263. handleDeleteQuery(api, organization, eventView).then(() => {
  264. browserHistory.push(
  265. normalizeUrl({
  266. pathname: getDiscoverQueriesUrl(organization),
  267. query: {},
  268. })
  269. );
  270. });
  271. };
  272. handleCreateAlertSuccess = () => {
  273. const {organization} = this.props;
  274. trackAnalytics('discover_v2.create_alert_clicked', {
  275. organization,
  276. status: 'success',
  277. });
  278. };
  279. renderButtonViewSaved(disabled: boolean) {
  280. const {organization} = this.props;
  281. return (
  282. <LinkButton
  283. onClick={() => {
  284. trackAnalytics('discover_v2.view_saved_queries', {organization});
  285. }}
  286. data-test-id="discover2-savedquery-button-view-saved"
  287. disabled={disabled}
  288. size="sm"
  289. icon={<IconStar isSolid />}
  290. to={getDiscoverQueriesUrl(organization)}
  291. >
  292. {t('Saved Queries')}
  293. </LinkButton>
  294. );
  295. }
  296. renderButtonSaveAs(disabled: boolean) {
  297. const {queryName} = this.state;
  298. return (
  299. <SaveAsDropdown
  300. queryName={queryName}
  301. onChangeInput={this.onChangeInput}
  302. modifiedHandleCreateQuery={this.handleCreateQuery}
  303. disabled={disabled}
  304. />
  305. );
  306. }
  307. renderButtonSave(disabled: boolean) {
  308. const {isNewQuery, isEditingQuery} = this.state;
  309. if (!isNewQuery && !isEditingQuery) {
  310. return null;
  311. }
  312. // Existing query with edits, show save and save as.
  313. if (!isNewQuery && isEditingQuery) {
  314. return (
  315. <Fragment>
  316. <Button
  317. onClick={this.handleUpdateQuery}
  318. data-test-id="discover2-savedquery-button-update"
  319. disabled={disabled}
  320. size="sm"
  321. >
  322. <IconUpdate />
  323. {t('Save Changes')}
  324. </Button>
  325. {this.renderButtonSaveAs(disabled)}
  326. </Fragment>
  327. );
  328. }
  329. // Is a new query enable saveas
  330. return this.renderButtonSaveAs(disabled);
  331. }
  332. renderButtonDelete(disabled: boolean) {
  333. const {isNewQuery} = this.state;
  334. if (isNewQuery) {
  335. return null;
  336. }
  337. return (
  338. <Button
  339. data-test-id="discover2-savedquery-button-delete"
  340. onClick={this.handleDeleteQuery}
  341. disabled={disabled}
  342. size="sm"
  343. icon={<IconDelete />}
  344. aria-label={t('Delete')}
  345. />
  346. );
  347. }
  348. renderButtonCreateAlert() {
  349. const {eventView, organization, projects, location, savedQuery} = this.props;
  350. const currentDataset = getDatasetFromLocationOrSavedQueryDataset(
  351. location,
  352. savedQuery?.queryDataset
  353. );
  354. let alertType: any;
  355. let buttonEventView = eventView;
  356. if (hasDatasetSelector(organization)) {
  357. alertType = defined(currentDataset)
  358. ? // @ts-expect-error TS(2339): Property 'discover' does not exist on type '{ tran... Remove this comment to see the full error message
  359. {
  360. [DiscoverDatasets.TRANSACTIONS]: 'throughput',
  361. [DiscoverDatasets.ERRORS]: 'num_errors',
  362. }[currentDataset]
  363. : undefined;
  364. if (currentDataset === DiscoverDatasets.TRANSACTIONS) {
  365. // Inject the event.type:transaction filter for to avoid triggering
  366. // the event.type missing banner error in the alerts form
  367. buttonEventView = eventView.clone();
  368. buttonEventView.query = eventView.query
  369. ? `(${eventView.query}) AND (event.type:transaction)`
  370. : 'event.type:transaction';
  371. }
  372. }
  373. return (
  374. <GuideAnchor target="create_alert_from_discover">
  375. <CreateAlertFromViewButton
  376. eventView={buttonEventView}
  377. organization={organization}
  378. projects={projects}
  379. onClick={this.handleCreateAlertSuccess}
  380. referrer="discover"
  381. size="sm"
  382. aria-label={t('Create Alert')}
  383. data-test-id="discover2-create-from-discover"
  384. alertType={alertType}
  385. />
  386. </GuideAnchor>
  387. );
  388. }
  389. renderButtonAddToDashboard() {
  390. const {organization, eventView, savedQuery, yAxis, router, location} = this.props;
  391. return (
  392. <Button
  393. key="add-dashboard-widget-from-discover"
  394. data-test-id="add-dashboard-widget-from-discover"
  395. size="sm"
  396. onClick={() =>
  397. handleAddQueryToDashboard({
  398. organization,
  399. location,
  400. eventView,
  401. query: savedQuery,
  402. yAxis,
  403. router,
  404. widgetType: hasDatasetSelector(organization)
  405. ? // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  406. SAVED_QUERY_DATASET_TO_WIDGET_TYPE[
  407. getSavedQueryDataset(organization, location, savedQuery)
  408. ]
  409. : undefined,
  410. source: DashboardWidgetSource.DISCOVERV2,
  411. })
  412. }
  413. >
  414. {t('Add to Dashboard')}
  415. </Button>
  416. );
  417. }
  418. renderSaveAsHomepage(disabled: boolean) {
  419. const {
  420. api,
  421. organization,
  422. eventView,
  423. location,
  424. isHomepage,
  425. setHomepageQuery,
  426. homepageQuery,
  427. queryDataLoading,
  428. } = this.props;
  429. const buttonDisabled = disabled || queryDataLoading;
  430. const analyticsEventSource = isHomepage
  431. ? 'homepage'
  432. : eventView.id
  433. ? 'saved-query'
  434. : 'prebuilt-query';
  435. if (
  436. homepageQuery &&
  437. eventView.isEqualTo(EventView.fromSavedQuery(homepageQuery), ['id', 'name'])
  438. ) {
  439. return (
  440. <Button
  441. key="reset-discover-homepage"
  442. data-test-id="reset-discover-homepage"
  443. onClick={async () => {
  444. await handleResetHomepageQuery(api, organization);
  445. trackAnalytics('discover_v2.remove_default', {
  446. organization,
  447. source: analyticsEventSource,
  448. });
  449. setHomepageQuery(undefined);
  450. if (isHomepage) {
  451. const nextEventView = EventView.fromNewQueryWithLocation(
  452. DEFAULT_EVENT_VIEW,
  453. location
  454. );
  455. browserHistory.push({
  456. pathname: location.pathname,
  457. query: nextEventView.generateQueryStringObject(),
  458. });
  459. }
  460. }}
  461. size="sm"
  462. icon={<IconBookmark isSolid />}
  463. disabled={buttonDisabled}
  464. >
  465. {t('Remove Default')}
  466. </Button>
  467. );
  468. }
  469. return (
  470. <Button
  471. key="set-as-default"
  472. data-test-id="set-as-default"
  473. onClick={async () => {
  474. const updatedHomepageQuery = await handleUpdateHomepageQuery(
  475. api,
  476. organization,
  477. eventView.toNewQuery()
  478. );
  479. trackAnalytics('discover_v2.set_as_default', {
  480. organization,
  481. source: analyticsEventSource,
  482. });
  483. if (updatedHomepageQuery) {
  484. setHomepageQuery(updatedHomepageQuery);
  485. }
  486. }}
  487. size="sm"
  488. icon={<IconBookmark />}
  489. disabled={buttonDisabled}
  490. >
  491. {t('Set as Default')}
  492. </Button>
  493. );
  494. }
  495. renderQueryButton(renderFunc: (disabled: boolean) => React.ReactNode) {
  496. const {organization} = this.props;
  497. return (
  498. <Feature
  499. organization={organization}
  500. features="discover-query"
  501. hookName="feature-disabled:discover-saved-query-create"
  502. renderDisabled={renderDisabled}
  503. >
  504. {({hasFeature}) => renderFunc(!hasFeature || this.props.disabled)}
  505. </Feature>
  506. );
  507. }
  508. render() {
  509. const {organization, eventView, savedQuery, yAxis, router, location, isHomepage} =
  510. this.props;
  511. const contextMenuItems: MenuItemProps[] = [];
  512. if (organization.features.includes('dashboards-edit')) {
  513. contextMenuItems.push({
  514. key: 'add-to-dashboard',
  515. label: t('Add to Dashboard'),
  516. onAction: () => {
  517. handleAddQueryToDashboard({
  518. organization,
  519. location,
  520. eventView,
  521. query: savedQuery,
  522. yAxis,
  523. router,
  524. widgetType: hasDatasetSelector(organization)
  525. ? // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  526. SAVED_QUERY_DATASET_TO_WIDGET_TYPE[
  527. getSavedQueryDataset(organization, location, savedQuery)
  528. ]
  529. : undefined,
  530. source: DashboardWidgetSource.DISCOVERV2,
  531. });
  532. },
  533. });
  534. }
  535. if (!isHomepage && savedQuery) {
  536. contextMenuItems.push({
  537. key: 'delete-saved-query',
  538. label: t('Delete Saved Query'),
  539. onAction: () => this.handleDeleteQuery(),
  540. });
  541. }
  542. const contextMenu = (
  543. <DropdownMenu
  544. items={contextMenuItems}
  545. trigger={triggerProps => (
  546. <Button
  547. {...triggerProps}
  548. aria-label={t('Discover Context Menu')}
  549. size="sm"
  550. onClick={e => {
  551. e.stopPropagation();
  552. e.preventDefault();
  553. triggerProps.onClick?.(e);
  554. }}
  555. icon={<IconEllipsis />}
  556. />
  557. )}
  558. position="bottom-end"
  559. offset={4}
  560. />
  561. );
  562. return (
  563. <ResponsiveButtonBar gap={1}>
  564. {this.renderQueryButton(disabled => this.renderSaveAsHomepage(disabled))}
  565. {this.renderQueryButton(disabled => this.renderButtonSave(disabled))}
  566. <Feature organization={organization} features="incidents">
  567. {({hasFeature}) => hasFeature && this.renderButtonCreateAlert()}
  568. </Feature>
  569. {contextMenuItems.length > 0 && contextMenu}
  570. {this.renderQueryButton(disabled => this.renderButtonViewSaved(disabled))}
  571. </ResponsiveButtonBar>
  572. );
  573. }
  574. }
  575. const ResponsiveButtonBar = styled(ButtonBar)`
  576. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  577. margin-top: 0;
  578. }
  579. `;
  580. const StyledOverlay = styled(Overlay)`
  581. padding: ${space(1)};
  582. `;
  583. const SaveAsButton = styled(Button)`
  584. width: 100%;
  585. `;
  586. const SaveAsInput = styled(InputControl)`
  587. margin-bottom: ${space(1)};
  588. `;
  589. const IconUpdate = styled('div')`
  590. display: inline-block;
  591. width: 10px;
  592. height: 10px;
  593. margin-right: ${space(0.75)};
  594. border-radius: 5px;
  595. background-color: ${p => p.theme.yellow300};
  596. `;
  597. export default withProjects(withApi(SavedQueryButtonGroup));