index.tsx 12 KB

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