widgetBuilder.tsx 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278
  1. import {useEffect, useMemo, useState} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import cloneDeep from 'lodash/cloneDeep';
  5. import isEmpty from 'lodash/isEmpty';
  6. import omit from 'lodash/omit';
  7. import set from 'lodash/set';
  8. import {validateWidget} from 'sentry/actionCreators/dashboards';
  9. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  10. import {fetchOrgMembers} from 'sentry/actionCreators/members';
  11. import {loadOrganizationTags} from 'sentry/actionCreators/tags';
  12. import {generateOrderOptions} from 'sentry/components/dashboards/widgetQueriesForm';
  13. import DatePageFilter from 'sentry/components/datePageFilter';
  14. import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
  15. import * as Layout from 'sentry/components/layouts/thirds';
  16. import List from 'sentry/components/list';
  17. import ListItem from 'sentry/components/list/listItem';
  18. import LoadingError from 'sentry/components/loadingError';
  19. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  20. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  21. import ProjectPageFilter from 'sentry/components/projectPageFilter';
  22. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  23. import {t} from 'sentry/locale';
  24. import {PageContent} from 'sentry/styles/organization';
  25. import space from 'sentry/styles/space';
  26. import {
  27. DateString,
  28. Organization,
  29. PageFilters,
  30. SelectValue,
  31. TagCollection,
  32. } from 'sentry/types';
  33. import {defined, objectIsEmpty} from 'sentry/utils';
  34. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  35. import {CustomMeasurementsProvider} from 'sentry/utils/customMeasurements/customMeasurementsProvider';
  36. import {
  37. explodeField,
  38. generateFieldAsString,
  39. getColumnsAndAggregates,
  40. getColumnsAndAggregatesAsStrings,
  41. QueryFieldValue,
  42. } from 'sentry/utils/discover/fields';
  43. import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
  44. import useApi from 'sentry/utils/useApi';
  45. import withPageFilters from 'sentry/utils/withPageFilters';
  46. import withTags from 'sentry/utils/withTags';
  47. import {
  48. assignTempId,
  49. enforceWidgetHeightValues,
  50. generateWidgetsAfterCompaction,
  51. getDefaultWidgetHeight,
  52. } from 'sentry/views/dashboardsV2/layoutUtils';
  53. import {
  54. DashboardDetails,
  55. DashboardListItem,
  56. DashboardWidgetSource,
  57. DisplayType,
  58. Widget,
  59. WidgetQuery,
  60. WidgetType,
  61. } from 'sentry/views/dashboardsV2/types';
  62. import {isCustomMeasurementWidget} from 'sentry/views/dashboardsV2/utils';
  63. import {DEFAULT_STATS_PERIOD} from '../data';
  64. import {getDatasetConfig} from '../datasetConfig/base';
  65. import {
  66. DashboardsMEPConsumer,
  67. DashboardsMEPProvider,
  68. } from '../widgetCard/dashboardsMEPContext';
  69. import {ColumnsStep} from './buildSteps/columnsStep';
  70. import {DataSetStep} from './buildSteps/dataSetStep';
  71. import {FilterResultsStep} from './buildSteps/filterResultsStep';
  72. import {GroupByStep} from './buildSteps/groupByStep';
  73. import {SortByStep} from './buildSteps/sortByStep';
  74. import {VisualizationStep} from './buildSteps/visualizationStep';
  75. import {YAxisStep} from './buildSteps/yAxisStep';
  76. import {Footer} from './footer';
  77. import {Header} from './header';
  78. import {
  79. DataSet,
  80. DEFAULT_RESULTS_LIMIT,
  81. getIsTimeseriesChart,
  82. getParsedDefaultWidgetQuery,
  83. getResultsLimit,
  84. mapErrors,
  85. NEW_DASHBOARD_ID,
  86. normalizeQueries,
  87. } from './utils';
  88. import {WidgetLibrary} from './widgetLibrary';
  89. const WIDGET_TYPE_TO_DATA_SET = {
  90. [WidgetType.DISCOVER]: DataSet.EVENTS,
  91. [WidgetType.ISSUE]: DataSet.ISSUES,
  92. [WidgetType.RELEASE]: DataSet.RELEASES,
  93. };
  94. export const DATA_SET_TO_WIDGET_TYPE = {
  95. [DataSet.EVENTS]: WidgetType.DISCOVER,
  96. [DataSet.ISSUES]: WidgetType.ISSUE,
  97. [DataSet.RELEASES]: WidgetType.RELEASE,
  98. };
  99. interface RouteParams {
  100. dashboardId: string;
  101. orgId: string;
  102. widgetIndex?: string;
  103. }
  104. interface QueryData {
  105. queryConditions: string[];
  106. queryFields: string[];
  107. queryNames: string[];
  108. queryOrderby: string;
  109. }
  110. interface Props extends RouteComponentProps<RouteParams, {}> {
  111. dashboard: DashboardDetails;
  112. onSave: (widgets: Widget[]) => void;
  113. organization: Organization;
  114. selection: PageFilters;
  115. tags: TagCollection;
  116. displayType?: DisplayType;
  117. end?: DateString;
  118. start?: DateString;
  119. statsPeriod?: string | null;
  120. }
  121. interface State {
  122. dashboards: DashboardListItem[];
  123. dataSet: DataSet;
  124. displayType: Widget['displayType'];
  125. interval: Widget['interval'];
  126. limit: Widget['limit'];
  127. loading: boolean;
  128. prebuiltWidgetId: null | string;
  129. queries: Widget['queries'];
  130. title: string;
  131. userHasModified: boolean;
  132. errors?: Record<string, any>;
  133. selectedDashboard?: SelectValue<string>;
  134. widgetToBeUpdated?: Widget;
  135. }
  136. function WidgetBuilder({
  137. dashboard,
  138. params,
  139. location,
  140. organization,
  141. selection,
  142. start,
  143. end,
  144. statsPeriod,
  145. onSave,
  146. route,
  147. router,
  148. tags,
  149. }: Props) {
  150. const {widgetIndex, orgId, dashboardId} = params;
  151. const {source, displayType, defaultTitle, limit} = location.query;
  152. const defaultWidgetQuery = getParsedDefaultWidgetQuery(
  153. location.query.defaultWidgetQuery
  154. );
  155. // defaultTableColumns can be a single string if location.query only contains
  156. // 1 value for this key. Ensure it is a string[]
  157. let {defaultTableColumns}: {defaultTableColumns: string[]} = location.query;
  158. if (typeof defaultTableColumns === 'string') {
  159. defaultTableColumns = [defaultTableColumns];
  160. }
  161. // Feature flag for new widget builder design. This feature is still a work in progress and not yet available internally.
  162. const widgetBuilderNewDesign = organization.features.includes(
  163. 'new-widget-builder-experience-design'
  164. );
  165. const hasReleaseHealthFeature = organization.features.includes('dashboards-releases');
  166. const filteredDashboardWidgets = dashboard.widgets.filter(({widgetType}) => {
  167. if (widgetType === WidgetType.RELEASE) {
  168. return hasReleaseHealthFeature;
  169. }
  170. return true;
  171. });
  172. const isEditing = defined(widgetIndex);
  173. const widgetIndexNum = Number(widgetIndex);
  174. const isValidWidgetIndex =
  175. widgetIndexNum >= 0 &&
  176. widgetIndexNum < filteredDashboardWidgets.length &&
  177. Number.isInteger(widgetIndexNum);
  178. const orgSlug = organization.slug;
  179. // Construct PageFilters object using statsPeriod/start/end props so we can
  180. // render widget graph using saved timeframe from Saved/Prebuilt Query
  181. const pageFilters: PageFilters = statsPeriod
  182. ? {...selection, datetime: {start: null, end: null, period: statsPeriod, utc: null}}
  183. : start && end
  184. ? {...selection, datetime: {start, end, period: null, utc: null}}
  185. : selection;
  186. // when opening from discover or issues page, the user selects the dashboard in the widget UI
  187. const notDashboardsOrigin = [
  188. DashboardWidgetSource.DISCOVERV2,
  189. DashboardWidgetSource.ISSUE_DETAILS,
  190. ].includes(source);
  191. const api = useApi();
  192. const [isSubmitting, setIsSubmitting] = useState(false);
  193. const [datasetConfig, setDataSetConfig] = useState<ReturnType<typeof getDatasetConfig>>(
  194. getDatasetConfig(WidgetType.DISCOVER)
  195. );
  196. const [state, setState] = useState<State>(() => {
  197. const defaultState: State = {
  198. title: defaultTitle ?? t('Custom Widget'),
  199. displayType:
  200. (widgetBuilderNewDesign && displayType === DisplayType.TOP_N
  201. ? DisplayType.AREA
  202. : displayType) ?? DisplayType.TABLE,
  203. interval: '5m',
  204. queries: [],
  205. limit: limit ? Number(limit) : undefined,
  206. errors: undefined,
  207. loading: !!notDashboardsOrigin,
  208. dashboards: [],
  209. userHasModified: false,
  210. prebuiltWidgetId: null,
  211. dataSet: DataSet.EVENTS,
  212. };
  213. if (defaultWidgetQuery) {
  214. if (widgetBuilderNewDesign) {
  215. defaultState.queries = [
  216. {
  217. ...defaultWidgetQuery,
  218. orderby:
  219. defaultWidgetQuery.orderby ||
  220. (datasetConfig.getTableSortOptions
  221. ? datasetConfig.getTableSortOptions(organization, defaultWidgetQuery)[0]
  222. .value
  223. : ''),
  224. },
  225. ];
  226. } else {
  227. defaultState.queries = [{...defaultWidgetQuery}];
  228. }
  229. if (
  230. ![DisplayType.TABLE, DisplayType.TOP_N].includes(defaultState.displayType) &&
  231. !(
  232. getIsTimeseriesChart(defaultState.displayType) &&
  233. defaultState.queries[0].columns.length
  234. )
  235. ) {
  236. defaultState.queries[0].orderby = '';
  237. }
  238. } else {
  239. defaultState.queries = [{...datasetConfig.defaultWidgetQuery}];
  240. }
  241. return defaultState;
  242. });
  243. const [widgetToBeUpdated, setWidgetToBeUpdated] = useState<Widget | null>(null);
  244. // For analytics around widget library selection
  245. const [latestLibrarySelectionTitle, setLatestLibrarySelectionTitle] = useState<
  246. string | null
  247. >(null);
  248. useEffect(() => {
  249. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.opened', {
  250. organization,
  251. new_widget: !isEditing,
  252. });
  253. if (objectIsEmpty(tags)) {
  254. loadOrganizationTags(api, organization.slug, {
  255. ...selection,
  256. // Pin the request to 14d to avoid timeouts, see DD-967 for
  257. // more information
  258. datetime: {period: '14d', start: null, end: null, utc: null},
  259. });
  260. }
  261. if (isEditing && isValidWidgetIndex) {
  262. const widgetFromDashboard = filteredDashboardWidgets[widgetIndexNum];
  263. let queries;
  264. let newDisplayType = widgetFromDashboard.displayType;
  265. let newLimit = widgetFromDashboard.limit;
  266. if (widgetFromDashboard.displayType === DisplayType.TOP_N) {
  267. newLimit = DEFAULT_RESULTS_LIMIT;
  268. newDisplayType = DisplayType.AREA;
  269. queries = normalizeQueries({
  270. displayType: newDisplayType,
  271. queries: widgetFromDashboard.queries,
  272. widgetType: widgetFromDashboard.widgetType ?? WidgetType.DISCOVER,
  273. widgetBuilderNewDesign,
  274. }).map(query => ({
  275. ...query,
  276. // Use the last aggregate because that's where the y-axis is stored
  277. aggregates: query.aggregates.length
  278. ? [query.aggregates[query.aggregates.length - 1]]
  279. : [],
  280. }));
  281. } else {
  282. queries = normalizeQueries({
  283. displayType: newDisplayType,
  284. queries: widgetFromDashboard.queries,
  285. widgetType: widgetFromDashboard.widgetType ?? WidgetType.DISCOVER,
  286. widgetBuilderNewDesign,
  287. });
  288. }
  289. setState({
  290. title: widgetFromDashboard.title,
  291. displayType: newDisplayType,
  292. interval: widgetFromDashboard.interval,
  293. queries,
  294. errors: undefined,
  295. loading: false,
  296. dashboards: [],
  297. userHasModified: false,
  298. dataSet: widgetFromDashboard.widgetType
  299. ? WIDGET_TYPE_TO_DATA_SET[widgetFromDashboard.widgetType]
  300. : DataSet.EVENTS,
  301. limit: newLimit,
  302. prebuiltWidgetId: null,
  303. });
  304. setDataSetConfig(getDatasetConfig(widgetFromDashboard.widgetType));
  305. setWidgetToBeUpdated(widgetFromDashboard);
  306. }
  307. // This should only run once on mount
  308. // eslint-disable-next-line react-hooks/exhaustive-deps
  309. }, []);
  310. useEffect(() => {
  311. async function fetchDashboards() {
  312. const promise: Promise<DashboardListItem[]> = api.requestPromise(
  313. `/organizations/${organization.slug}/dashboards/`,
  314. {
  315. method: 'GET',
  316. query: {sort: 'myDashboardsAndRecentlyViewed'},
  317. }
  318. );
  319. try {
  320. const dashboards = await promise;
  321. setState(prevState => ({...prevState, dashboards, loading: false}));
  322. } catch (error) {
  323. const errorMessage = t('Unable to fetch dashboards');
  324. addErrorMessage(errorMessage);
  325. handleXhrErrorResponse(errorMessage)(error);
  326. setState(prevState => ({...prevState, loading: false}));
  327. }
  328. }
  329. if (notDashboardsOrigin) {
  330. fetchDashboards();
  331. }
  332. if (widgetBuilderNewDesign) {
  333. setState(prevState => ({
  334. ...prevState,
  335. selectedDashboard: {
  336. label: dashboard.title,
  337. value: dashboard.id || NEW_DASHBOARD_ID,
  338. },
  339. }));
  340. }
  341. }, [
  342. api,
  343. dashboard.id,
  344. dashboard.title,
  345. notDashboardsOrigin,
  346. organization.slug,
  347. source,
  348. widgetBuilderNewDesign,
  349. ]);
  350. useEffect(() => {
  351. fetchOrgMembers(api, organization.slug, selection.projects?.map(String));
  352. }, [selection.projects, api, organization.slug]);
  353. useEffect(() => {
  354. const onUnload = () => {
  355. if (!isSubmitting && state.userHasModified) {
  356. return t('You have unsaved changes, are you sure you want to leave?');
  357. }
  358. return undefined;
  359. };
  360. router.setRouteLeaveHook(route, onUnload);
  361. }, [isSubmitting, state.userHasModified, route, router]);
  362. const widgetType =
  363. state.dataSet === DataSet.EVENTS
  364. ? WidgetType.DISCOVER
  365. : state.dataSet === DataSet.ISSUES
  366. ? WidgetType.ISSUE
  367. : WidgetType.RELEASE;
  368. const currentWidget = {
  369. title: state.title,
  370. displayType: state.displayType,
  371. interval: state.interval,
  372. queries: state.queries,
  373. limit: state.limit,
  374. widgetType,
  375. };
  376. const currentDashboardId = state.selectedDashboard?.value ?? dashboardId;
  377. const queryParamsWithoutSource = omit(location.query, 'source');
  378. const previousLocation = {
  379. pathname:
  380. defined(currentDashboardId) && currentDashboardId !== NEW_DASHBOARD_ID
  381. ? `/organizations/${orgId}/dashboard/${currentDashboardId}/`
  382. : `/organizations/${orgId}/dashboards/${NEW_DASHBOARD_ID}/`,
  383. query: isEmpty(queryParamsWithoutSource) ? undefined : queryParamsWithoutSource,
  384. };
  385. const isTimeseriesChart = getIsTimeseriesChart(state.displayType);
  386. const isTabularChart = [DisplayType.TABLE, DisplayType.TOP_N].includes(
  387. state.displayType
  388. );
  389. function updateFieldsAccordingToDisplayType(newDisplayType: DisplayType) {
  390. setState(prevState => {
  391. const newState = cloneDeep(prevState);
  392. if (!!!datasetConfig.supportedDisplayTypes.includes(newDisplayType)) {
  393. // Set to Events dataset if Display Type is not supported by
  394. // current dataset
  395. set(
  396. newState,
  397. 'queries',
  398. normalizeQueries({
  399. displayType: newDisplayType,
  400. queries: [{...getDatasetConfig(WidgetType.DISCOVER).defaultWidgetQuery}],
  401. widgetType: WidgetType.DISCOVER,
  402. widgetBuilderNewDesign,
  403. })
  404. );
  405. set(newState, 'dataSet', DataSet.EVENTS);
  406. setDataSetConfig(getDatasetConfig(WidgetType.DISCOVER));
  407. return {...newState, errors: undefined};
  408. }
  409. const normalized = normalizeQueries({
  410. displayType: newDisplayType,
  411. queries: prevState.queries,
  412. widgetType: DATA_SET_TO_WIDGET_TYPE[prevState.dataSet],
  413. widgetBuilderNewDesign,
  414. });
  415. if (newDisplayType === DisplayType.TOP_N) {
  416. // TOP N display should only allow a single query
  417. normalized.splice(1);
  418. }
  419. if (!prevState.userHasModified) {
  420. // Default widget provided by Add to Dashboard from Discover
  421. if (defaultWidgetQuery && defaultTableColumns) {
  422. // If switching to Table visualization, use saved query fields for Y-Axis if user has not made query changes
  423. // This is so the widget can reflect the same columns as the table in Discover without requiring additional user input
  424. if (newDisplayType === DisplayType.TABLE) {
  425. normalized.forEach(query => {
  426. const tableQuery = getColumnsAndAggregates(defaultTableColumns);
  427. query.columns = [...tableQuery.columns];
  428. query.aggregates = [...tableQuery.aggregates];
  429. query.fields = [...defaultTableColumns];
  430. query.orderby =
  431. defaultWidgetQuery.orderby ??
  432. (query.fields.length ? `${query.fields[0]}` : '-');
  433. });
  434. } else if (newDisplayType === displayType) {
  435. // When switching back to original display type, default fields back to the fields provided from the discover query
  436. normalized.forEach(query => {
  437. query.fields = [
  438. ...defaultWidgetQuery.columns,
  439. ...defaultWidgetQuery.aggregates,
  440. ];
  441. query.aggregates = [...defaultWidgetQuery.aggregates];
  442. query.columns = [...defaultWidgetQuery.columns];
  443. if (
  444. !!defaultWidgetQuery.orderby &&
  445. (displayType === DisplayType.TOP_N || defaultWidgetQuery.columns.length)
  446. ) {
  447. query.orderby = defaultWidgetQuery.orderby;
  448. }
  449. });
  450. }
  451. }
  452. }
  453. set(newState, 'queries', normalized);
  454. if (widgetBuilderNewDesign) {
  455. if (
  456. getIsTimeseriesChart(newDisplayType) &&
  457. normalized[0].columns.filter(column => !!column).length
  458. ) {
  459. // If a limit already exists (i.e. going between timeseries) then keep it,
  460. // otherwise calculate a limit
  461. newState.limit =
  462. prevState.limit ??
  463. Math.min(
  464. getResultsLimit(normalized.length, normalized[0].columns.length),
  465. DEFAULT_RESULTS_LIMIT
  466. );
  467. } else {
  468. newState.limit = undefined;
  469. }
  470. }
  471. set(newState, 'userHasModified', true);
  472. return {...newState, errors: undefined};
  473. });
  474. }
  475. function getUpdateWidgetIndex() {
  476. if (!widgetToBeUpdated) {
  477. return -1;
  478. }
  479. return dashboard.widgets.findIndex(widget => {
  480. if (defined(widget.id)) {
  481. return widget.id === widgetToBeUpdated.id;
  482. }
  483. if (defined(widget.tempId)) {
  484. return widget.tempId === widgetToBeUpdated.tempId;
  485. }
  486. return false;
  487. });
  488. }
  489. function handleDisplayTypeOrTitleChange<
  490. F extends keyof Pick<State, 'displayType' | 'title'>
  491. >(field: F, value: State[F]) {
  492. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.change', {
  493. from: source,
  494. field,
  495. value,
  496. widget_type: widgetType,
  497. organization,
  498. new_widget: !isEditing,
  499. });
  500. setState(prevState => {
  501. const newState = cloneDeep(prevState);
  502. set(newState, field, value);
  503. if (field === 'title') {
  504. set(newState, 'userHasModified', true);
  505. }
  506. return {...newState, errors: undefined};
  507. });
  508. if (field === 'displayType' && value !== state.displayType) {
  509. updateFieldsAccordingToDisplayType(value as DisplayType);
  510. }
  511. }
  512. function handleDataSetChange(newDataSet: string) {
  513. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.change', {
  514. from: source,
  515. field: 'dataSet',
  516. value: newDataSet,
  517. widget_type: widgetType,
  518. organization,
  519. new_widget: !isEditing,
  520. });
  521. setState(prevState => {
  522. const newState = cloneDeep(prevState);
  523. newState.queries.splice(0, newState.queries.length);
  524. set(newState, 'dataSet', newDataSet);
  525. if (newDataSet === DataSet.ISSUES) {
  526. set(newState, 'displayType', DisplayType.TABLE);
  527. }
  528. const config = getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[newDataSet]);
  529. setDataSetConfig(config);
  530. newState.queries.push(
  531. ...(widgetToBeUpdated?.widgetType &&
  532. WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === newDataSet
  533. ? widgetToBeUpdated.queries
  534. : [{...config.defaultWidgetQuery}])
  535. );
  536. set(newState, 'userHasModified', true);
  537. return {...newState, errors: undefined};
  538. });
  539. }
  540. function handleAddSearchConditions() {
  541. setState(prevState => {
  542. const newState = cloneDeep(prevState);
  543. const config = getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[prevState.dataSet]);
  544. const query = cloneDeep(config.defaultWidgetQuery);
  545. query.fields = prevState.queries[0].fields;
  546. query.aggregates = prevState.queries[0].aggregates;
  547. query.columns = prevState.queries[0].columns;
  548. query.orderby = prevState.queries[0].orderby;
  549. newState.queries.push(query);
  550. return newState;
  551. });
  552. }
  553. function handleQueryRemove(index: number) {
  554. setState(prevState => {
  555. const newState = cloneDeep(prevState);
  556. newState.queries.splice(index, 1);
  557. return {...newState, errors: undefined};
  558. });
  559. }
  560. function handleQueryChange(queryIndex: number, newQuery: WidgetQuery) {
  561. setState(prevState => {
  562. const newState = cloneDeep(prevState);
  563. set(newState, `queries.${queryIndex}`, newQuery);
  564. set(newState, 'userHasModified', true);
  565. return {...newState, errors: undefined};
  566. });
  567. }
  568. function handleColumnFieldChange(newFields: QueryFieldValue[]) {
  569. const fieldStrings = newFields.map(generateFieldAsString);
  570. const splitFields = getColumnsAndAggregatesAsStrings(newFields);
  571. const newState = cloneDeep(state);
  572. let newQuery = cloneDeep(newState.queries[0]);
  573. newQuery.fields = fieldStrings;
  574. newQuery.aggregates = splitFields.aggregates;
  575. newQuery.columns = splitFields.columns;
  576. newQuery.fieldAliases = splitFields.fieldAliases;
  577. if (datasetConfig.handleColumnFieldChangeOverride) {
  578. newQuery = datasetConfig.handleColumnFieldChangeOverride(newQuery);
  579. }
  580. if (datasetConfig.handleOrderByReset) {
  581. newQuery = datasetConfig.handleOrderByReset(newQuery, fieldStrings);
  582. }
  583. set(newState, 'queries', [newQuery]);
  584. set(newState, 'userHasModified', true);
  585. setState(newState);
  586. }
  587. function handleYAxisChange(newFields: QueryFieldValue[]) {
  588. const fieldStrings = newFields.map(generateFieldAsString);
  589. const newState = cloneDeep(state);
  590. const newQueries = state.queries.map(query => {
  591. let newQuery = cloneDeep(query);
  592. if (state.displayType === DisplayType.TOP_N) {
  593. // Top N queries use n-1 fields for columns and the nth field for y-axis
  594. newQuery.fields = [
  595. ...(newQuery.fields?.slice(0, newQuery.fields.length - 1) ?? []),
  596. ...fieldStrings,
  597. ];
  598. newQuery.aggregates = [
  599. ...newQuery.aggregates.slice(0, newQuery.aggregates.length - 1),
  600. ...fieldStrings,
  601. ];
  602. } else {
  603. newQuery.fields = [...newQuery.columns, ...fieldStrings];
  604. newQuery.aggregates = fieldStrings;
  605. }
  606. if (datasetConfig.handleOrderByReset) {
  607. newQuery = datasetConfig.handleOrderByReset(newQuery, fieldStrings);
  608. }
  609. return newQuery;
  610. });
  611. set(newState, 'queries', newQueries);
  612. set(newState, 'userHasModified', true);
  613. const groupByFields = newState.queries[0].columns.filter(
  614. field => !(field === 'equation|')
  615. );
  616. if (groupByFields.length === 0) {
  617. set(newState, 'limit', undefined);
  618. } else {
  619. set(
  620. newState,
  621. 'limit',
  622. Math.min(
  623. newState.limit ?? DEFAULT_RESULTS_LIMIT,
  624. getResultsLimit(newQueries.length, newQueries[0].aggregates.length)
  625. )
  626. );
  627. }
  628. setState(newState);
  629. }
  630. function handleGroupByChange(newFields: QueryFieldValue[]) {
  631. const fieldStrings = newFields.map(generateFieldAsString);
  632. const newState = cloneDeep(state);
  633. const newQueries = state.queries.map(query => {
  634. const newQuery = cloneDeep(query);
  635. newQuery.columns = fieldStrings;
  636. if (!fieldStrings.length) {
  637. // The grouping was cleared, so clear the orderby
  638. newQuery.orderby = '';
  639. } else if (!newQuery.orderby) {
  640. const orderOptions = generateOrderOptions({
  641. widgetType: widgetType ?? WidgetType.DISCOVER,
  642. widgetBuilderNewDesign,
  643. columns: query.columns,
  644. aggregates: query.aggregates,
  645. });
  646. let orderOption: string;
  647. // If no orderby options are available because of DISABLED_SORTS
  648. if (!!!orderOptions.length) {
  649. newQuery.orderby = '';
  650. } else {
  651. orderOption = orderOptions[0].value;
  652. newQuery.orderby = `-${orderOption}`;
  653. }
  654. }
  655. return newQuery;
  656. });
  657. set(newState, 'userHasModified', true);
  658. set(newState, 'queries', newQueries);
  659. const groupByFields = newState.queries[0].columns.filter(
  660. field => !(field === 'equation|')
  661. );
  662. if (groupByFields.length === 0) {
  663. set(newState, 'limit', undefined);
  664. } else {
  665. set(
  666. newState,
  667. 'limit',
  668. Math.min(
  669. newState.limit ?? DEFAULT_RESULTS_LIMIT,
  670. getResultsLimit(newQueries.length, newQueries[0].aggregates.length)
  671. )
  672. );
  673. }
  674. setState(newState);
  675. }
  676. function handleLimitChange(newLimit: number) {
  677. setState(prevState => ({...prevState, limit: newLimit}));
  678. }
  679. function handleSortByChange(newSortBy: string) {
  680. const newState = cloneDeep(state);
  681. state.queries.forEach((query, index) => {
  682. const newQuery = cloneDeep(query);
  683. newQuery.orderby = newSortBy;
  684. set(newState, `queries.${index}`, newQuery);
  685. });
  686. set(newState, 'userHasModified', true);
  687. setState(newState);
  688. }
  689. function handleDelete() {
  690. if (!isEditing) {
  691. return;
  692. }
  693. setIsSubmitting(true);
  694. let nextWidgetList = [...dashboard.widgets];
  695. const updateWidgetIndex = getUpdateWidgetIndex();
  696. nextWidgetList.splice(updateWidgetIndex, 1);
  697. nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
  698. onSave(nextWidgetList);
  699. router.push(previousLocation);
  700. }
  701. async function handleSave() {
  702. const widgetData: Widget = assignTempId(currentWidget);
  703. if (widgetToBeUpdated) {
  704. widgetData.layout = widgetToBeUpdated?.layout;
  705. }
  706. // Only Table and Top N views need orderby
  707. if (!widgetBuilderNewDesign && !isTabularChart) {
  708. widgetData.queries.forEach(query => {
  709. query.orderby = '';
  710. });
  711. }
  712. if (!widgetBuilderNewDesign) {
  713. widgetData.queries.forEach(query => omit(query, 'fieldAliases'));
  714. }
  715. // Only Time Series charts shall have a limit
  716. if (widgetBuilderNewDesign && !isTimeseriesChart) {
  717. widgetData.limit = undefined;
  718. }
  719. if (!(await dataIsValid(widgetData))) {
  720. return;
  721. }
  722. if (latestLibrarySelectionTitle) {
  723. // User has selected a widget library in this session
  724. trackAdvancedAnalyticsEvent('dashboards_views.widget_library.add_widget', {
  725. organization,
  726. title: latestLibrarySelectionTitle,
  727. });
  728. }
  729. setIsSubmitting(true);
  730. if (notDashboardsOrigin) {
  731. submitFromSelectedDashboard(widgetData);
  732. return;
  733. }
  734. if (!!widgetToBeUpdated) {
  735. let nextWidgetList = [...dashboard.widgets];
  736. const updateWidgetIndex = getUpdateWidgetIndex();
  737. const nextWidgetData = {...widgetData, id: widgetToBeUpdated.id};
  738. // Only modify and re-compact if the default height has changed
  739. if (
  740. getDefaultWidgetHeight(widgetToBeUpdated.displayType) !==
  741. getDefaultWidgetHeight(widgetData.displayType)
  742. ) {
  743. nextWidgetList[updateWidgetIndex] = enforceWidgetHeightValues(nextWidgetData);
  744. nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
  745. } else {
  746. nextWidgetList[updateWidgetIndex] = nextWidgetData;
  747. }
  748. onSave(nextWidgetList);
  749. addSuccessMessage(t('Updated widget.'));
  750. goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
  751. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.save', {
  752. organization,
  753. data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
  754. new_widget: false,
  755. });
  756. return;
  757. }
  758. onSave([...dashboard.widgets, widgetData]);
  759. addSuccessMessage(t('Added widget.'));
  760. goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
  761. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.save', {
  762. organization,
  763. data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
  764. new_widget: true,
  765. });
  766. }
  767. async function dataIsValid(widgetData: Widget): Promise<boolean> {
  768. if (notDashboardsOrigin) {
  769. // Validate that a dashboard was selected since api call to /dashboards/widgets/ does not check for dashboard
  770. if (
  771. !state.selectedDashboard ||
  772. !(
  773. state.dashboards.find(
  774. ({title, id}) =>
  775. title === state.selectedDashboard?.label &&
  776. id === state.selectedDashboard?.value
  777. ) || state.selectedDashboard.value === NEW_DASHBOARD_ID
  778. )
  779. ) {
  780. setState({
  781. ...state,
  782. errors: {...state.errors, dashboard: t('This field may not be blank')},
  783. });
  784. return false;
  785. }
  786. }
  787. setState({...state, loading: true});
  788. try {
  789. await validateWidget(api, organization.slug, widgetData);
  790. return true;
  791. } catch (error) {
  792. setState({
  793. ...state,
  794. loading: false,
  795. errors: {...state.errors, ...mapErrors(error?.responseJSON ?? {}, {})},
  796. });
  797. return false;
  798. }
  799. }
  800. function submitFromSelectedDashboard(widgetData: Widget) {
  801. if (!state.selectedDashboard) {
  802. return;
  803. }
  804. const queryData: QueryData = {
  805. queryNames: [],
  806. queryConditions: [],
  807. queryFields: [
  808. ...widgetData.queries[0].columns,
  809. ...widgetData.queries[0].aggregates,
  810. ],
  811. queryOrderby: widgetData.queries[0].orderby,
  812. };
  813. widgetData.queries.forEach(query => {
  814. queryData.queryNames.push(query.name);
  815. queryData.queryConditions.push(query.conditions);
  816. });
  817. const pathQuery = {
  818. displayType: widgetData.displayType,
  819. interval: widgetData.interval,
  820. title: widgetData.title,
  821. ...queryData,
  822. // Propagate page filters
  823. project: pageFilters.projects,
  824. environment: pageFilters.environments,
  825. ...omit(pageFilters.datetime, 'period'),
  826. statsPeriod: pageFilters.datetime?.period,
  827. };
  828. addSuccessMessage(t('Added widget.'));
  829. goToDashboards(state.selectedDashboard.value, pathQuery);
  830. }
  831. function goToDashboards(id: string, query?: Record<string, any>) {
  832. const pathQuery =
  833. !isEmpty(queryParamsWithoutSource) || query
  834. ? {
  835. ...queryParamsWithoutSource,
  836. ...query,
  837. }
  838. : undefined;
  839. if (id === NEW_DASHBOARD_ID) {
  840. router.push({
  841. pathname: `/organizations/${organization.slug}/dashboards/new/`,
  842. query: pathQuery,
  843. });
  844. return;
  845. }
  846. router.push({
  847. pathname: `/organizations/${organization.slug}/dashboard/${id}/`,
  848. query: pathQuery,
  849. });
  850. }
  851. function isFormInvalid(isMetricsData?: boolean) {
  852. // Block saving if the widget uses custom measurements and is not able to be fulfilled with metrics
  853. const incompatibleCustomMeasurementWidget =
  854. !isMetricsData && isCustomMeasurementWidget(currentWidget);
  855. if (
  856. incompatibleCustomMeasurementWidget ||
  857. (notDashboardsOrigin && !state.selectedDashboard)
  858. ) {
  859. return true;
  860. }
  861. return false;
  862. }
  863. const canAddSearchConditions =
  864. [DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(state.displayType) &&
  865. state.queries.length < 3;
  866. const hideLegendAlias = [
  867. DisplayType.TABLE,
  868. DisplayType.WORLD_MAP,
  869. DisplayType.BIG_NUMBER,
  870. ].includes(state.displayType);
  871. // Tabular visualizations will always have only one query and that query cannot be deleted,
  872. // so we will always have the first query available to get data from.
  873. const {columns, aggregates, fields, fieldAliases = []} = state.queries[0];
  874. const explodedColumns = useMemo(() => {
  875. return columns.map((field, index) =>
  876. explodeField({field, alias: fieldAliases[index]})
  877. );
  878. }, [columns, fieldAliases]);
  879. const explodedAggregates = useMemo(() => {
  880. return aggregates.map((field, index) =>
  881. explodeField({field, alias: fieldAliases[index]})
  882. );
  883. }, [aggregates, fieldAliases]);
  884. const explodedFields = defined(fields)
  885. ? fields.map((field, index) => explodeField({field, alias: fieldAliases[index]}))
  886. : [...explodedColumns, ...explodedAggregates];
  887. const groupByValueSelected = currentWidget.queries.some(query => {
  888. const noEmptyColumns = query.columns.filter(column => !!column);
  889. return noEmptyColumns.length > 0;
  890. });
  891. // The SortBy field shall only be displayed in tabular visualizations or
  892. // on time-series visualizations when at least one groupBy value is selected
  893. const displaySortByStep =
  894. (widgetBuilderNewDesign && isTimeseriesChart && groupByValueSelected) ||
  895. isTabularChart;
  896. if (isEditing && !isValidWidgetIndex) {
  897. return (
  898. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  899. <PageContent>
  900. <LoadingError message={t('The widget you want to edit was not found.')} />
  901. </PageContent>
  902. </SentryDocumentTitle>
  903. );
  904. }
  905. return (
  906. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  907. <PageFiltersContainer
  908. defaultSelection={{
  909. datetime: {start: null, end: null, utc: false, period: DEFAULT_STATS_PERIOD},
  910. }}
  911. >
  912. <CustomMeasurementsProvider organization={organization} selection={selection}>
  913. <DashboardsMEPProvider>
  914. <PageContentWithoutPadding>
  915. <Header
  916. orgSlug={orgSlug}
  917. title={state.title}
  918. dashboardTitle={dashboard.title}
  919. goBackLocation={previousLocation}
  920. onChangeTitle={newTitle => {
  921. handleDisplayTypeOrTitleChange('title', newTitle);
  922. }}
  923. />
  924. <Body>
  925. <MainWrapper>
  926. <Main>
  927. {!!!organization.features.includes('dashboards-top-level-filter') && (
  928. <StyledPageFilterBar condensed>
  929. <ProjectPageFilter />
  930. <EnvironmentPageFilter />
  931. <DatePageFilter alignDropdown="left" />
  932. </StyledPageFilterBar>
  933. )}
  934. <BuildSteps symbol="colored-numeric">
  935. <VisualizationStep
  936. widget={currentWidget}
  937. dashboardFilters={dashboard.filters}
  938. organization={organization}
  939. pageFilters={pageFilters}
  940. displayType={state.displayType}
  941. error={state.errors?.displayType}
  942. onChange={newDisplayType => {
  943. handleDisplayTypeOrTitleChange('displayType', newDisplayType);
  944. }}
  945. noDashboardsMEPProvider
  946. />
  947. <DataSetStep
  948. dataSet={state.dataSet}
  949. displayType={state.displayType}
  950. onChange={handleDataSetChange}
  951. hasReleaseHealthFeature={hasReleaseHealthFeature}
  952. />
  953. {isTabularChart && (
  954. <ColumnsStep
  955. dataSet={state.dataSet}
  956. queries={state.queries}
  957. displayType={state.displayType}
  958. widgetType={widgetType}
  959. queryErrors={state.errors?.queries}
  960. onQueryChange={handleQueryChange}
  961. handleColumnFieldChange={handleColumnFieldChange}
  962. explodedFields={explodedFields}
  963. tags={tags}
  964. organization={organization}
  965. />
  966. )}
  967. {![DisplayType.TABLE].includes(state.displayType) && (
  968. <YAxisStep
  969. dataSet={state.dataSet}
  970. displayType={state.displayType}
  971. widgetType={widgetType}
  972. queryErrors={state.errors?.queries}
  973. onYAxisChange={newFields => {
  974. handleYAxisChange(newFields);
  975. }}
  976. aggregates={explodedAggregates}
  977. tags={tags}
  978. organization={organization}
  979. />
  980. )}
  981. <FilterResultsStep
  982. queries={state.queries}
  983. hideLegendAlias={hideLegendAlias}
  984. canAddSearchConditions={canAddSearchConditions}
  985. organization={organization}
  986. queryErrors={state.errors?.queries}
  987. onAddSearchConditions={handleAddSearchConditions}
  988. onQueryChange={handleQueryChange}
  989. onQueryRemove={handleQueryRemove}
  990. selection={pageFilters}
  991. widgetType={widgetType}
  992. dashboardFilters={dashboard.filters}
  993. />
  994. {widgetBuilderNewDesign && isTimeseriesChart && (
  995. <GroupByStep
  996. columns={columns
  997. .filter(field => !(field === 'equation|'))
  998. .map((field, index) =>
  999. explodeField({field, alias: fieldAliases[index]})
  1000. )}
  1001. onGroupByChange={handleGroupByChange}
  1002. organization={organization}
  1003. tags={tags}
  1004. dataSet={state.dataSet}
  1005. />
  1006. )}
  1007. {displaySortByStep && (
  1008. <SortByStep
  1009. limit={state.limit}
  1010. displayType={state.displayType}
  1011. queries={state.queries}
  1012. dataSet={state.dataSet}
  1013. widgetBuilderNewDesign={widgetBuilderNewDesign}
  1014. error={state.errors?.orderby}
  1015. onSortByChange={handleSortByChange}
  1016. onLimitChange={handleLimitChange}
  1017. organization={organization}
  1018. widgetType={widgetType}
  1019. tags={tags}
  1020. />
  1021. )}
  1022. </BuildSteps>
  1023. </Main>
  1024. <DashboardsMEPConsumer>
  1025. {({isMetricsData}) => (
  1026. <Footer
  1027. goBackLocation={previousLocation}
  1028. isEditing={isEditing}
  1029. onSave={handleSave}
  1030. onDelete={handleDelete}
  1031. invalidForm={isFormInvalid(isMetricsData)}
  1032. />
  1033. )}
  1034. </DashboardsMEPConsumer>
  1035. </MainWrapper>
  1036. <Side>
  1037. <WidgetLibrary
  1038. organization={organization}
  1039. widgetBuilderNewDesign={widgetBuilderNewDesign}
  1040. selectedWidgetId={
  1041. state.userHasModified ? null : state.prebuiltWidgetId
  1042. }
  1043. onWidgetSelect={prebuiltWidget => {
  1044. setLatestLibrarySelectionTitle(prebuiltWidget.title);
  1045. setDataSetConfig(
  1046. getDatasetConfig(prebuiltWidget.widgetType || WidgetType.DISCOVER)
  1047. );
  1048. const {id, ...prebuiltWidgetProps} = prebuiltWidget;
  1049. setState({
  1050. ...state,
  1051. ...prebuiltWidgetProps,
  1052. dataSet: prebuiltWidget.widgetType
  1053. ? WIDGET_TYPE_TO_DATA_SET[prebuiltWidget.widgetType]
  1054. : DataSet.EVENTS,
  1055. userHasModified: false,
  1056. prebuiltWidgetId: id || null,
  1057. });
  1058. }}
  1059. bypassOverwriteModal={!state.userHasModified}
  1060. />
  1061. </Side>
  1062. </Body>
  1063. </PageContentWithoutPadding>
  1064. </DashboardsMEPProvider>
  1065. </CustomMeasurementsProvider>
  1066. </PageFiltersContainer>
  1067. </SentryDocumentTitle>
  1068. );
  1069. }
  1070. export default withPageFilters(withTags(WidgetBuilder));
  1071. const PageContentWithoutPadding = styled(PageContent)`
  1072. padding: 0;
  1073. `;
  1074. const StyledPageFilterBar = styled(PageFilterBar)`
  1075. margin-bottom: ${space(2)};
  1076. `;
  1077. const BuildSteps = styled(List)`
  1078. gap: ${space(4)};
  1079. max-width: 100%;
  1080. `;
  1081. const Body = styled(Layout.Body)`
  1082. && {
  1083. gap: 0;
  1084. padding: 0;
  1085. }
  1086. grid-template-rows: 1fr;
  1087. @media (min-width: ${p => p.theme.breakpoints.large}) {
  1088. grid-template-columns: minmax(100px, auto) 400px;
  1089. }
  1090. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  1091. grid-template-columns: 1fr;
  1092. }
  1093. `;
  1094. // HACK: Since we add 30px of padding to the ListItems
  1095. // there is 30px of overlap when the screen is just above 1200px.
  1096. // When we're up to 1230px (1200 + 30 to account for the padding)
  1097. // we decrease the width of ListItems by 30px
  1098. const Main = styled(Layout.Main)`
  1099. max-width: 1000px;
  1100. flex: 1;
  1101. padding: ${space(4)} ${space(2)};
  1102. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  1103. padding: ${space(4)};
  1104. }
  1105. @media (max-width: calc(${p => p.theme.breakpoints.large} + ${space(4)})) {
  1106. ${ListItem} {
  1107. width: calc(100% - ${space(4)});
  1108. }
  1109. }
  1110. `;
  1111. const Side = styled(Layout.Side)`
  1112. padding: ${space(4)} ${space(2)};
  1113. @media (max-width: ${p => p.theme.breakpoints.large}) {
  1114. border-top: 1px solid ${p => p.theme.gray200};
  1115. grid-row: 2/2;
  1116. grid-column: 1/-1;
  1117. max-width: 100%;
  1118. }
  1119. @media (min-width: ${p => p.theme.breakpoints.large}) {
  1120. border-left: 1px solid ${p => p.theme.gray200};
  1121. /* to be consistent with Layout.Body in other verticals */
  1122. padding-right: ${space(4)};
  1123. max-width: 400px;
  1124. }
  1125. `;
  1126. const MainWrapper = styled('div')`
  1127. display: flex;
  1128. flex-direction: column;
  1129. @media (max-width: ${p => p.theme.breakpoints.large}) {
  1130. grid-column: 1/-1;
  1131. }
  1132. `;