widgetBuilder.tsx 48 KB

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