useWidgetBuilderState.tsx 16 KB

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