addDashboardWidgetModal.tsx 29 KB

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