addDashboardWidgetModal.tsx 30 KB

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