widgetBuilder.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131
  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 * as Layout from 'sentry/components/layouts/thirds';
  14. import List from 'sentry/components/list';
  15. import LoadingError from 'sentry/components/loadingError';
  16. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  17. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  18. import {t} from 'sentry/locale';
  19. import {PageContent} from 'sentry/styles/organization';
  20. import space from 'sentry/styles/space';
  21. import {
  22. DateString,
  23. Organization,
  24. PageFilters,
  25. SelectValue,
  26. SessionMetric,
  27. TagCollection,
  28. } from 'sentry/types';
  29. import {defined, objectIsEmpty} from 'sentry/utils';
  30. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  31. import {
  32. explodeField,
  33. generateFieldAsString,
  34. getAggregateAlias,
  35. getColumnsAndAggregates,
  36. getColumnsAndAggregatesAsStrings,
  37. QueryFieldValue,
  38. stripDerivedMetricsPrefix,
  39. } from 'sentry/utils/discover/fields';
  40. import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
  41. import useApi from 'sentry/utils/useApi';
  42. import withPageFilters from 'sentry/utils/withPageFilters';
  43. import withTags from 'sentry/utils/withTags';
  44. import {
  45. assignTempId,
  46. enforceWidgetHeightValues,
  47. generateWidgetsAfterCompaction,
  48. getDefaultWidgetHeight,
  49. } from 'sentry/views/dashboardsV2/layoutUtils';
  50. import {
  51. DashboardDetails,
  52. DashboardListItem,
  53. DashboardWidgetSource,
  54. DisplayType,
  55. Widget,
  56. WidgetQuery,
  57. WidgetType,
  58. } from 'sentry/views/dashboardsV2/types';
  59. import {IssueSortOptions} from 'sentry/views/issueList/utils';
  60. import {DEFAULT_STATS_PERIOD} from '../data';
  61. import {ColumnsStep} from './buildSteps/columnsStep';
  62. import {DashboardStep} from './buildSteps/dashboardStep';
  63. import {DataSetStep} from './buildSteps/dataSetStep';
  64. import {FilterResultsStep} from './buildSteps/filterResultsStep';
  65. import {GroupByStep} from './buildSteps/groupByStep';
  66. import {SortByStep} from './buildSteps/sortByStep';
  67. import {VisualizationStep} from './buildSteps/visualizationStep';
  68. import {YAxisStep} from './buildSteps/yAxisStep';
  69. import {Footer} from './footer';
  70. import {Header} from './header';
  71. import {
  72. DataSet,
  73. DEFAULT_RESULTS_LIMIT,
  74. getParsedDefaultWidgetQuery,
  75. getResultsLimit,
  76. mapErrors,
  77. NEW_DASHBOARD_ID,
  78. normalizeQueries,
  79. } from './utils';
  80. import {WidgetLibrary} from './widgetLibrary';
  81. function getDataSetQuery(widgetBuilderNewDesign: boolean): Record<DataSet, WidgetQuery> {
  82. return {
  83. [DataSet.EVENTS]: {
  84. name: '',
  85. fields: ['count()'],
  86. columns: [],
  87. fieldAliases: [],
  88. aggregates: ['count()'],
  89. conditions: '',
  90. orderby: widgetBuilderNewDesign ? '-count' : '',
  91. },
  92. [DataSet.ISSUES]: {
  93. name: '',
  94. fields: ['issue', 'assignee', 'title'] as string[],
  95. columns: ['issue', 'assignee', 'title'],
  96. fieldAliases: [],
  97. aggregates: [],
  98. conditions: '',
  99. orderby: widgetBuilderNewDesign ? IssueSortOptions.DATE : '',
  100. },
  101. [DataSet.RELEASE]: {
  102. name: '',
  103. fields: [`sum(${SessionMetric.SESSION})`],
  104. columns: [],
  105. fieldAliases: [],
  106. aggregates: [`sum(${SessionMetric.SESSION})`],
  107. conditions: '',
  108. orderby: widgetBuilderNewDesign ? `-sum(${SessionMetric.SESSION})` : '',
  109. },
  110. };
  111. }
  112. const WIDGET_TYPE_TO_DATA_SET = {
  113. [WidgetType.DISCOVER]: DataSet.EVENTS,
  114. [WidgetType.ISSUE]: DataSet.ISSUES,
  115. [WidgetType.METRICS]: DataSet.RELEASE,
  116. };
  117. const DATA_SET_TO_WIDGET_TYPE = {
  118. [DataSet.EVENTS]: WidgetType.DISCOVER,
  119. [DataSet.ISSUES]: WidgetType.ISSUE,
  120. [DataSet.RELEASE]: WidgetType.METRICS,
  121. };
  122. interface RouteParams {
  123. dashboardId: string;
  124. orgId: string;
  125. widgetIndex?: string;
  126. }
  127. interface QueryData {
  128. queryConditions: string[];
  129. queryFields: string[];
  130. queryNames: string[];
  131. queryOrderby: string;
  132. }
  133. interface Props extends RouteComponentProps<RouteParams, {}> {
  134. dashboard: DashboardDetails;
  135. onSave: (widgets: Widget[]) => void;
  136. organization: Organization;
  137. selection: PageFilters;
  138. tags: TagCollection;
  139. displayType?: DisplayType;
  140. end?: DateString;
  141. start?: DateString;
  142. statsPeriod?: string | null;
  143. }
  144. interface State {
  145. dashboards: DashboardListItem[];
  146. dataSet: DataSet;
  147. displayType: Widget['displayType'];
  148. interval: Widget['interval'];
  149. limit: Widget['limit'];
  150. loading: boolean;
  151. queries: Widget['queries'];
  152. title: string;
  153. userHasModified: boolean;
  154. errors?: Record<string, any>;
  155. selectedDashboard?: SelectValue<string>;
  156. widgetToBeUpdated?: Widget;
  157. }
  158. function WidgetBuilder({
  159. dashboard,
  160. params,
  161. location,
  162. organization,
  163. selection,
  164. start,
  165. end,
  166. statsPeriod,
  167. onSave,
  168. router,
  169. tags,
  170. }: Props) {
  171. const {widgetIndex, orgId, dashboardId} = params;
  172. const {source, displayType, defaultTitle, defaultTableColumns, limit} = location.query;
  173. const defaultWidgetQuery = getParsedDefaultWidgetQuery(
  174. location.query.defaultWidgetQuery
  175. );
  176. useEffect(() => {
  177. if (objectIsEmpty(tags)) {
  178. loadOrganizationTags(api, organization.slug, selection);
  179. }
  180. }, []);
  181. const isEditing = defined(widgetIndex);
  182. const widgetIndexNum = Number(widgetIndex);
  183. const isValidWidgetIndex =
  184. widgetIndexNum >= 0 &&
  185. widgetIndexNum < dashboard.widgets.length &&
  186. Number.isInteger(widgetIndexNum);
  187. const orgSlug = organization.slug;
  188. // Feature flag for new widget builder design. This feature is still a work in progress and not yet available internally.
  189. const widgetBuilderNewDesign = organization.features.includes(
  190. 'new-widget-builder-experience-design'
  191. );
  192. // Construct PageFilters object using statsPeriod/start/end props so we can
  193. // render widget graph using saved timeframe from Saved/Prebuilt Query
  194. const pageFilters: PageFilters = statsPeriod
  195. ? {...selection, datetime: {start: null, end: null, period: statsPeriod, utc: null}}
  196. : start && end
  197. ? {...selection, datetime: {start, end, period: null, utc: null}}
  198. : selection;
  199. // when opening from discover or issues page, the user selects the dashboard in the widget UI
  200. const notDashboardsOrigin = [
  201. DashboardWidgetSource.DISCOVERV2,
  202. DashboardWidgetSource.ISSUE_DETAILS,
  203. ].includes(source);
  204. const api = useApi();
  205. const [state, setState] = useState<State>(() => {
  206. return {
  207. title: defaultTitle ?? t('Custom Widget'),
  208. displayType: displayType ?? DisplayType.TABLE,
  209. interval: '5m',
  210. queries: [
  211. defaultWidgetQuery
  212. ? widgetBuilderNewDesign
  213. ? {
  214. ...defaultWidgetQuery,
  215. orderby:
  216. defaultWidgetQuery.orderby ||
  217. generateOrderOptions({
  218. widgetType: WidgetType.DISCOVER,
  219. widgetBuilderNewDesign,
  220. columns: defaultWidgetQuery.columns,
  221. aggregates: defaultWidgetQuery.aggregates,
  222. })[0].value,
  223. }
  224. : {...defaultWidgetQuery}
  225. : {...getDataSetQuery(widgetBuilderNewDesign)[DataSet.EVENTS]},
  226. ],
  227. limit,
  228. errors: undefined,
  229. loading: !!notDashboardsOrigin,
  230. dashboards: [],
  231. userHasModified: false,
  232. dataSet: DataSet.EVENTS,
  233. };
  234. });
  235. const [widgetToBeUpdated, setWidgetToBeUpdated] = useState<Widget | null>(null);
  236. useEffect(() => {
  237. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.opened', {
  238. organization,
  239. new_widget: !isEditing,
  240. });
  241. if (isEditing && isValidWidgetIndex) {
  242. const widgetFromDashboard = dashboard.widgets[widgetIndexNum];
  243. const visualization =
  244. widgetBuilderNewDesign && widgetFromDashboard.displayType === DisplayType.TOP_N
  245. ? DisplayType.TABLE
  246. : widgetFromDashboard.displayType;
  247. setState({
  248. title: widgetFromDashboard.title,
  249. displayType: visualization,
  250. interval: widgetFromDashboard.interval,
  251. queries: normalizeQueries({
  252. displayType: visualization,
  253. queries: widgetFromDashboard.queries,
  254. widgetType: widgetFromDashboard.widgetType ?? WidgetType.DISCOVER,
  255. widgetBuilderNewDesign,
  256. }),
  257. errors: undefined,
  258. loading: false,
  259. dashboards: [],
  260. userHasModified: false,
  261. dataSet: widgetFromDashboard.widgetType
  262. ? WIDGET_TYPE_TO_DATA_SET[widgetFromDashboard.widgetType]
  263. : DataSet.EVENTS,
  264. limit: widgetFromDashboard.limit,
  265. });
  266. setWidgetToBeUpdated(widgetFromDashboard);
  267. }
  268. }, []);
  269. useEffect(() => {
  270. if (notDashboardsOrigin) {
  271. fetchDashboards();
  272. }
  273. if (widgetBuilderNewDesign) {
  274. setState(prevState => ({
  275. ...prevState,
  276. selectedDashboard: {
  277. label: dashboard.title,
  278. value: dashboard.id || NEW_DASHBOARD_ID,
  279. },
  280. }));
  281. }
  282. }, [source]);
  283. useEffect(() => {
  284. fetchOrgMembers(api, organization.slug, selection.projects?.map(String));
  285. }, [selection.projects]);
  286. const widgetType =
  287. state.dataSet === DataSet.EVENTS
  288. ? WidgetType.DISCOVER
  289. : state.dataSet === DataSet.ISSUES
  290. ? WidgetType.ISSUE
  291. : WidgetType.METRICS;
  292. const currentWidget = {
  293. title: state.title,
  294. displayType: state.displayType,
  295. interval: state.interval,
  296. queries: state.queries,
  297. limit: state.limit,
  298. widgetType,
  299. };
  300. const currentDashboardId = state.selectedDashboard?.value ?? dashboardId;
  301. const queryParamsWithoutSource = omit(location.query, 'source');
  302. const previousLocation = {
  303. pathname:
  304. defined(currentDashboardId) && currentDashboardId !== NEW_DASHBOARD_ID
  305. ? `/organizations/${orgId}/dashboard/${currentDashboardId}/`
  306. : `/organizations/${orgId}/dashboards/${NEW_DASHBOARD_ID}/`,
  307. query: isEmpty(queryParamsWithoutSource) ? undefined : queryParamsWithoutSource,
  308. };
  309. const isTimeseriesChart = [
  310. DisplayType.LINE,
  311. DisplayType.BAR,
  312. DisplayType.AREA,
  313. ].includes(state.displayType);
  314. const isTabularChart = [DisplayType.TABLE, DisplayType.TOP_N].includes(
  315. state.displayType
  316. );
  317. function updateFieldsAccordingToDisplayType(newDisplayType: DisplayType) {
  318. setState(prevState => {
  319. const newState = cloneDeep(prevState);
  320. const normalized = normalizeQueries({
  321. displayType: newDisplayType,
  322. queries: prevState.queries,
  323. widgetType: DATA_SET_TO_WIDGET_TYPE[prevState.dataSet],
  324. widgetBuilderNewDesign,
  325. });
  326. if (newDisplayType === DisplayType.TOP_N) {
  327. // TOP N display should only allow a single query
  328. normalized.splice(1);
  329. }
  330. if (widgetBuilderNewDesign && !isTabularChart && !isTimeseriesChart) {
  331. newState.limit = undefined;
  332. }
  333. if (
  334. (prevState.displayType === DisplayType.TABLE &&
  335. widgetToBeUpdated?.widgetType &&
  336. WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === DataSet.ISSUES) ||
  337. (prevState.dataSet === DataSet.RELEASE &&
  338. newDisplayType === DisplayType.WORLD_MAP)
  339. ) {
  340. // World Map display type only supports Events Dataset
  341. // so set state to default events query.
  342. set(
  343. newState,
  344. 'queries',
  345. normalizeQueries({
  346. displayType: newDisplayType,
  347. queries: [{...getDataSetQuery(widgetBuilderNewDesign)[DataSet.EVENTS]}],
  348. widgetType: WidgetType.DISCOVER,
  349. widgetBuilderNewDesign,
  350. })
  351. );
  352. set(newState, 'dataSet', DataSet.EVENTS);
  353. return {...newState, errors: undefined};
  354. }
  355. if (!prevState.userHasModified) {
  356. // If the Widget is an issue widget,
  357. if (
  358. newDisplayType === DisplayType.TABLE &&
  359. widgetToBeUpdated?.widgetType === WidgetType.ISSUE
  360. ) {
  361. set(newState, 'queries', widgetToBeUpdated.queries);
  362. set(newState, 'dataSet', DataSet.ISSUES);
  363. return {...newState, errors: undefined};
  364. }
  365. // Default widget provided by Add to Dashboard from Discover
  366. if (defaultWidgetQuery && defaultTableColumns) {
  367. // If switching to Table visualization, use saved query fields for Y-Axis if user has not made query changes
  368. // This is so the widget can reflect the same columns as the table in Discover without requiring additional user input
  369. if (newDisplayType === DisplayType.TABLE) {
  370. normalized.forEach(query => {
  371. const tableQuery = getColumnsAndAggregates(defaultTableColumns);
  372. query.columns = [...tableQuery.columns];
  373. query.aggregates = [...tableQuery.aggregates];
  374. query.fields = [...defaultTableColumns];
  375. });
  376. } else if (newDisplayType === displayType) {
  377. // When switching back to original display type, default fields back to the fields provided from the discover query
  378. normalized.forEach(query => {
  379. query.fields = [
  380. ...defaultWidgetQuery.columns,
  381. ...defaultWidgetQuery.aggregates,
  382. ];
  383. query.aggregates = [...defaultWidgetQuery.aggregates];
  384. query.columns = [...defaultWidgetQuery.columns];
  385. if (!!defaultWidgetQuery.orderby) {
  386. query.orderby = defaultWidgetQuery.orderby;
  387. }
  388. });
  389. }
  390. }
  391. }
  392. if (prevState.dataSet === DataSet.ISSUES) {
  393. set(newState, 'dataSet', DataSet.EVENTS);
  394. }
  395. set(newState, 'queries', normalized);
  396. return {...newState, errors: undefined};
  397. });
  398. }
  399. function handleDisplayTypeOrTitleChange<
  400. F extends keyof Pick<State, 'displayType' | 'title'>
  401. >(field: F, value: State[F]) {
  402. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.change', {
  403. from: source,
  404. field,
  405. value,
  406. widget_type: widgetType,
  407. organization,
  408. new_widget: !isEditing,
  409. });
  410. setState(prevState => {
  411. const newState = cloneDeep(prevState);
  412. set(newState, field, value);
  413. return {...newState, errors: undefined};
  414. });
  415. if (field === 'displayType' && value !== state.displayType) {
  416. updateFieldsAccordingToDisplayType(value as DisplayType);
  417. }
  418. }
  419. function handleDataSetChange(newDataSet: string) {
  420. setState(prevState => {
  421. const newState = cloneDeep(prevState);
  422. newState.queries.splice(0, newState.queries.length);
  423. set(newState, 'dataSet', newDataSet);
  424. if (newDataSet === DataSet.ISSUES) {
  425. set(newState, 'displayType', DisplayType.TABLE);
  426. }
  427. newState.queries.push(
  428. ...(widgetToBeUpdated?.widgetType &&
  429. WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === newDataSet
  430. ? widgetToBeUpdated.queries
  431. : [{...getDataSetQuery(widgetBuilderNewDesign)[newDataSet]}])
  432. );
  433. set(newState, 'userHasModified', true);
  434. return {...newState, errors: undefined};
  435. });
  436. }
  437. function handleAddSearchConditions() {
  438. setState(prevState => {
  439. const newState = cloneDeep(prevState);
  440. const query = cloneDeep(getDataSetQuery(widgetBuilderNewDesign)[prevState.dataSet]);
  441. query.fields = prevState.queries[0].fields;
  442. query.aggregates = prevState.queries[0].aggregates;
  443. query.columns = prevState.queries[0].columns;
  444. newState.queries.push(query);
  445. return newState;
  446. });
  447. }
  448. function handleQueryRemove(index: number) {
  449. setState(prevState => {
  450. const newState = cloneDeep(prevState);
  451. newState.queries.splice(index, 1);
  452. return {...newState, errors: undefined};
  453. });
  454. }
  455. function handleQueryChange(queryIndex: number, newQuery: WidgetQuery) {
  456. setState(prevState => {
  457. const newState = cloneDeep(prevState);
  458. set(newState, `queries.${queryIndex}`, newQuery);
  459. set(newState, 'userHasModified', true);
  460. return {...newState, errors: undefined};
  461. });
  462. }
  463. function handleYAxisOrColumnFieldChange(
  464. newFields: QueryFieldValue[],
  465. isColumn = false
  466. ) {
  467. const fieldStrings = newFields.map(generateFieldAsString);
  468. const aggregateAliasFieldStrings =
  469. state.dataSet === DataSet.RELEASE
  470. ? fieldStrings.map(stripDerivedMetricsPrefix)
  471. : fieldStrings.map(getAggregateAlias);
  472. const columnsAndAggregates = isColumn
  473. ? getColumnsAndAggregatesAsStrings(newFields)
  474. : undefined;
  475. const newState = cloneDeep(state);
  476. const newQueries = state.queries.map(query => {
  477. const isDescending = query.orderby.startsWith('-');
  478. const orderbyAggregateAliasField = query.orderby.replace('-', '');
  479. const prevAggregateAliasFieldStrings = query.aggregates.map(aggregate =>
  480. state.dataSet === DataSet.RELEASE
  481. ? stripDerivedMetricsPrefix(aggregate)
  482. : getAggregateAlias(aggregate)
  483. );
  484. const newQuery = cloneDeep(query);
  485. if (isColumn) {
  486. newQuery.fields = fieldStrings;
  487. newQuery.aggregates = columnsAndAggregates?.aggregates ?? [];
  488. } else if (state.displayType === DisplayType.TOP_N) {
  489. // Top N queries use n-1 fields for columns and the nth field for y-axis
  490. newQuery.fields = [
  491. ...(newQuery.fields?.slice(0, newQuery.fields.length - 1) ?? []),
  492. ...fieldStrings,
  493. ];
  494. newQuery.aggregates = [
  495. ...newQuery.aggregates.slice(0, newQuery.aggregates.length - 1),
  496. ...fieldStrings,
  497. ];
  498. } else {
  499. newQuery.fields = [...newQuery.columns, ...fieldStrings];
  500. newQuery.aggregates = fieldStrings;
  501. }
  502. // Prevent overwriting columns when setting y-axis for time series
  503. if (!(widgetBuilderNewDesign && isTimeseriesChart) && isColumn) {
  504. newQuery.columns = columnsAndAggregates?.columns ?? [];
  505. }
  506. if (
  507. !aggregateAliasFieldStrings.includes(orderbyAggregateAliasField) &&
  508. query.orderby !== ''
  509. ) {
  510. if (prevAggregateAliasFieldStrings.length === newFields.length) {
  511. // The Field that was used in orderby has changed. Get the new field.
  512. const newOrderByValue =
  513. aggregateAliasFieldStrings[
  514. prevAggregateAliasFieldStrings.indexOf(orderbyAggregateAliasField)
  515. ];
  516. if (isDescending) {
  517. newQuery.orderby = `-${newOrderByValue}`;
  518. } else {
  519. newQuery.orderby = newOrderByValue;
  520. }
  521. } else {
  522. newQuery.orderby = widgetBuilderNewDesign ? aggregateAliasFieldStrings[0] : '';
  523. }
  524. }
  525. if (widgetBuilderNewDesign) {
  526. newQuery.fieldAliases = columnsAndAggregates?.fieldAliases ?? [];
  527. }
  528. return newQuery;
  529. });
  530. set(newState, 'queries', newQueries);
  531. set(newState, 'userHasModified', true);
  532. if (widgetBuilderNewDesign && isTimeseriesChart) {
  533. const groupByFields = newState.queries[0].columns.filter(
  534. field => !(field === 'equation|')
  535. );
  536. if (groupByFields.length === 0) {
  537. set(newState, 'limit', undefined);
  538. } else {
  539. set(
  540. newState,
  541. 'limit',
  542. Math.min(
  543. newState.limit ?? DEFAULT_RESULTS_LIMIT,
  544. getResultsLimit(newQueries.length, newQueries[0].aggregates.length)
  545. )
  546. );
  547. }
  548. }
  549. setState(newState);
  550. }
  551. function handleGroupByChange(newFields: QueryFieldValue[]) {
  552. const fieldStrings = newFields.map(generateFieldAsString);
  553. const newState = cloneDeep(state);
  554. const newQueries = state.queries.map(query => {
  555. const newQuery = cloneDeep(query);
  556. newQuery.columns = fieldStrings;
  557. return newQuery;
  558. });
  559. set(newState, 'userHasModified', true);
  560. set(newState, 'queries', newQueries);
  561. if (widgetBuilderNewDesign && isTimeseriesChart) {
  562. const groupByFields = newState.queries[0].columns.filter(
  563. field => !(field === 'equation|')
  564. );
  565. if (groupByFields.length === 0) {
  566. set(newState, 'limit', undefined);
  567. } else {
  568. set(
  569. newState,
  570. 'limit',
  571. Math.min(
  572. newState.limit ?? DEFAULT_RESULTS_LIMIT,
  573. getResultsLimit(newQueries.length, newQueries[0].aggregates.length)
  574. )
  575. );
  576. }
  577. }
  578. setState(newState);
  579. }
  580. function handleLimitChange(newLimit: number) {
  581. setState(prevState => ({...prevState, limit: newLimit}));
  582. }
  583. function handleSortByChange(newSortBy: string) {
  584. const newState = cloneDeep(state);
  585. state.queries.forEach((query, index) => {
  586. const newQuery = cloneDeep(query);
  587. newQuery.orderby = newSortBy;
  588. set(newState, `queries.${index}`, newQuery);
  589. });
  590. set(newState, 'userHasModified', true);
  591. setState(newState);
  592. }
  593. function handleDelete() {
  594. if (!isEditing) {
  595. return;
  596. }
  597. let nextWidgetList = [...dashboard.widgets];
  598. nextWidgetList.splice(widgetIndexNum, 1);
  599. nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
  600. onSave(nextWidgetList);
  601. router.push(previousLocation);
  602. }
  603. async function handleSave() {
  604. const widgetData: Widget = assignTempId(currentWidget);
  605. if (widgetToBeUpdated) {
  606. widgetData.layout = widgetToBeUpdated?.layout;
  607. }
  608. // Only Table and Top N views need orderby
  609. if (!widgetBuilderNewDesign && !isTabularChart) {
  610. widgetData.queries.forEach(query => {
  611. query.orderby = '';
  612. });
  613. }
  614. if (!widgetBuilderNewDesign) {
  615. widgetData.queries.forEach(query => omit(query, 'fieldAliases'));
  616. }
  617. // Only Time Series charts shall have a limit
  618. if (widgetBuilderNewDesign && !isTimeseriesChart) {
  619. widgetData.limit = undefined;
  620. }
  621. if (!(await dataIsValid(widgetData))) {
  622. return;
  623. }
  624. if (notDashboardsOrigin) {
  625. submitFromSelectedDashboard(widgetData);
  626. return;
  627. }
  628. if (!!widgetToBeUpdated) {
  629. let nextWidgetList = [...dashboard.widgets];
  630. const updateIndex = nextWidgetList.indexOf(widgetToBeUpdated);
  631. const nextWidgetData = {...widgetData, id: widgetToBeUpdated.id};
  632. // Only modify and re-compact if the default height has changed
  633. if (
  634. getDefaultWidgetHeight(widgetToBeUpdated.displayType) !==
  635. getDefaultWidgetHeight(widgetData.displayType)
  636. ) {
  637. nextWidgetList[updateIndex] = enforceWidgetHeightValues(nextWidgetData);
  638. nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
  639. } else {
  640. nextWidgetList[updateIndex] = nextWidgetData;
  641. }
  642. onSave(nextWidgetList);
  643. addSuccessMessage(t('Updated widget.'));
  644. goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
  645. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.save', {
  646. organization,
  647. data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
  648. new_widget: false,
  649. });
  650. return;
  651. }
  652. onSave([...dashboard.widgets, widgetData]);
  653. addSuccessMessage(t('Added widget.'));
  654. goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
  655. trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.save', {
  656. organization,
  657. data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
  658. new_widget: true,
  659. });
  660. }
  661. async function dataIsValid(widgetData: Widget): Promise<boolean> {
  662. if (notDashboardsOrigin) {
  663. // Validate that a dashboard was selected since api call to /dashboards/widgets/ does not check for dashboard
  664. if (
  665. !state.selectedDashboard ||
  666. !(
  667. state.dashboards.find(
  668. ({title, id}) =>
  669. title === state.selectedDashboard?.label &&
  670. id === state.selectedDashboard?.value
  671. ) || state.selectedDashboard.value === NEW_DASHBOARD_ID
  672. )
  673. ) {
  674. setState({
  675. ...state,
  676. errors: {...state.errors, dashboard: t('This field may not be blank')},
  677. });
  678. return false;
  679. }
  680. }
  681. setState({...state, loading: true});
  682. try {
  683. await validateWidget(api, organization.slug, widgetData);
  684. return true;
  685. } catch (error) {
  686. setState({
  687. ...state,
  688. loading: false,
  689. errors: {...state.errors, ...mapErrors(error?.responseJSON ?? {}, {})},
  690. });
  691. return false;
  692. }
  693. }
  694. async function fetchDashboards() {
  695. const promise: Promise<DashboardListItem[]> = api.requestPromise(
  696. `/organizations/${organization.slug}/dashboards/`,
  697. {
  698. method: 'GET',
  699. query: {sort: 'myDashboardsAndRecentlyViewed'},
  700. }
  701. );
  702. try {
  703. const dashboards = await promise;
  704. setState(prevState => ({...prevState, dashboards, loading: false}));
  705. } catch (error) {
  706. const errorMessage = t('Unable to fetch dashboards');
  707. addErrorMessage(errorMessage);
  708. handleXhrErrorResponse(errorMessage)(error);
  709. setState(prevState => ({...prevState, loading: false}));
  710. }
  711. }
  712. function submitFromSelectedDashboard(widgetData: Widget) {
  713. if (!state.selectedDashboard) {
  714. return;
  715. }
  716. const queryData: QueryData = {
  717. queryNames: [],
  718. queryConditions: [],
  719. queryFields: [
  720. ...widgetData.queries[0].columns,
  721. ...widgetData.queries[0].aggregates,
  722. ],
  723. queryOrderby: widgetData.queries[0].orderby,
  724. };
  725. widgetData.queries.forEach(query => {
  726. queryData.queryNames.push(query.name);
  727. queryData.queryConditions.push(query.conditions);
  728. });
  729. const pathQuery = {
  730. displayType: widgetData.displayType,
  731. interval: widgetData.interval,
  732. title: widgetData.title,
  733. ...queryData,
  734. // Propagate page filters
  735. project: pageFilters.projects,
  736. environment: pageFilters.environments,
  737. ...omit(pageFilters.datetime, 'period'),
  738. statsPeriod: pageFilters.datetime?.period,
  739. };
  740. addSuccessMessage(t('Added widget.'));
  741. goToDashboards(state.selectedDashboard.value, pathQuery);
  742. }
  743. function goToDashboards(id: string, query?: Record<string, any>) {
  744. const pathQuery =
  745. !isEmpty(queryParamsWithoutSource) || query
  746. ? {
  747. ...queryParamsWithoutSource,
  748. ...query,
  749. }
  750. : undefined;
  751. if (id === NEW_DASHBOARD_ID) {
  752. router.push({
  753. pathname: `/organizations/${organization.slug}/dashboards/new/`,
  754. query: pathQuery,
  755. });
  756. return;
  757. }
  758. router.push({
  759. pathname: `/organizations/${organization.slug}/dashboard/${id}/`,
  760. query: pathQuery,
  761. });
  762. }
  763. function isFormInvalid() {
  764. if (notDashboardsOrigin && !state.selectedDashboard) {
  765. return true;
  766. }
  767. return false;
  768. }
  769. if (isEditing && !isValidWidgetIndex) {
  770. return (
  771. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  772. <PageContent>
  773. <LoadingError message={t('The widget you want to edit was not found.')} />
  774. </PageContent>
  775. </SentryDocumentTitle>
  776. );
  777. }
  778. const canAddSearchConditions =
  779. [DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(state.displayType) &&
  780. state.queries.length < 3;
  781. const hideLegendAlias = [
  782. DisplayType.TABLE,
  783. DisplayType.WORLD_MAP,
  784. DisplayType.BIG_NUMBER,
  785. ].includes(state.displayType);
  786. // Tabular visualizations will always have only one query and that query cannot be deleted,
  787. // so we will always have the first query available to get data from.
  788. const {columns, aggregates, fields, fieldAliases = []} = state.queries[0];
  789. const explodedColumns = useMemo(() => {
  790. return columns.map((field, index) =>
  791. explodeField({field, alias: fieldAliases[index]})
  792. );
  793. }, [columns, fieldAliases]);
  794. const explodedAggregates = useMemo(() => {
  795. return aggregates.map((field, index) =>
  796. explodeField({field, alias: fieldAliases[index]})
  797. );
  798. }, [aggregates, fieldAliases]);
  799. const explodedFields = defined(fields)
  800. ? fields.map((field, index) => explodeField({field, alias: fieldAliases[index]}))
  801. : [...explodedColumns, ...explodedAggregates];
  802. return (
  803. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  804. <PageFiltersContainer
  805. skipLoadLastUsed={organization.features.includes('global-views')}
  806. defaultSelection={{
  807. datetime: {start: null, end: null, utc: false, period: DEFAULT_STATS_PERIOD},
  808. }}
  809. >
  810. <PageContentWithoutPadding>
  811. <Header
  812. orgSlug={orgSlug}
  813. title={state.title}
  814. dashboardTitle={dashboard.title}
  815. goBackLocation={previousLocation}
  816. onChangeTitle={newTitle => {
  817. handleDisplayTypeOrTitleChange('title', newTitle);
  818. }}
  819. />
  820. <Body>
  821. <MainWrapper>
  822. <Main>
  823. <BuildSteps symbol="colored-numeric">
  824. <VisualizationStep
  825. widget={currentWidget}
  826. organization={organization}
  827. pageFilters={pageFilters}
  828. displayType={state.displayType}
  829. error={state.errors?.displayType}
  830. onChange={newDisplayType => {
  831. handleDisplayTypeOrTitleChange('displayType', newDisplayType);
  832. }}
  833. />
  834. <DataSetStep
  835. dataSet={state.dataSet}
  836. displayType={state.displayType}
  837. onChange={handleDataSetChange}
  838. widgetBuilderNewDesign={widgetBuilderNewDesign}
  839. />
  840. {isTabularChart && (
  841. <ColumnsStep
  842. dataSet={state.dataSet}
  843. queries={state.queries}
  844. displayType={state.displayType}
  845. widgetType={widgetType}
  846. queryErrors={state.errors?.queries}
  847. onQueryChange={handleQueryChange}
  848. onYAxisOrColumnFieldChange={newFields => {
  849. handleYAxisOrColumnFieldChange(newFields, true);
  850. }}
  851. explodedFields={explodedFields}
  852. tags={tags}
  853. organization={organization}
  854. />
  855. )}
  856. {![DisplayType.TABLE].includes(state.displayType) && (
  857. <YAxisStep
  858. dataSet={state.dataSet}
  859. displayType={state.displayType}
  860. widgetType={widgetType}
  861. queryErrors={state.errors?.queries}
  862. onYAxisChange={newFields => {
  863. handleYAxisOrColumnFieldChange(newFields);
  864. }}
  865. aggregates={explodedAggregates}
  866. tags={tags}
  867. organization={organization}
  868. />
  869. )}
  870. <FilterResultsStep
  871. queries={state.queries}
  872. hideLegendAlias={hideLegendAlias}
  873. canAddSearchConditions={canAddSearchConditions}
  874. organization={organization}
  875. queryErrors={state.errors?.queries}
  876. onAddSearchConditions={handleAddSearchConditions}
  877. onQueryChange={handleQueryChange}
  878. onQueryRemove={handleQueryRemove}
  879. selection={pageFilters}
  880. widgetType={widgetType}
  881. />
  882. {widgetBuilderNewDesign && isTimeseriesChart && (
  883. <GroupByStep
  884. columns={columns
  885. .filter(field => !(field === 'equation|'))
  886. .map((field, index) =>
  887. explodeField({field, alias: fieldAliases[index]})
  888. )}
  889. onGroupByChange={handleGroupByChange}
  890. organization={organization}
  891. tags={tags}
  892. dataSet={state.dataSet}
  893. />
  894. )}
  895. {((widgetBuilderNewDesign && isTimeseriesChart) || isTabularChart) && (
  896. <SortByStep
  897. limit={state.limit}
  898. displayType={state.displayType}
  899. queries={state.queries}
  900. dataSet={state.dataSet}
  901. widgetBuilderNewDesign={widgetBuilderNewDesign}
  902. error={state.errors?.orderby}
  903. onSortByChange={handleSortByChange}
  904. onLimitChange={handleLimitChange}
  905. organization={organization}
  906. widgetType={widgetType}
  907. />
  908. )}
  909. {notDashboardsOrigin && !widgetBuilderNewDesign && (
  910. <DashboardStep
  911. error={state.errors?.dashboard}
  912. dashboards={state.dashboards}
  913. onChange={selectedDashboard =>
  914. setState({
  915. ...state,
  916. selectedDashboard,
  917. errors: {...state.errors, dashboard: undefined},
  918. })
  919. }
  920. disabled={state.loading}
  921. />
  922. )}
  923. </BuildSteps>
  924. </Main>
  925. <Footer
  926. goBackLocation={previousLocation}
  927. isEditing={isEditing}
  928. onSave={handleSave}
  929. onDelete={handleDelete}
  930. invalidForm={isFormInvalid()}
  931. />
  932. </MainWrapper>
  933. <Side>
  934. <WidgetLibrary
  935. widgetBuilderNewDesign={widgetBuilderNewDesign}
  936. onWidgetSelect={prebuiltWidget =>
  937. setState({
  938. ...state,
  939. ...prebuiltWidget,
  940. dataSet: prebuiltWidget.widgetType
  941. ? WIDGET_TYPE_TO_DATA_SET[prebuiltWidget.widgetType]
  942. : DataSet.EVENTS,
  943. userHasModified: false,
  944. })
  945. }
  946. bypassOverwriteModal={!state.userHasModified}
  947. />
  948. </Side>
  949. </Body>
  950. </PageContentWithoutPadding>
  951. </PageFiltersContainer>
  952. </SentryDocumentTitle>
  953. );
  954. }
  955. export default withPageFilters(withTags(WidgetBuilder));
  956. const PageContentWithoutPadding = styled(PageContent)`
  957. padding: 0;
  958. `;
  959. const BuildSteps = styled(List)`
  960. gap: ${space(4)};
  961. max-width: 100%;
  962. `;
  963. const Body = styled(Layout.Body)`
  964. grid-template-rows: 1fr;
  965. && {
  966. gap: 0;
  967. padding: 0;
  968. }
  969. @media (max-width: ${p => p.theme.breakpoints[3]}) {
  970. grid-template-columns: 1fr;
  971. }
  972. @media (min-width: ${p => p.theme.breakpoints[2]}) {
  973. /* 325px + 16px + 16px to match Side component width, padding-left and padding-right */
  974. grid-template-columns: minmax(100px, auto) calc(325px + ${space(2) + space(2)});
  975. }
  976. @media (min-width: ${p => p.theme.breakpoints[3]}) {
  977. /* 325px + 16px + 30px to match Side component width, padding-left and padding-right */
  978. grid-template-columns: minmax(100px, auto) calc(325px + ${space(2) + space(4)});
  979. }
  980. `;
  981. const Main = styled(Layout.Main)`
  982. max-width: 1000px;
  983. flex: 1;
  984. padding: ${space(4)} ${space(2)};
  985. @media (min-width: ${p => p.theme.breakpoints[1]}) {
  986. padding: ${space(4)};
  987. }
  988. `;
  989. const Side = styled(Layout.Side)`
  990. padding: ${space(4)} ${space(2)};
  991. @media (min-width: ${p => p.theme.breakpoints[3]}) {
  992. border-left: 1px solid ${p => p.theme.gray200};
  993. /* to be consistent with Layout.Body in other verticals */
  994. padding-right: ${space(4)};
  995. }
  996. @media (max-width: ${p => p.theme.breakpoints[3]}) {
  997. border-top: 1px solid ${p => p.theme.gray200};
  998. }
  999. @media (max-width: ${p => p.theme.breakpoints[3]}) {
  1000. grid-row: 2/2;
  1001. grid-column: 1/1;
  1002. }
  1003. `;
  1004. const MainWrapper = styled('div')`
  1005. display: flex;
  1006. flex-direction: column;
  1007. `;