useWidgetBuilderState.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. import {useCallback, useMemo} from 'react';
  2. import partition from 'lodash/partition';
  3. import {defined} from 'sentry/utils';
  4. import {
  5. type Column,
  6. explodeField,
  7. generateFieldAsString,
  8. isAggregateFieldOrEquation,
  9. type QueryFieldValue,
  10. type Sort,
  11. } from 'sentry/utils/discover/fields';
  12. import {
  13. decodeInteger,
  14. decodeList,
  15. decodeScalar,
  16. decodeSorts,
  17. } from 'sentry/utils/queryString';
  18. import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
  19. import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
  20. import type {ThresholdsConfig} from 'sentry/views/dashboards/widgetBuilder/buildSteps/thresholdsStep/thresholdsStep';
  21. import {MAX_NUM_Y_AXES} from 'sentry/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector';
  22. import {useQueryParamState} from 'sentry/views/dashboards/widgetBuilder/hooks/useQueryParamState';
  23. import {DEFAULT_RESULTS_LIMIT} from 'sentry/views/dashboards/widgetBuilder/utils';
  24. import type {Thresholds} from 'sentry/views/dashboards/widgets/common/types';
  25. import {FieldValueKind} from 'sentry/views/discover/table/types';
  26. export type WidgetBuilderStateQueryParams = {
  27. dataset?: WidgetType;
  28. description?: string;
  29. displayType?: DisplayType;
  30. field?: string[];
  31. legendAlias?: string[];
  32. limit?: number;
  33. query?: string[];
  34. selectedAggregate?: number;
  35. sort?: string[];
  36. thresholds?: string;
  37. title?: string;
  38. yAxis?: string[];
  39. };
  40. export const BuilderStateAction = {
  41. SET_TITLE: 'SET_TITLE',
  42. SET_DESCRIPTION: 'SET_DESCRIPTION',
  43. SET_DISPLAY_TYPE: 'SET_DISPLAY_TYPE',
  44. SET_DATASET: 'SET_DATASET',
  45. SET_FIELDS: 'SET_FIELDS',
  46. SET_Y_AXIS: 'SET_Y_AXIS',
  47. SET_QUERY: 'SET_QUERY',
  48. SET_SORT: 'SET_SORT',
  49. SET_LIMIT: 'SET_LIMIT',
  50. SET_LEGEND_ALIAS: 'SET_LEGEND_ALIAS',
  51. SET_SELECTED_AGGREGATE: 'SET_SELECTED_AGGREGATE',
  52. SET_STATE: 'SET_STATE',
  53. SET_THRESHOLDS: 'SET_THRESHOLDS',
  54. } as const;
  55. type WidgetAction =
  56. | {payload: string; type: typeof BuilderStateAction.SET_TITLE}
  57. | {payload: string; type: typeof BuilderStateAction.SET_DESCRIPTION}
  58. | {payload: DisplayType; type: typeof BuilderStateAction.SET_DISPLAY_TYPE}
  59. | {payload: WidgetType; type: typeof BuilderStateAction.SET_DATASET}
  60. | {payload: Column[]; type: typeof BuilderStateAction.SET_FIELDS}
  61. | {payload: Column[]; type: typeof BuilderStateAction.SET_Y_AXIS}
  62. | {payload: string[]; type: typeof BuilderStateAction.SET_QUERY}
  63. | {payload: Sort[]; type: typeof BuilderStateAction.SET_SORT}
  64. | {payload: number; type: typeof BuilderStateAction.SET_LIMIT}
  65. | {payload: string[]; type: typeof BuilderStateAction.SET_LEGEND_ALIAS}
  66. | {payload: number | undefined; type: typeof BuilderStateAction.SET_SELECTED_AGGREGATE}
  67. | {payload: WidgetBuilderStateQueryParams; type: typeof BuilderStateAction.SET_STATE}
  68. | {
  69. payload: ThresholdsConfig | undefined;
  70. type: typeof BuilderStateAction.SET_THRESHOLDS;
  71. };
  72. export interface WidgetBuilderState {
  73. dataset?: WidgetType;
  74. description?: string;
  75. displayType?: DisplayType;
  76. fields?: Column[];
  77. legendAlias?: string[];
  78. limit?: number;
  79. query?: string[];
  80. selectedAggregate?: number;
  81. sort?: Sort[];
  82. thresholds?: Thresholds;
  83. title?: string;
  84. yAxis?: Column[];
  85. }
  86. function useWidgetBuilderState(): {
  87. dispatch: (action: WidgetAction) => void;
  88. state: WidgetBuilderState;
  89. } {
  90. const [title, setTitle] = useQueryParamState<string>({fieldName: 'title'});
  91. const [description, setDescription] = useQueryParamState<string>({
  92. fieldName: 'description',
  93. });
  94. const [displayType, setDisplayType] = useQueryParamState<DisplayType>({
  95. fieldName: 'displayType',
  96. deserializer: deserializeDisplayType,
  97. });
  98. const [dataset, setDataset] = useQueryParamState<WidgetType>({
  99. fieldName: 'dataset',
  100. deserializer: deserializeDataset,
  101. });
  102. const [fields, setFields] = useQueryParamState<Column[]>({
  103. fieldName: 'field',
  104. decoder: decodeList,
  105. deserializer: deserializeFields,
  106. serializer: serializeFields,
  107. });
  108. const [yAxis, setYAxis] = useQueryParamState<Column[]>({
  109. fieldName: 'yAxis',
  110. decoder: decodeList,
  111. deserializer: deserializeFields,
  112. serializer: serializeFields,
  113. });
  114. const [query, setQuery] = useQueryParamState<string[]>({
  115. fieldName: 'query',
  116. decoder: decodeList,
  117. deserializer: deserializeQuery,
  118. });
  119. const [sort, setSort] = useQueryParamState<Sort[]>({
  120. fieldName: 'sort',
  121. decoder: decodeSorts,
  122. serializer: serializeSorts,
  123. });
  124. const [limit, setLimit] = useQueryParamState<number>({
  125. fieldName: 'limit',
  126. decoder: decodeScalar,
  127. deserializer: deserializeLimit,
  128. });
  129. const [legendAlias, setLegendAlias] = useQueryParamState<string[]>({
  130. fieldName: 'legendAlias',
  131. decoder: decodeList,
  132. });
  133. const [selectedAggregate, setSelectedAggregate] = useQueryParamState<number>({
  134. fieldName: 'selectedAggregate',
  135. decoder: decodeScalar,
  136. deserializer: deserializeSelectedAggregate,
  137. });
  138. const [thresholds, setThresholds] = useQueryParamState<ThresholdsConfig>({
  139. fieldName: 'thresholds',
  140. decoder: decodeScalar,
  141. deserializer: deserializeThresholds,
  142. serializer: serializeThresholds,
  143. });
  144. const state = useMemo(
  145. () => ({
  146. title,
  147. description,
  148. displayType,
  149. dataset,
  150. fields,
  151. yAxis,
  152. query,
  153. sort,
  154. limit,
  155. legendAlias,
  156. thresholds,
  157. // The selected aggregate is the last aggregate for big number widgets
  158. // if it hasn't been explicitly set
  159. selectedAggregate:
  160. displayType === DisplayType.BIG_NUMBER && defined(fields) && fields.length > 1
  161. ? selectedAggregate ?? fields.length - 1
  162. : undefined,
  163. }),
  164. [
  165. title,
  166. description,
  167. displayType,
  168. dataset,
  169. fields,
  170. yAxis,
  171. query,
  172. sort,
  173. limit,
  174. legendAlias,
  175. selectedAggregate,
  176. thresholds,
  177. ]
  178. );
  179. const dispatch = useCallback(
  180. (action: WidgetAction) => {
  181. switch (action.type) {
  182. case BuilderStateAction.SET_TITLE:
  183. setTitle(action.payload);
  184. break;
  185. case BuilderStateAction.SET_DESCRIPTION:
  186. setDescription(action.payload);
  187. break;
  188. case BuilderStateAction.SET_DISPLAY_TYPE:
  189. setDisplayType(action.payload);
  190. const [aggregates, columns] = partition(fields, field => {
  191. const fieldString = generateFieldAsString(field);
  192. return isAggregateFieldOrEquation(fieldString);
  193. });
  194. const columnsWithoutAlias = columns.map(column => {
  195. return {...column, alias: undefined};
  196. });
  197. const aggregatesWithoutAlias = aggregates.map(aggregate => {
  198. return {...aggregate, alias: undefined};
  199. });
  200. const yAxisWithoutAlias = yAxis?.map(axis => {
  201. return {...axis, alias: undefined};
  202. });
  203. if (action.payload === DisplayType.TABLE) {
  204. setYAxis([]);
  205. setLegendAlias([]);
  206. const newFields = [
  207. ...columnsWithoutAlias,
  208. ...aggregatesWithoutAlias,
  209. ...(yAxisWithoutAlias ?? []),
  210. ];
  211. setFields(newFields);
  212. // Keep the sort if it's already contained in the new fields
  213. // Otherwise, reset sorting to the first field
  214. if (
  215. newFields.length > 0 &&
  216. !newFields.find(field => generateFieldAsString(field) === sort?.[0]?.field)
  217. ) {
  218. setSort([
  219. {
  220. kind: 'desc',
  221. field: generateFieldAsString(newFields[0] as QueryFieldValue),
  222. },
  223. ]);
  224. }
  225. } else if (action.payload === DisplayType.BIG_NUMBER) {
  226. // TODO: Reset the selected aggregate here for widgets with equations
  227. setSort([]);
  228. setYAxis([]);
  229. setLegendAlias([]);
  230. // Columns are ignored for big number widgets because there is no grouping
  231. setFields([...aggregatesWithoutAlias, ...(yAxisWithoutAlias ?? [])]);
  232. setQuery(query?.slice(0, 1));
  233. } else {
  234. setFields(columnsWithoutAlias);
  235. setYAxis([
  236. ...aggregatesWithoutAlias.slice(0, MAX_NUM_Y_AXES),
  237. ...(yAxisWithoutAlias?.slice(0, MAX_NUM_Y_AXES) ?? []),
  238. ]);
  239. }
  240. setThresholds(undefined);
  241. setSelectedAggregate(undefined);
  242. break;
  243. case BuilderStateAction.SET_DATASET:
  244. setDataset(action.payload);
  245. let nextDisplayType = displayType;
  246. if (action.payload === WidgetType.ISSUE) {
  247. // Issues only support table display type
  248. setDisplayType(DisplayType.TABLE);
  249. nextDisplayType = DisplayType.TABLE;
  250. }
  251. const config = getDatasetConfig(action.payload);
  252. setFields(
  253. config.defaultWidgetQuery.fields?.map(field => explodeField({field}))
  254. );
  255. if (
  256. nextDisplayType === DisplayType.TABLE ||
  257. nextDisplayType === DisplayType.BIG_NUMBER
  258. ) {
  259. setYAxis([]);
  260. setFields(
  261. config.defaultWidgetQuery.fields?.map(field => explodeField({field}))
  262. );
  263. setSort(
  264. nextDisplayType === DisplayType.BIG_NUMBER
  265. ? []
  266. : decodeSorts(config.defaultWidgetQuery.orderby)
  267. );
  268. } else {
  269. setFields([]);
  270. setYAxis(
  271. config.defaultWidgetQuery.aggregates?.map(aggregate =>
  272. explodeField({field: aggregate})
  273. )
  274. );
  275. setSort(decodeSorts(config.defaultWidgetQuery.orderby));
  276. }
  277. setThresholds(undefined);
  278. setQuery([config.defaultWidgetQuery.conditions]);
  279. setLegendAlias([]);
  280. setSelectedAggregate(undefined);
  281. break;
  282. case BuilderStateAction.SET_FIELDS:
  283. setFields(action.payload);
  284. const isRemoved = action.payload.length < (fields?.length ?? 0);
  285. if (
  286. displayType === DisplayType.TABLE &&
  287. action.payload.length > 0 &&
  288. !action.payload.find(
  289. field => generateFieldAsString(field) === sort?.[0]?.field
  290. )
  291. ) {
  292. if (dataset === WidgetType.ISSUE) {
  293. // Issue widgets can sort their tables by limited fields that aren't
  294. // in the fields array.
  295. return;
  296. }
  297. const firstActionPayloadNotEquation = action.payload.filter(
  298. field => field.kind !== FieldValueKind.EQUATION
  299. )[0];
  300. if (isRemoved) {
  301. setSort([
  302. {
  303. kind: 'desc',
  304. field: generateFieldAsString(
  305. firstActionPayloadNotEquation as QueryFieldValue
  306. ),
  307. },
  308. ]);
  309. } else {
  310. // Find the index of the first field that doesn't match the old fields and is not an equation.
  311. const changedFieldIndex = action.payload.findIndex(
  312. field =>
  313. !fields?.find(
  314. originalField =>
  315. generateFieldAsString(originalField) ===
  316. generateFieldAsString(field) ||
  317. originalField.kind === FieldValueKind.EQUATION
  318. )
  319. );
  320. if (changedFieldIndex !== -1) {
  321. // At this point, we can assume the fields are the same length so
  322. // using the changedFieldIndex in action.payload is safe.
  323. setSort([
  324. {
  325. kind: sort?.[0]?.kind ?? 'desc',
  326. field: generateFieldAsString(
  327. action.payload[changedFieldIndex] as QueryFieldValue
  328. ),
  329. },
  330. ]);
  331. }
  332. }
  333. }
  334. if (
  335. displayType !== DisplayType.TABLE &&
  336. displayType !== DisplayType.BIG_NUMBER &&
  337. action.payload.length > 0
  338. ) {
  339. const firstYAxisNotEquation = yAxis?.filter(
  340. field => field.kind !== FieldValueKind.EQUATION
  341. )[0];
  342. const firstActionPayloadNotEquation = action.payload.filter(
  343. field => field.kind !== FieldValueKind.EQUATION
  344. )[0];
  345. // Adding a grouping, so default the sort to the first aggregate if possible
  346. setSort([
  347. {
  348. kind: 'desc',
  349. field: generateFieldAsString(
  350. (firstYAxisNotEquation as QueryFieldValue) ??
  351. (firstActionPayloadNotEquation as QueryFieldValue)
  352. ),
  353. },
  354. ]);
  355. }
  356. break;
  357. case BuilderStateAction.SET_Y_AXIS:
  358. setYAxis(action.payload);
  359. if (action.payload.length > 0 && fields?.length === 0) {
  360. // Clear the sort if there is no grouping
  361. setSort([]);
  362. }
  363. break;
  364. case BuilderStateAction.SET_QUERY:
  365. setQuery(action.payload);
  366. break;
  367. case BuilderStateAction.SET_SORT:
  368. setSort(action.payload);
  369. break;
  370. case BuilderStateAction.SET_LIMIT:
  371. setLimit(action.payload);
  372. break;
  373. case BuilderStateAction.SET_LEGEND_ALIAS:
  374. setLegendAlias(action.payload);
  375. break;
  376. case BuilderStateAction.SET_SELECTED_AGGREGATE:
  377. setSelectedAggregate(action.payload);
  378. break;
  379. case BuilderStateAction.SET_STATE:
  380. setDataset(action.payload.dataset);
  381. setDescription(action.payload.description);
  382. setDisplayType(action.payload.displayType);
  383. if (action.payload.field) {
  384. setFields(deserializeFields(action.payload.field));
  385. }
  386. setLegendAlias(action.payload.legendAlias);
  387. setLimit(action.payload.limit);
  388. setQuery(action.payload.query);
  389. setSelectedAggregate(action.payload.selectedAggregate);
  390. setSort(decodeSorts(action.payload.sort));
  391. setTitle(action.payload.title);
  392. if (action.payload.yAxis) {
  393. setYAxis(deserializeFields(action.payload.yAxis));
  394. }
  395. break;
  396. case BuilderStateAction.SET_THRESHOLDS:
  397. setThresholds(action.payload);
  398. break;
  399. default:
  400. break;
  401. }
  402. },
  403. [
  404. setTitle,
  405. setDescription,
  406. setDisplayType,
  407. setDataset,
  408. setFields,
  409. setYAxis,
  410. setQuery,
  411. setSort,
  412. setLimit,
  413. setLegendAlias,
  414. setSelectedAggregate,
  415. setThresholds,
  416. fields,
  417. yAxis,
  418. displayType,
  419. query,
  420. sort,
  421. dataset,
  422. ]
  423. );
  424. return {
  425. state,
  426. dispatch,
  427. };
  428. }
  429. /**
  430. * Decodes the display type from the query params
  431. * Returns the default display type if the value is not a valid display type
  432. */
  433. function deserializeDisplayType(value: string): DisplayType {
  434. if (Object.values(DisplayType).includes(value as DisplayType)) {
  435. return value as DisplayType;
  436. }
  437. return DisplayType.TABLE;
  438. }
  439. /**
  440. * Decodes the dataset from the query params
  441. * Returns the default dataset if the value is not a valid dataset
  442. */
  443. function deserializeDataset(value: string): WidgetType {
  444. if (Object.values(WidgetType).includes(value as WidgetType)) {
  445. return value as WidgetType;
  446. }
  447. return WidgetType.ERRORS;
  448. }
  449. /**
  450. * Takes fields from the query params in list form and converts
  451. * them into a list of fields and functions
  452. */
  453. function deserializeFields(fields: string[]): Column[] {
  454. return fields.map(stringifiedField => {
  455. try {
  456. const {field, alias} = JSON.parse(stringifiedField);
  457. return explodeField({field, alias});
  458. } catch (error) {
  459. return explodeField({field: stringifiedField, alias: undefined});
  460. }
  461. });
  462. }
  463. /**
  464. * Takes fields in the field and function format and coverts
  465. * them into a list of strings compatible with query params
  466. */
  467. export function serializeFields(fields: Column[]): string[] {
  468. return fields.map(field => {
  469. if (field.alias) {
  470. return JSON.stringify({
  471. field: generateFieldAsString(field),
  472. alias: field.alias,
  473. });
  474. }
  475. return generateFieldAsString(field);
  476. });
  477. }
  478. function serializeSorts(sorts: Sort[]): string[] {
  479. return sorts.map(sort => {
  480. const direction = sort.kind === 'desc' ? '-' : '';
  481. return `${direction}${sort.field}`;
  482. });
  483. }
  484. /**
  485. * Decodes the limit from the query params
  486. * Returns the default limit if the value is not a valid limit
  487. */
  488. function deserializeLimit(value: string): number {
  489. return decodeInteger(value, DEFAULT_RESULTS_LIMIT);
  490. }
  491. function deserializeSelectedAggregate(value: string): number | undefined {
  492. return decodeInteger(value);
  493. }
  494. /**
  495. * Decodes the query from the query params
  496. * Returns an array with an empty string if the query is empty
  497. */
  498. function deserializeQuery(queries: string[]): string[] {
  499. if (queries.length === 0) {
  500. return [''];
  501. }
  502. return queries;
  503. }
  504. function deserializeThresholds(value: string): ThresholdsConfig | undefined {
  505. if (value === '') {
  506. return undefined;
  507. }
  508. return JSON.parse(value);
  509. }
  510. export function serializeThresholds(thresholds: ThresholdsConfig): string {
  511. return JSON.stringify(thresholds);
  512. }
  513. export default useWidgetBuilderState;