@adminforth/dashboard 1.3.0 → 1.4.1

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 (80) hide show
  1. package/README.md +103 -15
  2. package/custom/api/dashboardApi.ts +9 -8
  3. package/custom/model/dashboard.types.ts +63 -270
  4. package/custom/model/dashboardTopics.ts +5 -0
  5. package/custom/runtime/DashboardGroup.vue +2 -2
  6. package/custom/runtime/DashboardPage.vue +17 -7
  7. package/custom/runtime/DashboardRuntime.vue +20 -8
  8. package/custom/runtime/WidgetRenderer.vue +1 -2
  9. package/custom/runtime/WidgetShell.vue +3 -3
  10. package/custom/skills/adminforth-dashboard/SKILL.md +110 -3
  11. package/custom/widgets/{gauge-card/GaugeCardWidget.vue → GaugeCardWidget.vue} +63 -61
  12. package/custom/widgets/{kpi-card/KpiCardWidget.vue → KpiCardWidget.vue} +35 -33
  13. package/custom/widgets/{pivot-table/PivotTableWidget.vue → PivotTableWidget.vue} +71 -68
  14. package/custom/widgets/{table/TableWidget.vue → TableWidget.vue} +5 -5
  15. package/custom/widgets/chart/{bar/BarChart.vue → BarChart.vue} +2 -2
  16. package/custom/widgets/chart/ChartWidget.vue +24 -18
  17. package/{dist/custom/widgets/chart/funnel → custom/widgets/chart}/FunnelChart.vue +80 -78
  18. package/{dist/custom/widgets/chart/line → custom/widgets/chart}/LineChart.vue +2 -2
  19. package/custom/widgets/chart/{pie/PieChart.vue → PieChart.vue} +2 -2
  20. package/{dist/custom/widgets/chart/stacked-bar → custom/widgets/chart}/StackedBarChart.vue +97 -95
  21. package/custom/widgets/chart/chart.types.ts +0 -28
  22. package/dist/custom/api/dashboardApi.d.ts +4 -7
  23. package/dist/custom/api/dashboardApi.js +5 -0
  24. package/dist/custom/api/dashboardApi.ts +9 -8
  25. package/dist/custom/model/dashboard.types.d.ts +40 -31
  26. package/dist/custom/model/dashboard.types.js +13 -152
  27. package/dist/custom/model/dashboard.types.ts +63 -270
  28. package/dist/custom/model/dashboardTopics.d.ts +2 -0
  29. package/dist/custom/model/dashboardTopics.js +8 -0
  30. package/dist/custom/model/dashboardTopics.ts +5 -0
  31. package/dist/custom/queries/useDashboardConfig.d.ts +116 -96
  32. package/dist/custom/queries/useWidgetData.d.ts +116 -96
  33. package/dist/custom/runtime/DashboardGroup.vue +2 -2
  34. package/dist/custom/runtime/DashboardPage.vue +17 -7
  35. package/dist/custom/runtime/DashboardRuntime.vue +20 -8
  36. package/dist/custom/runtime/WidgetRenderer.vue +1 -2
  37. package/dist/custom/runtime/WidgetShell.vue +3 -3
  38. package/dist/custom/skills/adminforth-dashboard/SKILL.md +110 -3
  39. package/dist/custom/widgets/{gauge-card/GaugeCardWidget.vue → GaugeCardWidget.vue} +63 -61
  40. package/dist/custom/widgets/{kpi-card/KpiCardWidget.vue → KpiCardWidget.vue} +35 -33
  41. package/dist/custom/widgets/{pivot-table/PivotTableWidget.vue → PivotTableWidget.vue} +71 -68
  42. package/dist/custom/widgets/{table/TableWidget.vue → TableWidget.vue} +5 -5
  43. package/dist/custom/widgets/chart/{bar/BarChart.vue → BarChart.vue} +2 -2
  44. package/dist/custom/widgets/chart/ChartWidget.vue +24 -18
  45. package/{custom/widgets/chart/funnel → dist/custom/widgets/chart}/FunnelChart.vue +80 -78
  46. package/{custom/widgets/chart/line → dist/custom/widgets/chart}/LineChart.vue +2 -2
  47. package/dist/custom/widgets/chart/{pie/PieChart.vue → PieChart.vue} +2 -2
  48. package/{custom/widgets/chart/stacked-bar → dist/custom/widgets/chart}/StackedBarChart.vue +97 -95
  49. package/dist/custom/widgets/chart/chart.types.d.ts +0 -2
  50. package/dist/custom/widgets/chart/chart.types.js +0 -23
  51. package/dist/custom/widgets/chart/chart.types.ts +0 -28
  52. package/dist/endpoint/dashboard.d.ts +6 -2
  53. package/dist/endpoint/dashboard.js +29 -5
  54. package/dist/endpoint/groups.d.ts +2 -21
  55. package/dist/endpoint/groups.js +18 -16
  56. package/dist/endpoint/widgets.d.ts +2 -4
  57. package/dist/endpoint/widgets.js +28 -74
  58. package/dist/index.js +1 -3
  59. package/dist/schema/api.d.ts +2172 -500
  60. package/dist/schema/api.js +21 -13
  61. package/dist/schema/widget.d.ts +1076 -263
  62. package/dist/schema/widget.js +108 -49
  63. package/dist/services/dashboardConfigService.d.ts +0 -10
  64. package/dist/services/dashboardConfigService.js +6 -21
  65. package/dist/services/widgetDataService.d.ts +2 -1
  66. package/dist/services/widgetDataService.js +266 -206
  67. package/endpoint/dashboard.ts +47 -7
  68. package/endpoint/groups.ts +25 -42
  69. package/endpoint/widgets.ts +41 -96
  70. package/index.ts +0 -3
  71. package/package.json +3 -3
  72. package/schema/api.ts +23 -13
  73. package/schema/widget.ts +119 -55
  74. package/services/dashboardConfigService.ts +6 -25
  75. package/services/widgetDataService.ts +350 -237
  76. package/custom/widgets/chart/histogram/HistogramChart.vue +0 -21
  77. package/dist/custom/widgets/chart/histogram/HistogramChart.vue +0 -21
  78. package/dist/services/widgetConfigValidator.d.ts +0 -8
  79. package/dist/services/widgetConfigValidator.js +0 -27
  80. package/services/widgetConfigValidator.ts +0 -61
@@ -8,9 +8,9 @@ import type {
8
8
  import type {
9
9
  DashboardWidgetConfig,
10
10
  DashboardWidgetData,
11
- FilterExpression,
11
+ DashboardVariables,
12
12
  FunnelQueryConfig,
13
- QueryAggregateOperation,
13
+ FilterExpression,
14
14
  QueryAggregateSelectItem,
15
15
  QueryCalcSelectItem,
16
16
  QueryConfig,
@@ -26,6 +26,7 @@ export type DashboardWidgetDataOptions = {
26
26
  page: number;
27
27
  pageSize: number;
28
28
  };
29
+ variables?: DashboardVariables;
29
30
  };
30
31
 
31
32
  type DashboardWidgetFilters =
@@ -33,14 +34,55 @@ type DashboardWidgetFilters =
33
34
  | IAdminForthAndOrFilter
34
35
  | Array<IAdminForthSingleFilter | IAdminForthAndOrFilter>;
35
36
 
36
- type QueryRowGroup = {
37
- rows: Record<string, unknown>[];
38
- values: Record<string, unknown>;
37
+ type AggregateRule =
38
+ | { operation: 'count' }
39
+ | { operation: Exclude<QueryAggregateSelectItem['agg'], 'count'>; field: string };
40
+
41
+ type AggregateGroupByRule =
42
+ | {
43
+ type: 'date_trunc';
44
+ field: string;
45
+ truncation: TimeGrain;
46
+ timezone?: string;
47
+ as: string;
48
+ }
49
+ | {
50
+ type: 'field';
51
+ field: string;
52
+ as: string;
53
+ };
54
+
55
+ type AggregateResource = {
56
+ aggregate: (
57
+ filters: DashboardWidgetFilters,
58
+ aggregations: Record<string, AggregateRule>,
59
+ groupBy?: AggregateGroupByRule | AggregateGroupByRule[],
60
+ ) => Promise<Record<string, unknown>[]>;
61
+ };
62
+
63
+ type EffectiveGroupByItem = {
64
+ field: string;
65
+ as: string;
66
+ grain?: TimeGrain;
67
+ timezone?: string;
39
68
  };
40
69
 
41
- const NOW_MINUS_RE = /^(\d+)([dhw])$/;
42
70
  const CALC_IDENTIFIER_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;
71
+ 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;
72
+ const VARIABLE_PATH_PREFIX_RE = /^\$variables\.?/;
43
73
  const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s]+$/;
74
+ const FILTER_OPERATORS = {
75
+ eq: Filters.EQ,
76
+ neq: Filters.NEQ,
77
+ gt: Filters.GT,
78
+ gte: Filters.GTE,
79
+ lt: Filters.LT,
80
+ lte: Filters.LTE,
81
+ in: Filters.IN,
82
+ not_in: Filters.NOT_IN,
83
+ like: Filters.LIKE,
84
+ ilike: Filters.ILIKE,
85
+ } as const;
44
86
 
45
87
  export type WidgetDataService = {
46
88
  getWidgetData: (widget: DashboardWidgetConfig, options?: DashboardWidgetDataOptions) => Promise<DashboardWidgetData | null>;
@@ -56,8 +98,8 @@ export async function getWidgetData(
56
98
  }
57
99
 
58
100
  const data = 'steps' in widget.query
59
- ? await getFunnelWidgetData(adminforth, widget.query)
60
- : await getQueryWidgetData(adminforth, widget.query);
101
+ ? await getFunnelWidgetData(adminforth, widget.query, options.variables ?? {})
102
+ : await getQueryWidgetData(adminforth, widget.query, options.variables ?? {});
61
103
 
62
104
  if (widget.target !== 'table' || !options.pagination) {
63
105
  return data;
@@ -82,20 +124,38 @@ export async function getWidgetData(
82
124
  async function getFunnelWidgetData(
83
125
  adminforth: IAdminForth,
84
126
  query: FunnelQueryConfig,
127
+ variables: DashboardVariables,
85
128
  ): Promise<DashboardWidgetData> {
86
129
  const rows = await Promise.all(query.steps.map(async (step) => {
87
130
  const valueField = step.metric.as;
88
- const sourceRows = await getResourceRows(adminforth, step.resource, step.filters);
89
-
90
- return {
131
+ const [values = {}] = await getAggregateRows(
132
+ adminforth,
133
+ step.resource,
134
+ step.filters,
135
+ [step.metric],
136
+ [],
137
+ );
138
+
139
+ const row: Record<string, unknown> = {
91
140
  name: step.name,
92
- [valueField]: calculateAggregate(sourceRows, step.metric),
141
+ resource: step.resource,
142
+ [valueField]: values[valueField] ?? 0,
93
143
  };
144
+
145
+ for (const calc of query.calcs ?? []) {
146
+ row[calc.as] = evaluateCalc(calc.calc, row, variables);
147
+ }
148
+
149
+ return row;
94
150
  }));
95
151
 
96
152
  return {
97
153
  kind: 'aggregate',
98
- columns: ['name', ...Array.from(new Set(query.steps.map((step) => step.metric.as)))],
154
+ columns: [
155
+ 'name',
156
+ ...Array.from(new Set(query.steps.map((step) => step.metric.as))),
157
+ ...Array.from(new Set((query.calcs ?? []).map((calc) => calc.as))),
158
+ ],
99
159
  rows,
100
160
  };
101
161
  }
@@ -103,10 +163,16 @@ async function getFunnelWidgetData(
103
163
  async function getQueryWidgetData(
104
164
  adminforth: IAdminForth,
105
165
  query: QueryConfig,
166
+ variables: DashboardVariables,
106
167
  ): Promise<DashboardWidgetData> {
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);
168
+ const selectedRows = isAggregateQuery(query)
169
+ ? await buildAggregateQueryRows(adminforth, query, variables)
170
+ : buildPlainQueryRows(
171
+ await getResourceRows(adminforth, query.resource, query.filters, getBackendSort(query.order_by)),
172
+ query,
173
+ variables,
174
+ );
175
+ const orderedRows = sortRows(selectedRows, query.order_by);
110
176
  const slicedRows = typeof query.limit === 'number'
111
177
  ? orderedRows.slice(query.offset ?? 0, (query.offset ?? 0) + query.limit)
112
178
  : orderedRows.slice(query.offset ?? 0);
@@ -133,84 +199,99 @@ async function getQueryWidgetData(
133
199
  async function getResourceRows(
134
200
  adminforth: IAdminForth,
135
201
  resourceId: string,
136
- filters: unknown,
202
+ filters: FilterExpression | undefined,
137
203
  sort?: IAdminForthSort | IAdminForthSort[],
138
204
  ) {
139
205
  return adminforth.resource(resourceId).list(
140
- normalizeFilters(filters),
206
+ getAdminForthFilters(filters),
141
207
  undefined,
142
208
  0,
143
209
  sort,
144
210
  );
145
211
  }
146
212
 
147
- function buildQueryRows(rows: Record<string, unknown>[], query: QueryConfig) {
213
+ function buildPlainQueryRows(rows: Record<string, unknown>[], query: QueryConfig, variables: DashboardVariables) {
148
214
  const select = query.select ?? getDefaultSelect(rows);
149
- const groupBy = query.groupBy ?? [];
215
+ return rows.map((row) => buildPlainRow(row, select, query.calcs, variables));
216
+ }
150
217
 
151
- if (isAggregateQuery(query)) {
152
- return buildGroupedRows(rows, select, groupBy, query.calcs);
153
- }
218
+ async function buildAggregateQueryRows(
219
+ adminforth: IAdminForth,
220
+ query: QueryConfig,
221
+ variables: DashboardVariables,
222
+ ) {
223
+ const select = query.select ?? [];
224
+ const effectiveGroupBy = getEffectiveGroupBy(query.group_by, select);
225
+ const aggregateSelect = select.filter(isAggregateSelectItem);
226
+ const rows = await getAggregateRows(
227
+ adminforth,
228
+ query.resource,
229
+ query.filters,
230
+ aggregateSelect,
231
+ effectiveGroupBy,
232
+ );
154
233
 
155
- return rows.map((row) => buildPlainRow(row, select, query.calcs));
234
+ return rows.map((row) => buildCalculatedRow(row, select, query.calcs, variables));
156
235
  }
157
236
 
158
- function buildGroupedRows(
159
- rows: Record<string, unknown>[],
160
- select: QuerySelectItem[],
161
- groupBy: QueryGroupByItem[],
162
- calcs: QueryCalcSelectItem[] = [],
237
+ async function getAggregateRows(
238
+ adminforth: IAdminForth,
239
+ resourceId: string,
240
+ baseFilters: FilterExpression | undefined,
241
+ select: QueryAggregateSelectItem[],
242
+ groupBy: EffectiveGroupByItem[],
163
243
  ) {
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] : [];
244
+ const resource = adminforth.resource(resourceId) as unknown as AggregateResource;
245
+ const groups = new Map<string, Record<string, unknown>>();
246
+ const groupByRules = groupBy.length ? groupBy.map(toAggregateGroupByRule) : undefined;
247
+ const aggregateSelectGroups = groupAggregateSelectItems(select);
248
+
249
+ if (groupBy.length) {
250
+ const groupSeedAlias = getHiddenAggregateAlias(groupBy, select);
251
+ const groupSeedRows = await resource.aggregate(
252
+ getAdminForthFilters(baseFilters),
253
+ { [groupSeedAlias]: { operation: 'count' } },
254
+ groupByRules,
255
+ );
256
+
257
+ for (const row of groupSeedRows) {
258
+ ensureAggregateGroup(groups, row, groupBy);
259
+ }
172
260
  }
173
261
 
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);
262
+ for (const filterGroup of aggregateSelectGroups) {
263
+ const rows = await resource.aggregate(
264
+ mergeFilters(baseFilters, filterGroup.filters),
265
+ Object.fromEntries(filterGroup.items.map((item) => [item.as, toAggregationRule(item)])),
266
+ groupByRules,
267
+ );
179
268
 
180
- return [alias, formatGroupValue(row[field], grain)];
181
- }));
182
- const key = JSON.stringify(values);
183
- const group = groups.get(key) ?? { values, rows: [] };
269
+ for (const row of rows) {
270
+ const values = ensureAggregateGroup(groups, row, groupBy);
184
271
 
185
- group.rows.push(row);
186
- groups.set(key, group);
272
+ for (const item of filterGroup.items) {
273
+ values[item.as] = row[item.as] ?? 0;
274
+ }
275
+ }
187
276
  }
188
277
 
189
- return Array.from(groups.values()).map((group) => ({
190
- ...group.values,
191
- ...calculateGroupValues(group.rows, select, calcs),
192
- }));
278
+ if (!groups.size && !groupBy.length && select.length) {
279
+ groups.set(JSON.stringify({}), {});
280
+ }
281
+
282
+ return Array.from(groups.values(), (row) => applyAggregateDefaults(row, select));
193
283
  }
194
284
 
195
- function calculateGroupValues(
196
- rows: Record<string, unknown>[],
285
+ function buildCalculatedRow(
286
+ baseValues: Record<string, unknown>,
197
287
  select: QuerySelectItem[],
198
- calcs: QueryCalcSelectItem[],
288
+ calcs: QueryCalcSelectItem[] = [],
289
+ variables: DashboardVariables,
199
290
  ) {
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
- }
291
+ const values: Record<string, unknown> = { ...baseValues };
211
292
 
212
293
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
213
- values[item.as] = evaluateCalc(item.calc, values);
294
+ values[item.as] = evaluateCalc(item.calc, values, variables);
214
295
  }
215
296
 
216
297
  return values;
@@ -220,6 +301,7 @@ function buildPlainRow(
220
301
  row: Record<string, unknown>,
221
302
  select: QuerySelectItem[],
222
303
  calcs: QueryCalcSelectItem[] = [],
304
+ variables: DashboardVariables,
223
305
  ) {
224
306
  const values: Record<string, unknown> = {};
225
307
 
@@ -232,58 +314,23 @@ function buildPlainRow(
232
314
  }
233
315
 
234
316
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
235
- values[item.as] = evaluateCalc(item.calc, values);
317
+ values[item.as] = evaluateCalc(item.calc, values, variables);
236
318
  }
237
319
 
238
320
  return values;
239
321
  }
240
322
 
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
- }
323
+ function evaluateCalc(calc: string, values: Record<string, unknown>, variables: DashboardVariables) {
324
+ const expression = calc
325
+ .replace(LOOKUP_CALL_RE, (_match, path: string, keyField: string, defaultValue: string) => {
326
+ const map = resolveVariablePath(variables, path);
327
+ const key = String(values[keyField] ?? '');
263
328
 
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])));
329
+ return String(toFiniteNumber(isRecord(map) && Object.prototype.hasOwnProperty.call(map, key)
330
+ ? map[key]
331
+ : Number(defaultValue)));
332
+ })
333
+ .replace(CALC_IDENTIFIER_RE, (name) => String(toFiniteNumber(values[name])));
287
334
 
288
335
  if (!SAFE_CALC_EXPRESSION_RE.test(expression)) {
289
336
  throw new Error(`Unsupported calc expression: ${calc}`);
@@ -292,6 +339,14 @@ function evaluateCalc(calc: string, values: Record<string, unknown>) {
292
339
  return Function(`"use strict"; return (${expression});`)();
293
340
  }
294
341
 
342
+ function resolveVariablePath(variables: DashboardVariables, path: string) {
343
+ return path
344
+ .replace(VARIABLE_PATH_PREFIX_RE, '')
345
+ .split('.')
346
+ .filter(Boolean)
347
+ .reduce<unknown>((current, segment) => isRecord(current) ? current[segment] : undefined, variables);
348
+ }
349
+
295
350
  function sortRows(rows: Record<string, unknown>[], orderBy: QueryOrderByItem[] = []) {
296
351
  if (!orderBy.length) {
297
352
  return rows;
@@ -331,7 +386,7 @@ function getBackendSort(orderBy: QueryOrderByItem[] | undefined) {
331
386
 
332
387
  function getColumns(rows: Record<string, unknown>[], query: QueryConfig) {
333
388
  const selectColumns = [
334
- ...(query.groupBy ?? []).map(getGroupByAlias),
389
+ ...(query.group_by ?? []).map(getGroupByAlias),
335
390
  ...(query.select ?? []).map(getSelectAlias),
336
391
  ...(query.calcs ?? []).map((item) => item.as),
337
392
  ].filter(Boolean);
@@ -345,7 +400,7 @@ function getDefaultSelect(rows: Record<string, unknown>[]): QuerySelectItem[] {
345
400
 
346
401
  function isAggregateQuery(query: QueryConfig) {
347
402
  return Boolean(
348
- query.groupBy?.length
403
+ query.group_by?.length
349
404
  || query.select?.some((item) => isAggregateSelectItem(item)),
350
405
  );
351
406
  }
@@ -370,206 +425,264 @@ function getSelectAlias(item: QuerySelectItem) {
370
425
  return item.as;
371
426
  }
372
427
 
373
- function getGroupByField(item: QueryGroupByItem) {
374
- return typeof item === 'string' ? item : item.field;
375
- }
376
-
377
428
  function getGroupByAlias(item: QueryGroupByItem) {
378
429
  return typeof item === 'string' ? item : item.as ?? item.field;
379
430
  }
380
431
 
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;
432
+ function getEffectiveGroupBy(groupBy: QueryGroupByItem[] | undefined, select: QuerySelectItem[]) {
433
+ if (groupBy?.length) {
434
+ return groupBy.map((item) => normalizeGroupByItem(item));
388
435
  }
389
436
 
390
- const date = new Date(String(value));
437
+ return select
438
+ .filter(isFieldSelectItem)
439
+ .map((item) => normalizeGroupByItem({ field: item.field, as: item.as, grain: item.grain }));
440
+ }
391
441
 
392
- if (!Number.isFinite(date.getTime())) {
393
- return value;
442
+ function normalizeGroupByItem(item: QueryGroupByItem | Pick<QueryFieldSelectItem, 'field' | 'as' | 'grain'>): EffectiveGroupByItem {
443
+ if (typeof item === 'string') {
444
+ return { field: item, as: item };
394
445
  }
395
446
 
396
- if (grain === 'year') {
397
- return `${date.getUTCFullYear()}`;
398
- }
447
+ return {
448
+ field: item.field,
449
+ as: item.as ?? item.field,
450
+ grain: item.grain,
451
+ timezone: 'timezone' in item ? item.timezone : undefined,
452
+ };
453
+ }
399
454
 
400
- if (grain === 'quarter') {
401
- return `${date.getUTCFullYear()}-Q${Math.floor(date.getUTCMonth() / 3) + 1}`;
455
+ function toAggregateGroupByRule(item: EffectiveGroupByItem): AggregateGroupByRule {
456
+ if (item.grain) {
457
+ return {
458
+ type: 'date_trunc',
459
+ field: item.field,
460
+ truncation: item.grain,
461
+ timezone: item.timezone,
462
+ as: item.as,
463
+ };
402
464
  }
403
465
 
404
- const month = String(date.getUTCMonth() + 1).padStart(2, '0');
466
+ return {
467
+ type: 'field',
468
+ field: item.field,
469
+ as: item.as,
470
+ };
471
+ }
405
472
 
406
- if (grain === 'month') {
407
- return `${date.getUTCFullYear()}-${month}`;
473
+ function toAggregationRule(item: QueryAggregateSelectItem): AggregateRule {
474
+ switch (item.agg) {
475
+ case 'count':
476
+ return { operation: 'count' };
477
+ case 'count_distinct':
478
+ return { operation: 'count_distinct', field: item.field! };
479
+ case 'sum':
480
+ return { operation: 'sum', field: item.field! };
481
+ case 'avg':
482
+ return { operation: 'avg', field: item.field! };
483
+ case 'min':
484
+ return { operation: 'min', field: item.field! };
485
+ case 'max':
486
+ return { operation: 'max', field: item.field! };
487
+ case 'median':
488
+ return { operation: 'median', field: item.field! };
408
489
  }
490
+ }
409
491
 
410
- const day = String(date.getUTCDate()).padStart(2, '0');
492
+ function extractAggregateGroupValues(row: Record<string, unknown>, groupBy: EffectiveGroupByItem[]) {
493
+ return Object.fromEntries(groupBy.map((item) => [
494
+ item.as,
495
+ formatGroupValue(row[item.as], item.grain),
496
+ ]));
497
+ }
411
498
 
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
- }
499
+ function ensureAggregateGroup(
500
+ groups: Map<string, Record<string, unknown>>,
501
+ row: Record<string, unknown>,
502
+ groupBy: EffectiveGroupByItem[],
503
+ ) {
504
+ const groupValues = groupBy.length ? extractAggregateGroupValues(row, groupBy) : {};
505
+ const key = JSON.stringify(groupValues);
506
+ const existingGroup = groups.get(key);
417
507
 
418
- if (grain === 'day') {
419
- return `${date.getUTCFullYear()}-${month}-${day}`;
508
+ if (existingGroup) {
509
+ return existingGroup;
420
510
  }
421
511
 
422
- const hour = String(date.getUTCHours()).padStart(2, '0');
423
- return `${date.getUTCFullYear()}-${month}-${day}T${hour}:00:00.000Z`;
512
+ groups.set(key, groupValues);
513
+ return groupValues;
424
514
  }
425
515
 
426
- function normalizeFilters(filters: unknown): DashboardWidgetFilters {
427
- if (Array.isArray(filters)) {
428
- return filters.map((filter) => normalizeFilterNode(filter)) as DashboardWidgetFilters;
429
- }
430
-
431
- if (isRecord(filters)) {
432
- return normalizeFilterNode(filters);
516
+ function applyAggregateDefaults(values: Record<string, unknown>, select: QueryAggregateSelectItem[]) {
517
+ for (const item of select) {
518
+ if (typeof values[item.as] === 'undefined') {
519
+ values[item.as] = 0;
520
+ }
433
521
  }
434
522
 
435
- return [];
523
+ return values;
436
524
  }
437
525
 
438
- function normalizeFilterNode(filter: unknown): IAdminForthSingleFilter | IAdminForthAndOrFilter {
439
- if (!isRecord(filter)) {
440
- return filter as IAdminForthSingleFilter;
441
- }
526
+ function groupAggregateSelectItems(select: QueryAggregateSelectItem[]) {
527
+ const groups = new Map<string, {
528
+ filters: DashboardWidgetFilters;
529
+ items: QueryAggregateSelectItem[];
530
+ }>();
442
531
 
443
- if (Array.isArray(filter.and)) {
444
- return Filters.AND(filter.and.map((item) => normalizeFilterNode(item)));
445
- }
532
+ for (const item of select) {
533
+ const filters = getAdminForthFilters(item.filters);
534
+ const key = getFilterCacheKey(filters);
535
+ const group = groups.get(key) ?? { filters, items: [] };
446
536
 
447
- if (Array.isArray(filter.or)) {
448
- return Filters.OR(filter.or.map((item) => normalizeFilterNode(item)));
537
+ group.items.push(item);
538
+ groups.set(key, group);
449
539
  }
450
540
 
451
- if (typeof filter.field === 'string') {
452
- if (Object.prototype.hasOwnProperty.call(filter, 'eq')) {
453
- return Filters.EQ(filter.field, normalizeFilterValue(filter.eq));
454
- }
541
+ return Array.from(groups.values());
542
+ }
455
543
 
456
- if (Object.prototype.hasOwnProperty.call(filter, 'neq')) {
457
- return Filters.NEQ(filter.field, normalizeFilterValue(filter.neq));
458
- }
544
+ function getFilterCacheKey(filters: DashboardWidgetFilters) {
545
+ if (Array.isArray(filters) && !filters.length) {
546
+ return '__base__';
547
+ }
459
548
 
460
- if (Object.prototype.hasOwnProperty.call(filter, 'gt')) {
461
- return Filters.GT(filter.field, normalizeFilterValue(filter.gt));
462
- }
549
+ return JSON.stringify(filters);
550
+ }
463
551
 
464
- if (Object.prototype.hasOwnProperty.call(filter, 'gte')) {
465
- return Filters.GTE(filter.field, normalizeFilterValue(filter.gte));
466
- }
552
+ function mergeFilters(...filters: Array<FilterExpression | DashboardWidgetFilters | undefined>) {
553
+ const merged: Array<IAdminForthSingleFilter | IAdminForthAndOrFilter> = [];
467
554
 
468
- if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
469
- return Filters.LT(filter.field, normalizeFilterValue(filter.lt));
470
- }
555
+ for (const filter of filters) {
556
+ const normalized = getAdminForthFilters(filter);
471
557
 
472
- if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
473
- return Filters.LTE(filter.field, normalizeFilterValue(filter.lte));
558
+ if (Array.isArray(normalized)) {
559
+ merged.push(...normalized);
560
+ continue;
474
561
  }
475
562
 
476
- if (Object.prototype.hasOwnProperty.call(filter, 'in')) {
477
- return Filters.IN(filter.field, normalizeFilterValue(filter.in));
563
+ if (normalized) {
564
+ merged.push(normalized);
478
565
  }
566
+ }
479
567
 
480
- if (Object.prototype.hasOwnProperty.call(filter, 'not_in')) {
481
- return Filters.NOT_IN(filter.field, normalizeFilterValue(filter.not_in));
482
- }
568
+ if (!merged.length) {
569
+ return [] as DashboardWidgetFilters;
483
570
  }
484
571
 
485
- return filter as IAdminForthSingleFilter | IAdminForthAndOrFilter;
572
+ return merged.length === 1 ? merged[0] : merged;
486
573
  }
487
574
 
488
- function matchesFilterExpression(row: Record<string, unknown>, filter: FilterExpression): boolean {
489
- if (Array.isArray(filter)) {
490
- return filter.every((item) => matchesFilterExpression(row, item));
491
- }
575
+ function getHiddenAggregateAlias(groupBy: EffectiveGroupByItem[], select: QueryAggregateSelectItem[]) {
576
+ const usedAliases = new Set([
577
+ ...groupBy.map((item) => item.as),
578
+ ...select.map((item) => item.as),
579
+ ]);
580
+ let alias = '__adminforth_dashboard_group_seed__';
492
581
 
493
- if ('and' in filter) {
494
- return filter.and.every((item) => matchesFilterExpression(row, item));
582
+ while (usedAliases.has(alias)) {
583
+ alias = `_${alias}`;
495
584
  }
496
585
 
497
- if ('or' in filter) {
498
- return filter.or.some((item) => matchesFilterExpression(row, item));
586
+ return alias;
587
+ }
588
+
589
+ function formatGroupValue(value: unknown, grain: TimeGrain | undefined) {
590
+ if (!grain) {
591
+ return value;
499
592
  }
500
593
 
501
- const value = row[filter.field];
594
+ const date = new Date(String(value));
502
595
 
503
- if (Object.prototype.hasOwnProperty.call(filter, 'eq')) {
504
- return value === normalizeFilterValue(filter.eq);
596
+ if (!Number.isFinite(date.getTime())) {
597
+ return value;
505
598
  }
506
599
 
507
- if (Object.prototype.hasOwnProperty.call(filter, 'neq')) {
508
- return value !== normalizeFilterValue(filter.neq);
600
+ if (grain === 'year') {
601
+ return `${date.getUTCFullYear()}`;
509
602
  }
510
603
 
511
- if (Object.prototype.hasOwnProperty.call(filter, 'gt')) {
512
- return compareComparableValues(value, normalizeFilterValue(filter.gt)) > 0;
604
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
605
+
606
+ if (grain === 'month') {
607
+ return `${date.getUTCFullYear()}-${month}`;
513
608
  }
514
609
 
515
- if (Object.prototype.hasOwnProperty.call(filter, 'gte')) {
516
- return compareComparableValues(value, normalizeFilterValue(filter.gte)) >= 0;
610
+ const day = String(date.getUTCDate()).padStart(2, '0');
611
+
612
+ if (grain === 'week') {
613
+ const weekStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
614
+ weekStart.setUTCDate(weekStart.getUTCDate() - weekStart.getUTCDay());
615
+ return weekStart.toISOString().slice(0, 10);
517
616
  }
518
617
 
519
- if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
520
- return compareComparableValues(value, normalizeFilterValue(filter.lt)) < 0;
618
+ if (grain === 'day') {
619
+ return `${date.getUTCFullYear()}-${month}-${day}`;
521
620
  }
522
621
 
523
- if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
524
- return compareComparableValues(value, normalizeFilterValue(filter.lte)) <= 0;
622
+ return value;
623
+ }
624
+
625
+ function getAdminForthFilters(filters: FilterExpression | DashboardWidgetFilters | undefined): DashboardWidgetFilters {
626
+ if (Array.isArray(filters)) {
627
+ return filters.map((filter) => isDashboardFilterExpression(filter)
628
+ ? toAdminForthFilter(filter)
629
+ : filter);
525
630
  }
526
631
 
527
- if (Object.prototype.hasOwnProperty.call(filter, 'in')) {
528
- return filter.in?.includes(value) ?? false;
632
+ if (isDashboardFilterExpression(filters)) {
633
+ return toAdminForthFilter(filters);
529
634
  }
530
635
 
531
- if (Object.prototype.hasOwnProperty.call(filter, 'not_in')) {
532
- return !(filter.not_in?.includes(value) ?? false);
636
+ if (filters) {
637
+ return filters;
533
638
  }
534
639
 
535
- return true;
640
+ return [];
536
641
  }
537
642
 
538
- function compareComparableValues(left: unknown, right: unknown) {
539
- const leftNumber = Number(left);
540
- const rightNumber = Number(right);
643
+ function isDashboardFilterExpression(value: unknown): value is FilterExpression {
644
+ if (Array.isArray(value)) {
645
+ return true;
646
+ }
541
647
 
542
- if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
543
- return leftNumber - rightNumber;
648
+ if (!isRecord(value)) {
649
+ return false;
544
650
  }
545
651
 
546
- return String(left ?? '').localeCompare(String(right ?? ''));
652
+ return 'and' in value
653
+ || 'or' in value
654
+ || 'eq' in value
655
+ || 'neq' in value
656
+ || 'gt' in value
657
+ || 'gte' in value
658
+ || 'lt' in value
659
+ || 'lte' in value
660
+ || 'in' in value
661
+ || 'not_in' in value
662
+ || 'like' in value
663
+ || 'ilike' in value;
547
664
  }
548
665
 
549
- function normalizeFilterValue(value: unknown) {
550
- if (!isRecord(value) || typeof value.now_minus !== 'string') {
551
- return value;
666
+ function toAdminForthFilter(filter: FilterExpression): IAdminForthSingleFilter | IAdminForthAndOrFilter {
667
+ if (Array.isArray(filter)) {
668
+ return Filters.AND(filter.map((item) => toAdminForthFilter(item)));
552
669
  }
553
670
 
554
- const match = value.now_minus.match(NOW_MINUS_RE);
555
-
556
- if (!match) {
557
- return value;
671
+ if ('and' in filter) {
672
+ return Filters.AND(filter.and.map((item) => toAdminForthFilter(item)));
558
673
  }
559
674
 
560
- const amount = Number(match[1]);
561
- const unit = match[2];
562
- const date = new Date();
675
+ if ('or' in filter) {
676
+ return Filters.OR(filter.or.map((item) => toAdminForthFilter(item)));
677
+ }
563
678
 
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);
679
+ for (const [operator, createFilter] of Object.entries(FILTER_OPERATORS)) {
680
+ if (Object.prototype.hasOwnProperty.call(filter, operator)) {
681
+ return createFilter(filter.field, filter[operator as keyof typeof FILTER_OPERATORS]);
682
+ }
570
683
  }
571
684
 
572
- return date.toISOString();
685
+ return Filters.AND([]);
573
686
  }
574
687
 
575
688
  function toFiniteNumber(value: unknown) {