widgetBuilder.tsx 42 KB

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