@adminforth/dashboard 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +116 -39
  2. package/custom/api/dashboardApi.ts +4 -0
  3. package/custom/composables/useElementSize.ts +17 -2
  4. package/custom/model/dashboard.types.ts +337 -236
  5. package/custom/skills/adminforth-dashboard/SKILL.md +113 -2
  6. package/custom/widgets/chart/ChartWidget.vue +38 -53
  7. package/custom/widgets/chart/bar/BarChart.vue +20 -12
  8. package/custom/widgets/chart/chart.types.ts +17 -66
  9. package/custom/widgets/chart/chart.utils.ts +11 -0
  10. package/custom/widgets/chart/funnel/FunnelChart.vue +6 -4
  11. package/custom/widgets/chart/line/LineChart.vue +23 -15
  12. package/custom/widgets/chart/stacked-bar/StackedBarChart.vue +28 -43
  13. package/custom/widgets/gauge-card/GaugeCardWidget.vue +7 -12
  14. package/custom/widgets/kpi-card/KpiCardWidget.vue +6 -8
  15. package/custom/widgets/pivot-table/PivotTableWidget.vue +8 -7
  16. package/custom/widgets/table/TableWidget.vue +8 -3
  17. package/dist/custom/api/dashboardApi.d.ts +1 -0
  18. package/dist/custom/api/dashboardApi.js +5 -0
  19. package/dist/custom/api/dashboardApi.ts +4 -0
  20. package/dist/custom/composables/useElementSize.js +14 -2
  21. package/dist/custom/composables/useElementSize.ts +17 -2
  22. package/dist/custom/model/dashboard.types.d.ts +181 -61
  23. package/dist/custom/model/dashboard.types.js +82 -93
  24. package/dist/custom/model/dashboard.types.ts +337 -236
  25. package/dist/custom/queries/useDashboardConfig.d.ts +852 -66
  26. package/dist/custom/queries/useWidgetData.d.ts +848 -62
  27. package/dist/custom/skills/adminforth-dashboard/SKILL.md +113 -2
  28. package/dist/custom/widgets/chart/ChartWidget.vue +38 -53
  29. package/dist/custom/widgets/chart/bar/BarChart.vue +20 -12
  30. package/dist/custom/widgets/chart/chart.types.d.ts +13 -22
  31. package/dist/custom/widgets/chart/chart.types.js +2 -25
  32. package/dist/custom/widgets/chart/chart.types.ts +17 -66
  33. package/dist/custom/widgets/chart/chart.utils.d.ts +1 -0
  34. package/dist/custom/widgets/chart/chart.utils.js +7 -0
  35. package/dist/custom/widgets/chart/chart.utils.ts +11 -0
  36. package/dist/custom/widgets/chart/funnel/FunnelChart.vue +6 -4
  37. package/dist/custom/widgets/chart/line/LineChart.vue +23 -15
  38. package/dist/custom/widgets/chart/stacked-bar/StackedBarChart.vue +28 -43
  39. package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +7 -12
  40. package/dist/custom/widgets/kpi-card/KpiCardWidget.vue +6 -8
  41. package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +8 -7
  42. package/dist/custom/widgets/table/TableWidget.vue +8 -3
  43. package/dist/endpoint/dashboard.d.ts +7 -2
  44. package/dist/endpoint/dashboard.js +45 -1
  45. package/dist/endpoint/widgets.d.ts +2 -1
  46. package/dist/endpoint/widgets.js +6 -2
  47. package/dist/schema/api.d.ts +2773 -736
  48. package/dist/schema/api.js +5 -0
  49. package/dist/schema/widget.d.ts +1648 -476
  50. package/dist/schema/widget.js +208 -139
  51. package/dist/services/widgetConfigValidator.js +16 -40
  52. package/dist/services/widgetDataService.d.ts +2 -1
  53. package/dist/services/widgetDataService.js +389 -82
  54. package/endpoint/dashboard.ts +77 -4
  55. package/endpoint/widgets.ts +11 -4
  56. package/package.json +1 -1
  57. package/schema/api.ts +6 -0
  58. package/schema/widget.ts +225 -139
  59. package/services/widgetConfigValidator.ts +29 -53
  60. package/services/widgetDataService.ts +522 -100
@@ -1,4 +1,4 @@
1
- import { Aggregates, GroupBy, Sorts } from 'adminforth';
1
+ import { Filters, Sorts } from 'adminforth';
2
2
  import type {
3
3
  IAdminForth,
4
4
  IAdminForthAndOrFilter,
@@ -6,11 +6,20 @@ import type {
6
6
  IAdminForthSort,
7
7
  } from 'adminforth';
8
8
  import type {
9
- AggregationRule,
10
9
  DashboardWidgetConfig,
11
10
  DashboardWidgetData,
12
- GroupByRule,
13
- WidgetDataSource,
11
+ DashboardVariables,
12
+ FilterExpression,
13
+ FunnelQueryConfig,
14
+ QueryAggregateOperation,
15
+ QueryAggregateSelectItem,
16
+ QueryCalcSelectItem,
17
+ QueryConfig,
18
+ QueryFieldSelectItem,
19
+ QueryGroupByItem,
20
+ QueryOrderByItem,
21
+ QuerySelectItem,
22
+ TimeGrain,
14
23
  } from '../custom/model/dashboard.types.js';
15
24
 
16
25
  export type DashboardWidgetDataOptions = {
@@ -18,6 +27,7 @@ export type DashboardWidgetDataOptions = {
18
27
  page: number;
19
28
  pageSize: number;
20
29
  };
30
+ variables?: DashboardVariables;
21
31
  };
22
32
 
23
33
  type DashboardWidgetFilters =
@@ -25,6 +35,17 @@ type DashboardWidgetFilters =
25
35
  | IAdminForthAndOrFilter
26
36
  | Array<IAdminForthSingleFilter | IAdminForthAndOrFilter>;
27
37
 
38
+ type QueryRowGroup = {
39
+ rows: Record<string, unknown>[];
40
+ values: Record<string, unknown>;
41
+ };
42
+
43
+ const NOW_MINUS_RE = /^(\d+)([dhw])$/;
44
+ const CALC_IDENTIFIER_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;
45
+ const LOOKUP_CALL_RE = /lookup\(\s*(\$variables(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
46
+ const VARIABLE_PATH_PREFIX_RE = /^\$variables\.?/;
47
+ const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s]+$/;
48
+
28
49
  export type WidgetDataService = {
29
50
  getWidgetData: (widget: DashboardWidgetConfig, options?: DashboardWidgetDataOptions) => Promise<DashboardWidgetData | null>;
30
51
  };
@@ -34,163 +55,564 @@ export async function getWidgetData(
34
55
  widget: DashboardWidgetConfig,
35
56
  options: DashboardWidgetDataOptions = {},
36
57
  ): Promise<DashboardWidgetData | null> {
37
- const dataSource = getWidgetDataSource(widget.dataSource);
38
-
39
- if (!dataSource) {
58
+ if (!('query' in widget)) {
40
59
  return null;
41
60
  }
42
61
 
43
- if (dataSource.type === 'aggregate') {
44
- return getAggregateWidgetData(adminforth, dataSource);
62
+ const data = 'steps' in widget.query
63
+ ? await getFunnelWidgetData(adminforth, widget.query, options.variables ?? {})
64
+ : await getQueryWidgetData(adminforth, widget.query, options.variables ?? {});
65
+
66
+ if (widget.target !== 'table' || !options.pagination) {
67
+ return data;
45
68
  }
46
69
 
47
- return getResourceWidgetData(adminforth, dataSource, options);
70
+ const page = options.pagination.page;
71
+ const pageSize = options.pagination.pageSize;
72
+ const offset = (page - 1) * pageSize;
73
+
74
+ return {
75
+ ...data,
76
+ rows: data.rows.slice(offset, offset + pageSize),
77
+ pagination: {
78
+ page,
79
+ pageSize,
80
+ total: data.rows.length,
81
+ totalPages: Math.max(Math.ceil(data.rows.length / pageSize), 1),
82
+ },
83
+ };
48
84
  }
49
85
 
50
- async function getResourceWidgetData(
86
+ async function getFunnelWidgetData(
51
87
  adminforth: IAdminForth,
52
- dataSource: Extract<WidgetDataSource, { type: 'resource' }>,
53
- options: DashboardWidgetDataOptions,
88
+ query: FunnelQueryConfig,
89
+ variables: DashboardVariables,
54
90
  ): Promise<DashboardWidgetData> {
55
- const resource = adminforth.resource(dataSource.resourceId);
56
- const filters = normalizeFilters(dataSource.filters);
57
- const sort = normalizeSort(dataSource.sort);
58
- const pagination = options.pagination;
59
- const offset = pagination ? (pagination.page - 1) * pagination.pageSize : 0;
60
- const limit = pagination ? pagination.pageSize : undefined;
61
-
62
- const rows = await resource.list(
63
- filters,
64
- limit ?? undefined,
65
- offset,
66
- sort,
67
- );
91
+ const rows = await Promise.all(query.steps.map(async (step) => {
92
+ const valueField = step.metric.as;
93
+ const sourceRows = await getResourceRows(adminforth, step.resource, step.filters);
94
+
95
+ const row: Record<string, unknown> = {
96
+ name: step.name,
97
+ resource: step.resource,
98
+ [valueField]: calculateAggregate(sourceRows, step.metric),
99
+ };
68
100
 
69
- const columns = dataSource.columns ?? Object.keys(rows[0] ?? {});
70
- const total = pagination ? await resource.count(filters) : 0;
101
+ for (const calc of query.calcs ?? []) {
102
+ row[calc.as] = evaluateCalc(calc.calc, row, variables);
103
+ }
104
+
105
+ return row;
106
+ }));
71
107
 
72
108
  return {
73
- columns,
74
- rows: rows.map((row) => (
75
- Object.fromEntries(columns.map((column) => [column, row[column]]))
76
- )),
77
- ...(pagination ? {
78
- pagination: {
79
- page: pagination.page,
80
- pageSize: pagination.pageSize,
81
- total,
82
- totalPages: Math.max(Math.ceil(total / pagination.pageSize), 1),
83
- },
84
- } : {}),
109
+ kind: 'aggregate',
110
+ columns: [
111
+ 'name',
112
+ ...Array.from(new Set(query.steps.map((step) => step.metric.as))),
113
+ ...Array.from(new Set((query.calcs ?? []).map((calc) => calc.as))),
114
+ ],
115
+ rows,
85
116
  };
86
117
  }
87
118
 
88
- async function getAggregateWidgetData(
119
+ async function getQueryWidgetData(
89
120
  adminforth: IAdminForth,
90
- dataSource: Extract<WidgetDataSource, { type: 'aggregate' }>,
121
+ query: QueryConfig,
122
+ variables: DashboardVariables,
91
123
  ): Promise<DashboardWidgetData> {
92
- const resource = adminforth.resource(dataSource.resourceId);
93
- const rows = await resource.aggregate(
94
- normalizeFilters(dataSource.filters),
95
- Object.fromEntries(
96
- Object.entries(dataSource.aggregations).map(([alias, rule]) => [
97
- alias,
98
- createAggregationRule(rule),
99
- ]),
100
- ),
101
- dataSource.groupBy ? createGroupByRule(dataSource.groupBy) : undefined,
102
- );
103
- const columns = Object.keys(rows[0] ?? {});
124
+ const rows = await getResourceRows(adminforth, query.resource, query.filters, getBackendSort(query.orderBy));
125
+ const selectedRows = buildQueryRows(rows, query, variables);
126
+ const orderedRows = sortRows(selectedRows, query.orderBy);
127
+ const slicedRows = typeof query.limit === 'number'
128
+ ? orderedRows.slice(query.offset ?? 0, (query.offset ?? 0) + query.limit)
129
+ : orderedRows.slice(query.offset ?? 0);
130
+ const columns = getColumns(slicedRows, query);
104
131
 
105
- if (!dataSource.groupBy) {
106
- const values = rows[0] ?? {};
132
+ if (isAggregateQuery(query)) {
133
+ const values = slicedRows.length === 1 ? slicedRows[0] : undefined;
107
134
 
108
135
  return {
109
136
  kind: 'aggregate',
110
- columns: Object.keys(values),
111
- rows: Object.keys(values).length ? [values] : [],
112
- values,
137
+ columns,
138
+ rows: slicedRows,
139
+ ...(values ? { values } : {}),
113
140
  };
114
141
  }
115
142
 
116
143
  return {
117
- kind: 'aggregate',
144
+ kind: 'table',
118
145
  columns,
119
- rows,
146
+ rows: slicedRows,
120
147
  };
121
148
  }
122
149
 
123
- function getWidgetDataSource(dataSource: unknown): WidgetDataSource | undefined {
124
- if (isWidgetDataSource(dataSource)) {
125
- return dataSource;
150
+ async function getResourceRows(
151
+ adminforth: IAdminForth,
152
+ resourceId: string,
153
+ filters: unknown,
154
+ sort?: IAdminForthSort | IAdminForthSort[],
155
+ ) {
156
+ return adminforth.resource(resourceId).list(
157
+ normalizeFilters(filters),
158
+ undefined,
159
+ 0,
160
+ sort,
161
+ );
162
+ }
163
+
164
+ function buildQueryRows(rows: Record<string, unknown>[], query: QueryConfig, variables: DashboardVariables) {
165
+ const select = query.select ?? getDefaultSelect(rows);
166
+ const groupBy = query.groupBy ?? [];
167
+
168
+ if (isAggregateQuery(query)) {
169
+ return buildGroupedRows(rows, select, groupBy, variables, query.calcs);
126
170
  }
127
171
 
128
- return undefined;
172
+ return rows.map((row) => buildPlainRow(row, select, query.calcs, variables));
129
173
  }
130
174
 
131
- function isWidgetDataSource(value: unknown): value is WidgetDataSource {
132
- return isRecord(value)
133
- && (value.type === 'resource' || value.type === 'aggregate')
134
- && typeof value.resourceId === 'string';
135
- }
175
+ function buildGroupedRows(
176
+ rows: Record<string, unknown>[],
177
+ select: QuerySelectItem[],
178
+ groupBy: QueryGroupByItem[],
179
+ variables: DashboardVariables,
180
+ calcs: QueryCalcSelectItem[] = [],
181
+ ) {
182
+ const groups = new Map<string, QueryRowGroup>();
183
+ const effectiveGroupBy = groupBy.length
184
+ ? groupBy
185
+ : select.filter(isFieldSelectItem).map((item) => ({ field: item.field, as: item.as, grain: item.grain }));
136
186
 
137
- function normalizeFilters(filters: unknown): DashboardWidgetFilters {
138
- if (Array.isArray(filters)) {
139
- return filters as DashboardWidgetFilters;
187
+ if (!effectiveGroupBy.length) {
188
+ const values = calculateGroupValues(rows, select, calcs, variables);
189
+ return Object.keys(values).length ? [values] : [];
140
190
  }
141
191
 
142
- if (isRecord(filters)) {
143
- return filters as DashboardWidgetFilters;
192
+ for (const row of rows) {
193
+ const values = Object.fromEntries(effectiveGroupBy.map((item) => {
194
+ const field = getGroupByField(item);
195
+ const alias = getGroupByAlias(item);
196
+ const grain = getGroupByGrain(item);
197
+
198
+ return [alias, formatGroupValue(row[field], grain)];
199
+ }));
200
+ const key = JSON.stringify(values);
201
+ const group = groups.get(key) ?? { values, rows: [] };
202
+
203
+ group.rows.push(row);
204
+ groups.set(key, group);
144
205
  }
145
206
 
146
- return [];
207
+ return Array.from(groups.values()).map((group) => ({
208
+ ...group.values,
209
+ ...calculateGroupValues(group.rows, select, calcs, variables, group.values),
210
+ }));
147
211
  }
148
212
 
149
- function normalizeSort(sort: unknown): IAdminForthSort | IAdminForthSort[] | undefined {
150
- if (Array.isArray(sort)) {
151
- return sort as IAdminForthSort[];
213
+ function calculateGroupValues(
214
+ rows: Record<string, unknown>[],
215
+ select: QuerySelectItem[],
216
+ calcs: QueryCalcSelectItem[],
217
+ variables: DashboardVariables,
218
+ baseValues: Record<string, unknown> = {},
219
+ ) {
220
+ const values: Record<string, unknown> = { ...baseValues };
221
+
222
+ for (const item of select) {
223
+ if (isAggregateSelectItem(item)) {
224
+ const filteredRows = item.filters
225
+ ? rows.filter((row) => matchesFilterExpression(row, item.filters as FilterExpression))
226
+ : rows;
227
+
228
+ values[item.as] = calculateAggregate(filteredRows, item);
229
+ }
152
230
  }
153
231
 
154
- if (!isRecord(sort) || typeof sort.field !== 'string') {
155
- return undefined;
232
+ for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
233
+ values[item.as] = evaluateCalc(item.calc, values, variables);
156
234
  }
157
235
 
158
- if (sort.direction === 'asc') {
159
- return [Sorts.ASC(sort.field)];
236
+ return values;
237
+ }
238
+
239
+ function buildPlainRow(
240
+ row: Record<string, unknown>,
241
+ select: QuerySelectItem[],
242
+ calcs: QueryCalcSelectItem[] = [],
243
+ variables: DashboardVariables,
244
+ ) {
245
+ const values: Record<string, unknown> = {};
246
+
247
+ for (const item of select) {
248
+ if (isFieldSelectItem(item)) {
249
+ values[item.as ?? item.field] = item.grain
250
+ ? formatGroupValue(row[item.field], item.grain)
251
+ : row[item.field];
252
+ }
160
253
  }
161
254
 
162
- if (sort.direction === 'desc') {
163
- return [Sorts.DESC(sort.field)];
255
+ for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
256
+ values[item.as] = evaluateCalc(item.calc, values, variables);
164
257
  }
165
258
 
166
- return sort as IAdminForthSort;
259
+ return values;
167
260
  }
168
261
 
169
- function createAggregationRule(rule: AggregationRule) {
170
- switch (rule.operation) {
171
- case 'sum':
172
- return Aggregates.sum(rule.field!);
262
+ function calculateAggregate(rows: Record<string, unknown>[], item: QueryAggregateSelectItem) {
263
+ switch (item.agg) {
173
264
  case 'count':
174
- return Aggregates.count();
265
+ return rows.length;
266
+ case 'count_distinct':
267
+ return new Set(rows.map((row) => row[item.field!])).size;
268
+ case 'sum':
269
+ return aggregateNumbers(rows, item.field!, (values) => values.reduce((sum, value) => sum + value, 0));
175
270
  case 'avg':
176
- return Aggregates.avg(rule.field!);
271
+ return aggregateNumbers(rows, item.field!, (values) => values.length
272
+ ? values.reduce((sum, value) => sum + value, 0) / values.length
273
+ : 0);
177
274
  case 'min':
178
- return Aggregates.min(rule.field!);
275
+ return aggregateNumbers(rows, item.field!, (values) => values.length ? Math.min(...values) : 0);
179
276
  case 'max':
180
- return Aggregates.max(rule.field!);
277
+ return aggregateNumbers(rows, item.field!, (values) => values.length ? Math.max(...values) : 0);
181
278
  case 'median':
182
- return Aggregates.median(rule.field!);
279
+ return aggregateNumbers(rows, item.field!, calculateMedian);
183
280
  default:
184
- throw new Error(`Unsupported aggregation operation: ${(rule as AggregationRule).operation}`);
281
+ throw new Error(`Unsupported aggregation operation: ${(item as { agg: QueryAggregateOperation }).agg}`);
282
+ }
283
+ }
284
+
285
+ function aggregateNumbers(
286
+ rows: Record<string, unknown>[],
287
+ field: string,
288
+ aggregate: (values: number[]) => number,
289
+ ) {
290
+ return aggregate(rows.map((row) => toFiniteNumber(row[field])).filter(Number.isFinite));
291
+ }
292
+
293
+ function calculateMedian(values: number[]) {
294
+ if (!values.length) {
295
+ return 0;
296
+ }
297
+
298
+ const sorted = [...values].sort((left, right) => left - right);
299
+ const middle = Math.floor(sorted.length / 2);
300
+
301
+ return sorted.length % 2
302
+ ? sorted[middle]
303
+ : (sorted[middle - 1] + sorted[middle]) / 2;
304
+ }
305
+
306
+ function evaluateCalc(calc: string, values: Record<string, unknown>, variables: DashboardVariables) {
307
+ const expression = calc
308
+ .replace(LOOKUP_CALL_RE, (_match, path: string, keyField: string, defaultValue: string) => {
309
+ const map = resolveVariablePath(variables, path);
310
+ const key = String(values[keyField] ?? '');
311
+
312
+ return String(toFiniteNumber(isRecord(map) && Object.prototype.hasOwnProperty.call(map, key)
313
+ ? map[key]
314
+ : Number(defaultValue)));
315
+ })
316
+ .replace(CALC_IDENTIFIER_RE, (name) => String(toFiniteNumber(values[name])));
317
+
318
+ if (!SAFE_CALC_EXPRESSION_RE.test(expression)) {
319
+ throw new Error(`Unsupported calc expression: ${calc}`);
320
+ }
321
+
322
+ return Function(`"use strict"; return (${expression});`)();
323
+ }
324
+
325
+ function resolveVariablePath(variables: DashboardVariables, path: string) {
326
+ return path
327
+ .replace(VARIABLE_PATH_PREFIX_RE, '')
328
+ .split('.')
329
+ .filter(Boolean)
330
+ .reduce<unknown>((current, segment) => isRecord(current) ? current[segment] : undefined, variables);
331
+ }
332
+
333
+ function sortRows(rows: Record<string, unknown>[], orderBy: QueryOrderByItem[] = []) {
334
+ if (!orderBy.length) {
335
+ return rows;
336
+ }
337
+
338
+ return [...rows].sort((left, right) => {
339
+ for (const order of orderBy) {
340
+ const direction = order.direction === 'asc' ? 1 : -1;
341
+ const result = compareValues(left[order.field], right[order.field]);
342
+
343
+ if (result !== 0) {
344
+ return result * direction;
345
+ }
346
+ }
347
+
348
+ return 0;
349
+ });
350
+ }
351
+
352
+ function compareValues(left: unknown, right: unknown) {
353
+ if (typeof left === 'number' && typeof right === 'number') {
354
+ return left - right;
355
+ }
356
+
357
+ return String(left ?? '').localeCompare(String(right ?? ''));
358
+ }
359
+
360
+ function getBackendSort(orderBy: QueryOrderByItem[] | undefined) {
361
+ if (!orderBy?.length) {
362
+ return undefined;
363
+ }
364
+
365
+ return orderBy.map((order) => order.direction === 'asc'
366
+ ? Sorts.ASC(order.field)
367
+ : Sorts.DESC(order.field));
368
+ }
369
+
370
+ function getColumns(rows: Record<string, unknown>[], query: QueryConfig) {
371
+ const selectColumns = [
372
+ ...(query.groupBy ?? []).map(getGroupByAlias),
373
+ ...(query.select ?? []).map(getSelectAlias),
374
+ ...(query.calcs ?? []).map((item) => item.as),
375
+ ].filter(Boolean);
376
+
377
+ return Array.from(new Set(selectColumns.length ? selectColumns : Object.keys(rows[0] ?? {})));
378
+ }
379
+
380
+ function getDefaultSelect(rows: Record<string, unknown>[]): QuerySelectItem[] {
381
+ return Object.keys(rows[0] ?? {}).map((field) => ({ field }));
382
+ }
383
+
384
+ function isAggregateQuery(query: QueryConfig) {
385
+ return Boolean(
386
+ query.groupBy?.length
387
+ || query.select?.some((item) => isAggregateSelectItem(item)),
388
+ );
389
+ }
390
+
391
+ function isFieldSelectItem(item: QuerySelectItem): item is QueryFieldSelectItem {
392
+ return 'field' in item && !('agg' in item);
393
+ }
394
+
395
+ function isAggregateSelectItem(item: QuerySelectItem): item is QueryAggregateSelectItem {
396
+ return 'agg' in item;
397
+ }
398
+
399
+ function isCalcSelectItem(item: QuerySelectItem): item is QueryCalcSelectItem {
400
+ return 'calc' in item;
401
+ }
402
+
403
+ function getSelectAlias(item: QuerySelectItem) {
404
+ if (isFieldSelectItem(item)) {
405
+ return item.as ?? item.field;
406
+ }
407
+
408
+ return item.as;
409
+ }
410
+
411
+ function getGroupByField(item: QueryGroupByItem) {
412
+ return typeof item === 'string' ? item : item.field;
413
+ }
414
+
415
+ function getGroupByAlias(item: QueryGroupByItem) {
416
+ return typeof item === 'string' ? item : item.as ?? item.field;
417
+ }
418
+
419
+ function getGroupByGrain(item: QueryGroupByItem) {
420
+ return typeof item === 'string' ? undefined : item.grain;
421
+ }
422
+
423
+ function formatGroupValue(value: unknown, grain: TimeGrain | undefined) {
424
+ if (!grain) {
425
+ return value;
426
+ }
427
+
428
+ const date = new Date(String(value));
429
+
430
+ if (!Number.isFinite(date.getTime())) {
431
+ return value;
432
+ }
433
+
434
+ if (grain === 'year') {
435
+ return `${date.getUTCFullYear()}`;
436
+ }
437
+
438
+ if (grain === 'quarter') {
439
+ return `${date.getUTCFullYear()}-Q${Math.floor(date.getUTCMonth() / 3) + 1}`;
440
+ }
441
+
442
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
443
+
444
+ if (grain === 'month') {
445
+ return `${date.getUTCFullYear()}-${month}`;
446
+ }
447
+
448
+ const day = String(date.getUTCDate()).padStart(2, '0');
449
+
450
+ if (grain === 'week') {
451
+ const weekStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
452
+ weekStart.setUTCDate(weekStart.getUTCDate() - weekStart.getUTCDay());
453
+ return weekStart.toISOString().slice(0, 10);
454
+ }
455
+
456
+ if (grain === 'day') {
457
+ return `${date.getUTCFullYear()}-${month}-${day}`;
185
458
  }
459
+
460
+ const hour = String(date.getUTCHours()).padStart(2, '0');
461
+ return `${date.getUTCFullYear()}-${month}-${day}T${hour}:00:00.000Z`;
462
+ }
463
+
464
+ function normalizeFilters(filters: unknown): DashboardWidgetFilters {
465
+ if (Array.isArray(filters)) {
466
+ return filters.map((filter) => normalizeFilterNode(filter)) as DashboardWidgetFilters;
467
+ }
468
+
469
+ if (isRecord(filters)) {
470
+ return normalizeFilterNode(filters);
471
+ }
472
+
473
+ return [];
186
474
  }
187
475
 
188
- function createGroupByRule(rule: GroupByRule) {
189
- if (rule.type === 'field') {
190
- return GroupBy.Field(rule.field);
476
+ function normalizeFilterNode(filter: unknown): IAdminForthSingleFilter | IAdminForthAndOrFilter {
477
+ if (!isRecord(filter)) {
478
+ return filter as IAdminForthSingleFilter;
191
479
  }
192
480
 
193
- return GroupBy.DateTrunc(rule.field, rule.truncation, rule.timezone);
481
+ if (Array.isArray(filter.and)) {
482
+ return Filters.AND(filter.and.map((item) => normalizeFilterNode(item)));
483
+ }
484
+
485
+ if (Array.isArray(filter.or)) {
486
+ return Filters.OR(filter.or.map((item) => normalizeFilterNode(item)));
487
+ }
488
+
489
+ if (typeof filter.field === 'string') {
490
+ if (Object.prototype.hasOwnProperty.call(filter, 'eq')) {
491
+ return Filters.EQ(filter.field, normalizeFilterValue(filter.eq));
492
+ }
493
+
494
+ if (Object.prototype.hasOwnProperty.call(filter, 'neq')) {
495
+ return Filters.NEQ(filter.field, normalizeFilterValue(filter.neq));
496
+ }
497
+
498
+ if (Object.prototype.hasOwnProperty.call(filter, 'gt')) {
499
+ return Filters.GT(filter.field, normalizeFilterValue(filter.gt));
500
+ }
501
+
502
+ if (Object.prototype.hasOwnProperty.call(filter, 'gte')) {
503
+ return Filters.GTE(filter.field, normalizeFilterValue(filter.gte));
504
+ }
505
+
506
+ if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
507
+ return Filters.LT(filter.field, normalizeFilterValue(filter.lt));
508
+ }
509
+
510
+ if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
511
+ return Filters.LTE(filter.field, normalizeFilterValue(filter.lte));
512
+ }
513
+
514
+ if (Object.prototype.hasOwnProperty.call(filter, 'in')) {
515
+ return Filters.IN(filter.field, normalizeFilterValue(filter.in));
516
+ }
517
+
518
+ if (Object.prototype.hasOwnProperty.call(filter, 'not_in')) {
519
+ return Filters.NOT_IN(filter.field, normalizeFilterValue(filter.not_in));
520
+ }
521
+ }
522
+
523
+ return filter as IAdminForthSingleFilter | IAdminForthAndOrFilter;
524
+ }
525
+
526
+ function matchesFilterExpression(row: Record<string, unknown>, filter: FilterExpression): boolean {
527
+ if (Array.isArray(filter)) {
528
+ return filter.every((item) => matchesFilterExpression(row, item));
529
+ }
530
+
531
+ if ('and' in filter) {
532
+ return filter.and.every((item) => matchesFilterExpression(row, item));
533
+ }
534
+
535
+ if ('or' in filter) {
536
+ return filter.or.some((item) => matchesFilterExpression(row, item));
537
+ }
538
+
539
+ const value = row[filter.field];
540
+
541
+ if (Object.prototype.hasOwnProperty.call(filter, 'eq')) {
542
+ return value === normalizeFilterValue(filter.eq);
543
+ }
544
+
545
+ if (Object.prototype.hasOwnProperty.call(filter, 'neq')) {
546
+ return value !== normalizeFilterValue(filter.neq);
547
+ }
548
+
549
+ if (Object.prototype.hasOwnProperty.call(filter, 'gt')) {
550
+ return compareComparableValues(value, normalizeFilterValue(filter.gt)) > 0;
551
+ }
552
+
553
+ if (Object.prototype.hasOwnProperty.call(filter, 'gte')) {
554
+ return compareComparableValues(value, normalizeFilterValue(filter.gte)) >= 0;
555
+ }
556
+
557
+ if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
558
+ return compareComparableValues(value, normalizeFilterValue(filter.lt)) < 0;
559
+ }
560
+
561
+ if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
562
+ return compareComparableValues(value, normalizeFilterValue(filter.lte)) <= 0;
563
+ }
564
+
565
+ if (Object.prototype.hasOwnProperty.call(filter, 'in')) {
566
+ return filter.in?.includes(value) ?? false;
567
+ }
568
+
569
+ if (Object.prototype.hasOwnProperty.call(filter, 'not_in')) {
570
+ return !(filter.not_in?.includes(value) ?? false);
571
+ }
572
+
573
+ return true;
574
+ }
575
+
576
+ function compareComparableValues(left: unknown, right: unknown) {
577
+ const leftNumber = Number(left);
578
+ const rightNumber = Number(right);
579
+
580
+ if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
581
+ return leftNumber - rightNumber;
582
+ }
583
+
584
+ return String(left ?? '').localeCompare(String(right ?? ''));
585
+ }
586
+
587
+ function normalizeFilterValue(value: unknown) {
588
+ if (!isRecord(value) || typeof value.now_minus !== 'string') {
589
+ return value;
590
+ }
591
+
592
+ const match = value.now_minus.match(NOW_MINUS_RE);
593
+
594
+ if (!match) {
595
+ return value;
596
+ }
597
+
598
+ const amount = Number(match[1]);
599
+ const unit = match[2];
600
+ const date = new Date();
601
+
602
+ if (unit === 'h') {
603
+ date.setHours(date.getHours() - amount);
604
+ } else if (unit === 'w') {
605
+ date.setDate(date.getDate() - amount * 7);
606
+ } else {
607
+ date.setDate(date.getDate() - amount);
608
+ }
609
+
610
+ return date.toISOString();
611
+ }
612
+
613
+ function toFiniteNumber(value: unknown) {
614
+ const numberValue = typeof value === 'number' ? value : Number(value);
615
+ return Number.isFinite(numberValue) ? numberValue : 0;
194
616
  }
195
617
 
196
618
  function isRecord(value: unknown): value is Record<string, any> {