widgetBuilder.tsx 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273
  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. {!!!organization.features.includes('dashboards-top-level-filter') && (
  923. <StyledPageFilterBar condensed>
  924. <ProjectPageFilter />
  925. <EnvironmentPageFilter />
  926. <DatePageFilter alignDropdown="left" />
  927. </StyledPageFilterBar>
  928. )}
  929. <BuildSteps symbol="colored-numeric">
  930. <VisualizationStep
  931. widget={currentWidget}
  932. dashboardFilters={dashboard.filters}
  933. organization={organization}
  934. pageFilters={pageFilters}
  935. displayType={state.displayType}
  936. error={state.errors?.displayType}
  937. onChange={newDisplayType => {
  938. handleDisplayTypeOrTitleChange('displayType', newDisplayType);
  939. }}
  940. noDashboardsMEPProvider
  941. />
  942. <DataSetStep
  943. dataSet={state.dataSet}
  944. displayType={state.displayType}
  945. onChange={handleDataSetChange}
  946. hasReleaseHealthFeature={hasReleaseHealthFeature}
  947. />
  948. {isTabularChart && (
  949. <ColumnsStep
  950. dataSet={state.dataSet}
  951. queries={state.queries}
  952. displayType={state.displayType}
  953. widgetType={widgetType}
  954. queryErrors={state.errors?.queries}
  955. onQueryChange={handleQueryChange}
  956. handleColumnFieldChange={handleColumnFieldChange}
  957. explodedFields={explodedFields}
  958. tags={tags}
  959. organization={organization}
  960. />
  961. )}
  962. {![DisplayType.TABLE].includes(state.displayType) && (
  963. <YAxisStep
  964. dataSet={state.dataSet}
  965. displayType={state.displayType}
  966. widgetType={widgetType}
  967. queryErrors={state.errors?.queries}
  968. onYAxisChange={newFields => {
  969. handleYAxisChange(newFields);
  970. }}
  971. aggregates={explodedAggregates}
  972. tags={tags}
  973. organization={organization}
  974. />
  975. )}
  976. <FilterResultsStep
  977. queries={state.queries}
  978. hideLegendAlias={hideLegendAlias}
  979. canAddSearchConditions={canAddSearchConditions}
  980. organization={organization}
  981. queryErrors={state.errors?.queries}
  982. onAddSearchConditions={handleAddSearchConditions}
  983. onQueryChange={handleQueryChange}
  984. onQueryRemove={handleQueryRemove}
  985. selection={pageFilters}
  986. widgetType={widgetType}
  987. dashboardFilters={dashboard.filters}
  988. />
  989. {widgetBuilderNewDesign && isTimeseriesChart && (
  990. <GroupByStep
  991. columns={columns
  992. .filter(field => !(field === 'equation|'))
  993. .map((field, index) =>
  994. explodeField({field, alias: fieldAliases[index]})
  995. )}
  996. onGroupByChange={handleGroupByChange}
  997. organization={organization}
  998. tags={tags}
  999. dataSet={state.dataSet}
  1000. />
  1001. )}
  1002. {displaySortByStep && (
  1003. <SortByStep
  1004. limit={state.limit}
  1005. displayType={state.displayType}
  1006. queries={state.queries}
  1007. dataSet={state.dataSet}
  1008. widgetBuilderNewDesign={widgetBuilderNewDesign}
  1009. error={state.errors?.orderby}
  1010. onSortByChange={handleSortByChange}
  1011. onLimitChange={handleLimitChange}
  1012. organization={organization}
  1013. widgetType={widgetType}
  1014. tags={tags}
  1015. />
  1016. )}
  1017. </BuildSteps>
  1018. </Main>
  1019. <DashboardsMEPConsumer>
  1020. {({isMetricsData}) => (
  1021. <Footer
  1022. goBackLocation={previousLocation}
  1023. isEditing={isEditing}
  1024. onSave={handleSave}
  1025. onDelete={handleDelete}
  1026. invalidForm={isFormInvalid(isMetricsData)}
  1027. />
  1028. )}
  1029. </DashboardsMEPConsumer>
  1030. </MainWrapper>
  1031. <Side>
  1032. <WidgetLibrary
  1033. organization={organization}
  1034. widgetBuilderNewDesign={widgetBuilderNewDesign}
  1035. selectedWidgetId={
  1036. state.userHasModified ? null : state.prebuiltWidgetId
  1037. }
  1038. onWidgetSelect={prebuiltWidget => {
  1039. setLatestLibrarySelectionTitle(prebuiltWidget.title);
  1040. setDataSetConfig(
  1041. getDatasetConfig(prebuiltWidget.widgetType || WidgetType.DISCOVER)
  1042. );
  1043. const {id, ...prebuiltWidgetProps} = prebuiltWidget;
  1044. setState({
  1045. ...state,
  1046. ...prebuiltWidgetProps,
  1047. dataSet: prebuiltWidget.widgetType
  1048. ? WIDGET_TYPE_TO_DATA_SET[prebuiltWidget.widgetType]
  1049. : DataSet.EVENTS,
  1050. userHasModified: false,
  1051. prebuiltWidgetId: id || null,
  1052. });
  1053. }}
  1054. bypassOverwriteModal={!state.userHasModified}
  1055. />
  1056. </Side>
  1057. </Body>
  1058. </PageContentWithoutPadding>
  1059. </DashboardsMEPProvider>
  1060. </CustomMeasurementsProvider>
  1061. </PageFiltersContainer>
  1062. </SentryDocumentTitle>
  1063. );
  1064. }
  1065. export default withPageFilters(withTags(WidgetBuilder));
  1066. const PageContentWithoutPadding = styled(PageContent)`
  1067. padding: 0;
  1068. `;
  1069. const StyledPageFilterBar = styled(PageFilterBar)`
  1070. margin-bottom: ${space(2)};
  1071. `;
  1072. const BuildSteps = styled(List)`
  1073. gap: ${space(4)};
  1074. max-width: 100%;
  1075. `;
  1076. const Body = styled(Layout.Body)`
  1077. && {
  1078. gap: 0;
  1079. padding: 0;
  1080. }
  1081. grid-template-rows: 1fr;
  1082. @media (min-width: ${p => p.theme.breakpoints.large}) {
  1083. grid-template-columns: minmax(100px, auto) 400px;
  1084. }
  1085. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  1086. grid-template-columns: 1fr;
  1087. }
  1088. `;
  1089. // HACK: Since we add 30px of padding to the ListItems
  1090. // there is 30px of overlap when the screen is just above 1200px.
  1091. // When we're up to 1230px (1200 + 30 to account for the padding)
  1092. // we decrease the width of ListItems by 30px
  1093. const Main = styled(Layout.Main)`
  1094. max-width: 1000px;
  1095. flex: 1;
  1096. padding: ${space(4)} ${space(2)};
  1097. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  1098. padding: ${space(4)};
  1099. }
  1100. @media (max-width: calc(${p => p.theme.breakpoints.large} + ${space(4)})) {
  1101. ${ListItem} {
  1102. width: calc(100% - ${space(4)});
  1103. }
  1104. }
  1105. `;
  1106. const Side = styled(Layout.Side)`
  1107. padding: ${space(4)} ${space(2)};
  1108. @media (max-width: ${p => p.theme.breakpoints.large}) {
  1109. border-top: 1px solid ${p => p.theme.gray200};
  1110. grid-row: 2/2;
  1111. grid-column: 1/-1;
  1112. max-width: 100%;
  1113. }
  1114. @media (min-width: ${p => p.theme.breakpoints.large}) {
  1115. border-left: 1px solid ${p => p.theme.gray200};
  1116. /* to be consistent with Layout.Body in other verticals */
  1117. padding-right: ${space(4)};
  1118. max-width: 400px;
  1119. }
  1120. `;
  1121. const MainWrapper = styled('div')`
  1122. display: flex;
  1123. flex-direction: column;
  1124. @media (max-width: ${p => p.theme.breakpoints.large}) {
  1125. grid-column: 1/-1;
  1126. }
  1127. `;