index.tsx 19 KB

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