@adminforth/dashboard 1.1.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 (55) hide show
  1. package/README.md +43 -52
  2. package/custom/composables/useElementSize.ts +17 -2
  3. package/custom/model/dashboard.types.ts +385 -98
  4. package/custom/runtime/DashboardRuntime.vue +2 -1
  5. package/custom/runtime/WidgetRenderer.vue +2 -1
  6. package/custom/skills/adminforth-dashboard/SKILL.md +8 -4
  7. package/custom/widgets/chart/ChartWidget.vue +36 -35
  8. package/custom/widgets/chart/bar/BarChart.vue +20 -12
  9. package/custom/widgets/chart/chart.types.ts +42 -8
  10. package/custom/widgets/chart/chart.utils.ts +11 -0
  11. package/custom/widgets/chart/funnel/FunnelChart.vue +6 -4
  12. package/custom/widgets/chart/line/LineChart.vue +23 -15
  13. package/custom/widgets/chart/stacked-bar/StackedBarChart.vue +28 -43
  14. package/custom/widgets/gauge-card/GaugeCardWidget.vue +7 -43
  15. package/custom/widgets/kpi-card/KpiCardWidget.vue +6 -10
  16. package/custom/widgets/pivot-table/PivotTableWidget.vue +10 -11
  17. package/custom/widgets/table/TableWidget.vue +9 -4
  18. package/dist/custom/composables/useElementSize.js +14 -2
  19. package/dist/custom/composables/useElementSize.ts +17 -2
  20. package/dist/custom/model/dashboard.types.d.ts +179 -38
  21. package/dist/custom/model/dashboard.types.js +108 -42
  22. package/dist/custom/model/dashboard.types.ts +385 -98
  23. package/dist/custom/queries/useDashboardConfig.d.ts +832 -68
  24. package/dist/custom/queries/useWidgetData.d.ts +828 -64
  25. package/dist/custom/runtime/DashboardRuntime.vue +2 -1
  26. package/dist/custom/runtime/WidgetRenderer.vue +2 -1
  27. package/dist/custom/skills/adminforth-dashboard/SKILL.md +8 -4
  28. package/dist/custom/widgets/chart/ChartWidget.vue +36 -35
  29. package/dist/custom/widgets/chart/bar/BarChart.vue +20 -12
  30. package/dist/custom/widgets/chart/chart.types.d.ts +14 -8
  31. package/dist/custom/widgets/chart/chart.types.js +23 -0
  32. package/dist/custom/widgets/chart/chart.types.ts +42 -8
  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 -43
  40. package/dist/custom/widgets/kpi-card/KpiCardWidget.vue +6 -10
  41. package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +10 -11
  42. package/dist/custom/widgets/table/TableWidget.vue +9 -4
  43. package/dist/endpoint/widgets.js +23 -3
  44. package/dist/schema/api.d.ts +2637 -933
  45. package/dist/schema/widget.d.ts +1562 -582
  46. package/dist/schema/widget.js +207 -127
  47. package/dist/services/widgetConfigValidator.js +16 -80
  48. package/dist/services/widgetDataService.d.ts +0 -9
  49. package/dist/services/widgetDataService.js +356 -97
  50. package/endpoint/dashboard.ts +1 -1
  51. package/endpoint/widgets.ts +29 -3
  52. package/package.json +1 -1
  53. package/schema/widget.ts +221 -121
  54. package/services/widgetConfigValidator.ts +29 -100
  55. package/services/widgetDataService.ts +478 -129
@@ -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,23 +6,21 @@ 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
- export type DashboardWidgetQueryConfig = {
17
- resource: string;
18
- select?: string[];
19
- order?: {
20
- field: string;
21
- direction: 'asc' | 'desc';
22
- };
23
- limit?: number;
24
- };
25
-
26
24
  export type DashboardWidgetDataOptions = {
27
25
  pagination?: {
28
26
  page: number;
@@ -35,6 +33,15 @@ type DashboardWidgetFilters =
35
33
  | IAdminForthAndOrFilter
36
34
  | Array<IAdminForthSingleFilter | IAdminForthAndOrFilter>;
37
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
+
38
45
  export type WidgetDataService = {
39
46
  getWidgetData: (widget: DashboardWidgetConfig, options?: DashboardWidgetDataOptions) => Promise<DashboardWidgetData | null>;
40
47
  };
@@ -44,188 +51,530 @@ export async function getWidgetData(
44
51
  widget: DashboardWidgetConfig,
45
52
  options: DashboardWidgetDataOptions = {},
46
53
  ): Promise<DashboardWidgetData | null> {
47
- const legacyQuery = getLegacyQueryConfig(widget.query);
48
- const dataSource = getWidgetDataSource(widget, legacyQuery);
49
-
50
- if (!dataSource) {
54
+ if (!('query' in widget)) {
51
55
  return null;
52
56
  }
53
57
 
54
- if (dataSource.type === 'aggregate') {
55
- 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;
56
64
  }
57
65
 
58
- return getResourceWidgetData(adminforth, dataSource, legacyQuery, 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
+ };
59
80
  }
60
81
 
61
- async function getResourceWidgetData(
82
+ async function getFunnelWidgetData(
62
83
  adminforth: IAdminForth,
63
- dataSource: Extract<WidgetDataSource, { type: 'resource' }>,
64
- legacyQuery: DashboardWidgetQueryConfig | undefined,
65
- options: DashboardWidgetDataOptions,
84
+ query: FunnelQueryConfig,
66
85
  ): Promise<DashboardWidgetData> {
67
- const resource = adminforth.resource(dataSource.resourceId);
68
- const filters = normalizeFilters(dataSource.filters);
69
- const sort = normalizeSort(dataSource.sort ?? legacyQuery?.order);
70
- const pagination = options.pagination;
71
- const offset = pagination ? (pagination.page - 1) * pagination.pageSize : 0;
72
- const queryLimit = legacyQuery?.limit;
73
- const limit = pagination
74
- ? Math.max(Math.min(pagination.pageSize, (queryLimit ?? Infinity) - offset), 0)
75
- : queryLimit;
76
-
77
- const rows = await resource.list(
78
- filters,
79
- limit ?? undefined,
80
- offset,
81
- sort,
82
- );
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);
83
89
 
84
- const columns = dataSource.columns ?? legacyQuery?.select ?? Object.keys(rows[0] ?? {});
85
- const total = pagination ? Math.min(await resource.count(filters), queryLimit ?? Infinity) : 0;
90
+ return {
91
+ name: step.name,
92
+ [valueField]: calculateAggregate(sourceRows, step.metric),
93
+ };
94
+ }));
86
95
 
87
96
  return {
88
- columns,
89
- rows: rows.map((row) => (
90
- Object.fromEntries(columns.map((column) => [column, row[column]]))
91
- )),
92
- ...(pagination ? {
93
- pagination: {
94
- page: pagination.page,
95
- pageSize: pagination.pageSize,
96
- total,
97
- totalPages: Math.max(Math.ceil(total / pagination.pageSize), 1),
98
- },
99
- } : {}),
97
+ kind: 'aggregate',
98
+ columns: ['name', ...Array.from(new Set(query.steps.map((step) => step.metric.as)))],
99
+ rows,
100
100
  };
101
101
  }
102
102
 
103
- async function getAggregateWidgetData(
103
+ async function getQueryWidgetData(
104
104
  adminforth: IAdminForth,
105
- dataSource: Extract<WidgetDataSource, { type: 'aggregate' }>,
105
+ query: QueryConfig,
106
106
  ): Promise<DashboardWidgetData> {
107
- const resource = adminforth.resource(dataSource.resourceId);
108
- const rows = await resource.aggregate(
109
- normalizeFilters(dataSource.filters),
110
- Object.fromEntries(
111
- Object.entries(dataSource.aggregations).map(([alias, rule]) => [
112
- alias,
113
- createAggregationRule(rule),
114
- ]),
115
- ),
116
- dataSource.groupBy ? createGroupByRule(dataSource.groupBy) : undefined,
117
- );
118
- 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);
119
114
 
120
- if (!dataSource.groupBy) {
121
- const values = rows[0] ?? {};
115
+ if (isAggregateQuery(query)) {
116
+ const values = slicedRows.length === 1 ? slicedRows[0] : undefined;
122
117
 
123
118
  return {
124
119
  kind: 'aggregate',
125
- columns: Object.keys(values),
126
- rows: Object.keys(values).length ? [values] : [],
127
- values,
120
+ columns,
121
+ rows: slicedRows,
122
+ ...(values ? { values } : {}),
128
123
  };
129
124
  }
130
125
 
131
126
  return {
132
- kind: 'aggregate',
127
+ kind: 'table',
133
128
  columns,
134
- rows,
129
+ rows: slicedRows,
135
130
  };
136
131
  }
137
132
 
138
- function getLegacyQueryConfig(query: unknown): DashboardWidgetQueryConfig | undefined {
139
- if (!isRecord(query) || typeof query.resource !== 'string') {
140
- return undefined;
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);
141
153
  }
142
154
 
143
- return query as DashboardWidgetQueryConfig;
155
+ return rows.map((row) => buildPlainRow(row, select, query.calcs));
144
156
  }
145
157
 
146
- function getWidgetDataSource(
147
- widget: DashboardWidgetConfig,
148
- legacyQuery: DashboardWidgetQueryConfig | undefined,
149
- ): WidgetDataSource | undefined {
150
- if (isWidgetDataSource(widget.dataSource)) {
151
- return widget.dataSource;
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 }));
168
+
169
+ if (!effectiveGroupBy.length) {
170
+ const values = calculateGroupValues(rows, select, calcs);
171
+ return Object.keys(values).length ? [values] : [];
172
+ }
173
+
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);
187
+ }
188
+
189
+ return Array.from(groups.values()).map((group) => ({
190
+ ...group.values,
191
+ ...calculateGroupValues(group.rows, select, calcs),
192
+ }));
193
+ }
194
+
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
+ }
210
+ }
211
+
212
+ for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
213
+ values[item.as] = evaluateCalc(item.calc, values);
214
+ }
215
+
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
+ }
152
232
  }
153
233
 
154
- if (!legacyQuery) {
234
+ for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
235
+ values[item.as] = evaluateCalc(item.calc, values);
236
+ }
237
+
238
+ return values;
239
+ }
240
+
241
+ function calculateAggregate(rows: Record<string, unknown>[], item: QueryAggregateSelectItem) {
242
+ switch (item.agg) {
243
+ case '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));
249
+ case 'avg':
250
+ return aggregateNumbers(rows, item.field!, (values) => values.length
251
+ ? values.reduce((sum, value) => sum + value, 0) / values.length
252
+ : 0);
253
+ case 'min':
254
+ return aggregateNumbers(rows, item.field!, (values) => values.length ? Math.min(...values) : 0);
255
+ case 'max':
256
+ return aggregateNumbers(rows, item.field!, (values) => values.length ? Math.max(...values) : 0);
257
+ case 'median':
258
+ return aggregateNumbers(rows, item.field!, calculateMedian);
259
+ default:
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) {
155
324
  return undefined;
156
325
  }
157
326
 
158
- return {
159
- type: 'resource',
160
- resourceId: legacyQuery.resource,
161
- columns: legacyQuery.select,
162
- sort: legacyQuery.order,
163
- };
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 }));
164
344
  }
165
345
 
166
- function isWidgetDataSource(value: unknown): value is WidgetDataSource {
167
- return isRecord(value)
168
- && (value.type === 'resource' || value.type === 'aggregate')
169
- && typeof value.resourceId === 'string';
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`;
170
424
  }
171
425
 
172
426
  function normalizeFilters(filters: unknown): DashboardWidgetFilters {
173
427
  if (Array.isArray(filters)) {
174
- return filters as DashboardWidgetFilters;
428
+ return filters.map((filter) => normalizeFilterNode(filter)) as DashboardWidgetFilters;
175
429
  }
176
430
 
177
431
  if (isRecord(filters)) {
178
- return filters as DashboardWidgetFilters;
432
+ return normalizeFilterNode(filters);
179
433
  }
180
434
 
181
435
  return [];
182
436
  }
183
437
 
184
- function normalizeSort(sort: unknown): IAdminForthSort | IAdminForthSort[] | undefined {
185
- if (Array.isArray(sort)) {
186
- return sort as IAdminForthSort[];
438
+ function normalizeFilterNode(filter: unknown): IAdminForthSingleFilter | IAdminForthAndOrFilter {
439
+ if (!isRecord(filter)) {
440
+ return filter as IAdminForthSingleFilter;
187
441
  }
188
442
 
189
- if (!isRecord(sort) || typeof sort.field !== 'string') {
190
- return undefined;
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)));
449
+ }
450
+
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;
191
517
  }
192
518
 
193
- if (sort.direction === 'asc') {
194
- return [Sorts.ASC(sort.field)];
519
+ if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
520
+ return compareComparableValues(value, normalizeFilterValue(filter.lt)) < 0;
195
521
  }
196
522
 
197
- if (sort.direction === 'desc') {
198
- return [Sorts.DESC(sort.field)];
523
+ if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
524
+ return compareComparableValues(value, normalizeFilterValue(filter.lte)) <= 0;
199
525
  }
200
526
 
201
- return sort as IAdminForthSort;
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;
202
536
  }
203
537
 
204
- function createAggregationRule(rule: AggregationRule) {
205
- switch (rule.operation) {
206
- case 'sum':
207
- return Aggregates.sum(rule.field!);
208
- case 'count':
209
- return Aggregates.count();
210
- case 'avg':
211
- return Aggregates.avg(rule.field!);
212
- case 'min':
213
- return Aggregates.min(rule.field!);
214
- case 'max':
215
- return Aggregates.max(rule.field!);
216
- case 'median':
217
- return Aggregates.median(rule.field!);
218
- default:
219
- throw new Error(`Unsupported aggregation operation: ${(rule as AggregationRule).operation}`);
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;
220
544
  }
545
+
546
+ return String(left ?? '').localeCompare(String(right ?? ''));
221
547
  }
222
548
 
223
- function createGroupByRule(rule: GroupByRule) {
224
- if (rule.type === 'field') {
225
- return GroupBy.Field(rule.field);
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;
226
558
  }
227
559
 
228
- return GroupBy.DateTrunc(rule.field, rule.truncation, rule.timezone);
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;
229
578
  }
230
579
 
231
580
  function isRecord(value: unknown): value is Record<string, any> {