widgetBuilder.tsx 37 KB

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