index.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  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(
  241. normalizeUrl(view.getResultsViewUrlTarget(organization.slug))
  242. );
  243. }
  244. );
  245. };
  246. handleUpdateQuery = (event: React.MouseEvent<Element>) => {
  247. event.preventDefault();
  248. event.stopPropagation();
  249. const {api, organization, eventView, updateCallback, yAxis, setSavedQuery} =
  250. this.props;
  251. handleUpdateQuery(api, organization, eventView, yAxis).then(
  252. (savedQuery: SavedQuery) => {
  253. const view = EventView.fromSavedQuery(savedQuery);
  254. setSavedQuery(savedQuery);
  255. this.setState({queryName: ''});
  256. browserHistory.push(view.getResultsViewShortUrlTarget(organization.slug));
  257. updateCallback();
  258. }
  259. );
  260. };
  261. handleDeleteQuery = (event?: React.MouseEvent<Element>) => {
  262. event?.preventDefault();
  263. event?.stopPropagation();
  264. const {api, organization, eventView} = this.props;
  265. handleDeleteQuery(api, organization, eventView).then(() => {
  266. browserHistory.push(
  267. normalizeUrl({
  268. pathname: getDiscoverQueriesUrl(organization),
  269. query: {},
  270. })
  271. );
  272. });
  273. };
  274. handleCreateAlertSuccess = () => {
  275. const {organization} = this.props;
  276. trackAnalytics('discover_v2.create_alert_clicked', {
  277. organization,
  278. status: 'success',
  279. });
  280. };
  281. renderButtonViewSaved(disabled: boolean) {
  282. const {organization} = this.props;
  283. return (
  284. <LinkButton
  285. onClick={() => {
  286. trackAnalytics('discover_v2.view_saved_queries', {organization});
  287. }}
  288. data-test-id="discover2-savedquery-button-view-saved"
  289. disabled={disabled}
  290. size="sm"
  291. icon={<IconStar isSolid />}
  292. to={getDiscoverQueriesUrl(organization)}
  293. >
  294. {t('Saved Queries')}
  295. </LinkButton>
  296. );
  297. }
  298. renderButtonSaveAs(disabled: boolean) {
  299. const {queryName} = this.state;
  300. return (
  301. <SaveAsDropdown
  302. queryName={queryName}
  303. onChangeInput={this.onChangeInput}
  304. modifiedHandleCreateQuery={this.handleCreateQuery}
  305. disabled={disabled}
  306. />
  307. );
  308. }
  309. renderButtonSave(disabled: boolean) {
  310. const {isNewQuery, isEditingQuery} = this.state;
  311. if (!isNewQuery && !isEditingQuery) {
  312. return null;
  313. }
  314. // Existing query with edits, show save and save as.
  315. if (!isNewQuery && isEditingQuery) {
  316. return (
  317. <Fragment>
  318. <Button
  319. onClick={this.handleUpdateQuery}
  320. data-test-id="discover2-savedquery-button-update"
  321. disabled={disabled}
  322. size="sm"
  323. >
  324. <IconUpdate />
  325. {t('Save Changes')}
  326. </Button>
  327. {this.renderButtonSaveAs(disabled)}
  328. </Fragment>
  329. );
  330. }
  331. // Is a new query enable saveas
  332. return this.renderButtonSaveAs(disabled);
  333. }
  334. renderButtonDelete(disabled: boolean) {
  335. const {isNewQuery} = this.state;
  336. if (isNewQuery) {
  337. return null;
  338. }
  339. return (
  340. <Button
  341. data-test-id="discover2-savedquery-button-delete"
  342. onClick={this.handleDeleteQuery}
  343. disabled={disabled}
  344. size="sm"
  345. icon={<IconDelete />}
  346. aria-label={t('Delete')}
  347. />
  348. );
  349. }
  350. renderButtonCreateAlert() {
  351. const {eventView, organization, projects, location, savedQuery} = this.props;
  352. const currentDataset = getDatasetFromLocationOrSavedQueryDataset(
  353. location,
  354. savedQuery?.queryDataset
  355. );
  356. let alertType: any;
  357. let buttonEventView = eventView;
  358. if (hasDatasetSelector(organization)) {
  359. alertType = defined(currentDataset)
  360. ? // @ts-expect-error TS(2339): Property 'discover' does not exist on type '{ tran... Remove this comment to see the full error message
  361. {
  362. [DiscoverDatasets.TRANSACTIONS]: 'throughput',
  363. [DiscoverDatasets.ERRORS]: 'num_errors',
  364. }[currentDataset]
  365. : undefined;
  366. if (currentDataset === DiscoverDatasets.TRANSACTIONS) {
  367. // Inject the event.type:transaction filter for to avoid triggering
  368. // the event.type missing banner error in the alerts form
  369. buttonEventView = eventView.clone();
  370. buttonEventView.query = eventView.query
  371. ? `(${eventView.query}) AND (event.type:transaction)`
  372. : 'event.type:transaction';
  373. }
  374. }
  375. return (
  376. <GuideAnchor target="create_alert_from_discover">
  377. <CreateAlertFromViewButton
  378. eventView={buttonEventView}
  379. organization={organization}
  380. projects={projects}
  381. onClick={this.handleCreateAlertSuccess}
  382. referrer="discover"
  383. size="sm"
  384. aria-label={t('Create Alert')}
  385. data-test-id="discover2-create-from-discover"
  386. alertType={alertType}
  387. />
  388. </GuideAnchor>
  389. );
  390. }
  391. renderButtonAddToDashboard() {
  392. const {organization, eventView, savedQuery, yAxis, router, location} = this.props;
  393. return (
  394. <Button
  395. key="add-dashboard-widget-from-discover"
  396. data-test-id="add-dashboard-widget-from-discover"
  397. size="sm"
  398. onClick={() =>
  399. handleAddQueryToDashboard({
  400. organization,
  401. location,
  402. eventView,
  403. query: savedQuery,
  404. yAxis,
  405. router,
  406. widgetType: hasDatasetSelector(organization)
  407. ? // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  408. SAVED_QUERY_DATASET_TO_WIDGET_TYPE[
  409. getSavedQueryDataset(organization, location, savedQuery)
  410. ]
  411. : undefined,
  412. source: DashboardWidgetSource.DISCOVERV2,
  413. })
  414. }
  415. >
  416. {t('Add to Dashboard')}
  417. </Button>
  418. );
  419. }
  420. renderSaveAsHomepage(disabled: boolean) {
  421. const {
  422. api,
  423. organization,
  424. eventView,
  425. location,
  426. isHomepage,
  427. setHomepageQuery,
  428. homepageQuery,
  429. queryDataLoading,
  430. } = this.props;
  431. const buttonDisabled = disabled || queryDataLoading;
  432. const analyticsEventSource = isHomepage
  433. ? 'homepage'
  434. : eventView.id
  435. ? 'saved-query'
  436. : 'prebuilt-query';
  437. if (
  438. homepageQuery &&
  439. eventView.isEqualTo(EventView.fromSavedQuery(homepageQuery), ['id', 'name'])
  440. ) {
  441. return (
  442. <Button
  443. key="reset-discover-homepage"
  444. data-test-id="reset-discover-homepage"
  445. onClick={async () => {
  446. await handleResetHomepageQuery(api, organization);
  447. trackAnalytics('discover_v2.remove_default', {
  448. organization,
  449. source: analyticsEventSource,
  450. });
  451. setHomepageQuery(undefined);
  452. if (isHomepage) {
  453. const nextEventView = EventView.fromNewQueryWithLocation(
  454. DEFAULT_EVENT_VIEW,
  455. location
  456. );
  457. browserHistory.push({
  458. pathname: location.pathname,
  459. query: nextEventView.generateQueryStringObject(),
  460. });
  461. }
  462. }}
  463. size="sm"
  464. icon={<IconBookmark isSolid />}
  465. disabled={buttonDisabled}
  466. >
  467. {t('Remove Default')}
  468. </Button>
  469. );
  470. }
  471. return (
  472. <Button
  473. key="set-as-default"
  474. data-test-id="set-as-default"
  475. onClick={async () => {
  476. const updatedHomepageQuery = await handleUpdateHomepageQuery(
  477. api,
  478. organization,
  479. eventView.toNewQuery()
  480. );
  481. trackAnalytics('discover_v2.set_as_default', {
  482. organization,
  483. source: analyticsEventSource,
  484. });
  485. if (updatedHomepageQuery) {
  486. setHomepageQuery(updatedHomepageQuery);
  487. }
  488. }}
  489. size="sm"
  490. icon={<IconBookmark />}
  491. disabled={buttonDisabled}
  492. >
  493. {t('Set as Default')}
  494. </Button>
  495. );
  496. }
  497. renderQueryButton(renderFunc: (disabled: boolean) => React.ReactNode) {
  498. const {organization} = this.props;
  499. return (
  500. <Feature
  501. organization={organization}
  502. features="discover-query"
  503. hookName="feature-disabled:discover-saved-query-create"
  504. renderDisabled={renderDisabled}
  505. >
  506. {({hasFeature}) => renderFunc(!hasFeature || this.props.disabled)}
  507. </Feature>
  508. );
  509. }
  510. render() {
  511. const {organization, eventView, savedQuery, yAxis, router, location, isHomepage} =
  512. this.props;
  513. const contextMenuItems: MenuItemProps[] = [];
  514. if (organization.features.includes('dashboards-edit')) {
  515. contextMenuItems.push({
  516. key: 'add-to-dashboard',
  517. label: t('Add to Dashboard'),
  518. onAction: () => {
  519. handleAddQueryToDashboard({
  520. organization,
  521. location,
  522. eventView,
  523. query: savedQuery,
  524. yAxis,
  525. router,
  526. widgetType: hasDatasetSelector(organization)
  527. ? // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  528. SAVED_QUERY_DATASET_TO_WIDGET_TYPE[
  529. getSavedQueryDataset(organization, location, savedQuery)
  530. ]
  531. : undefined,
  532. source: DashboardWidgetSource.DISCOVERV2,
  533. });
  534. },
  535. });
  536. }
  537. if (!isHomepage && savedQuery) {
  538. contextMenuItems.push({
  539. key: 'delete-saved-query',
  540. label: t('Delete Saved Query'),
  541. onAction: () => this.handleDeleteQuery(),
  542. });
  543. }
  544. const contextMenu = (
  545. <DropdownMenu
  546. items={contextMenuItems}
  547. trigger={triggerProps => (
  548. <Button
  549. {...triggerProps}
  550. aria-label={t('Discover Context Menu')}
  551. size="sm"
  552. onClick={e => {
  553. e.stopPropagation();
  554. e.preventDefault();
  555. triggerProps.onClick?.(e);
  556. }}
  557. icon={<IconEllipsis />}
  558. />
  559. )}
  560. position="bottom-end"
  561. offset={4}
  562. />
  563. );
  564. return (
  565. <ResponsiveButtonBar gap={1}>
  566. {this.renderQueryButton(disabled => this.renderSaveAsHomepage(disabled))}
  567. {this.renderQueryButton(disabled => this.renderButtonSave(disabled))}
  568. <Feature organization={organization} features="incidents">
  569. {({hasFeature}) => hasFeature && this.renderButtonCreateAlert()}
  570. </Feature>
  571. {contextMenuItems.length > 0 && contextMenu}
  572. {this.renderQueryButton(disabled => this.renderButtonViewSaved(disabled))}
  573. </ResponsiveButtonBar>
  574. );
  575. }
  576. }
  577. const ResponsiveButtonBar = styled(ButtonBar)`
  578. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  579. margin-top: 0;
  580. }
  581. `;
  582. const StyledOverlay = styled(Overlay)`
  583. padding: ${space(1)};
  584. `;
  585. const SaveAsButton = styled(Button)`
  586. width: 100%;
  587. `;
  588. const SaveAsInput = styled(InputControl)`
  589. margin-bottom: ${space(1)};
  590. `;
  591. const IconUpdate = styled('div')`
  592. display: inline-block;
  593. width: 10px;
  594. height: 10px;
  595. margin-right: ${space(0.75)};
  596. border-radius: 5px;
  597. background-color: ${p => p.theme.yellow300};
  598. `;
  599. export default withProjects(withApi(SavedQueryButtonGroup));