widgetBuilder.tsx 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269
  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, selection);
  255. }
  256. if (isEditing && isValidWidgetIndex) {
  257. const widgetFromDashboard = filteredDashboardWidgets[widgetIndexNum];
  258. let queries;
  259. let newDisplayType = widgetFromDashboard.displayType;
  260. let newLimit = widgetFromDashboard.limit;
  261. if (widgetFromDashboard.displayType === DisplayType.TOP_N) {
  262. newLimit = DEFAULT_RESULTS_LIMIT;
  263. newDisplayType = DisplayType.AREA;
  264. queries = normalizeQueries({
  265. displayType: newDisplayType,
  266. queries: widgetFromDashboard.queries,
  267. widgetType: widgetFromDashboard.widgetType ?? WidgetType.DISCOVER,
  268. widgetBuilderNewDesign,
  269. }).map(query => ({
  270. ...query,
  271. // Use the last aggregate because that's where the y-axis is stored
  272. aggregates: query.aggregates.length
  273. ? [query.aggregates[query.aggregates.length - 1]]
  274. : [],
  275. }));
  276. } else {
  277. queries = normalizeQueries({
  278. displayType: newDisplayType,
  279. queries: widgetFromDashboard.queries,
  280. widgetType: widgetFromDashboard.widgetType ?? WidgetType.DISCOVER,
  281. widgetBuilderNewDesign,
  282. });
  283. }
  284. setState({
  285. title: widgetFromDashboard.title,
  286. displayType: newDisplayType,
  287. interval: widgetFromDashboard.interval,
  288. queries,
  289. errors: undefined,
  290. loading: false,
  291. dashboards: [],
  292. userHasModified: false,
  293. dataSet: widgetFromDashboard.widgetType
  294. ? WIDGET_TYPE_TO_DATA_SET[widgetFromDashboard.widgetType]
  295. : DataSet.EVENTS,
  296. limit: newLimit,
  297. prebuiltWidgetId: null,
  298. });
  299. setDataSetConfig(getDatasetConfig(widgetFromDashboard.widgetType));
  300. setWidgetToBeUpdated(widgetFromDashboard);
  301. }
  302. // This should only run once on mount
  303. // eslint-disable-next-line react-hooks/exhaustive-deps
  304. }, []);
  305. useEffect(() => {
  306. async function fetchDashboards() {
  307. const promise: Promise<DashboardListItem[]> = api.requestPromise(
  308. `/organizations/${organization.slug}/dashboards/`,
  309. {
  310. method: 'GET',
  311. query: {sort: 'myDashboardsAndRecentlyViewed'},
  312. }
  313. );
  314. try {
  315. const dashboards = await promise;
  316. setState(prevState => ({...prevState, dashboards, loading: false}));
  317. } catch (error) {
  318. const errorMessage = t('Unable to fetch dashboards');
  319. addErrorMessage(errorMessage);
  320. handleXhrErrorResponse(errorMessage)(error);
  321. setState(prevState => ({...prevState, loading: false}));
  322. }
  323. }
  324. if (notDashboardsOrigin) {
  325. fetchDashboards();
  326. }
  327. if (widgetBuilderNewDesign) {
  328. setState(prevState => ({
  329. ...prevState,
  330. selectedDashboard: {
  331. label: dashboard.title,
  332. value: dashboard.id || NEW_DASHBOARD_ID,
  333. },
  334. }));
  335. }
  336. }, [
  337. api,
  338. dashboard.id,
  339. dashboard.title,
  340. notDashboardsOrigin,
  341. organization.slug,
  342. source,
  343. widgetBuilderNewDesign,
  344. ]);
  345. useEffect(() => {
  346. fetchOrgMembers(api, organization.slug, selection.projects?.map(String));
  347. }, [selection.projects, api, organization.slug]);
  348. useEffect(() => {
  349. const onUnload = () => {
  350. if (!isSubmitting && state.userHasModified) {
  351. return t('You have unsaved changes, are you sure you want to leave?');
  352. }
  353. return undefined;
  354. };
  355. router.setRouteLeaveHook(route, onUnload);
  356. }, [isSubmitting, state.userHasModified, route, router]);
  357. const widgetType =
  358. state.dataSet === DataSet.EVENTS
  359. ? WidgetType.DISCOVER
  360. : state.dataSet === DataSet.ISSUES
  361. ? WidgetType.ISSUE
  362. : WidgetType.RELEASE;
  363. const currentWidget = {
  364. title: state.title,
  365. displayType: state.displayType,
  366. interval: state.interval,
  367. queries: state.queries,
  368. limit: state.limit,
  369. widgetType,
  370. };
  371. const currentDashboardId = state.selectedDashboard?.value ?? dashboardId;
  372. const queryParamsWithoutSource = omit(location.query, 'source');
  373. const previousLocation = {
  374. pathname:
  375. defined(currentDashboardId) && currentDashboardId !== NEW_DASHBOARD_ID
  376. ? `/organizations/${orgId}/dashboard/${currentDashboardId}/`
  377. : `/organizations/${orgId}/dashboards/${NEW_DASHBOARD_ID}/`,
  378. query: isEmpty(queryParamsWithoutSource) ? undefined : queryParamsWithoutSource,
  379. };
  380. const isTimeseriesChart = getIsTimeseriesChart(state.displayType);
  381. const isTabularChart = [DisplayType.TABLE, DisplayType.TOP_N].includes(
  382. state.displayType
  383. );
  384. function updateFieldsAccordingToDisplayType(newDisplayType: DisplayType) {
  385. setState(prevState => {
  386. const newState = cloneDeep(prevState);
  387. if (!!!datasetConfig.supportedDisplayTypes.includes(newDisplayType)) {
  388. // Set to Events dataset if Display Type is not supported by
  389. // current dataset
  390. set(
  391. newState,
  392. 'queries',
  393. normalizeQueries({
  394. displayType: newDisplayType,
  395. queries: [{...getDatasetConfig(WidgetType.DISCOVER).defaultWidgetQuery}],
  396. widgetType: WidgetType.DISCOVER,
  397. widgetBuilderNewDesign,
  398. })
  399. );
  400. set(newState, 'dataSet', DataSet.EVENTS);
  401. setDataSetConfig(getDatasetConfig(WidgetType.DISCOVER));
  402. return {...newState, errors: undefined};
  403. }
  404. const normalized = normalizeQueries({
  405. displayType: newDisplayType,
  406. queries: prevState.queries,
  407. widgetType: DATA_SET_TO_WIDGET_TYPE[prevState.dataSet],
  408. widgetBuilderNewDesign,
  409. });
  410. if (newDisplayType === DisplayType.TOP_N) {
  411. // TOP N display should only allow a single query
  412. normalized.splice(1);
  413. }
  414. if (!prevState.userHasModified) {
  415. // Default widget provided by Add to Dashboard from Discover
  416. if (defaultWidgetQuery && defaultTableColumns) {
  417. // If switching to Table visualization, use saved query fields for Y-Axis if user has not made query changes
  418. // This is so the widget can reflect the same columns as the table in Discover without requiring additional user input
  419. if (newDisplayType === DisplayType.TABLE) {
  420. normalized.forEach(query => {
  421. const tableQuery = getColumnsAndAggregates(defaultTableColumns);
  422. query.columns = [...tableQuery.columns];
  423. query.aggregates = [...tableQuery.aggregates];
  424. query.fields = [...defaultTableColumns];
  425. query.orderby =
  426. defaultWidgetQuery.orderby ??
  427. (query.fields.length ? `${query.fields[0]}` : '-');
  428. });
  429. } else if (newDisplayType === displayType) {
  430. // When switching back to original display type, default fields back to the fields provided from the discover query
  431. normalized.forEach(query => {
  432. query.fields = [
  433. ...defaultWidgetQuery.columns,
  434. ...defaultWidgetQuery.aggregates,
  435. ];
  436. query.aggregates = [...defaultWidgetQuery.aggregates];
  437. query.columns = [...defaultWidgetQuery.columns];
  438. if (
  439. !!defaultWidgetQuery.orderby &&
  440. (displayType === DisplayType.TOP_N || defaultWidgetQuery.columns.length)
  441. ) {
  442. query.orderby = defaultWidgetQuery.orderby;
  443. }
  444. });
  445. }
  446. }
  447. }
  448. set(newState, 'queries', normalized);
  449. if (widgetBuilderNewDesign) {
  450. if (
  451. getIsTimeseriesChart(newDisplayType) &&
  452. normalized[0].columns.filter(column => !!column).length
  453. ) {
  454. // If a limit already exists (i.e. going between timeseries) then keep it,
  455. // otherwise calculate a limit
  456. newState.limit =
  457. prevState.limit ??
  458. Math.min(
  459. getResultsLimit(normalized.length, normalized[0].columns.length),
  460. DEFAULT_RESULTS_LIMIT
  461. );
  462. } else {
  463. newState.limit = undefined;
  464. }
  465. }
  466. set(newState, 'userHasModified', true);
  467. return {...newState, errors: undefined};
  468. });
  469. }
  470. function getUpdateWidgetIndex() {
  471. if (!widgetToBeUpdated) {
  472. return -1;
  473. }
  474. return dashboard.widgets.findIndex(widget => {
  475. if (defined(widget.id)) {
  476. return widget.id === widgetToBeUpdated.id;
  477. }
  478. if (defined(widget.tempId)) {
  479. return widget.tempId === widgetToBeUpdated.tempId;
  480. }
  481. return false;
  482. });
  483. }
  484. function handleDisplayTypeOrTitleChange<
  485. F extends keyof Pick<State, 'displayType' | 'title'>
  486. >(field: F, value: State[F]) {
  487. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.change', {
  488. from: source,
  489. field,
  490. value,
  491. widget_type: widgetType,
  492. organization,
  493. new_widget: !isEditing,
  494. });
  495. setState(prevState => {
  496. const newState = cloneDeep(prevState);
  497. set(newState, field, value);
  498. if (field === 'title') {
  499. set(newState, 'userHasModified', true);
  500. }
  501. return {...newState, errors: undefined};
  502. });
  503. if (field === 'displayType' && value !== state.displayType) {
  504. updateFieldsAccordingToDisplayType(value as DisplayType);
  505. }
  506. }
  507. function handleDataSetChange(newDataSet: string) {
  508. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.change', {
  509. from: source,
  510. field: 'dataSet',
  511. value: newDataSet,
  512. widget_type: widgetType,
  513. organization,
  514. new_widget: !isEditing,
  515. });
  516. setState(prevState => {
  517. const newState = cloneDeep(prevState);
  518. newState.queries.splice(0, newState.queries.length);
  519. set(newState, 'dataSet', newDataSet);
  520. if (newDataSet === DataSet.ISSUES) {
  521. set(newState, 'displayType', DisplayType.TABLE);
  522. }
  523. const config = getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[newDataSet]);
  524. setDataSetConfig(config);
  525. newState.queries.push(
  526. ...(widgetToBeUpdated?.widgetType &&
  527. WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === newDataSet
  528. ? widgetToBeUpdated.queries
  529. : [{...config.defaultWidgetQuery}])
  530. );
  531. set(newState, 'userHasModified', true);
  532. return {...newState, errors: undefined};
  533. });
  534. }
  535. function handleAddSearchConditions() {
  536. setState(prevState => {
  537. const newState = cloneDeep(prevState);
  538. const config = getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[prevState.dataSet]);
  539. const query = cloneDeep(config.defaultWidgetQuery);
  540. query.fields = prevState.queries[0].fields;
  541. query.aggregates = prevState.queries[0].aggregates;
  542. query.columns = prevState.queries[0].columns;
  543. query.orderby = prevState.queries[0].orderby;
  544. newState.queries.push(query);
  545. return newState;
  546. });
  547. }
  548. function handleQueryRemove(index: number) {
  549. setState(prevState => {
  550. const newState = cloneDeep(prevState);
  551. newState.queries.splice(index, 1);
  552. return {...newState, errors: undefined};
  553. });
  554. }
  555. function handleQueryChange(queryIndex: number, newQuery: WidgetQuery) {
  556. setState(prevState => {
  557. const newState = cloneDeep(prevState);
  558. set(newState, `queries.${queryIndex}`, newQuery);
  559. set(newState, 'userHasModified', true);
  560. return {...newState, errors: undefined};
  561. });
  562. }
  563. function handleColumnFieldChange(newFields: QueryFieldValue[]) {
  564. const fieldStrings = newFields.map(generateFieldAsString);
  565. const splitFields = getColumnsAndAggregatesAsStrings(newFields);
  566. const newState = cloneDeep(state);
  567. let newQuery = cloneDeep(newState.queries[0]);
  568. newQuery.fields = fieldStrings;
  569. newQuery.aggregates = splitFields.aggregates;
  570. newQuery.columns = splitFields.columns;
  571. newQuery.fieldAliases = splitFields.fieldAliases;
  572. if (datasetConfig.handleColumnFieldChangeOverride) {
  573. newQuery = datasetConfig.handleColumnFieldChangeOverride(newQuery);
  574. }
  575. if (datasetConfig.handleOrderByReset) {
  576. newQuery = datasetConfig.handleOrderByReset(newQuery, fieldStrings);
  577. }
  578. set(newState, 'queries', [newQuery]);
  579. set(newState, 'userHasModified', true);
  580. setState(newState);
  581. }
  582. function handleYAxisChange(newFields: QueryFieldValue[]) {
  583. const fieldStrings = newFields.map(generateFieldAsString);
  584. const newState = cloneDeep(state);
  585. const newQueries = state.queries.map(query => {
  586. let newQuery = cloneDeep(query);
  587. if (state.displayType === DisplayType.TOP_N) {
  588. // Top N queries use n-1 fields for columns and the nth field for y-axis
  589. newQuery.fields = [
  590. ...(newQuery.fields?.slice(0, newQuery.fields.length - 1) ?? []),
  591. ...fieldStrings,
  592. ];
  593. newQuery.aggregates = [
  594. ...newQuery.aggregates.slice(0, newQuery.aggregates.length - 1),
  595. ...fieldStrings,
  596. ];
  597. } else {
  598. newQuery.fields = [...newQuery.columns, ...fieldStrings];
  599. newQuery.aggregates = fieldStrings;
  600. }
  601. if (datasetConfig.handleOrderByReset) {
  602. newQuery = datasetConfig.handleOrderByReset(newQuery, fieldStrings);
  603. }
  604. return newQuery;
  605. });
  606. set(newState, 'queries', newQueries);
  607. set(newState, 'userHasModified', true);
  608. const groupByFields = newState.queries[0].columns.filter(
  609. field => !(field === 'equation|')
  610. );
  611. if (groupByFields.length === 0) {
  612. set(newState, 'limit', undefined);
  613. } else {
  614. set(
  615. newState,
  616. 'limit',
  617. Math.min(
  618. newState.limit ?? DEFAULT_RESULTS_LIMIT,
  619. getResultsLimit(newQueries.length, newQueries[0].aggregates.length)
  620. )
  621. );
  622. }
  623. setState(newState);
  624. }
  625. function handleGroupByChange(newFields: QueryFieldValue[]) {
  626. const fieldStrings = newFields.map(generateFieldAsString);
  627. const newState = cloneDeep(state);
  628. const newQueries = state.queries.map(query => {
  629. const newQuery = cloneDeep(query);
  630. newQuery.columns = fieldStrings;
  631. if (!fieldStrings.length) {
  632. // The grouping was cleared, so clear the orderby
  633. newQuery.orderby = '';
  634. } else if (!newQuery.orderby) {
  635. const orderOptions = generateOrderOptions({
  636. widgetType: widgetType ?? WidgetType.DISCOVER,
  637. widgetBuilderNewDesign,
  638. columns: query.columns,
  639. aggregates: query.aggregates,
  640. });
  641. let orderOption: string;
  642. // If no orderby options are available because of DISABLED_SORTS
  643. if (!!!orderOptions.length) {
  644. newQuery.orderby = '';
  645. } else {
  646. orderOption = orderOptions[0].value;
  647. newQuery.orderby = `-${orderOption}`;
  648. }
  649. }
  650. return newQuery;
  651. });
  652. set(newState, 'userHasModified', true);
  653. set(newState, 'queries', newQueries);
  654. const groupByFields = newState.queries[0].columns.filter(
  655. field => !(field === 'equation|')
  656. );
  657. if (groupByFields.length === 0) {
  658. set(newState, 'limit', undefined);
  659. } else {
  660. set(
  661. newState,
  662. 'limit',
  663. Math.min(
  664. newState.limit ?? DEFAULT_RESULTS_LIMIT,
  665. getResultsLimit(newQueries.length, newQueries[0].aggregates.length)
  666. )
  667. );
  668. }
  669. setState(newState);
  670. }
  671. function handleLimitChange(newLimit: number) {
  672. setState(prevState => ({...prevState, limit: newLimit}));
  673. }
  674. function handleSortByChange(newSortBy: string) {
  675. const newState = cloneDeep(state);
  676. state.queries.forEach((query, index) => {
  677. const newQuery = cloneDeep(query);
  678. newQuery.orderby = newSortBy;
  679. set(newState, `queries.${index}`, newQuery);
  680. });
  681. set(newState, 'userHasModified', true);
  682. setState(newState);
  683. }
  684. function handleDelete() {
  685. if (!isEditing) {
  686. return;
  687. }
  688. setIsSubmitting(true);
  689. let nextWidgetList = [...dashboard.widgets];
  690. const updateWidgetIndex = getUpdateWidgetIndex();
  691. nextWidgetList.splice(updateWidgetIndex, 1);
  692. nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
  693. onSave(nextWidgetList);
  694. router.push(previousLocation);
  695. }
  696. async function handleSave() {
  697. const widgetData: Widget = assignTempId(currentWidget);
  698. if (widgetToBeUpdated) {
  699. widgetData.layout = widgetToBeUpdated?.layout;
  700. }
  701. // Only Table and Top N views need orderby
  702. if (!widgetBuilderNewDesign && !isTabularChart) {
  703. widgetData.queries.forEach(query => {
  704. query.orderby = '';
  705. });
  706. }
  707. if (!widgetBuilderNewDesign) {
  708. widgetData.queries.forEach(query => omit(query, 'fieldAliases'));
  709. }
  710. // Only Time Series charts shall have a limit
  711. if (widgetBuilderNewDesign && !isTimeseriesChart) {
  712. widgetData.limit = undefined;
  713. }
  714. if (!(await dataIsValid(widgetData))) {
  715. return;
  716. }
  717. if (latestLibrarySelectionTitle) {
  718. // User has selected a widget library in this session
  719. trackAdvancedAnalyticsEvent('dashboards_views.widget_library.add_widget', {
  720. organization,
  721. title: latestLibrarySelectionTitle,
  722. });
  723. }
  724. setIsSubmitting(true);
  725. if (notDashboardsOrigin) {
  726. submitFromSelectedDashboard(widgetData);
  727. return;
  728. }
  729. if (!!widgetToBeUpdated) {
  730. let nextWidgetList = [...dashboard.widgets];
  731. const updateWidgetIndex = getUpdateWidgetIndex();
  732. const nextWidgetData = {...widgetData, id: widgetToBeUpdated.id};
  733. // Only modify and re-compact if the default height has changed
  734. if (
  735. getDefaultWidgetHeight(widgetToBeUpdated.displayType) !==
  736. getDefaultWidgetHeight(widgetData.displayType)
  737. ) {
  738. nextWidgetList[updateWidgetIndex] = enforceWidgetHeightValues(nextWidgetData);
  739. nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
  740. } else {
  741. nextWidgetList[updateWidgetIndex] = nextWidgetData;
  742. }
  743. onSave(nextWidgetList);
  744. addSuccessMessage(t('Updated widget.'));
  745. goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
  746. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.save', {
  747. organization,
  748. data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
  749. new_widget: false,
  750. });
  751. return;
  752. }
  753. onSave([...dashboard.widgets, widgetData]);
  754. addSuccessMessage(t('Added widget.'));
  755. goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
  756. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.save', {
  757. organization,
  758. data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
  759. new_widget: true,
  760. });
  761. }
  762. async function dataIsValid(widgetData: Widget): Promise<boolean> {
  763. if (notDashboardsOrigin) {
  764. // Validate that a dashboard was selected since api call to /dashboards/widgets/ does not check for dashboard
  765. if (
  766. !state.selectedDashboard ||
  767. !(
  768. state.dashboards.find(
  769. ({title, id}) =>
  770. title === state.selectedDashboard?.label &&
  771. id === state.selectedDashboard?.value
  772. ) || state.selectedDashboard.value === NEW_DASHBOARD_ID
  773. )
  774. ) {
  775. setState({
  776. ...state,
  777. errors: {...state.errors, dashboard: t('This field may not be blank')},
  778. });
  779. return false;
  780. }
  781. }
  782. setState({...state, loading: true});
  783. try {
  784. await validateWidget(api, organization.slug, widgetData);
  785. return true;
  786. } catch (error) {
  787. setState({
  788. ...state,
  789. loading: false,
  790. errors: {...state.errors, ...mapErrors(error?.responseJSON ?? {}, {})},
  791. });
  792. return false;
  793. }
  794. }
  795. function submitFromSelectedDashboard(widgetData: Widget) {
  796. if (!state.selectedDashboard) {
  797. return;
  798. }
  799. const queryData: QueryData = {
  800. queryNames: [],
  801. queryConditions: [],
  802. queryFields: [
  803. ...widgetData.queries[0].columns,
  804. ...widgetData.queries[0].aggregates,
  805. ],
  806. queryOrderby: widgetData.queries[0].orderby,
  807. };
  808. widgetData.queries.forEach(query => {
  809. queryData.queryNames.push(query.name);
  810. queryData.queryConditions.push(query.conditions);
  811. });
  812. const pathQuery = {
  813. displayType: widgetData.displayType,
  814. interval: widgetData.interval,
  815. title: widgetData.title,
  816. ...queryData,
  817. // Propagate page filters
  818. project: pageFilters.projects,
  819. environment: pageFilters.environments,
  820. ...omit(pageFilters.datetime, 'period'),
  821. statsPeriod: pageFilters.datetime?.period,
  822. };
  823. addSuccessMessage(t('Added widget.'));
  824. goToDashboards(state.selectedDashboard.value, pathQuery);
  825. }
  826. function goToDashboards(id: string, query?: Record<string, any>) {
  827. const pathQuery =
  828. !isEmpty(queryParamsWithoutSource) || query
  829. ? {
  830. ...queryParamsWithoutSource,
  831. ...query,
  832. }
  833. : undefined;
  834. if (id === NEW_DASHBOARD_ID) {
  835. router.push({
  836. pathname: `/organizations/${organization.slug}/dashboards/new/`,
  837. query: pathQuery,
  838. });
  839. return;
  840. }
  841. router.push({
  842. pathname: `/organizations/${organization.slug}/dashboard/${id}/`,
  843. query: pathQuery,
  844. });
  845. }
  846. function isFormInvalid(isMetricsData?: boolean) {
  847. // Block saving if the widget uses custom measurements and is not able to be fulfilled with metrics
  848. const incompatibleCustomMeasurementWidget =
  849. !isMetricsData && isCustomMeasurementWidget(currentWidget);
  850. if (
  851. incompatibleCustomMeasurementWidget ||
  852. (notDashboardsOrigin && !state.selectedDashboard)
  853. ) {
  854. return true;
  855. }
  856. return false;
  857. }
  858. const canAddSearchConditions =
  859. [DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(state.displayType) &&
  860. state.queries.length < 3;
  861. const hideLegendAlias = [
  862. DisplayType.TABLE,
  863. DisplayType.WORLD_MAP,
  864. DisplayType.BIG_NUMBER,
  865. ].includes(state.displayType);
  866. // Tabular visualizations will always have only one query and that query cannot be deleted,
  867. // so we will always have the first query available to get data from.
  868. const {columns, aggregates, fields, fieldAliases = []} = state.queries[0];
  869. const explodedColumns = useMemo(() => {
  870. return columns.map((field, index) =>
  871. explodeField({field, alias: fieldAliases[index]})
  872. );
  873. }, [columns, fieldAliases]);
  874. const explodedAggregates = useMemo(() => {
  875. return aggregates.map((field, index) =>
  876. explodeField({field, alias: fieldAliases[index]})
  877. );
  878. }, [aggregates, fieldAliases]);
  879. const explodedFields = defined(fields)
  880. ? fields.map((field, index) => explodeField({field, alias: fieldAliases[index]}))
  881. : [...explodedColumns, ...explodedAggregates];
  882. const groupByValueSelected = currentWidget.queries.some(query => {
  883. const noEmptyColumns = query.columns.filter(column => !!column);
  884. return noEmptyColumns.length > 0;
  885. });
  886. // The SortBy field shall only be displayed in tabular visualizations or
  887. // on time-series visualizations when at least one groupBy value is selected
  888. const displaySortByStep =
  889. (widgetBuilderNewDesign && isTimeseriesChart && groupByValueSelected) ||
  890. isTabularChart;
  891. if (isEditing && !isValidWidgetIndex) {
  892. return (
  893. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  894. <PageContent>
  895. <LoadingError message={t('The widget you want to edit was not found.')} />
  896. </PageContent>
  897. </SentryDocumentTitle>
  898. );
  899. }
  900. return (
  901. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  902. <PageFiltersContainer
  903. defaultSelection={{
  904. datetime: {start: null, end: null, utc: false, period: DEFAULT_STATS_PERIOD},
  905. }}
  906. >
  907. <CustomMeasurementsProvider organization={organization} selection={selection}>
  908. <DashboardsMEPProvider>
  909. <PageContentWithoutPadding>
  910. <Header
  911. orgSlug={orgSlug}
  912. title={state.title}
  913. dashboardTitle={dashboard.title}
  914. goBackLocation={previousLocation}
  915. onChangeTitle={newTitle => {
  916. handleDisplayTypeOrTitleChange('title', newTitle);
  917. }}
  918. />
  919. <Body>
  920. <MainWrapper>
  921. <Main>
  922. <StyledPageFilterBar condensed>
  923. <ProjectPageFilter />
  924. <EnvironmentPageFilter />
  925. <DatePageFilter alignDropdown="left" />
  926. </StyledPageFilterBar>
  927. <BuildSteps symbol="colored-numeric">
  928. <VisualizationStep
  929. widget={currentWidget}
  930. organization={organization}
  931. pageFilters={pageFilters}
  932. displayType={state.displayType}
  933. error={state.errors?.displayType}
  934. onChange={newDisplayType => {
  935. handleDisplayTypeOrTitleChange('displayType', newDisplayType);
  936. }}
  937. noDashboardsMEPProvider
  938. />
  939. <DataSetStep
  940. dataSet={state.dataSet}
  941. displayType={state.displayType}
  942. onChange={handleDataSetChange}
  943. hasReleaseHealthFeature={hasReleaseHealthFeature}
  944. />
  945. {isTabularChart && (
  946. <ColumnsStep
  947. dataSet={state.dataSet}
  948. queries={state.queries}
  949. displayType={state.displayType}
  950. widgetType={widgetType}
  951. queryErrors={state.errors?.queries}
  952. onQueryChange={handleQueryChange}
  953. handleColumnFieldChange={handleColumnFieldChange}
  954. explodedFields={explodedFields}
  955. tags={tags}
  956. organization={organization}
  957. />
  958. )}
  959. {![DisplayType.TABLE].includes(state.displayType) && (
  960. <YAxisStep
  961. dataSet={state.dataSet}
  962. displayType={state.displayType}
  963. widgetType={widgetType}
  964. queryErrors={state.errors?.queries}
  965. onYAxisChange={newFields => {
  966. handleYAxisChange(newFields);
  967. }}
  968. aggregates={explodedAggregates}
  969. tags={tags}
  970. organization={organization}
  971. />
  972. )}
  973. <FilterResultsStep
  974. queries={state.queries}
  975. hideLegendAlias={hideLegendAlias}
  976. canAddSearchConditions={canAddSearchConditions}
  977. organization={organization}
  978. queryErrors={state.errors?.queries}
  979. onAddSearchConditions={handleAddSearchConditions}
  980. onQueryChange={handleQueryChange}
  981. onQueryRemove={handleQueryRemove}
  982. selection={pageFilters}
  983. widgetType={widgetType}
  984. />
  985. {widgetBuilderNewDesign && isTimeseriesChart && (
  986. <GroupByStep
  987. columns={columns
  988. .filter(field => !(field === 'equation|'))
  989. .map((field, index) =>
  990. explodeField({field, alias: fieldAliases[index]})
  991. )}
  992. onGroupByChange={handleGroupByChange}
  993. organization={organization}
  994. tags={tags}
  995. dataSet={state.dataSet}
  996. />
  997. )}
  998. {displaySortByStep && (
  999. <SortByStep
  1000. limit={state.limit}
  1001. displayType={state.displayType}
  1002. queries={state.queries}
  1003. dataSet={state.dataSet}
  1004. widgetBuilderNewDesign={widgetBuilderNewDesign}
  1005. error={state.errors?.orderby}
  1006. onSortByChange={handleSortByChange}
  1007. onLimitChange={handleLimitChange}
  1008. organization={organization}
  1009. widgetType={widgetType}
  1010. tags={tags}
  1011. />
  1012. )}
  1013. </BuildSteps>
  1014. </Main>
  1015. <DashboardsMEPConsumer>
  1016. {({isMetricsData}) => (
  1017. <Footer
  1018. goBackLocation={previousLocation}
  1019. isEditing={isEditing}
  1020. onSave={handleSave}
  1021. onDelete={handleDelete}
  1022. invalidForm={isFormInvalid(isMetricsData)}
  1023. />
  1024. )}
  1025. </DashboardsMEPConsumer>
  1026. </MainWrapper>
  1027. <Side>
  1028. <WidgetLibrary
  1029. organization={organization}
  1030. widgetBuilderNewDesign={widgetBuilderNewDesign}
  1031. selectedWidgetId={
  1032. state.userHasModified ? null : state.prebuiltWidgetId
  1033. }
  1034. onWidgetSelect={prebuiltWidget => {
  1035. setLatestLibrarySelectionTitle(prebuiltWidget.title);
  1036. setDataSetConfig(
  1037. getDatasetConfig(prebuiltWidget.widgetType || WidgetType.DISCOVER)
  1038. );
  1039. const {id, ...prebuiltWidgetProps} = prebuiltWidget;
  1040. setState({
  1041. ...state,
  1042. ...prebuiltWidgetProps,
  1043. dataSet: prebuiltWidget.widgetType
  1044. ? WIDGET_TYPE_TO_DATA_SET[prebuiltWidget.widgetType]
  1045. : DataSet.EVENTS,
  1046. userHasModified: false,
  1047. prebuiltWidgetId: id || null,
  1048. });
  1049. }}
  1050. bypassOverwriteModal={!state.userHasModified}
  1051. />
  1052. </Side>
  1053. </Body>
  1054. </PageContentWithoutPadding>
  1055. </DashboardsMEPProvider>
  1056. </CustomMeasurementsProvider>
  1057. </PageFiltersContainer>
  1058. </SentryDocumentTitle>
  1059. );
  1060. }
  1061. export default withPageFilters(withTags(WidgetBuilder));
  1062. const PageContentWithoutPadding = styled(PageContent)`
  1063. padding: 0;
  1064. `;
  1065. const StyledPageFilterBar = styled(PageFilterBar)`
  1066. margin-bottom: ${space(2)};
  1067. `;
  1068. const BuildSteps = styled(List)`
  1069. gap: ${space(4)};
  1070. max-width: 100%;
  1071. `;
  1072. const Body = styled(Layout.Body)`
  1073. && {
  1074. gap: 0;
  1075. padding: 0;
  1076. }
  1077. grid-template-rows: 1fr;
  1078. @media (min-width: ${p => p.theme.breakpoints.large}) {
  1079. grid-template-columns: minmax(100px, auto) 400px;
  1080. }
  1081. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  1082. grid-template-columns: 1fr;
  1083. }
  1084. `;
  1085. // HACK: Since we add 30px of padding to the ListItems
  1086. // there is 30px of overlap when the screen is just above 1200px.
  1087. // When we're up to 1230px (1200 + 30 to account for the padding)
  1088. // we decrease the width of ListItems by 30px
  1089. const Main = styled(Layout.Main)`
  1090. max-width: 1000px;
  1091. flex: 1;
  1092. padding: ${space(4)} ${space(2)};
  1093. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  1094. padding: ${space(4)};
  1095. }
  1096. @media (max-width: calc(${p => p.theme.breakpoints.large} + ${space(4)})) {
  1097. ${ListItem} {
  1098. width: calc(100% - ${space(4)});
  1099. }
  1100. }
  1101. `;
  1102. const Side = styled(Layout.Side)`
  1103. padding: ${space(4)} ${space(2)};
  1104. @media (max-width: ${p => p.theme.breakpoints.large}) {
  1105. border-top: 1px solid ${p => p.theme.gray200};
  1106. grid-row: 2/2;
  1107. grid-column: 1/-1;
  1108. max-width: 100%;
  1109. }
  1110. @media (min-width: ${p => p.theme.breakpoints.large}) {
  1111. border-left: 1px solid ${p => p.theme.gray200};
  1112. /* to be consistent with Layout.Body in other verticals */
  1113. padding-right: ${space(4)};
  1114. max-width: 400px;
  1115. }
  1116. `;
  1117. const MainWrapper = styled('div')`
  1118. display: flex;
  1119. flex-direction: column;
  1120. @media (max-width: ${p => p.theme.breakpoints.large}) {
  1121. grid-column: 1/-1;
  1122. }
  1123. `;