useWidgetBuilderState.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import {useCallback, useMemo} from 'react';
  2. import partition from 'lodash/partition';
  3. import {
  4. type Column,
  5. explodeField,
  6. generateFieldAsString,
  7. isAggregateFieldOrEquation,
  8. type QueryFieldValue,
  9. type Sort,
  10. } from 'sentry/utils/discover/fields';
  11. import {
  12. decodeInteger,
  13. decodeList,
  14. decodeScalar,
  15. decodeSorts,
  16. } from 'sentry/utils/queryString';
  17. import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
  18. import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
  19. import {MAX_NUM_Y_AXES} from 'sentry/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector';
  20. import {useQueryParamState} from 'sentry/views/dashboards/widgetBuilder/hooks/useQueryParamState';
  21. import {DEFAULT_RESULTS_LIMIT} from 'sentry/views/dashboards/widgetBuilder/utils';
  22. export type WidgetBuilderStateQueryParams = {
  23. dataset?: WidgetType;
  24. description?: string;
  25. displayType?: DisplayType;
  26. field?: (string | undefined)[];
  27. legendAlias?: string[];
  28. limit?: number;
  29. query?: string[];
  30. sort?: string[];
  31. title?: string;
  32. yAxis?: string[];
  33. };
  34. export const BuilderStateAction = {
  35. SET_TITLE: 'SET_TITLE',
  36. SET_DESCRIPTION: 'SET_DESCRIPTION',
  37. SET_DISPLAY_TYPE: 'SET_DISPLAY_TYPE',
  38. SET_DATASET: 'SET_DATASET',
  39. SET_FIELDS: 'SET_FIELDS',
  40. SET_Y_AXIS: 'SET_Y_AXIS',
  41. SET_QUERY: 'SET_QUERY',
  42. SET_SORT: 'SET_SORT',
  43. SET_LIMIT: 'SET_LIMIT',
  44. SET_LEGEND_ALIAS: 'SET_LEGEND_ALIAS',
  45. } as const;
  46. type WidgetAction =
  47. | {payload: string; type: typeof BuilderStateAction.SET_TITLE}
  48. | {payload: string; type: typeof BuilderStateAction.SET_DESCRIPTION}
  49. | {payload: DisplayType; type: typeof BuilderStateAction.SET_DISPLAY_TYPE}
  50. | {payload: WidgetType; type: typeof BuilderStateAction.SET_DATASET}
  51. | {payload: Column[]; type: typeof BuilderStateAction.SET_FIELDS}
  52. | {payload: Column[]; type: typeof BuilderStateAction.SET_Y_AXIS}
  53. | {payload: string[]; type: typeof BuilderStateAction.SET_QUERY}
  54. | {payload: Sort[]; type: typeof BuilderStateAction.SET_SORT}
  55. | {payload: number; type: typeof BuilderStateAction.SET_LIMIT}
  56. | {payload: string[]; type: typeof BuilderStateAction.SET_LEGEND_ALIAS};
  57. export interface WidgetBuilderState {
  58. dataset?: WidgetType;
  59. description?: string;
  60. displayType?: DisplayType;
  61. fields?: Column[];
  62. legendAlias?: string[];
  63. limit?: number;
  64. query?: string[];
  65. sort?: Sort[];
  66. title?: string;
  67. yAxis?: Column[];
  68. }
  69. function useWidgetBuilderState(): {
  70. dispatch: (action: WidgetAction) => void;
  71. state: WidgetBuilderState;
  72. } {
  73. const [title, setTitle] = useQueryParamState<string>({fieldName: 'title'});
  74. const [description, setDescription] = useQueryParamState<string>({
  75. fieldName: 'description',
  76. });
  77. const [displayType, setDisplayType] = useQueryParamState<DisplayType>({
  78. fieldName: 'displayType',
  79. deserializer: deserializeDisplayType,
  80. });
  81. const [dataset, setDataset] = useQueryParamState<WidgetType>({
  82. fieldName: 'dataset',
  83. deserializer: deserializeDataset,
  84. });
  85. const [fields, setFields] = useQueryParamState<Column[]>({
  86. fieldName: 'field',
  87. decoder: decodeList,
  88. deserializer: deserializeFields,
  89. serializer: serializeFields,
  90. });
  91. const [yAxis, setYAxis] = useQueryParamState<Column[]>({
  92. fieldName: 'yAxis',
  93. decoder: decodeList,
  94. deserializer: deserializeFields,
  95. serializer: serializeFields,
  96. });
  97. const [query, setQuery] = useQueryParamState<string[]>({
  98. fieldName: 'query',
  99. decoder: decodeList,
  100. deserializer: deserializeQuery,
  101. });
  102. const [sort, setSort] = useQueryParamState<Sort[]>({
  103. fieldName: 'sort',
  104. decoder: decodeSorts,
  105. serializer: serializeSorts,
  106. });
  107. const [limit, setLimit] = useQueryParamState<number>({
  108. fieldName: 'limit',
  109. decoder: decodeScalar,
  110. deserializer: deserializeLimit,
  111. });
  112. const [legendAlias, setLegendAlias] = useQueryParamState<string[]>({
  113. fieldName: 'legendAlias',
  114. decoder: decodeList,
  115. });
  116. const state = useMemo(
  117. () => ({
  118. title,
  119. description,
  120. displayType,
  121. dataset,
  122. fields,
  123. yAxis,
  124. query,
  125. sort,
  126. limit,
  127. legendAlias,
  128. }),
  129. [
  130. title,
  131. description,
  132. displayType,
  133. dataset,
  134. fields,
  135. yAxis,
  136. query,
  137. sort,
  138. limit,
  139. legendAlias,
  140. ]
  141. );
  142. const dispatch = useCallback(
  143. (action: WidgetAction) => {
  144. switch (action.type) {
  145. case BuilderStateAction.SET_TITLE:
  146. setTitle(action.payload);
  147. break;
  148. case BuilderStateAction.SET_DESCRIPTION:
  149. setDescription(action.payload);
  150. break;
  151. case BuilderStateAction.SET_DISPLAY_TYPE:
  152. setDisplayType(action.payload);
  153. const [aggregates, columns] = partition(fields, field => {
  154. const fieldString = generateFieldAsString(field);
  155. return isAggregateFieldOrEquation(fieldString);
  156. });
  157. if (action.payload === DisplayType.TABLE) {
  158. setYAxis([]);
  159. setLegendAlias([]);
  160. const newFields = [...columns, ...aggregates, ...(yAxis ?? [])];
  161. setFields(newFields);
  162. // Keep the sort if it's already contained in the new fields
  163. // Otherwise, reset sorting to the first field
  164. if (
  165. newFields.length > 0 &&
  166. !newFields.find(field => generateFieldAsString(field) === sort?.[0]?.field)
  167. ) {
  168. setSort([
  169. {
  170. kind: 'desc',
  171. field: generateFieldAsString(newFields[0] as QueryFieldValue),
  172. },
  173. ]);
  174. }
  175. } else if (action.payload === DisplayType.BIG_NUMBER) {
  176. // TODO: Reset the selected aggregate here for widgets with equations
  177. setSort([]);
  178. setYAxis([]);
  179. setLegendAlias([]);
  180. // Columns are ignored for big number widgets because there is no grouping
  181. setFields([...aggregates, ...(yAxis ?? [])]);
  182. setQuery(query?.slice(0, 1));
  183. } else {
  184. setFields(columns);
  185. setYAxis([
  186. ...aggregates.slice(0, MAX_NUM_Y_AXES),
  187. ...(yAxis?.slice(0, MAX_NUM_Y_AXES) ?? []),
  188. ]);
  189. }
  190. break;
  191. case BuilderStateAction.SET_DATASET:
  192. setDataset(action.payload);
  193. let nextDisplayType = displayType;
  194. if (action.payload === WidgetType.ISSUE) {
  195. // Issues only support table display type
  196. setDisplayType(DisplayType.TABLE);
  197. nextDisplayType = DisplayType.TABLE;
  198. }
  199. const config = getDatasetConfig(action.payload);
  200. setFields(
  201. config.defaultWidgetQuery.fields?.map(field => explodeField({field}))
  202. );
  203. if (
  204. nextDisplayType === DisplayType.TABLE ||
  205. nextDisplayType === DisplayType.BIG_NUMBER
  206. ) {
  207. setYAxis([]);
  208. setFields(
  209. config.defaultWidgetQuery.fields?.map(field => explodeField({field}))
  210. );
  211. } else {
  212. setFields([]);
  213. setYAxis(
  214. config.defaultWidgetQuery.aggregates?.map(aggregate =>
  215. explodeField({field: aggregate})
  216. )
  217. );
  218. }
  219. setQuery([config.defaultWidgetQuery.conditions]);
  220. setSort(decodeSorts(config.defaultWidgetQuery.orderby));
  221. break;
  222. case BuilderStateAction.SET_FIELDS:
  223. setFields(action.payload);
  224. const isRemoved = action.payload.length < (fields?.length ?? 0);
  225. if (
  226. displayType === DisplayType.TABLE &&
  227. action.payload.length > 0 &&
  228. !action.payload.find(
  229. field => generateFieldAsString(field) === sort?.[0]?.field
  230. )
  231. ) {
  232. if (dataset === WidgetType.ISSUE) {
  233. // Issue widgets can sort their tables by limited fields that aren't
  234. // in the fields array.
  235. return;
  236. }
  237. if (isRemoved) {
  238. setSort([
  239. {
  240. kind: 'desc',
  241. field: generateFieldAsString(action.payload[0] as QueryFieldValue),
  242. },
  243. ]);
  244. } else {
  245. // Find the index of the first field that doesn't match the old fields.
  246. const changedFieldIndex = action.payload.findIndex(
  247. field =>
  248. !fields?.find(
  249. originalField =>
  250. generateFieldAsString(originalField) ===
  251. generateFieldAsString(field)
  252. )
  253. );
  254. if (changedFieldIndex !== -1) {
  255. // At this point, we can assume the fields are the same length so
  256. // using the changedFieldIndex in action.payload is safe.
  257. setSort([
  258. {
  259. kind: sort?.[0]?.kind ?? 'desc',
  260. field: generateFieldAsString(
  261. action.payload[changedFieldIndex] as QueryFieldValue
  262. ),
  263. },
  264. ]);
  265. }
  266. }
  267. }
  268. break;
  269. case BuilderStateAction.SET_Y_AXIS:
  270. setYAxis(action.payload);
  271. break;
  272. case BuilderStateAction.SET_QUERY:
  273. setQuery(action.payload);
  274. break;
  275. case BuilderStateAction.SET_SORT:
  276. setSort(action.payload);
  277. break;
  278. case BuilderStateAction.SET_LIMIT:
  279. setLimit(action.payload);
  280. break;
  281. case BuilderStateAction.SET_LEGEND_ALIAS:
  282. setLegendAlias(action.payload);
  283. break;
  284. default:
  285. break;
  286. }
  287. },
  288. [
  289. setTitle,
  290. setDescription,
  291. setDisplayType,
  292. setDataset,
  293. setFields,
  294. setYAxis,
  295. setQuery,
  296. setSort,
  297. setLimit,
  298. setLegendAlias,
  299. fields,
  300. yAxis,
  301. displayType,
  302. query,
  303. sort,
  304. dataset,
  305. ]
  306. );
  307. return {
  308. state,
  309. dispatch,
  310. };
  311. }
  312. /**
  313. * Decodes the display type from the query params
  314. * Returns the default display type if the value is not a valid display type
  315. */
  316. function deserializeDisplayType(value: string): DisplayType {
  317. if (Object.values(DisplayType).includes(value as DisplayType)) {
  318. return value as DisplayType;
  319. }
  320. return DisplayType.TABLE;
  321. }
  322. /**
  323. * Decodes the dataset from the query params
  324. * Returns the default dataset if the value is not a valid dataset
  325. */
  326. function deserializeDataset(value: string): WidgetType {
  327. if (Object.values(WidgetType).includes(value as WidgetType)) {
  328. return value as WidgetType;
  329. }
  330. return WidgetType.ERRORS;
  331. }
  332. /**
  333. * Takes fields from the query params in list form and converts
  334. * them into a list of fields and functions
  335. */
  336. function deserializeFields(fields: string[]): Column[] {
  337. return fields.map(stringifiedField => {
  338. try {
  339. const {field, alias} = JSON.parse(stringifiedField);
  340. return explodeField({field, alias});
  341. } catch (error) {
  342. return explodeField({field: stringifiedField, alias: undefined});
  343. }
  344. });
  345. }
  346. /**
  347. * Takes fields in the field and function format and coverts
  348. * them into a list of strings compatible with query params
  349. */
  350. export function serializeFields(fields: Column[]): string[] {
  351. return fields.map(field => {
  352. if (field.alias) {
  353. return JSON.stringify({
  354. field: generateFieldAsString(field),
  355. alias: field.alias,
  356. });
  357. }
  358. return generateFieldAsString(field);
  359. });
  360. }
  361. function serializeSorts(sorts: Sort[]): string[] {
  362. return sorts.map(sort => {
  363. const direction = sort.kind === 'desc' ? '-' : '';
  364. return `${direction}${sort.field}`;
  365. });
  366. }
  367. /**
  368. * Decodes the limit from the query params
  369. * Returns the default limit if the value is not a valid limit
  370. */
  371. function deserializeLimit(value: string): number {
  372. return decodeInteger(value, DEFAULT_RESULTS_LIMIT);
  373. }
  374. /**
  375. * Decodes the query from the query params
  376. * Returns an array with an empty string if the query is empty
  377. */
  378. function deserializeQuery(queries: string[]): string[] {
  379. if (queries.length === 0) {
  380. return [''];
  381. }
  382. return queries;
  383. }
  384. export default useWidgetBuilderState;