addDashboardWidgetModal.tsx 29 KB

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