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