@adminforth/dashboard 1.7.0 → 1.9.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 (33) hide show
  1. package/README.md +81 -55
  2. package/custom/model/dashboard.types.ts +17 -9
  3. package/custom/skills/adminforth-dashboard/SKILL.md +28 -12
  4. package/dist/custom/model/dashboard.types.d.ts +15 -8
  5. package/dist/custom/model/dashboard.types.ts +17 -9
  6. package/dist/custom/queries/useDashboardConfig.d.ts +222 -4
  7. package/dist/custom/queries/useWidgetData.d.ts +222 -4
  8. package/dist/custom/skills/adminforth-dashboard/SKILL.md +28 -12
  9. package/dist/schema/api.d.ts +5440 -941
  10. package/dist/schema/api.js +2 -2
  11. package/dist/schema/widget.d.ts +432 -23
  12. package/dist/schema/widget.js +1 -1
  13. package/dist/schema/widgets/charts.d.ts +558 -28
  14. package/dist/schema/widgets/charts.js +2 -2
  15. package/dist/schema/widgets/common.d.ts +17 -6
  16. package/dist/schema/widgets/common.js +16 -7
  17. package/dist/schema/widgets/gauge-card.d.ts +38 -2
  18. package/dist/schema/widgets/kpi-card.d.ts +38 -2
  19. package/dist/schema/widgets/pivot-table.d.ts +38 -2
  20. package/dist/schema/widgets/table.d.ts +38 -2
  21. package/dist/services/calc-evaluator.d.ts +1 -0
  22. package/dist/services/calc-evaluator.js +28 -0
  23. package/dist/services/dashboardFilterService.d.ts +5 -0
  24. package/dist/services/dashboardFilterService.js +125 -0
  25. package/dist/services/widgetDataService.js +53 -201
  26. package/package.json +2 -1
  27. package/schema/api.ts +1 -2
  28. package/schema/widget.ts +0 -1
  29. package/schema/widgets/charts.ts +1 -2
  30. package/schema/widgets/common.ts +16 -7
  31. package/services/calc-evaluator.ts +33 -0
  32. package/services/dashboardFilterService.ts +162 -0
  33. package/services/widgetDataService.ts +88 -263
@@ -1,15 +1,12 @@
1
- import { Filters, Sorts } from 'adminforth';
1
+ import { Sorts } from 'adminforth';
2
2
  import type {
3
3
  IAdminForth,
4
- IAdminForthAndOrFilter,
5
- IAdminForthSingleFilter,
6
4
  IAdminForthSort,
7
5
  } from 'adminforth';
8
6
  import type {
9
7
  DashboardWidgetConfig,
10
8
  DashboardWidgetData,
11
9
  DashboardVariables,
12
- FunnelQueryConfig,
13
10
  FilterExpression,
14
11
  QueryAggregateSelectItem,
15
12
  QueryCalcSelectItem,
@@ -17,9 +14,17 @@ import type {
17
14
  QueryFieldSelectItem,
18
15
  QueryGroupByItem,
19
16
  QueryOrderByItem,
17
+ ResourceQueryConfig,
18
+ StepsQueryStepConfig,
20
19
  QuerySelectItem,
21
20
  TimeGrain,
22
21
  } from '../custom/model/dashboard.types.js';
22
+ import {
23
+ getAdminForthFilters,
24
+ mergeFilters,
25
+ type DashboardQueryFilters,
26
+ } from './dashboardFilterService.js';
27
+ import { evaluateCalc } from './calc-evaluator.js';
23
28
 
24
29
  export type DashboardWidgetDataOptions = {
25
30
  pagination?: {
@@ -29,11 +34,6 @@ export type DashboardWidgetDataOptions = {
29
34
  variables?: DashboardVariables;
30
35
  };
31
36
 
32
- type DashboardWidgetFilters =
33
- | IAdminForthSingleFilter
34
- | IAdminForthAndOrFilter
35
- | Array<IAdminForthSingleFilter | IAdminForthAndOrFilter>;
36
-
37
37
  type AggregateRule =
38
38
  | { operation: 'count' }
39
39
  | { operation: Exclude<QueryAggregateSelectItem['agg'], 'count'>; field: string };
@@ -54,7 +54,7 @@ type AggregateGroupByRule =
54
54
 
55
55
  type AggregateResource = {
56
56
  aggregate: (
57
- filters: DashboardWidgetFilters,
57
+ filters: DashboardQueryFilters,
58
58
  aggregations: Record<string, AggregateRule>,
59
59
  groupBy?: AggregateGroupByRule | AggregateGroupByRule[],
60
60
  ) => Promise<Record<string, unknown>[]>;
@@ -67,24 +67,6 @@ type EffectiveGroupByItem = {
67
67
  timezone?: string;
68
68
  };
69
69
 
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\.?/;
73
- const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s?:<>=!]+$/;
74
- const RELATIVE_DURATION_RE = /^(\d+)(h|d|w|mo|y)$/;
75
- const FILTER_OPERATORS = {
76
- eq: Filters.EQ,
77
- neq: Filters.NEQ,
78
- gt: Filters.GT,
79
- gte: Filters.GTE,
80
- lt: Filters.LT,
81
- lte: Filters.LTE,
82
- in: Filters.IN,
83
- not_in: Filters.NOT_IN,
84
- like: Filters.LIKE,
85
- ilike: Filters.ILIKE,
86
- } as const;
87
-
88
70
  export type WidgetDataService = {
89
71
  getWidgetData: (widget: DashboardWidgetConfig, options?: DashboardWidgetDataOptions) => Promise<DashboardWidgetData | null>;
90
72
  };
@@ -98,9 +80,7 @@ export async function getWidgetData(
98
80
  return null;
99
81
  }
100
82
 
101
- const data = 'steps' in widget.query
102
- ? await getFunnelWidgetData(adminforth, widget.query, options.variables ?? {})
103
- : await getQueryWidgetData(adminforth, widget.query, options.variables ?? {});
83
+ const data = await getQueryWidgetData(adminforth, widget.query, options.variables ?? {});
104
84
 
105
85
  if (widget.target !== 'table' || !options.pagination) {
106
86
  return data;
@@ -122,54 +102,19 @@ export async function getWidgetData(
122
102
  };
123
103
  }
124
104
 
125
- async function getFunnelWidgetData(
126
- adminforth: IAdminForth,
127
- query: FunnelQueryConfig,
128
- variables: DashboardVariables,
129
- ): Promise<DashboardWidgetData> {
130
- const rows = await Promise.all(query.steps.map(async (step) => {
131
- const valueField = step.metric.as;
132
- const [values = {}] = await getAggregateRows(
133
- adminforth,
134
- step.resource,
135
- step.filters,
136
- [step.metric],
137
- [],
138
- );
139
-
140
- const row: Record<string, unknown> = {
141
- name: step.name,
142
- resource: step.resource,
143
- [valueField]: values[valueField] ?? 0,
144
- };
145
-
146
- for (const calc of query.calcs ?? []) {
147
- row[calc.as] = evaluateCalc(calc.calc, row, variables);
148
- }
149
-
150
- return row;
151
- }));
152
-
153
- return {
154
- kind: 'aggregate',
155
- columns: [
156
- 'name',
157
- ...Array.from(new Set(query.steps.map((step) => step.metric.as))),
158
- ...Array.from(new Set((query.calcs ?? []).map((calc) => calc.as))),
159
- ],
160
- rows,
161
- };
162
- }
163
-
164
105
  async function getQueryWidgetData(
165
106
  adminforth: IAdminForth,
166
107
  query: QueryConfig,
167
108
  variables: DashboardVariables,
168
109
  ): Promise<DashboardWidgetData> {
169
- const metricSelect = getSingleAggregateMetricSelect(query);
110
+ if (isStepsQuery(query)) {
111
+ return getStepsQueryData(adminforth, query, variables);
112
+ }
113
+
114
+ const singleAggregateSelect = getSingleAggregateSelectItem(query);
170
115
 
171
- if (metricSelect) {
172
- return getMetricWidgetData(adminforth, query, metricSelect);
116
+ if (singleAggregateSelect) {
117
+ return getSingleAggregateWidgetData(adminforth, query, singleAggregateSelect);
173
118
  }
174
119
 
175
120
  const selectedRows = isAggregateQuery(query)
@@ -203,27 +148,67 @@ async function getQueryWidgetData(
203
148
  };
204
149
  }
205
150
 
206
- async function getMetricWidgetData(
151
+ async function getStepsQueryData(
207
152
  adminforth: IAdminForth,
208
- query: QueryConfig,
209
- metric: QueryAggregateSelectItem,
153
+ query: Extract<QueryConfig, { source: 'steps' }>,
154
+ variables: DashboardVariables,
155
+ ): Promise<DashboardWidgetData> {
156
+ const rows = await Promise.all(query.steps.map(async (step) => {
157
+ const select = getStepSelect(step);
158
+ const [values = {}] = await getAggregateRows(
159
+ adminforth,
160
+ step.resource,
161
+ step.filters,
162
+ select,
163
+ [],
164
+ );
165
+ const row = buildCalculatedRow({
166
+ name: step.name,
167
+ resource: step.resource,
168
+ ...values,
169
+ }, select, query.calcs, variables);
170
+
171
+ return row;
172
+ }));
173
+ const orderedRows = sortRows(rows, query.order_by);
174
+ const slicedRows = typeof query.limit === 'number'
175
+ ? orderedRows.slice(query.offset ?? 0, (query.offset ?? 0) + query.limit)
176
+ : orderedRows.slice(query.offset ?? 0);
177
+ const columns = Array.from(new Set([
178
+ 'name',
179
+ 'resource',
180
+ ...query.steps.flatMap((step) => getStepSelect(step).map((item) => item.as)),
181
+ ...(query.calcs ?? []).map((item) => item.as),
182
+ ]));
183
+
184
+ return {
185
+ kind: 'aggregate',
186
+ columns,
187
+ rows: slicedRows,
188
+ };
189
+ }
190
+
191
+ async function getSingleAggregateWidgetData(
192
+ adminforth: IAdminForth,
193
+ query: ResourceQueryConfig,
194
+ aggregate: QueryAggregateSelectItem,
210
195
  ): Promise<DashboardWidgetData> {
211
196
  const [currentValues = {}] = await getAggregateRows(
212
197
  adminforth,
213
198
  query.resource,
214
199
  query.filters,
215
- [metric],
200
+ [aggregate],
216
201
  [],
217
202
  );
218
203
  const values: Record<string, unknown> = {
219
- [metric.as]: currentValues[metric.as] ?? 0,
204
+ [aggregate.as]: currentValues[aggregate.as] ?? 0,
220
205
  };
221
206
 
222
207
  const rows = query.sparkline
223
- ? await getMetricSparklineRows(adminforth, query, metric, getAdminForthFilters(query.filters))
208
+ ? await getSingleAggregateSparklineRows(adminforth, query, aggregate, getAdminForthFilters(query.filters))
224
209
  : [values];
225
210
  const columns = Array.from(new Set([
226
- metric.as,
211
+ aggregate.as,
227
212
  ...(query.sparkline ? [query.sparkline.as] : []),
228
213
  ]));
229
214
 
@@ -235,11 +220,11 @@ async function getMetricWidgetData(
235
220
  };
236
221
  }
237
222
 
238
- async function getMetricSparklineRows(
223
+ async function getSingleAggregateSparklineRows(
239
224
  adminforth: IAdminForth,
240
- query: QueryConfig,
241
- metric: QueryAggregateSelectItem,
242
- filters: DashboardWidgetFilters,
225
+ query: ResourceQueryConfig,
226
+ aggregate: QueryAggregateSelectItem,
227
+ filters: DashboardQueryFilters,
243
228
  ) {
244
229
  const sparkline = query.sparkline!;
245
230
  const groupBy = [{
@@ -251,7 +236,7 @@ async function getMetricSparklineRows(
251
236
  adminforth,
252
237
  query.resource,
253
238
  filters,
254
- [metric],
239
+ [aggregate],
255
240
  groupBy,
256
241
  );
257
242
 
@@ -275,14 +260,14 @@ async function getResourceRows(
275
260
  );
276
261
  }
277
262
 
278
- function buildPlainQueryRows(rows: Record<string, unknown>[], query: QueryConfig, variables: DashboardVariables) {
263
+ function buildPlainQueryRows(rows: Record<string, unknown>[], query: ResourceQueryConfig, variables: DashboardVariables) {
279
264
  const select = query.select ?? getDefaultSelect(rows);
280
265
  return rows.map((row) => buildPlainRow(row, select, query.calcs, variables));
281
266
  }
282
267
 
283
268
  async function buildAggregateQueryRows(
284
269
  adminforth: IAdminForth,
285
- query: QueryConfig,
270
+ query: ResourceQueryConfig,
286
271
  variables: DashboardVariables,
287
272
  ) {
288
273
  const select = query.select ?? [];
@@ -302,7 +287,7 @@ async function buildAggregateQueryRows(
302
287
  async function getAggregateRows(
303
288
  adminforth: IAdminForth,
304
289
  resourceId: string,
305
- baseFilters: FilterExpression | DashboardWidgetFilters | undefined,
290
+ baseFilters: FilterExpression | DashboardQueryFilters | undefined,
306
291
  select: QueryAggregateSelectItem[],
307
292
  groupBy: EffectiveGroupByItem[],
308
293
  ) {
@@ -356,7 +341,7 @@ function buildCalculatedRow(
356
341
  const values: Record<string, unknown> = { ...baseValues };
357
342
 
358
343
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
359
- values[item.as] = evaluateCalc(item.calc, values, variables);
344
+ values[item.as] = evaluateCalc(item.calc, values);
360
345
  }
361
346
 
362
347
  return values;
@@ -379,39 +364,12 @@ function buildPlainRow(
379
364
  }
380
365
 
381
366
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
382
- values[item.as] = evaluateCalc(item.calc, values, variables);
367
+ values[item.as] = evaluateCalc(item.calc, values);
383
368
  }
384
369
 
385
370
  return values;
386
371
  }
387
372
 
388
- function evaluateCalc(calc: string, values: Record<string, unknown>, variables: DashboardVariables) {
389
- const expression = calc
390
- .replace(LOOKUP_CALL_RE, (_match, path: string, keyField: string, defaultValue: string) => {
391
- const map = resolveVariablePath(variables, path);
392
- const key = String(values[keyField] ?? '');
393
-
394
- return String(toFiniteNumber(isRecord(map) && Object.prototype.hasOwnProperty.call(map, key)
395
- ? map[key]
396
- : Number(defaultValue)));
397
- })
398
- .replace(CALC_IDENTIFIER_RE, (name) => String(toFiniteNumber(values[name])));
399
-
400
- if (!SAFE_CALC_EXPRESSION_RE.test(expression)) {
401
- throw new Error(`Unsupported calc expression: ${calc}`);
402
- }
403
-
404
- return Function(`"use strict"; return (${expression});`)();
405
- }
406
-
407
- function resolveVariablePath(variables: DashboardVariables, path: string) {
408
- return path
409
- .replace(VARIABLE_PATH_PREFIX_RE, '')
410
- .split('.')
411
- .filter(Boolean)
412
- .reduce<unknown>((current, segment) => isRecord(current) ? current[segment] : undefined, variables);
413
- }
414
-
415
373
  function sortRows(rows: Record<string, unknown>[], orderBy: QueryOrderByItem[] = []) {
416
374
  if (!orderBy.length) {
417
375
  return rows;
@@ -449,7 +407,7 @@ function getBackendSort(orderBy: QueryOrderByItem[] | undefined) {
449
407
  : Sorts.DESC(order.field));
450
408
  }
451
409
 
452
- function getColumns(rows: Record<string, unknown>[], query: QueryConfig) {
410
+ function getColumns(rows: Record<string, unknown>[], query: ResourceQueryConfig) {
453
411
  const selectColumns = [
454
412
  ...(query.group_by ?? []).map(getGroupByAlias),
455
413
  ...(query.select ?? []).map(getSelectAlias),
@@ -463,14 +421,14 @@ function getDefaultSelect(rows: Record<string, unknown>[]): QuerySelectItem[] {
463
421
  return Object.keys(rows[0] ?? {}).map((field) => ({ field }));
464
422
  }
465
423
 
466
- function isAggregateQuery(query: QueryConfig) {
424
+ function isAggregateQuery(query: ResourceQueryConfig) {
467
425
  return Boolean(
468
426
  query.group_by?.length
469
427
  || query.select?.some((item) => isAggregateSelectItem(item)),
470
428
  );
471
429
  }
472
430
 
473
- function getSingleAggregateMetricSelect(query: QueryConfig) {
431
+ function getSingleAggregateSelectItem(query: ResourceQueryConfig) {
474
432
  if (query.group_by?.length) {
475
433
  return undefined;
476
434
  }
@@ -485,6 +443,14 @@ function getSingleAggregateMetricSelect(query: QueryConfig) {
485
443
  return aggregateItems[0];
486
444
  }
487
445
 
446
+ function isStepsQuery(query: QueryConfig): query is Extract<QueryConfig, { source: 'steps' }> {
447
+ return query.source === 'steps';
448
+ }
449
+
450
+ function getStepSelect(step: StepsQueryStepConfig): QueryAggregateSelectItem[] {
451
+ return step.select;
452
+ }
453
+
488
454
  function isFieldSelectItem(item: QuerySelectItem): item is QueryFieldSelectItem {
489
455
  return 'field' in item && !('agg' in item);
490
456
  }
@@ -605,7 +571,7 @@ function applyAggregateDefaults(values: Record<string, unknown>, select: QueryAg
605
571
 
606
572
  function groupAggregateSelectItems(select: QueryAggregateSelectItem[]) {
607
573
  const groups = new Map<string, {
608
- filters: DashboardWidgetFilters;
574
+ filters: DashboardQueryFilters;
609
575
  items: QueryAggregateSelectItem[];
610
576
  }>();
611
577
 
@@ -621,7 +587,7 @@ function groupAggregateSelectItems(select: QueryAggregateSelectItem[]) {
621
587
  return Array.from(groups.values());
622
588
  }
623
589
 
624
- function getFilterCacheKey(filters: DashboardWidgetFilters) {
590
+ function getFilterCacheKey(filters: DashboardQueryFilters) {
625
591
  if (Array.isArray(filters) && !filters.length) {
626
592
  return '__base__';
627
593
  }
@@ -629,29 +595,6 @@ function getFilterCacheKey(filters: DashboardWidgetFilters) {
629
595
  return JSON.stringify(filters);
630
596
  }
631
597
 
632
- function mergeFilters(...filters: Array<FilterExpression | DashboardWidgetFilters | undefined>) {
633
- const merged: Array<IAdminForthSingleFilter | IAdminForthAndOrFilter> = [];
634
-
635
- for (const filter of filters) {
636
- const normalized = getAdminForthFilters(filter);
637
-
638
- if (Array.isArray(normalized)) {
639
- merged.push(...normalized);
640
- continue;
641
- }
642
-
643
- if (normalized) {
644
- merged.push(normalized);
645
- }
646
- }
647
-
648
- if (!merged.length) {
649
- return [] as DashboardWidgetFilters;
650
- }
651
-
652
- return merged.length === 1 ? merged[0] : merged;
653
- }
654
-
655
598
  function getHiddenAggregateAlias(groupBy: EffectiveGroupByItem[], select: QueryAggregateSelectItem[]) {
656
599
  const usedAliases = new Set([
657
600
  ...groupBy.map((item) => item.as),
@@ -702,124 +645,6 @@ function formatGroupValue(value: unknown, grain: TimeGrain | undefined) {
702
645
  return value;
703
646
  }
704
647
 
705
- function getAdminForthFilters(filters: FilterExpression | DashboardWidgetFilters | undefined): DashboardWidgetFilters {
706
- if (Array.isArray(filters)) {
707
- return filters.map((filter) => isDashboardFilterExpression(filter)
708
- ? toAdminForthFilter(filter)
709
- : filter);
710
- }
711
-
712
- if (isDashboardFilterExpression(filters)) {
713
- return toAdminForthFilter(filters);
714
- }
715
-
716
- if (filters) {
717
- return filters;
718
- }
719
-
720
- return [];
721
- }
722
-
723
- function isDashboardFilterExpression(value: unknown): value is FilterExpression {
724
- if (Array.isArray(value)) {
725
- return true;
726
- }
727
-
728
- if (!isRecord(value)) {
729
- return false;
730
- }
731
-
732
- return 'and' in value
733
- || 'or' in value
734
- || 'eq' in value
735
- || 'neq' in value
736
- || 'gt' in value
737
- || 'gte' in value
738
- || 'lt' in value
739
- || 'lte' in value
740
- || 'in' in value
741
- || 'not_in' in value
742
- || 'like' in value
743
- || 'ilike' in value;
744
- }
745
-
746
- function toAdminForthFilter(filter: FilterExpression): IAdminForthSingleFilter | IAdminForthAndOrFilter {
747
- if (Array.isArray(filter)) {
748
- return Filters.AND(filter.map((item) => toAdminForthFilter(item)));
749
- }
750
-
751
- if ('and' in filter) {
752
- return Filters.AND(filter.and.map((item) => toAdminForthFilter(item)));
753
- }
754
-
755
- if ('or' in filter) {
756
- return Filters.OR(filter.or.map((item) => toAdminForthFilter(item)));
757
- }
758
-
759
- for (const [operator, createFilter] of Object.entries(FILTER_OPERATORS)) {
760
- if (Object.prototype.hasOwnProperty.call(filter, operator)) {
761
- return createFilter(filter.field, resolveFilterValue(filter[operator as keyof typeof FILTER_OPERATORS]));
762
- }
763
- }
764
-
765
- return Filters.AND([]);
766
- }
767
-
768
- function resolveFilterValue(value: unknown): unknown {
769
- if (Array.isArray(value)) {
770
- return value.map((item) => resolveFilterValue(item));
771
- }
772
-
773
- if (!isRecord(value)) {
774
- return value;
775
- }
776
-
777
- if (value.now === true) {
778
- return new Date().toISOString();
779
- }
780
-
781
- if (typeof value.now_minus === 'string') {
782
- return subtractDuration(new Date(), value.now_minus).toISOString();
783
- }
784
-
785
- return value;
786
- }
787
-
788
- function subtractDuration(now: Date, duration: string) {
789
- const match = duration.match(RELATIVE_DURATION_RE);
790
-
791
- if (!match) {
792
- throw new Error(`Unsupported relative date duration: ${duration}`);
793
- }
794
-
795
- const amount = Number(match[1]);
796
- const unit = match[2];
797
- const date = new Date(now);
798
-
799
- if (unit === 'h') {
800
- date.setUTCHours(date.getUTCHours() - amount);
801
- } else if (unit === 'd') {
802
- date.setUTCDate(date.getUTCDate() - amount);
803
- } else if (unit === 'w') {
804
- date.setUTCDate(date.getUTCDate() - amount * 7);
805
- } else if (unit === 'mo') {
806
- date.setUTCMonth(date.getUTCMonth() - amount);
807
- } else if (unit === 'y') {
808
- date.setUTCFullYear(date.getUTCFullYear() - amount);
809
- }
810
-
811
- return date;
812
- }
813
-
814
- function toFiniteNumber(value: unknown) {
815
- const numberValue = typeof value === 'number' ? value : Number(value);
816
- return Number.isFinite(numberValue) ? numberValue : 0;
817
- }
818
-
819
- function isRecord(value: unknown): value is Record<string, any> {
820
- return typeof value === 'object' && value !== null;
821
- }
822
-
823
648
  export function createWidgetDataService(adminforth: IAdminForth): WidgetDataService {
824
649
  return {
825
650
  getWidgetData: (widget, options) => getWidgetData(adminforth, widget, options),