addDashboardWidgetModal.tsx 25 KB

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