widgetBuilder.tsx 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514
  1. import {useEffect, useMemo, useRef, useState} from 'react';
  2. import type {Location} from 'react-router-dom';
  3. import styled from '@emotion/styled';
  4. import cloneDeep from 'lodash/cloneDeep';
  5. import omit from 'lodash/omit';
  6. import set from 'lodash/set';
  7. import {validateWidget} from 'sentry/actionCreators/dashboards';
  8. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  9. import {fetchOrgMembers} from 'sentry/actionCreators/members';
  10. import {loadOrganizationTags} from 'sentry/actionCreators/tags';
  11. import FieldWrapper from 'sentry/components/forms/fieldGroup/fieldWrapper';
  12. import TextareaField from 'sentry/components/forms/fields/textareaField';
  13. import TextField from 'sentry/components/forms/fields/textField';
  14. import * as Layout from 'sentry/components/layouts/thirds';
  15. import List from 'sentry/components/list';
  16. import ListItem from 'sentry/components/list/listItem';
  17. import LoadingError from 'sentry/components/loadingError';
  18. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  19. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  20. import {t} from 'sentry/locale';
  21. import {space} from 'sentry/styles/space';
  22. import type {DateString, PageFilters} from 'sentry/types/core';
  23. import type {TagCollection} from 'sentry/types/group';
  24. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  25. import {defined} from 'sentry/utils';
  26. import {trackAnalytics} from 'sentry/utils/analytics';
  27. import {WidgetBuilderVersion} from 'sentry/utils/analytics/dashboardsAnalyticsEvents';
  28. import {CustomMeasurementsProvider} from 'sentry/utils/customMeasurements/customMeasurementsProvider';
  29. import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  30. import EventView from 'sentry/utils/discover/eventView';
  31. import type {QueryFieldValue} from 'sentry/utils/discover/fields';
  32. import {
  33. explodeField,
  34. generateFieldAsString,
  35. getColumnsAndAggregates,
  36. getColumnsAndAggregatesAsStrings,
  37. } from 'sentry/utils/discover/fields';
  38. import {DatasetSource} from 'sentry/utils/discover/types';
  39. import {isEmptyObject} from 'sentry/utils/object/isEmptyObject';
  40. import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
  41. import {MetricsResultsMetaProvider} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext';
  42. import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  43. import {
  44. isOnDemandMetricWidget,
  45. OnDemandControlProvider,
  46. } from 'sentry/utils/performance/contexts/onDemandControl';
  47. import {OnRouteLeave} from 'sentry/utils/reactRouter6Compat/onRouteLeave';
  48. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  49. import useApi from 'sentry/utils/useApi';
  50. import useOrganization from 'sentry/utils/useOrganization';
  51. import useTags from 'sentry/utils/useTags';
  52. import withPageFilters from 'sentry/utils/withPageFilters';
  53. import {
  54. assignTempId,
  55. enforceWidgetHeightValues,
  56. generateWidgetsAfterCompaction,
  57. getDefaultWidgetHeight,
  58. } from 'sentry/views/dashboards/layoutUtils';
  59. import type {DashboardDetails, Widget, WidgetQuery} from 'sentry/views/dashboards/types';
  60. import {
  61. DashboardWidgetSource,
  62. DisplayType,
  63. WidgetType,
  64. } from 'sentry/views/dashboards/types';
  65. import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext';
  66. import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
  67. import {DEFAULT_STATS_PERIOD} from '../data';
  68. import {getDatasetConfig} from '../datasetConfig/base';
  69. import {useValidateWidgetQuery} from '../hooks/useValidateWidget';
  70. import {hasThresholdMaxValue} from '../utils';
  71. import {
  72. DashboardsMEPConsumer,
  73. DashboardsMEPProvider,
  74. } from '../widgetCard/dashboardsMEPContext';
  75. import type WidgetLegendSelectionState from '../widgetLegendSelectionState';
  76. import {BuildStep} from './buildSteps/buildStep';
  77. import {ColumnsStep} from './buildSteps/columnsStep';
  78. import {DataSetStep} from './buildSteps/dataSetStep';
  79. import {FilterResultsStep} from './buildSteps/filterResultsStep';
  80. import {GroupByStep} from './buildSteps/groupByStep';
  81. import {SortByStep} from './buildSteps/sortByStep';
  82. import type {
  83. ThresholdMaxKeys,
  84. ThresholdsConfig,
  85. } from './buildSteps/thresholdsStep/thresholdsStep';
  86. import ThresholdsStep from './buildSteps/thresholdsStep/thresholdsStep';
  87. import {VisualizationStep} from './buildSteps/visualizationStep';
  88. import {YAxisStep} from './buildSteps/yAxisStep';
  89. import {Footer} from './footer';
  90. import {Header} from './header';
  91. import {
  92. DataSet,
  93. DEFAULT_RESULTS_LIMIT,
  94. generateOrderOptions,
  95. getIsTimeseriesChart,
  96. getParsedDefaultWidgetQuery,
  97. getResultsLimit,
  98. mapErrors,
  99. NEW_DASHBOARD_ID,
  100. normalizeQueries,
  101. } from './utils';
  102. import {WidgetLibrary} from './widgetLibrary';
  103. const UNSAVED_CHANGES_MESSAGE = t(
  104. 'You have unsaved changes, are you sure you want to leave?'
  105. );
  106. const WIDGET_TYPE_TO_DATA_SET = {
  107. [WidgetType.DISCOVER]: DataSet.EVENTS,
  108. [WidgetType.ISSUE]: DataSet.ISSUES,
  109. [WidgetType.RELEASE]: DataSet.RELEASES,
  110. [WidgetType.METRICS]: DataSet.METRICS,
  111. [WidgetType.ERRORS]: DataSet.ERRORS,
  112. [WidgetType.TRANSACTIONS]: DataSet.TRANSACTIONS,
  113. [WidgetType.SPANS]: DataSet.SPANS,
  114. };
  115. export const DATA_SET_TO_WIDGET_TYPE = {
  116. [DataSet.EVENTS]: WidgetType.DISCOVER,
  117. [DataSet.ISSUES]: WidgetType.ISSUE,
  118. [DataSet.RELEASES]: WidgetType.RELEASE,
  119. [DataSet.METRICS]: WidgetType.METRICS,
  120. [DataSet.ERRORS]: WidgetType.ERRORS,
  121. [DataSet.TRANSACTIONS]: WidgetType.TRANSACTIONS,
  122. [DataSet.SPANS]: WidgetType.SPANS,
  123. };
  124. interface RouteParams {
  125. dashboardId: string;
  126. orgId: string;
  127. widgetIndex?: string;
  128. }
  129. interface QueryData {
  130. queryConditions: string[];
  131. queryFields: string[];
  132. queryNames: string[];
  133. queryOrderby: string;
  134. }
  135. interface Props extends RouteComponentProps<RouteParams, {}> {
  136. dashboard: DashboardDetails;
  137. onSave: (widgets: Widget[]) => void;
  138. selection: PageFilters;
  139. widgetLegendState: WidgetLegendSelectionState;
  140. displayType?: DisplayType;
  141. end?: DateString;
  142. start?: DateString;
  143. statsPeriod?: string | null;
  144. updateDashboardSplitDecision?: (widgetId: string, splitDecision: WidgetType) => void;
  145. }
  146. interface State {
  147. dataSet: DataSet;
  148. displayType: Widget['displayType'];
  149. interval: Widget['interval'];
  150. limit: Widget['limit'];
  151. loading: boolean;
  152. prebuiltWidgetId: null | string;
  153. queries: Widget['queries'];
  154. queryConditionsValid: boolean;
  155. title: string;
  156. userHasModified: boolean;
  157. dataType?: string;
  158. dataUnit?: string;
  159. description?: string;
  160. errors?: Record<string, any>;
  161. id?: string;
  162. selectedDashboard?: DashboardDetails['id'];
  163. thresholds?: ThresholdsConfig | null;
  164. widgetToBeUpdated?: Widget;
  165. }
  166. function WidgetBuilder({
  167. dashboard,
  168. params,
  169. location,
  170. selection,
  171. start,
  172. end,
  173. statsPeriod,
  174. onSave,
  175. router,
  176. updateDashboardSplitDecision,
  177. widgetLegendState,
  178. }: Props) {
  179. const organization = useOrganization();
  180. const {widgetIndex, orgId, dashboardId} = params;
  181. const {source, displayType, defaultTitle, limit, dataset} = location.query;
  182. const defaultWidgetQuery = getParsedDefaultWidgetQuery(
  183. location.query.defaultWidgetQuery
  184. );
  185. // defaultTableColumns can be a single string if location.query only contains
  186. // 1 value for this key. Ensure it is a string[]
  187. let {defaultTableColumns}: {defaultTableColumns: string[]} = location.query;
  188. if (typeof defaultTableColumns === 'string') {
  189. defaultTableColumns = [defaultTableColumns];
  190. }
  191. const isEditing = defined(widgetIndex);
  192. const widgetIndexNum = Number(widgetIndex);
  193. const isValidWidgetIndex =
  194. widgetIndexNum >= 0 &&
  195. widgetIndexNum < dashboard.widgets.length &&
  196. Number.isInteger(widgetIndexNum);
  197. const orgSlug = organization.slug;
  198. // Construct PageFilters object using statsPeriod/start/end props so we can
  199. // render widget graph using saved timeframe from Saved/Prebuilt Query
  200. const pageFilters: PageFilters = statsPeriod
  201. ? {...selection, datetime: {start: null, end: null, period: statsPeriod, utc: null}}
  202. : start && end
  203. ? {...selection, datetime: {start, end, period: null, utc: null}}
  204. : selection;
  205. // when opening from discover or issues page, the user selects the dashboard in the widget UI
  206. const notDashboardsOrigin = [
  207. DashboardWidgetSource.DISCOVERV2,
  208. DashboardWidgetSource.ISSUE_DETAILS,
  209. ].includes(source);
  210. const defaultWidgetType =
  211. organization.features.includes('performance-discover-dataset-selector') && !isEditing // i.e. creating
  212. ? WidgetType.ERRORS
  213. : WidgetType.DISCOVER;
  214. const defaultDataset =
  215. organization.features.includes('performance-discover-dataset-selector') && !isEditing // i.e. creating
  216. ? DataSet.ERRORS
  217. : DataSet.EVENTS;
  218. const dataSet = dataset ? dataset : defaultDataset;
  219. const api = useApi();
  220. const isSubmittingRef = useRef(false);
  221. const [datasetConfig, setDataSetConfig] = useState<ReturnType<typeof getDatasetConfig>>(
  222. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  223. getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[dataSet])
  224. );
  225. const defaultThresholds: ThresholdsConfig = {max_values: {}, unit: null};
  226. const [state, setState] = useState<State>(() => {
  227. const defaultState: State = {
  228. title: defaultTitle ?? t('Custom Widget'),
  229. displayType:
  230. (displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType) ??
  231. DisplayType.TABLE,
  232. interval: '5m',
  233. queries: [],
  234. thresholds: defaultThresholds,
  235. limit: limit ? Number(limit) : undefined,
  236. errors: undefined,
  237. description: undefined,
  238. dataType: undefined,
  239. dataUnit: undefined,
  240. loading: !!notDashboardsOrigin,
  241. userHasModified: false,
  242. prebuiltWidgetId: null,
  243. dataSet,
  244. queryConditionsValid: true,
  245. selectedDashboard: dashboard.id || NEW_DASHBOARD_ID,
  246. };
  247. if (defaultWidgetQuery) {
  248. defaultState.queries = [
  249. {
  250. ...defaultWidgetQuery,
  251. orderby:
  252. defaultWidgetQuery.orderby ||
  253. (datasetConfig.getTableSortOptions
  254. ? datasetConfig.getTableSortOptions(organization, defaultWidgetQuery)[0]!
  255. .value
  256. : ''),
  257. },
  258. ];
  259. if (
  260. ![DisplayType.TABLE, DisplayType.TOP_N].includes(defaultState.displayType) &&
  261. !(
  262. getIsTimeseriesChart(defaultState.displayType) &&
  263. defaultState.queries[0]!.columns.length
  264. )
  265. ) {
  266. defaultState.queries[0]!.orderby = '';
  267. }
  268. } else {
  269. defaultState.queries = [{...datasetConfig.defaultWidgetQuery}];
  270. }
  271. return defaultState;
  272. });
  273. const [widgetToBeUpdated, setWidgetToBeUpdated] = useState<Widget | undefined>(
  274. undefined
  275. );
  276. // For analytics around widget library selection
  277. const [latestLibrarySelectionTitle, setLatestLibrarySelectionTitle] = useState<
  278. string | null
  279. >(null);
  280. const [splitDecision, setSplitDecision] = useState<WidgetType | undefined>(undefined);
  281. let tags: TagCollection = useTags();
  282. // HACK: Inject EAP dataset tags when selecting the Spans dataset
  283. const numericSpanTags = useSpanTags('number');
  284. const stringSpanTags = useSpanTags('string');
  285. if (state.dataSet === DataSet.SPANS) {
  286. tags = {...numericSpanTags, ...stringSpanTags};
  287. }
  288. useEffect(() => {
  289. trackAnalytics('dashboards_views.widget_builder.opened', {
  290. organization,
  291. new_widget: !isEditing,
  292. builder_version: WidgetBuilderVersion.PAGE,
  293. from: source,
  294. });
  295. if (isEmptyObject(tags) && dataSet !== DataSet.SPANS) {
  296. loadOrganizationTags(api, organization.slug, {
  297. ...selection,
  298. // Pin the request to 14d to avoid timeouts, see DD-967 for
  299. // more information
  300. datetime: {period: '14d', start: null, end: null, utc: null},
  301. });
  302. }
  303. if (isEditing && isValidWidgetIndex) {
  304. const widgetFromDashboard = dashboard.widgets[widgetIndexNum]!;
  305. let queries: any;
  306. let newDisplayType = widgetFromDashboard.displayType;
  307. let newLimit = widgetFromDashboard.limit;
  308. if (widgetFromDashboard.displayType === DisplayType.TOP_N) {
  309. newLimit = DEFAULT_RESULTS_LIMIT;
  310. newDisplayType = DisplayType.AREA;
  311. queries = normalizeQueries({
  312. displayType: newDisplayType,
  313. queries: widgetFromDashboard.queries,
  314. widgetType: widgetFromDashboard.widgetType ?? defaultWidgetType,
  315. organization,
  316. }).map(query => ({
  317. ...query,
  318. // Use the last aggregate because that's where the y-axis is stored
  319. aggregates: query.aggregates.length
  320. ? [query.aggregates[query.aggregates.length - 1]]
  321. : [],
  322. }));
  323. } else {
  324. queries = normalizeQueries({
  325. displayType: newDisplayType,
  326. queries: widgetFromDashboard.queries,
  327. widgetType: widgetFromDashboard.widgetType ?? defaultWidgetType,
  328. organization,
  329. });
  330. }
  331. setState({
  332. id: widgetFromDashboard.id,
  333. title: widgetFromDashboard.title,
  334. description: widgetFromDashboard.description,
  335. displayType: newDisplayType,
  336. interval: widgetFromDashboard.interval,
  337. queries,
  338. errors: undefined,
  339. loading: false,
  340. userHasModified: false,
  341. thresholds: widgetFromDashboard.thresholds ?? defaultThresholds,
  342. dataSet: widgetFromDashboard.widgetType
  343. ? WIDGET_TYPE_TO_DATA_SET[widgetFromDashboard.widgetType]
  344. : defaultDataset,
  345. limit: newLimit,
  346. prebuiltWidgetId: null,
  347. queryConditionsValid: true,
  348. });
  349. setDataSetConfig(getDatasetConfig(widgetFromDashboard.widgetType));
  350. setWidgetToBeUpdated(widgetFromDashboard);
  351. }
  352. // This should only run once on mount
  353. // eslint-disable-next-line react-hooks/exhaustive-deps
  354. }, []);
  355. useEffect(() => {
  356. fetchOrgMembers(api, organization.slug, selection.projects?.map(String));
  357. }, [selection.projects, api, organization.slug]);
  358. function onRouteLeave(locationChange: {
  359. currentLocation: Location;
  360. nextLocation: Location;
  361. }): boolean {
  362. return (
  363. locationChange.currentLocation.pathname !== locationChange.nextLocation.pathname &&
  364. !isSubmittingRef.current &&
  365. state.userHasModified
  366. );
  367. }
  368. const widgetType = DATA_SET_TO_WIDGET_TYPE[state.dataSet];
  369. const currentWidget = {
  370. id: state.id,
  371. title: state.title,
  372. description: state.description,
  373. displayType: state.displayType,
  374. thresholds: state.thresholds,
  375. interval: state.interval,
  376. queries: state.queries,
  377. limit: state.limit,
  378. widgetType,
  379. };
  380. const isOnDemandWidget = isOnDemandMetricWidget(currentWidget);
  381. const validatedWidgetResponse = useValidateWidgetQuery(currentWidget);
  382. const currentDashboardId = state.selectedDashboard ?? dashboardId;
  383. const queryParamsWithoutSource = omit(location.query, 'source');
  384. const previousLocation = {
  385. pathname:
  386. defined(currentDashboardId) && currentDashboardId !== NEW_DASHBOARD_ID
  387. ? `/organizations/${orgId}/dashboard/${currentDashboardId}/`
  388. : `/organizations/${orgId}/dashboards/${NEW_DASHBOARD_ID}/`,
  389. query:
  390. Object.keys(queryParamsWithoutSource).length === 0
  391. ? undefined
  392. : queryParamsWithoutSource,
  393. };
  394. const isTimeseriesChart = getIsTimeseriesChart(state.displayType);
  395. const isTabularChart = [DisplayType.TABLE, DisplayType.TOP_N].includes(
  396. state.displayType
  397. );
  398. function updateFieldsAccordingToDisplayType(newDisplayType: DisplayType) {
  399. setState(prevState => {
  400. const newState = cloneDeep(prevState);
  401. if (!datasetConfig.supportedDisplayTypes.includes(newDisplayType)) {
  402. // Set to Events dataset if Display Type is not supported by
  403. // current dataset
  404. set(
  405. newState,
  406. 'queries',
  407. normalizeQueries({
  408. displayType: newDisplayType,
  409. queries: [{...getDatasetConfig(defaultWidgetType).defaultWidgetQuery}],
  410. widgetType: defaultWidgetType,
  411. organization,
  412. })
  413. );
  414. set(newState, 'dataSet', defaultDataset);
  415. setDataSetConfig(getDatasetConfig(defaultWidgetType));
  416. return {...newState, errors: undefined};
  417. }
  418. const normalized = normalizeQueries({
  419. displayType: newDisplayType,
  420. queries: prevState.queries,
  421. widgetType: DATA_SET_TO_WIDGET_TYPE[prevState.dataSet],
  422. organization,
  423. });
  424. if (newDisplayType === DisplayType.TOP_N) {
  425. // TOP N display should only allow a single query
  426. normalized.splice(1);
  427. }
  428. if (!prevState.userHasModified) {
  429. // Default widget provided by Add to Dashboard from Discover
  430. if (defaultWidgetQuery && defaultTableColumns) {
  431. // If switching to Table visualization, use saved query fields for Y-Axis if user has not made query changes
  432. // This is so the widget can reflect the same columns as the table in Discover without requiring additional user input
  433. if (newDisplayType === DisplayType.TABLE) {
  434. normalized.forEach(query => {
  435. const tableQuery = getColumnsAndAggregates(defaultTableColumns);
  436. query.columns = [...tableQuery.columns];
  437. query.aggregates = [...tableQuery.aggregates];
  438. query.fields = [...defaultTableColumns];
  439. query.orderby =
  440. defaultWidgetQuery.orderby ??
  441. (query.fields.length ? `${query.fields[0]}` : '-');
  442. });
  443. } else if (newDisplayType === displayType) {
  444. // When switching back to original display type, default fields back to the fields provided from the discover query
  445. normalized.forEach(query => {
  446. query.fields = [
  447. ...defaultWidgetQuery.columns,
  448. ...defaultWidgetQuery.aggregates,
  449. ];
  450. query.aggregates = [...defaultWidgetQuery.aggregates];
  451. query.columns = [...defaultWidgetQuery.columns];
  452. if (
  453. !!defaultWidgetQuery.orderby &&
  454. (displayType === DisplayType.TOP_N || defaultWidgetQuery.columns.length)
  455. ) {
  456. query.orderby = defaultWidgetQuery.orderby;
  457. }
  458. });
  459. }
  460. }
  461. }
  462. set(newState, 'queries', normalized);
  463. if (
  464. getIsTimeseriesChart(newDisplayType) &&
  465. normalized[0]!.columns.filter(column => !!column).length
  466. ) {
  467. // If a limit already exists (i.e. going between timeseries) then keep it,
  468. // otherwise calculate a limit
  469. newState.limit =
  470. prevState.limit ??
  471. Math.min(
  472. getResultsLimit(normalized.length, normalized[0]!.columns.length),
  473. DEFAULT_RESULTS_LIMIT
  474. );
  475. } else {
  476. newState.limit = undefined;
  477. }
  478. set(newState, 'userHasModified', true);
  479. return {...newState, errors: undefined};
  480. });
  481. }
  482. function getUpdateWidgetIndex() {
  483. if (!widgetToBeUpdated) {
  484. return -1;
  485. }
  486. return dashboard.widgets.findIndex(widget => {
  487. if (defined(widget.id)) {
  488. return widget.id === widgetToBeUpdated.id;
  489. }
  490. if (defined(widget.tempId)) {
  491. return widget.tempId === widgetToBeUpdated.tempId;
  492. }
  493. return false;
  494. });
  495. }
  496. function handleDisplayTypeOrAnnotationChange<
  497. F extends keyof Pick<State, 'displayType' | 'title' | 'description'>,
  498. >(field: F, value: State[F]) {
  499. setState(prevState => {
  500. const newState = cloneDeep(prevState);
  501. set(newState, field, value);
  502. if (['title', 'description'].includes(field)) {
  503. set(newState, 'userHasModified', true);
  504. }
  505. return {...newState, errors: undefined};
  506. });
  507. if (field === 'displayType' && value !== state.displayType) {
  508. updateFieldsAccordingToDisplayType(value as DisplayType);
  509. }
  510. }
  511. function handleDataSetChange(newDataSet: string) {
  512. trackAnalytics('dashboards_views.widget_builder.change', {
  513. from: source,
  514. field: 'dataSet',
  515. value: newDataSet,
  516. widget_type: widgetType,
  517. organization,
  518. new_widget: !isEditing,
  519. builder_version: WidgetBuilderVersion.PAGE,
  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. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  529. const config = getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[newDataSet]);
  530. setDataSetConfig(config);
  531. const didDatasetChange =
  532. widgetToBeUpdated?.widgetType &&
  533. WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === newDataSet;
  534. if (
  535. [DataSet.ERRORS, DataSet.TRANSACTIONS].includes(prevState.dataSet) &&
  536. [DataSet.ERRORS, DataSet.TRANSACTIONS].includes(newDataSet as DataSet)
  537. ) {
  538. newState.queries = prevState.queries;
  539. } else {
  540. newState.queries.push(
  541. ...(didDatasetChange
  542. ? widgetToBeUpdated.queries
  543. : [{...config.defaultWidgetQuery}])
  544. );
  545. }
  546. set(newState, 'userHasModified', true);
  547. return {...newState, errors: undefined};
  548. });
  549. }
  550. function handleAddSearchConditions() {
  551. setState(prevState => {
  552. const newState = cloneDeep(prevState);
  553. const config = getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[prevState.dataSet]);
  554. const query = cloneDeep(config.defaultWidgetQuery);
  555. query.fields = prevState.queries[0]!.fields;
  556. query.aggregates = prevState.queries[0]!.aggregates;
  557. query.columns = prevState.queries[0]!.columns;
  558. query.orderby = prevState.queries[0]!.orderby;
  559. newState.queries.push(query);
  560. return newState;
  561. });
  562. }
  563. function handleQueryRemove(index: number) {
  564. setState(prevState => {
  565. const newState = cloneDeep(prevState);
  566. newState.queries.splice(index, 1);
  567. return {...newState, errors: undefined};
  568. });
  569. }
  570. function handleQueryChange(queryIndex: number, newQuery: WidgetQuery) {
  571. setState(prevState => {
  572. const newState = cloneDeep(prevState);
  573. set(newState, `queries.${queryIndex}`, newQuery);
  574. set(newState, 'userHasModified', true);
  575. return {...newState, errors: undefined};
  576. });
  577. }
  578. function getHandleColumnFieldChange(isMetricsData?: boolean) {
  579. function handleColumnFieldChange(newFields: QueryFieldValue[]) {
  580. const fieldStrings = newFields.map(generateFieldAsString);
  581. const splitFields = getColumnsAndAggregatesAsStrings(newFields);
  582. const newState = cloneDeep(state);
  583. let newQuery = cloneDeep(newState.queries[0]!);
  584. newQuery.fields = fieldStrings;
  585. newQuery.aggregates = splitFields.aggregates;
  586. newQuery.columns = splitFields.columns;
  587. newQuery.fieldAliases = splitFields.fieldAliases;
  588. if (datasetConfig.handleColumnFieldChangeOverride) {
  589. newQuery = datasetConfig.handleColumnFieldChangeOverride(newQuery);
  590. }
  591. if (datasetConfig.handleOrderByReset) {
  592. // If widget is metric backed, don't default to sorting by transaction unless its the only column
  593. // Sorting by transaction is not supported in metrics
  594. if (
  595. isMetricsData &&
  596. fieldStrings.some(
  597. fieldString => !['transaction', 'title'].includes(fieldString)
  598. )
  599. ) {
  600. newQuery = datasetConfig.handleOrderByReset(
  601. newQuery,
  602. fieldStrings.filter(
  603. fieldString => !['transaction', 'title'].includes(fieldString)
  604. )
  605. );
  606. } else {
  607. newQuery = datasetConfig.handleOrderByReset(newQuery, fieldStrings);
  608. }
  609. }
  610. set(newState, 'queries', [newQuery]);
  611. set(newState, 'userHasModified', true);
  612. setState(newState);
  613. }
  614. return handleColumnFieldChange;
  615. }
  616. function handleYAxisChange(
  617. newFields: QueryFieldValue[],
  618. newSelectedAggregate?: number
  619. ) {
  620. const fieldStrings = newFields.map(generateFieldAsString);
  621. const newState = cloneDeep(state);
  622. const newQueries = state.queries.map(query => {
  623. let newQuery = cloneDeep(query);
  624. if (state.displayType === DisplayType.TOP_N) {
  625. // Top N queries use n-1 fields for columns and the nth field for y-axis
  626. newQuery.fields = [
  627. ...(newQuery.fields?.slice(0, newQuery.fields.length - 1) ?? []),
  628. ...fieldStrings,
  629. ];
  630. newQuery.aggregates = [
  631. ...newQuery.aggregates.slice(0, newQuery.aggregates.length - 1),
  632. ...fieldStrings,
  633. ];
  634. } else {
  635. newQuery.fields = [...newQuery.columns, ...fieldStrings];
  636. newQuery.aggregates = fieldStrings;
  637. }
  638. if (datasetConfig.handleOrderByReset) {
  639. newQuery = datasetConfig.handleOrderByReset(newQuery, fieldStrings);
  640. }
  641. return newQuery;
  642. });
  643. if (defined(newSelectedAggregate)) {
  644. newQueries[0]!.selectedAggregate = newSelectedAggregate;
  645. }
  646. set(newState, 'queries', newQueries);
  647. set(newState, 'userHasModified', true);
  648. const groupByFields = newState.queries[0]!.columns.filter(
  649. field => !(field === 'equation|')
  650. );
  651. if (groupByFields.length === 0) {
  652. set(newState, 'limit', undefined);
  653. } else {
  654. set(
  655. newState,
  656. 'limit',
  657. Math.min(
  658. newState.limit ?? DEFAULT_RESULTS_LIMIT,
  659. getResultsLimit(newQueries.length, newQueries[0]!.aggregates.length)
  660. )
  661. );
  662. }
  663. newState.thresholds = defaultThresholds;
  664. setState(newState);
  665. }
  666. function handleGroupByChange(newFields: QueryFieldValue[]) {
  667. const fieldStrings = newFields.map(generateFieldAsString);
  668. const newState = cloneDeep(state);
  669. const newQueries = state.queries.map(query => {
  670. const newQuery = cloneDeep(query);
  671. newQuery.columns = fieldStrings;
  672. if (!fieldStrings.length) {
  673. // The grouping was cleared, so clear the orderby
  674. newQuery.orderby = '';
  675. } else if (!newQuery.orderby) {
  676. const orderOptions = generateOrderOptions({
  677. widgetType: widgetType ?? defaultWidgetType,
  678. columns: query.columns,
  679. aggregates: query.aggregates,
  680. });
  681. let orderOption: string;
  682. // If no orderby options are available because of DISABLED_SORTS
  683. if (!orderOptions.length) {
  684. newQuery.orderby = '';
  685. } else {
  686. orderOption = orderOptions[0]!.value;
  687. newQuery.orderby = `-${orderOption}`;
  688. }
  689. }
  690. return newQuery;
  691. });
  692. set(newState, 'userHasModified', true);
  693. set(newState, 'queries', newQueries);
  694. const groupByFields = newState.queries[0]!.columns.filter(
  695. field => !(field === 'equation|')
  696. );
  697. if (groupByFields.length === 0) {
  698. set(newState, 'limit', undefined);
  699. } else {
  700. set(
  701. newState,
  702. 'limit',
  703. Math.min(
  704. newState.limit ?? DEFAULT_RESULTS_LIMIT,
  705. getResultsLimit(newQueries.length, newQueries[0]!.aggregates.length)
  706. )
  707. );
  708. }
  709. setState(newState);
  710. }
  711. function handleLimitChange(newLimit: number) {
  712. setState(prevState => ({...prevState, limit: newLimit}));
  713. }
  714. function handleSortByChange(newSortBy: string) {
  715. const newState = cloneDeep(state);
  716. state.queries.forEach((query, index) => {
  717. const newQuery = cloneDeep(query);
  718. newQuery.orderby = newSortBy;
  719. set(newState, `queries.${index}`, newQuery);
  720. });
  721. set(newState, 'userHasModified', true);
  722. setState(newState);
  723. }
  724. function handleDelete() {
  725. if (!isEditing) {
  726. return;
  727. }
  728. isSubmittingRef.current = true;
  729. let nextWidgetList = [...dashboard.widgets];
  730. const updateWidgetIndex = getUpdateWidgetIndex();
  731. nextWidgetList.splice(updateWidgetIndex, 1);
  732. nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
  733. const unselectedSeriesQuery = widgetLegendState.setMultipleWidgetSelectionStateURL(
  734. {...dashboard, widgets: nextWidgetList},
  735. widgetToBeUpdated
  736. );
  737. onSave(nextWidgetList);
  738. router.push(
  739. normalizeUrl({
  740. ...previousLocation,
  741. query: {...previousLocation.query, unselectedSeries: unselectedSeriesQuery},
  742. })
  743. );
  744. }
  745. async function handleSave() {
  746. const widgetData: Widget = assignTempId(currentWidget);
  747. if (widgetData.thresholds && !hasThresholdMaxValue(widgetData.thresholds)) {
  748. widgetData.thresholds = null;
  749. }
  750. if (widgetToBeUpdated) {
  751. widgetData.layout = widgetToBeUpdated?.layout;
  752. }
  753. // Only Time Series charts shall have a limit
  754. if (!isTimeseriesChart) {
  755. widgetData.limit = undefined;
  756. }
  757. const isValid = await dataIsValid(widgetData);
  758. if (!isValid) {
  759. return;
  760. }
  761. if (latestLibrarySelectionTitle) {
  762. // User has selected a widget library in this session
  763. trackAnalytics('dashboards_views.widget_library.add_widget', {
  764. organization,
  765. title: latestLibrarySelectionTitle,
  766. });
  767. }
  768. isSubmittingRef.current = true;
  769. if (notDashboardsOrigin) {
  770. submitFromSelectedDashboard(widgetData);
  771. return;
  772. }
  773. if (widgetToBeUpdated) {
  774. let nextWidgetList = [...dashboard.widgets];
  775. const updateWidgetIndex = getUpdateWidgetIndex();
  776. const nextWidgetData = {
  777. ...widgetData,
  778. id: widgetToBeUpdated.id,
  779. };
  780. // Only modify and re-compact if the default height has changed
  781. if (
  782. getDefaultWidgetHeight(widgetToBeUpdated.displayType) !==
  783. getDefaultWidgetHeight(widgetData.displayType)
  784. ) {
  785. nextWidgetList[updateWidgetIndex] = enforceWidgetHeightValues(nextWidgetData);
  786. nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
  787. } else {
  788. nextWidgetList[updateWidgetIndex] = nextWidgetData;
  789. }
  790. const unselectedSeriesParam = widgetLegendState.setMultipleWidgetSelectionStateURL(
  791. {
  792. ...dashboard,
  793. widgets: [...nextWidgetList],
  794. },
  795. nextWidgetData
  796. );
  797. const query = {...location.query, unselectedSeries: unselectedSeriesParam};
  798. onSave(nextWidgetList);
  799. addSuccessMessage(t('Updated widget.'));
  800. goToDashboards(dashboardId ?? NEW_DASHBOARD_ID, query);
  801. trackAnalytics('dashboards_views.widget_builder.save', {
  802. organization,
  803. data_set: widgetData.widgetType ?? defaultWidgetType,
  804. new_widget: false,
  805. builder_version: WidgetBuilderVersion.PAGE,
  806. });
  807. return;
  808. }
  809. onSave([...dashboard.widgets, widgetData]);
  810. addSuccessMessage(t('Added widget.'));
  811. goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
  812. trackAnalytics('dashboards_views.widget_builder.save', {
  813. organization,
  814. data_set: widgetData.widgetType ?? defaultWidgetType,
  815. new_widget: true,
  816. builder_version: WidgetBuilderVersion.PAGE,
  817. });
  818. }
  819. async function dataIsValid(widgetData: Widget): Promise<boolean> {
  820. setState({...state, loading: true});
  821. try {
  822. await validateWidget(api, organization.slug, widgetData);
  823. return true;
  824. } catch (error) {
  825. setState({
  826. ...state,
  827. loading: false,
  828. errors: {...state.errors, ...mapErrors(error?.responseJSON ?? {}, {})},
  829. });
  830. addErrorMessage(t('Unable to save widget'));
  831. return false;
  832. }
  833. }
  834. function submitFromSelectedDashboard(widgetData: Widget) {
  835. if (!state.selectedDashboard) {
  836. return;
  837. }
  838. const queryData: QueryData = {
  839. queryNames: [],
  840. queryConditions: [],
  841. queryFields: [
  842. ...widgetData.queries[0]!.columns,
  843. ...widgetData.queries[0]!.aggregates,
  844. ],
  845. queryOrderby: widgetData.queries[0]!.orderby,
  846. };
  847. widgetData.queries.forEach(query => {
  848. queryData.queryNames.push(query.name);
  849. queryData.queryConditions.push(query.conditions);
  850. });
  851. const pathQuery = {
  852. displayType: widgetData.displayType,
  853. interval: widgetData.interval,
  854. title: widgetData.title,
  855. widgetType: widgetData.widgetType,
  856. ...queryData,
  857. // Propagate page filters
  858. project: pageFilters.projects,
  859. environment: pageFilters.environments,
  860. ...omit(pageFilters.datetime, 'period'),
  861. statsPeriod: pageFilters.datetime?.period,
  862. };
  863. addSuccessMessage(t('Added widget.'));
  864. goToDashboards(state.selectedDashboard, pathQuery);
  865. }
  866. function goToDashboards(id: string, query?: Record<string, any>) {
  867. const pathQuery =
  868. Object.keys(queryParamsWithoutSource).length > 0 || query
  869. ? {
  870. ...queryParamsWithoutSource,
  871. ...query,
  872. }
  873. : {};
  874. const sanitizedQuery = omit(pathQuery, ['defaultWidgetQuery', 'defaultTitle']);
  875. if (id === NEW_DASHBOARD_ID) {
  876. router.push(
  877. normalizeUrl({
  878. pathname: `/organizations/${organization.slug}/dashboards/new/`,
  879. query: sanitizedQuery,
  880. })
  881. );
  882. return;
  883. }
  884. router.push(
  885. normalizeUrl({
  886. pathname: `/organizations/${organization.slug}/dashboard/${id}/`,
  887. query: sanitizedQuery,
  888. })
  889. );
  890. }
  891. function handleThresholdChange(maxKey: ThresholdMaxKeys, value: string) {
  892. setState(prevState => {
  893. const newState = cloneDeep(prevState);
  894. if (value === '') {
  895. delete newState.thresholds?.max_values[maxKey];
  896. if (newState.thresholds && !hasThresholdMaxValue(newState.thresholds)) {
  897. newState.thresholds.max_values = {};
  898. }
  899. } else {
  900. if (newState.thresholds) {
  901. newState.thresholds.max_values[maxKey] = Number(value);
  902. }
  903. }
  904. return newState;
  905. });
  906. }
  907. function handleThresholdUnitChange(unit: string) {
  908. setState(prevState => {
  909. const newState = cloneDeep(prevState);
  910. if (newState.thresholds) {
  911. newState.thresholds.unit = unit;
  912. }
  913. return newState;
  914. });
  915. }
  916. function handleWidgetDataFetched(tableData: TableDataWithTitle[]) {
  917. const tableMeta = {...tableData[0]!.meta};
  918. const keys = Object.keys(tableMeta);
  919. const field = keys[0]!;
  920. const dataType = tableMeta[field];
  921. const dataUnit = tableMeta.units?.[field];
  922. setState(prevState => {
  923. const newState = cloneDeep(prevState);
  924. newState.dataType = dataType;
  925. newState.dataUnit = dataUnit;
  926. if (newState.thresholds && !newState.thresholds.unit) {
  927. newState.thresholds.unit = dataUnit ?? null;
  928. }
  929. return newState;
  930. });
  931. }
  932. function handleUpdateWidgetSplitDecision(decision: WidgetType) {
  933. setState(prevState => {
  934. return {...cloneDeep(prevState), dataSet: WIDGET_TYPE_TO_DATA_SET[decision]};
  935. });
  936. if (currentWidget.id) {
  937. // Update the dashboard state with the split decision, in case
  938. // the user cancels editing the widget after the decision was made
  939. updateDashboardSplitDecision?.(currentWidget.id, decision);
  940. }
  941. setSplitDecision(decision);
  942. }
  943. function isFormInvalid() {
  944. if (
  945. (notDashboardsOrigin && !state.selectedDashboard) ||
  946. !state.queryConditionsValid
  947. ) {
  948. return true;
  949. }
  950. return false;
  951. }
  952. function setQueryConditionsValid(validSearch: boolean) {
  953. setState({...state, queryConditionsValid: validSearch});
  954. }
  955. const canAddSearchConditions =
  956. [DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(state.displayType) &&
  957. state.dataSet !== DataSet.SPANS &&
  958. state.queries.length < 3;
  959. const hideLegendAlias = [DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(
  960. state.displayType
  961. );
  962. // Tabular visualizations will always have only one query and that query cannot be deleted,
  963. // so we will always have the first query available to get data from.
  964. const {columns, aggregates, fields, fieldAliases = []} = state.queries[0]!;
  965. const explodedColumns = useMemo(() => {
  966. return columns.map((field, index) =>
  967. explodeField({field, alias: fieldAliases[index]})
  968. );
  969. }, [columns, fieldAliases]);
  970. const explodedAggregates = useMemo(() => {
  971. return aggregates.map((field, index) =>
  972. explodeField({field, alias: fieldAliases[index]})
  973. );
  974. }, [aggregates, fieldAliases]);
  975. const explodedFields = defined(fields)
  976. ? fields.map((field, index) => explodeField({field, alias: fieldAliases[index]}))
  977. : [...explodedColumns, ...explodedAggregates];
  978. const groupByValueSelected = currentWidget.queries.some(query => {
  979. const noEmptyColumns = query.columns.filter(column => !!column);
  980. return noEmptyColumns.length > 0;
  981. });
  982. // The SortBy field shall only be displayed in tabular visualizations or
  983. // on time-series visualizations when at least one groupBy value is selected
  984. const displaySortByStep = (isTimeseriesChart && groupByValueSelected) || isTabularChart;
  985. if (isEditing && !isValidWidgetIndex) {
  986. return (
  987. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  988. <Layout.Page withPadding>
  989. <LoadingError message={t('The widget you want to edit was not found.')} />
  990. </Layout.Page>
  991. </SentryDocumentTitle>
  992. );
  993. }
  994. const widgetDiscoverSplitSource = isValidWidgetIndex
  995. ? dashboard.widgets[widgetIndexNum]!.datasetSource
  996. : undefined;
  997. const originalWidgetType = isValidWidgetIndex
  998. ? dashboard.widgets[widgetIndexNum]!.widgetType
  999. : undefined;
  1000. return (
  1001. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  1002. <PageFiltersContainer
  1003. defaultSelection={{
  1004. datetime: {start: null, end: null, utc: null, period: DEFAULT_STATS_PERIOD},
  1005. }}
  1006. >
  1007. <OnRouteLeave message={UNSAVED_CHANGES_MESSAGE} when={onRouteLeave} />
  1008. <CustomMeasurementsProvider organization={organization} selection={selection}>
  1009. <OnDemandControlProvider location={location}>
  1010. <MetricsResultsMetaProvider>
  1011. <DashboardsMEPProvider>
  1012. <MetricsCardinalityProvider
  1013. organization={organization}
  1014. location={location}
  1015. >
  1016. <MetricsDataSwitcher
  1017. organization={organization}
  1018. eventView={EventView.fromLocation(location)}
  1019. location={location}
  1020. hideLoadingIndicator
  1021. >
  1022. {metricsDataSide => (
  1023. <MEPSettingProvider
  1024. location={location}
  1025. forceTransactions={metricsDataSide.forceTransactionsOnly}
  1026. >
  1027. <Layout.Page>
  1028. <Header
  1029. orgSlug={orgSlug}
  1030. dashboardTitle={dashboard.title}
  1031. goBackLocation={previousLocation}
  1032. />
  1033. <Body>
  1034. <MainWrapper>
  1035. <Main>
  1036. <BuildSteps symbol="colored-numeric">
  1037. <NameWidgetStep title={t('Name your widget')}>
  1038. <TitleInput
  1039. name="title"
  1040. inline={false}
  1041. aria-label={t('Widget title')}
  1042. placeholder={t('Enter title')}
  1043. error={state.errors?.title}
  1044. data-test-id="widget-builder-title-input"
  1045. onChange={(newTitle: any) => {
  1046. handleDisplayTypeOrAnnotationChange(
  1047. 'title',
  1048. newTitle
  1049. );
  1050. }}
  1051. value={state.title}
  1052. onBlur={() => {
  1053. trackAnalytics(
  1054. 'dashboards_views.widget_builder.change',
  1055. {
  1056. from: source,
  1057. field: 'title',
  1058. value: '',
  1059. widget_type: widgetType,
  1060. organization,
  1061. new_widget: !isEditing,
  1062. builder_version: WidgetBuilderVersion.PAGE,
  1063. }
  1064. );
  1065. }}
  1066. />
  1067. <StyledTextAreaField
  1068. name="description"
  1069. rows={4}
  1070. autosize
  1071. inline={false}
  1072. aria-label={t('Widget Description')}
  1073. placeholder={t('Enter description (Optional)')}
  1074. error={state.errors?.description}
  1075. onChange={(newDescription: any) => {
  1076. handleDisplayTypeOrAnnotationChange(
  1077. 'description',
  1078. newDescription
  1079. );
  1080. }}
  1081. value={state.description}
  1082. onBlur={() => {
  1083. trackAnalytics(
  1084. 'dashboards_views.widget_builder.change',
  1085. {
  1086. from: source,
  1087. field: 'description',
  1088. value: '',
  1089. widget_type: widgetType,
  1090. organization,
  1091. new_widget: !isEditing,
  1092. builder_version: WidgetBuilderVersion.PAGE,
  1093. }
  1094. );
  1095. }}
  1096. />
  1097. </NameWidgetStep>
  1098. <VisualizationStep
  1099. location={location}
  1100. onDataFetched={handleWidgetDataFetched}
  1101. widget={currentWidget}
  1102. dashboardFilters={dashboard.filters}
  1103. pageFilters={pageFilters}
  1104. displayType={state.displayType}
  1105. error={state.errors?.displayType}
  1106. onChange={newDisplayType => {
  1107. handleDisplayTypeOrAnnotationChange(
  1108. 'displayType',
  1109. newDisplayType
  1110. );
  1111. trackAnalytics(
  1112. 'dashboards_views.widget_builder.change',
  1113. {
  1114. from: source,
  1115. field: 'displayType',
  1116. value: newDisplayType,
  1117. widget_type: widgetType,
  1118. organization,
  1119. new_widget: !isEditing,
  1120. builder_version: WidgetBuilderVersion.PAGE,
  1121. }
  1122. );
  1123. }}
  1124. isWidgetInvalid={!state.queryConditionsValid}
  1125. onWidgetSplitDecision={
  1126. handleUpdateWidgetSplitDecision
  1127. }
  1128. widgetLegendState={widgetLegendState}
  1129. />
  1130. <DataSetStep
  1131. dataSet={state.dataSet}
  1132. displayType={state.displayType}
  1133. onChange={handleDataSetChange}
  1134. splitDecision={
  1135. splitDecision ??
  1136. // The original widget type is used for a forced split decision
  1137. (widgetDiscoverSplitSource === DatasetSource.FORCED
  1138. ? originalWidgetType
  1139. : undefined)
  1140. }
  1141. source={widgetDiscoverSplitSource}
  1142. />
  1143. {isTabularChart && (
  1144. <DashboardsMEPConsumer>
  1145. {({isMetricsData}) => (
  1146. <ColumnsStep
  1147. dataSet={state.dataSet}
  1148. displayType={state.displayType}
  1149. widgetType={widgetType}
  1150. queryErrors={state.errors?.queries}
  1151. onQueryChange={handleQueryChange}
  1152. handleColumnFieldChange={getHandleColumnFieldChange(
  1153. isMetricsData
  1154. )}
  1155. explodedFields={explodedFields}
  1156. tags={tags}
  1157. isOnDemandWidget={isOnDemandWidget}
  1158. />
  1159. )}
  1160. </DashboardsMEPConsumer>
  1161. )}
  1162. {![DisplayType.TABLE].includes(state.displayType) && (
  1163. <YAxisStep
  1164. dataSet={state.dataSet}
  1165. displayType={state.displayType}
  1166. widgetType={widgetType}
  1167. queryErrors={state.errors?.queries}
  1168. onYAxisChange={(newFields, newSelectedField) => {
  1169. handleYAxisChange(newFields, newSelectedField);
  1170. }}
  1171. aggregates={explodedAggregates}
  1172. selectedAggregate={
  1173. state.queries[0]!.selectedAggregate
  1174. }
  1175. tags={tags}
  1176. />
  1177. )}
  1178. <FilterResultsStep
  1179. queries={state.queries}
  1180. hideLegendAlias={hideLegendAlias}
  1181. canAddSearchConditions={canAddSearchConditions}
  1182. queryErrors={state.errors?.queries}
  1183. onAddSearchConditions={handleAddSearchConditions}
  1184. onQueryChange={handleQueryChange}
  1185. onQueryRemove={handleQueryRemove}
  1186. selection={pageFilters}
  1187. widgetType={widgetType}
  1188. dashboardFilters={dashboard.filters}
  1189. location={location}
  1190. onQueryConditionChange={setQueryConditionsValid}
  1191. validatedWidgetResponse={validatedWidgetResponse}
  1192. />
  1193. {isTimeseriesChart && (
  1194. <GroupByStep
  1195. columns={columns
  1196. .filter(field => !(field === 'equation|'))
  1197. .map((field, index) =>
  1198. explodeField({
  1199. field,
  1200. alias: fieldAliases[index],
  1201. })
  1202. )}
  1203. onGroupByChange={handleGroupByChange}
  1204. validatedWidgetResponse={validatedWidgetResponse}
  1205. tags={tags}
  1206. dataSet={state.dataSet}
  1207. />
  1208. )}
  1209. {displaySortByStep && (
  1210. <SortByStep
  1211. limit={state.limit}
  1212. displayType={state.displayType}
  1213. queries={state.queries}
  1214. dataSet={state.dataSet}
  1215. error={state.errors?.orderby}
  1216. onSortByChange={handleSortByChange}
  1217. onLimitChange={handleLimitChange}
  1218. widgetType={widgetType}
  1219. tags={tags}
  1220. />
  1221. )}
  1222. {state.displayType === 'big_number' &&
  1223. state.dataType !== 'date' && (
  1224. <ThresholdsStep
  1225. onThresholdChange={handleThresholdChange}
  1226. onUnitChange={handleThresholdUnitChange}
  1227. thresholdsConfig={state.thresholds ?? null}
  1228. dataType={state.dataType}
  1229. dataUnit={state.dataUnit}
  1230. errors={state.errors?.thresholds}
  1231. />
  1232. )}
  1233. </BuildSteps>
  1234. </Main>
  1235. <Footer
  1236. goBackLocation={previousLocation}
  1237. isEditing={isEditing}
  1238. onSave={handleSave}
  1239. onDelete={handleDelete}
  1240. invalidForm={isFormInvalid()}
  1241. />
  1242. </MainWrapper>
  1243. <Side>
  1244. <WidgetLibrary
  1245. selectedWidgetId={
  1246. state.userHasModified ? null : state.prebuiltWidgetId
  1247. }
  1248. onWidgetSelect={prebuiltWidget => {
  1249. setLatestLibrarySelectionTitle(prebuiltWidget.title);
  1250. setDataSetConfig(
  1251. getDatasetConfig(
  1252. prebuiltWidget.widgetType || defaultWidgetType
  1253. )
  1254. );
  1255. const {id, ...prebuiltWidgetProps} = prebuiltWidget;
  1256. setState({
  1257. ...state,
  1258. ...prebuiltWidgetProps,
  1259. dataSet: prebuiltWidget.widgetType
  1260. ? WIDGET_TYPE_TO_DATA_SET[prebuiltWidget.widgetType]
  1261. : defaultDataset,
  1262. userHasModified: false,
  1263. prebuiltWidgetId: id || null,
  1264. });
  1265. }}
  1266. bypassOverwriteModal={!state.userHasModified}
  1267. />
  1268. </Side>
  1269. </Body>
  1270. </Layout.Page>
  1271. </MEPSettingProvider>
  1272. )}
  1273. </MetricsDataSwitcher>
  1274. </MetricsCardinalityProvider>
  1275. </DashboardsMEPProvider>
  1276. </MetricsResultsMetaProvider>
  1277. </OnDemandControlProvider>
  1278. </CustomMeasurementsProvider>
  1279. </PageFiltersContainer>
  1280. </SentryDocumentTitle>
  1281. );
  1282. }
  1283. export default withPageFilters(WidgetBuilder);
  1284. const TitleInput = styled(TextField)`
  1285. padding: 0 ${space(2)} 0 0;
  1286. `;
  1287. const BuildSteps = styled(List)`
  1288. gap: ${space(4)};
  1289. max-width: 100%;
  1290. `;
  1291. const Body = styled(Layout.Body)`
  1292. && {
  1293. gap: 0;
  1294. padding: 0;
  1295. }
  1296. grid-template-rows: 1fr;
  1297. @media (min-width: ${p => p.theme.breakpoints.large}) {
  1298. grid-template-columns: minmax(100px, auto) 400px;
  1299. }
  1300. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  1301. grid-template-columns: 1fr;
  1302. }
  1303. `;
  1304. // HACK: Since we add 30px of padding to the ListItems
  1305. // there is 30px of overlap when the screen is just above 1200px.
  1306. // When we're up to 1230px (1200 + 30 to account for the padding)
  1307. // we decrease the width of ListItems by 30px
  1308. const Main = styled(Layout.Main)`
  1309. max-width: 1000px;
  1310. flex: 1;
  1311. padding: ${space(4)} ${space(2)};
  1312. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  1313. padding: ${space(4)};
  1314. }
  1315. @media (max-width: calc(${p => p.theme.breakpoints.large} + ${space(4)})) {
  1316. ${ListItem} {
  1317. width: calc(100% - ${space(4)});
  1318. }
  1319. }
  1320. `;
  1321. const Side = styled(Layout.Side)`
  1322. padding: ${space(4)} ${space(2)};
  1323. @media (max-width: ${p => p.theme.breakpoints.large}) {
  1324. border-top: 1px solid ${p => p.theme.gray200};
  1325. grid-row: 2/2;
  1326. grid-column: 1/-1;
  1327. max-width: 100%;
  1328. }
  1329. @media (min-width: ${p => p.theme.breakpoints.large}) {
  1330. border-left: 1px solid ${p => p.theme.gray200};
  1331. /* to be consistent with Layout.Body in other verticals */
  1332. padding-right: ${space(4)};
  1333. max-width: 400px;
  1334. }
  1335. `;
  1336. const MainWrapper = styled('div')`
  1337. display: flex;
  1338. flex-direction: column;
  1339. @media (max-width: ${p => p.theme.breakpoints.large}) {
  1340. grid-column: 1/-1;
  1341. }
  1342. `;
  1343. const NameWidgetStep = styled(BuildStep)`
  1344. ${FieldWrapper} {
  1345. padding: 0 ${space(2)} 0 0;
  1346. border-bottom: none;
  1347. }
  1348. `;
  1349. const StyledTextAreaField = styled(TextareaField)`
  1350. margin-top: ${space(1.5)};
  1351. `;