index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import * as React from 'react';
  2. import {browserHistory, InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import isEqual from 'lodash/isEqual';
  6. import {Client} from 'sentry/api';
  7. import Feature from 'sentry/components/acl/feature';
  8. import FeatureDisabled from 'sentry/components/acl/featureDisabled';
  9. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  10. import Banner from 'sentry/components/banner';
  11. import Button from 'sentry/components/button';
  12. import ButtonBar from 'sentry/components/buttonBar';
  13. import {CreateAlertFromViewButton} from 'sentry/components/createAlertButton';
  14. import DropdownControl from 'sentry/components/dropdownControl';
  15. import InputControl from 'sentry/components/forms/controls/input';
  16. import {Hovercard} from 'sentry/components/hovercard';
  17. import {IconDelete, IconStar} from 'sentry/icons';
  18. import {t} from 'sentry/locale';
  19. import space from 'sentry/styles/space';
  20. import {Organization, Project, SavedQuery} from 'sentry/types';
  21. import {trackAnalyticsEvent} from 'sentry/utils/analytics';
  22. import EventView from 'sentry/utils/discover/eventView';
  23. import {getDiscoverLandingUrl} from 'sentry/utils/discover/urls';
  24. import withApi from 'sentry/utils/withApi';
  25. import withProjects from 'sentry/utils/withProjects';
  26. import {
  27. constructAddQueryToDashboardLink,
  28. handleAddQueryToDashboard,
  29. } from 'sentry/views/eventsV2/utils';
  30. import {handleCreateQuery, handleDeleteQuery, handleUpdateQuery} from './utils';
  31. type DefaultProps = {
  32. disabled: boolean;
  33. };
  34. type Props = DefaultProps & {
  35. api: Client;
  36. eventView: EventView;
  37. /**
  38. * DO NOT USE `Location` TO GENERATE `EventView` IN THIS COMPONENT.
  39. *
  40. * In this component, state is generated from EventView and SavedQueriesStore.
  41. * Using Location to rebuild EventView will break the tests. `Location` is
  42. * passed down only because it is needed for navigation.
  43. */
  44. location: Location;
  45. onIncompatibleAlertQuery: React.ComponentProps<
  46. typeof CreateAlertFromViewButton
  47. >['onIncompatibleQuery'];
  48. organization: Organization;
  49. projects: Project[];
  50. router: InjectedRouter;
  51. savedQuery: SavedQuery | undefined;
  52. savedQueryLoading: boolean;
  53. updateCallback: () => void;
  54. yAxis: string[];
  55. };
  56. type State = {
  57. isEditingQuery: boolean;
  58. isNewQuery: boolean;
  59. queryName: string;
  60. };
  61. class SavedQueryButtonGroup extends React.PureComponent<Props, State> {
  62. static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State): State {
  63. const {eventView: nextEventView, savedQuery, savedQueryLoading, yAxis} = nextProps;
  64. // For a new unsaved query
  65. if (!savedQuery) {
  66. return {
  67. isNewQuery: true,
  68. isEditingQuery: false,
  69. queryName: prevState.queryName || '',
  70. };
  71. }
  72. if (savedQueryLoading) {
  73. return prevState;
  74. }
  75. const savedEventView = EventView.fromSavedQuery(savedQuery);
  76. // Switching from a SavedQuery to another SavedQuery
  77. if (savedEventView.id !== nextEventView.id) {
  78. return {
  79. isNewQuery: false,
  80. isEditingQuery: false,
  81. queryName: '',
  82. };
  83. }
  84. // For modifying a SavedQuery
  85. const isEqualQuery = nextEventView.isEqualTo(savedEventView);
  86. // undefined saved yAxis defaults to count() and string values are converted to array
  87. const isEqualYAxis = isEqual(
  88. yAxis,
  89. !savedQuery.yAxis
  90. ? ['count()']
  91. : typeof savedQuery.yAxis === 'string'
  92. ? [savedQuery.yAxis]
  93. : savedQuery.yAxis
  94. );
  95. return {
  96. isNewQuery: false,
  97. isEditingQuery: !isEqualQuery || !isEqualYAxis,
  98. // HACK(leedongwei): See comment at SavedQueryButtonGroup.onFocusInput
  99. queryName: prevState.queryName || '',
  100. };
  101. }
  102. /**
  103. * Stop propagation for the input and container so people can interact with
  104. * the inputs in the dropdown.
  105. */
  106. static stopEventPropagation = (event: React.MouseEvent) => {
  107. const capturedElements = ['LI', 'INPUT'];
  108. if (
  109. event.target instanceof Element &&
  110. capturedElements.includes(event.target.nodeName)
  111. ) {
  112. event.preventDefault();
  113. event.stopPropagation();
  114. }
  115. };
  116. static defaultProps: DefaultProps = {
  117. disabled: false,
  118. };
  119. state: State = {
  120. isNewQuery: true,
  121. isEditingQuery: false,
  122. queryName: '',
  123. };
  124. onBlurInput = (event: React.FormEvent<HTMLInputElement>) => {
  125. const target = event.target as HTMLInputElement;
  126. this.setState({queryName: target.value});
  127. };
  128. onChangeInput = (event: React.FormEvent<HTMLInputElement>) => {
  129. const target = event.target as HTMLInputElement;
  130. this.setState({queryName: target.value});
  131. };
  132. /**
  133. * There are two ways to create a query
  134. * 1) Creating a query from scratch and saving it
  135. * 2) Modifying an existing query and saving it
  136. */
  137. handleCreateQuery = (event: React.MouseEvent<Element>) => {
  138. event.preventDefault();
  139. event.stopPropagation();
  140. const {api, organization, eventView, yAxis} = this.props;
  141. if (!this.state.queryName) {
  142. return;
  143. }
  144. const nextEventView = eventView.clone();
  145. nextEventView.name = this.state.queryName;
  146. // Checks if "Save as" button is clicked from a clean state, or it is
  147. // clicked while modifying an existing query
  148. const isNewQuery = !eventView.id;
  149. handleCreateQuery(api, organization, nextEventView, yAxis, isNewQuery).then(
  150. (savedQuery: SavedQuery) => {
  151. const view = EventView.fromSavedQuery(savedQuery);
  152. Banner.dismiss('discover');
  153. this.setState({queryName: ''});
  154. browserHistory.push(view.getResultsViewUrlTarget(organization.slug));
  155. }
  156. );
  157. };
  158. handleUpdateQuery = (event: React.MouseEvent<Element>) => {
  159. event.preventDefault();
  160. event.stopPropagation();
  161. const {api, organization, eventView, updateCallback, yAxis} = this.props;
  162. handleUpdateQuery(api, organization, eventView, yAxis).then(
  163. (savedQuery: SavedQuery) => {
  164. const view = EventView.fromSavedQuery(savedQuery);
  165. this.setState({queryName: ''});
  166. browserHistory.push(view.getResultsViewShortUrlTarget(organization.slug));
  167. updateCallback();
  168. }
  169. );
  170. };
  171. handleDeleteQuery = (event: React.MouseEvent<Element>) => {
  172. event.preventDefault();
  173. event.stopPropagation();
  174. const {api, organization, eventView} = this.props;
  175. handleDeleteQuery(api, organization, eventView).then(() => {
  176. browserHistory.push({
  177. pathname: getDiscoverLandingUrl(organization),
  178. query: {},
  179. });
  180. });
  181. };
  182. handleCreateAlertSuccess = () => {
  183. const {organization} = this.props;
  184. trackAnalyticsEvent({
  185. eventKey: 'discover_v2.create_alert_clicked',
  186. eventName: 'Discoverv2: Create alert clicked',
  187. status: 'success',
  188. organization_id: organization.id,
  189. url: window.location.href,
  190. });
  191. };
  192. renderButtonSaveAs(disabled: boolean) {
  193. const {queryName} = this.state;
  194. /**
  195. * For a great UX, we should focus on `ButtonSaveInput` when `ButtonSave`
  196. * is clicked. However, `DropdownControl` wraps them in a FunctionComponent
  197. * which breaks `React.createRef`.
  198. */
  199. return (
  200. <DropdownControl
  201. alignRight
  202. menuWidth="220px"
  203. priority="default"
  204. buttonProps={{
  205. 'aria-label': t('Save as'),
  206. showChevron: false,
  207. icon: <IconStar />,
  208. disabled,
  209. }}
  210. label={`${t('Save as')}\u{2026}`}
  211. >
  212. <ButtonSaveDropDown onClick={SavedQueryButtonGroup.stopEventPropagation}>
  213. <ButtonSaveInput
  214. type="text"
  215. name="query_name"
  216. placeholder={t('Display name')}
  217. value={queryName || ''}
  218. onBlur={this.onBlurInput}
  219. onChange={this.onChangeInput}
  220. disabled={disabled}
  221. />
  222. <Button
  223. onClick={this.handleCreateQuery}
  224. priority="primary"
  225. disabled={disabled || !this.state.queryName}
  226. style={{width: '100%'}}
  227. >
  228. {t('Save for Org')}
  229. </Button>
  230. </ButtonSaveDropDown>
  231. </DropdownControl>
  232. );
  233. }
  234. renderButtonSave(disabled: boolean) {
  235. const {isNewQuery, isEditingQuery} = this.state;
  236. // Existing query that hasn't been modified.
  237. if (!isNewQuery && !isEditingQuery) {
  238. return (
  239. <Button
  240. icon={<IconStar color="yellow100" isSolid size="sm" />}
  241. disabled
  242. data-test-id="discover2-savedquery-button-saved"
  243. >
  244. {t('Saved for Org')}
  245. </Button>
  246. );
  247. }
  248. // Existing query with edits, show save and save as.
  249. if (!isNewQuery && isEditingQuery) {
  250. return (
  251. <React.Fragment>
  252. <Button
  253. onClick={this.handleUpdateQuery}
  254. data-test-id="discover2-savedquery-button-update"
  255. disabled={disabled}
  256. >
  257. <IconUpdate />
  258. {t('Save Changes')}
  259. </Button>
  260. {this.renderButtonSaveAs(disabled)}
  261. </React.Fragment>
  262. );
  263. }
  264. // Is a new query enable saveas
  265. return this.renderButtonSaveAs(disabled);
  266. }
  267. renderButtonDelete(disabled: boolean) {
  268. const {isNewQuery} = this.state;
  269. if (isNewQuery) {
  270. return null;
  271. }
  272. return (
  273. <Button
  274. data-test-id="discover2-savedquery-button-delete"
  275. onClick={this.handleDeleteQuery}
  276. disabled={disabled}
  277. icon={<IconDelete />}
  278. aria-label={t('Delete')}
  279. />
  280. );
  281. }
  282. renderButtonCreateAlert() {
  283. const {eventView, organization, projects, onIncompatibleAlertQuery} = this.props;
  284. return (
  285. <GuideAnchor target="create_alert_from_discover">
  286. <CreateAlertFromViewButton
  287. eventView={eventView}
  288. organization={organization}
  289. projects={projects}
  290. onIncompatibleQuery={onIncompatibleAlertQuery}
  291. onSuccess={this.handleCreateAlertSuccess}
  292. referrer="discover"
  293. aria-label={t('Create Alert')}
  294. data-test-id="discover2-create-from-discover"
  295. />
  296. </GuideAnchor>
  297. );
  298. }
  299. renderButtonAddToDashboard() {
  300. const {organization, location, eventView, savedQuery, yAxis, router} = this.props;
  301. return (
  302. <Button
  303. key="add-dashboard-widget-from-discover"
  304. data-test-id="add-dashboard-widget-from-discover"
  305. {...(organization.features.includes('new-widget-builder-experience') &&
  306. !organization.features.includes('new-widget-builder-experience-design')
  307. ? {
  308. to: constructAddQueryToDashboardLink({
  309. organization,
  310. location,
  311. eventView,
  312. query: savedQuery,
  313. yAxis,
  314. }),
  315. }
  316. : {
  317. onClick: () =>
  318. handleAddQueryToDashboard({
  319. organization,
  320. eventView,
  321. query: savedQuery,
  322. yAxis,
  323. router,
  324. }),
  325. })}
  326. >
  327. {t('Add to Dashboard')}
  328. </Button>
  329. );
  330. }
  331. render() {
  332. const {organization} = this.props;
  333. const renderDisabled = p => (
  334. <Hovercard
  335. body={
  336. <FeatureDisabled
  337. features={p.features}
  338. hideHelpToggle
  339. message={t('Discover queries are disabled')}
  340. featureName={t('Discover queries')}
  341. />
  342. }
  343. >
  344. {p.children(p)}
  345. </Hovercard>
  346. );
  347. const renderQueryButton = (renderFunc: (disabled: boolean) => React.ReactNode) => {
  348. return (
  349. <Feature
  350. organization={organization}
  351. features={['discover-query']}
  352. hookName="feature-disabled:discover-saved-query-create"
  353. renderDisabled={renderDisabled}
  354. >
  355. {({hasFeature}) => renderFunc(!hasFeature || this.props.disabled)}
  356. </Feature>
  357. );
  358. };
  359. return (
  360. <ResponsiveButtonBar gap={1}>
  361. {renderQueryButton(disabled => this.renderButtonSave(disabled))}
  362. <Feature organization={organization} features={['incidents']}>
  363. {({hasFeature}) => hasFeature && this.renderButtonCreateAlert()}
  364. </Feature>
  365. <Feature organization={organization} features={['dashboards-edit']}>
  366. {({hasFeature}) => hasFeature && this.renderButtonAddToDashboard()}
  367. </Feature>
  368. {renderQueryButton(disabled => this.renderButtonDelete(disabled))}
  369. </ResponsiveButtonBar>
  370. );
  371. }
  372. }
  373. const ResponsiveButtonBar = styled(ButtonBar)`
  374. @media (min-width: ${p => p.theme.breakpoints[1]}) {
  375. margin-top: 0;
  376. }
  377. `;
  378. const ButtonSaveDropDown = styled('div')`
  379. display: flex;
  380. flex-direction: column;
  381. padding: ${space(1)};
  382. gap: ${space(1)};
  383. `;
  384. const ButtonSaveInput = styled(InputControl)`
  385. height: 40px;
  386. `;
  387. const IconUpdate = styled('div')`
  388. display: inline-block;
  389. width: 10px;
  390. height: 10px;
  391. margin-right: ${space(0.75)};
  392. border-radius: 5px;
  393. background-color: ${p => p.theme.yellow300};
  394. `;
  395. export default withProjects(withApi(SavedQueryButtonGroup));