addDashboardWidgetModal.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936
  1. import * as React from 'react';
  2. import {browserHistory} from 'react-router';
  3. import {OptionProps} from 'react-select';
  4. import {css} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import cloneDeep from 'lodash/cloneDeep';
  7. import pick from 'lodash/pick';
  8. import set from 'lodash/set';
  9. import {validateWidget} from 'sentry/actionCreators/dashboards';
  10. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  11. import {fetchMetricsFields, fetchMetricsTags} from 'sentry/actionCreators/metrics';
  12. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  13. import {Client} from 'sentry/api';
  14. import Button from 'sentry/components/button';
  15. import ButtonBar from 'sentry/components/buttonBar';
  16. import IssueWidgetQueriesForm from 'sentry/components/dashboards/issueWidgetQueriesForm';
  17. import WidgetQueriesForm from 'sentry/components/dashboards/widgetQueriesForm';
  18. import Input from 'sentry/components/forms/controls/input';
  19. import RadioGroup from 'sentry/components/forms/controls/radioGroup';
  20. import Field from 'sentry/components/forms/field';
  21. import FieldLabel from 'sentry/components/forms/field/fieldLabel';
  22. import SelectControl from 'sentry/components/forms/selectControl';
  23. import {PanelAlert} from 'sentry/components/panels';
  24. import {t, tct} from 'sentry/locale';
  25. import space from 'sentry/styles/space';
  26. import {
  27. DateString,
  28. MetricMeta,
  29. MetricTag,
  30. Organization,
  31. PageFilters,
  32. SelectValue,
  33. TagCollection,
  34. } from 'sentry/types';
  35. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  36. import Measurements from 'sentry/utils/measurements/measurements';
  37. import {SessionMetric} from 'sentry/utils/metrics/fields';
  38. import {SPAN_OP_BREAKDOWN_FIELDS} from 'sentry/utils/performance/spanOperationBreakdowns/constants';
  39. import withApi from 'sentry/utils/withApi';
  40. import withPageFilters from 'sentry/utils/withPageFilters';
  41. import withTags from 'sentry/utils/withTags';
  42. import {DISPLAY_TYPE_CHOICES} from 'sentry/views/dashboardsV2/data';
  43. import {assignTempId} from 'sentry/views/dashboardsV2/layoutUtils';
  44. import {
  45. DashboardDetails,
  46. DashboardListItem,
  47. DashboardWidgetSource,
  48. DisplayType,
  49. MAX_WIDGETS,
  50. Widget,
  51. WidgetQuery,
  52. WidgetType,
  53. } from 'sentry/views/dashboardsV2/types';
  54. import {
  55. mapErrors,
  56. normalizeQueries,
  57. } from 'sentry/views/dashboardsV2/widgetBuilder/eventWidget/utils';
  58. import {generateIssueWidgetFieldOptions} from 'sentry/views/dashboardsV2/widgetBuilder/issueWidget/utils';
  59. import {
  60. DEFAULT_METRICS_FIELDS,
  61. generateMetricsWidgetFieldOptions,
  62. METRICS_FIELDS_ALLOW_LIST,
  63. } from 'sentry/views/dashboardsV2/widgetBuilder/metricWidget/fields';
  64. import WidgetCard from 'sentry/views/dashboardsV2/widgetCard';
  65. import {WidgetTemplate} from 'sentry/views/dashboardsV2/widgetLibrary/data';
  66. import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
  67. import Option from '../forms/selectOption';
  68. import Tooltip from '../tooltip';
  69. import {TAB, TabsButtonBar} from './dashboardWidgetLibraryModal/tabsButtonBar';
  70. export type DashboardWidgetModalOptions = {
  71. organization: Organization;
  72. source: DashboardWidgetSource;
  73. dashboard?: DashboardDetails;
  74. defaultTableColumns?: readonly string[];
  75. defaultTitle?: string;
  76. defaultWidgetQuery?: WidgetQuery;
  77. displayType?: DisplayType;
  78. end?: DateString;
  79. onAddLibraryWidget?: (widgets: Widget[]) => void;
  80. onAddWidget?: (data: Widget) => void;
  81. onUpdateWidget?: (nextWidget: Widget) => void;
  82. selectedWidgets?: WidgetTemplate[];
  83. selection?: PageFilters;
  84. start?: DateString;
  85. statsPeriod?: string | null;
  86. widget?: Widget;
  87. };
  88. type Props = ModalRenderProps &
  89. DashboardWidgetModalOptions & {
  90. api: Client;
  91. organization: Organization;
  92. selection: PageFilters;
  93. tags: TagCollection;
  94. };
  95. type FlatValidationError = {
  96. [key: string]: string | FlatValidationError[] | FlatValidationError;
  97. };
  98. type State = {
  99. dashboards: DashboardListItem[];
  100. displayType: Widget['displayType'];
  101. interval: Widget['interval'];
  102. loading: boolean;
  103. metricFields: MetricMeta[];
  104. metricTags: MetricTag[];
  105. queries: Widget['queries'];
  106. title: string;
  107. userHasModified: boolean;
  108. widgetType: WidgetType;
  109. errors?: Record<string, any>;
  110. selectedDashboard?: SelectValue<string>;
  111. };
  112. const newDiscoverQuery = {
  113. name: '',
  114. fields: ['count()'],
  115. conditions: '',
  116. orderby: '',
  117. };
  118. const newIssueQuery = {
  119. name: '',
  120. fields: ['issue', 'assignee', 'title'] as string[],
  121. conditions: '',
  122. orderby: '',
  123. };
  124. const newMetricsQuery = {
  125. name: '',
  126. fields: [`sum(${SessionMetric.SENTRY_SESSIONS_SESSION})`],
  127. conditions: '',
  128. orderby: '',
  129. };
  130. const DiscoverDataset: [WidgetType, string] = [
  131. WidgetType.DISCOVER,
  132. t('All Events (Errors and Transactions)'),
  133. ];
  134. const IssueDataset: [WidgetType, string] = [
  135. WidgetType.ISSUE,
  136. t('Issues (States, Assignment, Time, etc.)'),
  137. ];
  138. const MetricsDataset: [WidgetType, string] = [
  139. WidgetType.METRICS,
  140. t('Metrics (Release Health)'),
  141. ];
  142. class AddDashboardWidgetModal extends React.Component<Props, State> {
  143. constructor(props: Props) {
  144. super(props);
  145. const {widget, defaultWidgetQuery, defaultTitle, displayType} = props;
  146. if (!widget) {
  147. this.state = {
  148. title: defaultTitle ?? '',
  149. displayType: displayType ?? DisplayType.TABLE,
  150. interval: '5m',
  151. queries: [defaultWidgetQuery ? {...defaultWidgetQuery} : {...newDiscoverQuery}],
  152. errors: undefined,
  153. loading: !!this.omitDashboardProp,
  154. dashboards: [],
  155. metricTags: [],
  156. metricFields: [],
  157. userHasModified: false,
  158. widgetType: WidgetType.DISCOVER,
  159. };
  160. return;
  161. }
  162. this.state = {
  163. title: widget.title,
  164. displayType: widget.displayType,
  165. interval: widget.interval,
  166. queries: normalizeQueries(widget.displayType, widget.queries),
  167. errors: undefined,
  168. loading: false,
  169. dashboards: [],
  170. metricTags: [],
  171. metricFields: [],
  172. userHasModified: false,
  173. widgetType: widget.widgetType ?? WidgetType.DISCOVER,
  174. };
  175. }
  176. componentDidMount() {
  177. if (this.omitDashboardProp) {
  178. this.fetchDashboards();
  179. }
  180. if (this.props.organization.features.includes('dashboards-metrics')) {
  181. this.fetchMetricsTags();
  182. this.fetchMetricsFields();
  183. }
  184. }
  185. get omitDashboardProp() {
  186. // when opening from discover or issues page, the user selects the dashboard in the widget UI
  187. return [
  188. DashboardWidgetSource.DISCOVERV2,
  189. DashboardWidgetSource.ISSUE_DETAILS,
  190. ].includes(this.props.source);
  191. }
  192. get fromLibrary() {
  193. return this.props.source === DashboardWidgetSource.LIBRARY;
  194. }
  195. handleSubmit = async (event: React.FormEvent) => {
  196. event.preventDefault();
  197. const {
  198. api,
  199. closeModal,
  200. organization,
  201. onAddWidget,
  202. onUpdateWidget,
  203. widget: previousWidget,
  204. source,
  205. } = this.props;
  206. this.setState({loading: true});
  207. let errors: FlatValidationError = {};
  208. const widgetData: Widget = assignTempId(
  209. pick(this.state, ['title', 'displayType', 'interval', 'queries', 'widgetType'])
  210. );
  211. if (previousWidget) {
  212. widgetData.layout = previousWidget?.layout;
  213. }
  214. // Only Table and Top N views need orderby
  215. if (![DisplayType.TABLE, DisplayType.TOP_N].includes(widgetData.displayType)) {
  216. widgetData.queries.forEach(query => {
  217. query.orderby = '';
  218. });
  219. }
  220. try {
  221. await validateWidget(api, organization.slug, widgetData);
  222. if (typeof onUpdateWidget === 'function' && !!previousWidget) {
  223. onUpdateWidget({
  224. id: previousWidget?.id,
  225. layout: previousWidget?.layout,
  226. ...widgetData,
  227. });
  228. addSuccessMessage(t('Updated widget.'));
  229. trackAdvancedAnalyticsEvent('dashboards_views.edit_widget_modal.confirm', {
  230. organization,
  231. });
  232. } else if (onAddWidget) {
  233. onAddWidget(widgetData);
  234. addSuccessMessage(t('Added widget.'));
  235. trackAdvancedAnalyticsEvent('dashboards_views.add_widget_modal.confirm', {
  236. organization,
  237. data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
  238. });
  239. }
  240. if (source === DashboardWidgetSource.DASHBOARDS) {
  241. closeModal();
  242. }
  243. } catch (err) {
  244. errors = mapErrors(err?.responseJSON ?? {}, {});
  245. this.setState({errors});
  246. } finally {
  247. this.setState({loading: false});
  248. if (this.omitDashboardProp) {
  249. this.handleSubmitFromSelectedDashboard(errors, widgetData);
  250. }
  251. if (this.fromLibrary) {
  252. this.handleSubmitFromLibrary(errors, widgetData);
  253. }
  254. }
  255. };
  256. handleSubmitFromSelectedDashboard = async (
  257. errors: FlatValidationError,
  258. widgetData: Widget
  259. ) => {
  260. const {closeModal, organization, selection} = this.props;
  261. const {selectedDashboard, dashboards} = this.state;
  262. // Validate that a dashboard was selected since api call to /dashboards/widgets/ does not check for dashboard
  263. if (
  264. !selectedDashboard ||
  265. !(
  266. dashboards.find(({title, id}) => {
  267. return title === selectedDashboard?.label && id === selectedDashboard?.value;
  268. }) || selectedDashboard.value === 'new'
  269. )
  270. ) {
  271. errors.dashboard = t('This field may not be blank');
  272. this.setState({errors});
  273. }
  274. if (!Object.keys(errors).length && selectedDashboard) {
  275. closeModal();
  276. const queryData: {
  277. queryConditions: string[];
  278. queryFields: string[];
  279. queryNames: string[];
  280. queryOrderby: string;
  281. } = {
  282. queryNames: [],
  283. queryConditions: [],
  284. queryFields: widgetData.queries[0].fields,
  285. queryOrderby: widgetData.queries[0].orderby,
  286. };
  287. widgetData.queries.forEach(query => {
  288. queryData.queryNames.push(query.name);
  289. queryData.queryConditions.push(query.conditions);
  290. });
  291. const pathQuery = {
  292. displayType: widgetData.displayType,
  293. interval: widgetData.interval,
  294. title: widgetData.title,
  295. ...queryData,
  296. // Propagate page filters
  297. ...selection.datetime,
  298. project: selection.projects,
  299. environment: selection.environments,
  300. };
  301. trackAdvancedAnalyticsEvent('discover_views.add_to_dashboard.confirm', {
  302. organization,
  303. });
  304. if (selectedDashboard.value === 'new') {
  305. browserHistory.push({
  306. pathname: `/organizations/${organization.slug}/dashboards/new/`,
  307. query: pathQuery,
  308. });
  309. } else {
  310. browserHistory.push({
  311. pathname: `/organizations/${organization.slug}/dashboard/${selectedDashboard.value}/`,
  312. query: pathQuery,
  313. });
  314. }
  315. }
  316. };
  317. handleSubmitFromLibrary = async (errors: FlatValidationError, widgetData: Widget) => {
  318. const {closeModal, dashboard, onAddLibraryWidget, organization} = this.props;
  319. if (!dashboard) {
  320. errors.dashboard = t('This field may not be blank');
  321. this.setState({errors});
  322. addErrorMessage(t('Widget may only be added to a Dashboard'));
  323. }
  324. if (!Object.keys(errors).length && dashboard && onAddLibraryWidget) {
  325. onAddLibraryWidget([...dashboard.widgets, widgetData]);
  326. closeModal();
  327. }
  328. trackAdvancedAnalyticsEvent('dashboards_views.add_widget_modal.save', {
  329. organization,
  330. data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
  331. });
  332. };
  333. handleDefaultFields = (newDisplayType: DisplayType) => {
  334. const {displayType, defaultWidgetQuery, defaultTableColumns, widget} = this.props;
  335. this.setState(prevState => {
  336. const newState = cloneDeep(prevState);
  337. const normalized = normalizeQueries(newDisplayType, prevState.queries);
  338. if (newDisplayType === DisplayType.TOP_N) {
  339. // TOP N display should only allow a single query
  340. normalized.splice(1);
  341. }
  342. if (
  343. newDisplayType === DisplayType.WORLD_MAP &&
  344. prevState.widgetType === WidgetType.METRICS
  345. ) {
  346. // World Map display type only supports Discover Dataset
  347. // so set state to default discover query.
  348. set(newState, 'queries', normalizeQueries(newDisplayType, [newDiscoverQuery]));
  349. set(newState, 'widgetType', WidgetType.DISCOVER);
  350. return {...newState, errors: undefined};
  351. }
  352. if (!prevState.userHasModified) {
  353. // If the Widget is an issue widget,
  354. if (
  355. newDisplayType === DisplayType.TABLE &&
  356. widget?.widgetType === WidgetType.ISSUE
  357. ) {
  358. set(newState, 'queries', widget.queries);
  359. set(newState, 'widgetType', WidgetType.ISSUE);
  360. return {...newState, errors: undefined};
  361. }
  362. // Default widget provided by Add to Dashboard from Discover
  363. if (defaultWidgetQuery && defaultTableColumns) {
  364. // If switching to Table visualization, use saved query fields for Y-Axis if user has not made query changes
  365. // This is so the widget can reflect the same columns as the table in Discover without requiring additional user input
  366. if (newDisplayType === DisplayType.TABLE) {
  367. normalized.forEach(query => {
  368. query.fields = [...defaultTableColumns];
  369. });
  370. } else if (newDisplayType === displayType) {
  371. // When switching back to original display type, default fields back to the fields provided from the discover query
  372. normalized.forEach(query => {
  373. query.fields = [...defaultWidgetQuery.fields];
  374. query.orderby = defaultWidgetQuery.orderby;
  375. });
  376. }
  377. }
  378. }
  379. if (prevState.widgetType === WidgetType.ISSUE) {
  380. set(newState, 'widgetType', WidgetType.DISCOVER);
  381. }
  382. set(newState, 'queries', normalized);
  383. return {...newState, errors: undefined};
  384. });
  385. };
  386. handleFieldChange = (field: string) => (value: string) => {
  387. const {organization, source} = this.props;
  388. const {displayType} = this.state;
  389. this.setState(prevState => {
  390. const newState = cloneDeep(prevState);
  391. set(newState, field, value);
  392. trackAdvancedAnalyticsEvent('dashboards_views.add_widget_modal.change', {
  393. from: source,
  394. field,
  395. value,
  396. widget_type: prevState.widgetType,
  397. organization,
  398. });
  399. return {...newState, errors: undefined};
  400. });
  401. if (field === 'displayType' && value !== displayType) {
  402. this.handleDefaultFields(value as DisplayType);
  403. }
  404. };
  405. handleQueryChange = (widgetQuery: WidgetQuery, index: number) => {
  406. this.setState(prevState => {
  407. const newState = cloneDeep(prevState);
  408. set(newState, `queries.${index}`, widgetQuery);
  409. set(newState, 'userHasModified', true);
  410. return {...newState, errors: undefined};
  411. });
  412. };
  413. handleQueryRemove = (index: number) => {
  414. this.setState(prevState => {
  415. const newState = cloneDeep(prevState);
  416. newState.queries.splice(index, 1);
  417. return {...newState, errors: undefined};
  418. });
  419. };
  420. handleAddSearchConditions = () => {
  421. this.setState(prevState => {
  422. const newState = cloneDeep(prevState);
  423. const query = cloneDeep(newDiscoverQuery);
  424. query.fields = this.state.queries[0].fields;
  425. newState.queries.push(query);
  426. return newState;
  427. });
  428. };
  429. defaultQuery(widgetType: string): WidgetQuery {
  430. switch (widgetType) {
  431. case WidgetType.ISSUE:
  432. return newIssueQuery;
  433. case WidgetType.METRICS:
  434. return newMetricsQuery;
  435. case WidgetType.DISCOVER:
  436. default:
  437. return newDiscoverQuery;
  438. }
  439. }
  440. handleDatasetChange = (widgetType: string) => {
  441. const {widget} = this.props;
  442. this.setState(prevState => {
  443. const newState = cloneDeep(prevState);
  444. newState.queries.splice(0, newState.queries.length);
  445. set(newState, 'widgetType', widgetType);
  446. newState.queries.push(
  447. ...(widget?.widgetType === widgetType
  448. ? widget.queries
  449. : [this.defaultQuery(widgetType)])
  450. );
  451. set(newState, 'userHasModified', true);
  452. return {...newState, errors: undefined};
  453. });
  454. };
  455. canAddSearchConditions() {
  456. const rightDisplayType = ['line', 'area', 'stacked_area', 'bar'].includes(
  457. this.state.displayType
  458. );
  459. const underQueryLimit = this.state.queries.length < 3;
  460. return rightDisplayType && underQueryLimit;
  461. }
  462. async fetchDashboards() {
  463. const {api, organization} = this.props;
  464. const promise: Promise<DashboardListItem[]> = api.requestPromise(
  465. `/organizations/${organization.slug}/dashboards/`,
  466. {
  467. method: 'GET',
  468. query: {sort: 'myDashboardsAndRecentlyViewed'},
  469. }
  470. );
  471. try {
  472. const dashboards = await promise;
  473. this.setState({
  474. dashboards,
  475. });
  476. } catch (error) {
  477. const errorResponse = error?.responseJSON ?? null;
  478. if (errorResponse) {
  479. addErrorMessage(errorResponse);
  480. } else {
  481. addErrorMessage(t('Unable to fetch dashboards'));
  482. }
  483. }
  484. this.setState({loading: false});
  485. }
  486. async fetchMetricsTags() {
  487. const {api, organization, selection} = this.props;
  488. const projects = !selection.projects.length ? undefined : selection.projects;
  489. const metricTags = await fetchMetricsTags(api, organization.slug, projects);
  490. this.setState({
  491. metricTags,
  492. });
  493. }
  494. async fetchMetricsFields() {
  495. const {api, organization, selection} = this.props;
  496. const projects = !selection.projects.length ? undefined : selection.projects;
  497. const metricFields = await fetchMetricsFields(api, organization.slug, projects);
  498. const filteredFields = metricFields.filter(field =>
  499. METRICS_FIELDS_ALLOW_LIST.includes(field.name)
  500. );
  501. this.setState({
  502. metricFields: filteredFields,
  503. });
  504. }
  505. handleDashboardChange(option: SelectValue<string>) {
  506. this.setState({selectedDashboard: option});
  507. }
  508. renderDashboardSelector() {
  509. const {errors, loading, dashboards} = this.state;
  510. const dashboardOptions = dashboards.map(d => {
  511. return {
  512. label: d.title,
  513. value: d.id,
  514. isDisabled: d.widgetDisplay.length >= MAX_WIDGETS,
  515. };
  516. });
  517. return (
  518. <React.Fragment>
  519. <p>
  520. {t(
  521. `Choose which dashboard you'd like to add this query to. It will appear as a widget.`
  522. )}
  523. </p>
  524. <Field
  525. label={t('Custom Dashboard')}
  526. inline={false}
  527. flexibleControlStateSize
  528. stacked
  529. error={errors?.dashboard}
  530. style={{marginBottom: space(1), position: 'relative'}}
  531. required
  532. >
  533. <SelectControl
  534. name="dashboard"
  535. options={[
  536. {label: t('+ Create New Dashboard'), value: 'new'},
  537. ...dashboardOptions,
  538. ]}
  539. onChange={(option: SelectValue<string>) => this.handleDashboardChange(option)}
  540. disabled={loading}
  541. components={{
  542. Option: ({label, data, ...optionProps}: OptionProps<any>) => (
  543. <Tooltip
  544. disabled={!!!data.isDisabled}
  545. title={tct('Max widgets ([maxWidgets]) per dashboard reached.', {
  546. maxWidgets: MAX_WIDGETS,
  547. })}
  548. containerDisplayMode="block"
  549. position="right"
  550. >
  551. <Option label={label} data={data} {...(optionProps as any)} />
  552. </Tooltip>
  553. ),
  554. }}
  555. />
  556. </Field>
  557. </React.Fragment>
  558. );
  559. }
  560. renderWidgetQueryForm() {
  561. const {organization, selection, tags, start, end, statsPeriod} = this.props;
  562. const state = this.state;
  563. const errors = state.errors;
  564. // Construct PageFilters object using statsPeriod/start/end props so we can
  565. // render widget graph using saved timeframe from Saved/Prebuilt Query
  566. const querySelection: PageFilters = statsPeriod
  567. ? {...selection, datetime: {start: null, end: null, period: statsPeriod, utc: null}}
  568. : start && end
  569. ? {...selection, datetime: {start, end, period: null, utc: null}}
  570. : selection;
  571. const issueWidgetFieldOptions = generateIssueWidgetFieldOptions();
  572. const metricsWidgetFieldOptions = generateMetricsWidgetFieldOptions(
  573. state.metricFields.length ? state.metricFields : DEFAULT_METRICS_FIELDS,
  574. Object.values(state.metricTags).map(({key}) => key)
  575. );
  576. const fieldOptions = (measurementKeys: string[]) =>
  577. generateFieldOptions({
  578. organization,
  579. tagKeys: Object.values(tags).map(({key}) => key),
  580. measurementKeys,
  581. spanOperationBreakdownKeys: SPAN_OP_BREAKDOWN_FIELDS,
  582. });
  583. switch (state.widgetType) {
  584. case WidgetType.ISSUE:
  585. return (
  586. <React.Fragment>
  587. <IssueWidgetQueriesForm
  588. organization={organization}
  589. selection={querySelection}
  590. fieldOptions={issueWidgetFieldOptions}
  591. query={state.queries[0]}
  592. error={errors?.queries?.[0]}
  593. onChange={widgetQuery => this.handleQueryChange(widgetQuery, 0)}
  594. />
  595. <WidgetCard
  596. organization={organization}
  597. selection={querySelection}
  598. widget={{...this.state, displayType: DisplayType.TABLE}}
  599. isEditing={false}
  600. onDelete={() => undefined}
  601. onEdit={() => undefined}
  602. onDuplicate={() => undefined}
  603. widgetLimitReached={false}
  604. renderErrorMessage={errorMessage =>
  605. typeof errorMessage === 'string' && (
  606. <PanelAlert type="error">{errorMessage}</PanelAlert>
  607. )
  608. }
  609. isSorting={false}
  610. currentWidgetDragging={false}
  611. noLazyLoad
  612. />
  613. </React.Fragment>
  614. );
  615. case WidgetType.METRICS:
  616. return (
  617. <React.Fragment>
  618. <WidgetQueriesForm
  619. organization={organization}
  620. selection={querySelection}
  621. displayType={state.displayType}
  622. widgetType={state.widgetType}
  623. queries={state.queries}
  624. errors={errors?.queries}
  625. fieldOptions={metricsWidgetFieldOptions}
  626. onChange={(queryIndex: number, widgetQuery: WidgetQuery) =>
  627. this.handleQueryChange(widgetQuery, queryIndex)
  628. }
  629. canAddSearchConditions={this.canAddSearchConditions()}
  630. handleAddSearchConditions={this.handleAddSearchConditions}
  631. handleDeleteQuery={this.handleQueryRemove}
  632. />
  633. <WidgetCard
  634. organization={organization}
  635. selection={querySelection}
  636. widget={this.state}
  637. isEditing={false}
  638. onDelete={() => undefined}
  639. onEdit={() => undefined}
  640. onDuplicate={() => undefined}
  641. widgetLimitReached={false}
  642. renderErrorMessage={errorMessage =>
  643. typeof errorMessage === 'string' && (
  644. <PanelAlert type="error">{errorMessage}</PanelAlert>
  645. )
  646. }
  647. isSorting={false}
  648. currentWidgetDragging={false}
  649. noLazyLoad
  650. />
  651. </React.Fragment>
  652. );
  653. case WidgetType.DISCOVER:
  654. default:
  655. return (
  656. <React.Fragment>
  657. <Measurements>
  658. {({measurements}) => {
  659. const measurementKeys = Object.values(measurements).map(({key}) => key);
  660. const amendedFieldOptions = fieldOptions(measurementKeys);
  661. return (
  662. <WidgetQueriesForm
  663. organization={organization}
  664. selection={querySelection}
  665. fieldOptions={amendedFieldOptions}
  666. displayType={state.displayType}
  667. widgetType={state.widgetType}
  668. queries={state.queries}
  669. errors={errors?.queries}
  670. onChange={(queryIndex: number, widgetQuery: WidgetQuery) =>
  671. this.handleQueryChange(widgetQuery, queryIndex)
  672. }
  673. canAddSearchConditions={this.canAddSearchConditions()}
  674. handleAddSearchConditions={this.handleAddSearchConditions}
  675. handleDeleteQuery={this.handleQueryRemove}
  676. />
  677. );
  678. }}
  679. </Measurements>
  680. <WidgetCard
  681. organization={organization}
  682. selection={querySelection}
  683. widget={this.state}
  684. isEditing={false}
  685. onDelete={() => undefined}
  686. onEdit={() => undefined}
  687. onDuplicate={() => undefined}
  688. widgetLimitReached={false}
  689. renderErrorMessage={errorMessage =>
  690. typeof errorMessage === 'string' && (
  691. <PanelAlert type="error">{errorMessage}</PanelAlert>
  692. )
  693. }
  694. isSorting={false}
  695. currentWidgetDragging={false}
  696. noLazyLoad
  697. />
  698. </React.Fragment>
  699. );
  700. }
  701. }
  702. render() {
  703. const {
  704. Footer,
  705. Body,
  706. Header,
  707. organization,
  708. widget: previousWidget,
  709. dashboard,
  710. selectedWidgets,
  711. onUpdateWidget,
  712. onAddLibraryWidget,
  713. source,
  714. } = this.props;
  715. const state = this.state;
  716. const errors = state.errors;
  717. const isUpdatingWidget = typeof onUpdateWidget === 'function' && !!previousWidget;
  718. const showDatasetSelector =
  719. [DashboardWidgetSource.DASHBOARDS, DashboardWidgetSource.LIBRARY].includes(
  720. source
  721. ) && state.displayType !== DisplayType.WORLD_MAP;
  722. const showIssueDatasetSelector =
  723. showDatasetSelector &&
  724. organization.features.includes('issues-in-dashboards') &&
  725. state.displayType === DisplayType.TABLE;
  726. const showMetricsDatasetSelector =
  727. showDatasetSelector && organization.features.includes('dashboards-metrics');
  728. const datasetChoices: [WidgetType, string][] = [DiscoverDataset];
  729. if (showIssueDatasetSelector) {
  730. datasetChoices.push(IssueDataset);
  731. }
  732. if (showMetricsDatasetSelector) {
  733. datasetChoices.push(MetricsDataset);
  734. }
  735. return (
  736. <React.Fragment>
  737. <Header closeButton>
  738. <h4>
  739. {this.omitDashboardProp
  740. ? t('Add Widget to Dashboard')
  741. : this.fromLibrary
  742. ? t('Add Widget(s)')
  743. : isUpdatingWidget
  744. ? t('Edit Widget')
  745. : t('Add Widget')}
  746. </h4>
  747. </Header>
  748. <Body>
  749. {this.omitDashboardProp && this.renderDashboardSelector()}
  750. {this.fromLibrary && dashboard && onAddLibraryWidget ? (
  751. <TabsButtonBar
  752. activeTab={TAB.Custom}
  753. organization={organization}
  754. dashboard={dashboard}
  755. selectedWidgets={selectedWidgets}
  756. customWidget={this.state}
  757. onAddWidget={onAddLibraryWidget}
  758. />
  759. ) : null}
  760. <DoubleFieldWrapper>
  761. <StyledField
  762. data-test-id="widget-name"
  763. label={t('Widget Name')}
  764. inline={false}
  765. flexibleControlStateSize
  766. stacked
  767. error={errors?.title}
  768. required
  769. >
  770. <Input
  771. data-test-id="widget-title-input"
  772. type="text"
  773. name="title"
  774. maxLength={255}
  775. required
  776. value={state.title}
  777. onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
  778. this.handleFieldChange('title')(event.target.value);
  779. }}
  780. disabled={state.loading}
  781. />
  782. </StyledField>
  783. <StyledField
  784. data-test-id="chart-type"
  785. label={t('Visualization Display')}
  786. inline={false}
  787. flexibleControlStateSize
  788. stacked
  789. error={errors?.displayType}
  790. required
  791. >
  792. <SelectControl
  793. options={DISPLAY_TYPE_CHOICES.slice()}
  794. name="displayType"
  795. value={state.displayType}
  796. onChange={option => this.handleFieldChange('displayType')(option.value)}
  797. disabled={state.loading}
  798. />
  799. </StyledField>
  800. </DoubleFieldWrapper>
  801. {(showIssueDatasetSelector || showMetricsDatasetSelector) && (
  802. <React.Fragment>
  803. <StyledFieldLabel>{t('Data Set')}</StyledFieldLabel>
  804. <StyledRadioGroup
  805. style={{flex: 1}}
  806. choices={datasetChoices}
  807. value={state.widgetType}
  808. label={t('Dataset')}
  809. onChange={this.handleDatasetChange}
  810. />
  811. </React.Fragment>
  812. )}
  813. {this.renderWidgetQueryForm()}
  814. </Body>
  815. <Footer>
  816. <ButtonBar gap={1}>
  817. <Button
  818. external
  819. href="https://docs.sentry.io/product/dashboards/custom-dashboards/#widget-builder"
  820. >
  821. {t('Read the docs')}
  822. </Button>
  823. <Button
  824. data-test-id="add-widget"
  825. priority="primary"
  826. type="button"
  827. onClick={this.handleSubmit}
  828. disabled={state.loading}
  829. busy={state.loading}
  830. >
  831. {this.fromLibrary
  832. ? t('Save')
  833. : isUpdatingWidget
  834. ? t('Update Widget')
  835. : t('Add Widget')}
  836. </Button>
  837. </ButtonBar>
  838. </Footer>
  839. </React.Fragment>
  840. );
  841. }
  842. }
  843. const DoubleFieldWrapper = styled('div')`
  844. display: inline-grid;
  845. grid-template-columns: repeat(2, 1fr);
  846. grid-column-gap: ${space(1)};
  847. width: 100%;
  848. `;
  849. export const modalCss = css`
  850. width: 100%;
  851. max-width: 700px;
  852. margin: 70px auto;
  853. `;
  854. const StyledField = styled(Field)`
  855. position: relative;
  856. `;
  857. const StyledRadioGroup = styled(RadioGroup)`
  858. padding-bottom: ${space(2)};
  859. `;
  860. const StyledFieldLabel = styled(FieldLabel)`
  861. padding-bottom: ${space(1)};
  862. display: inline-flex;
  863. `;
  864. export default withApi(withPageFilters(withTags(AddDashboardWidgetModal)));