@adminforth/dashboard 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -123,6 +123,7 @@ const QueryCalcItemSchema = z.object({
123
123
  as: z.string(),
124
124
  }).strict();
125
125
  const FormattingConfigSchema = z.record(z.string(), z.unknown());
126
+ const VariablesConfigSchema = z.record(z.string(), z.unknown());
126
127
  export const QueryConfigSchema = z.object({
127
128
  resource: z.string(),
128
129
  select: z.array(QuerySelectItemSchema).optional(),
@@ -145,11 +146,13 @@ const FunnelQueryStepSchema = z.object({
145
146
  }).strict();
146
147
  export const FunnelQueryConfigSchema = z.object({
147
148
  steps: z.array(FunnelQueryStepSchema).min(1),
149
+ calcs: z.array(QueryCalcItemSchema).optional(),
148
150
  }).strict();
149
151
  const WidgetBaseSchema = z.object({
150
152
  id: z.string().optional(),
151
153
  group_id: z.string().optional(),
152
154
  label: z.string().optional(),
155
+ variables: VariablesConfigSchema.optional(),
153
156
  size: DashboardWidgetSizeSchema.optional(),
154
157
  width: z.number().positive('Width must be greater than 0').optional(),
155
158
  height: z.number().positive('Height must be greater than 0').optional(),
@@ -191,8 +194,8 @@ const BarChartSchema = ChartBaseSchema.extend({
191
194
  const StackedBarChartSchema = ChartBaseSchema.extend({
192
195
  type: z.literal('stacked_bar'),
193
196
  x: ChartFieldRefSchema,
194
- y: ChartFieldRefSchema,
195
- series: ChartSeriesRefSchema,
197
+ y: z.union([ChartFieldRefSchema, z.array(ChartFieldRefSchema).min(1)]),
198
+ series: ChartSeriesRefSchema.optional(),
196
199
  colors: z.array(z.string()).optional(),
197
200
  });
198
201
  const PieChartSchema = ChartBaseSchema.extend({
@@ -283,11 +286,11 @@ const ChartWidgetTargetConfigSchema = WidgetBaseSchema.extend({
283
286
  }).superRefine((widget, ctx) => {
284
287
  const isFunnelChart = widget.chart.type === 'funnel';
285
288
  const isFunnelQuery = 'steps' in widget.query;
286
- if (isFunnelChart !== isFunnelQuery) {
289
+ if (isFunnelChart && !isFunnelQuery) {
287
290
  ctx.addIssue({
288
291
  code: z.ZodIssueCode.custom,
289
292
  path: ['query'],
290
- message: 'Funnel charts must use steps query, other charts must use resource query',
293
+ message: 'Funnel charts must use steps query',
291
294
  });
292
295
  }
293
296
  });
@@ -1,10 +1,11 @@
1
1
  import type { IAdminForth } from 'adminforth';
2
- import type { DashboardWidgetConfig, DashboardWidgetData } from '../custom/model/dashboard.types.js';
2
+ import type { DashboardWidgetConfig, DashboardWidgetData, DashboardVariables } from '../custom/model/dashboard.types.js';
3
3
  export type DashboardWidgetDataOptions = {
4
4
  pagination?: {
5
5
  page: number;
6
6
  pageSize: number;
7
7
  };
8
+ variables?: DashboardVariables;
8
9
  };
9
10
  export type WidgetDataService = {
10
11
  getWidgetData: (widget: DashboardWidgetConfig, options?: DashboardWidgetDataOptions) => Promise<DashboardWidgetData | null>;
@@ -10,15 +10,18 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  import { Filters, Sorts } from 'adminforth';
11
11
  const NOW_MINUS_RE = /^(\d+)([dhw])$/;
12
12
  const CALC_IDENTIFIER_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;
13
+ 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;
14
+ const VARIABLE_PATH_PREFIX_RE = /^\$variables\.?/;
13
15
  const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s]+$/;
14
16
  export function getWidgetData(adminforth_1, widget_1) {
15
17
  return __awaiter(this, arguments, void 0, function* (adminforth, widget, options = {}) {
18
+ var _a, _b;
16
19
  if (!('query' in widget)) {
17
20
  return null;
18
21
  }
19
22
  const data = 'steps' in widget.query
20
- ? yield getFunnelWidgetData(adminforth, widget.query)
21
- : yield getQueryWidgetData(adminforth, widget.query);
23
+ ? yield getFunnelWidgetData(adminforth, widget.query, (_a = options.variables) !== null && _a !== void 0 ? _a : {})
24
+ : yield getQueryWidgetData(adminforth, widget.query, (_b = options.variables) !== null && _b !== void 0 ? _b : {});
22
25
  if (widget.target !== 'table' || !options.pagination) {
23
26
  return data;
24
27
  }
@@ -33,28 +36,39 @@ export function getWidgetData(adminforth_1, widget_1) {
33
36
  } });
34
37
  });
35
38
  }
36
- function getFunnelWidgetData(adminforth, query) {
39
+ function getFunnelWidgetData(adminforth, query, variables) {
37
40
  return __awaiter(this, void 0, void 0, function* () {
41
+ var _a;
38
42
  const rows = yield Promise.all(query.steps.map((step) => __awaiter(this, void 0, void 0, function* () {
43
+ var _a;
39
44
  const valueField = step.metric.as;
40
45
  const sourceRows = yield getResourceRows(adminforth, step.resource, step.filters);
41
- return {
46
+ const row = {
42
47
  name: step.name,
48
+ resource: step.resource,
43
49
  [valueField]: calculateAggregate(sourceRows, step.metric),
44
50
  };
51
+ for (const calc of (_a = query.calcs) !== null && _a !== void 0 ? _a : []) {
52
+ row[calc.as] = evaluateCalc(calc.calc, row, variables);
53
+ }
54
+ return row;
45
55
  })));
46
56
  return {
47
57
  kind: 'aggregate',
48
- columns: ['name', ...Array.from(new Set(query.steps.map((step) => step.metric.as)))],
58
+ columns: [
59
+ 'name',
60
+ ...Array.from(new Set(query.steps.map((step) => step.metric.as))),
61
+ ...Array.from(new Set(((_a = query.calcs) !== null && _a !== void 0 ? _a : []).map((calc) => calc.as))),
62
+ ],
49
63
  rows,
50
64
  };
51
65
  });
52
66
  }
53
- function getQueryWidgetData(adminforth, query) {
67
+ function getQueryWidgetData(adminforth, query, variables) {
54
68
  return __awaiter(this, void 0, void 0, function* () {
55
69
  var _a, _b, _c;
56
70
  const rows = yield getResourceRows(adminforth, query.resource, query.filters, getBackendSort(query.orderBy));
57
- const selectedRows = buildQueryRows(rows, query);
71
+ const selectedRows = buildQueryRows(rows, query, variables);
58
72
  const orderedRows = sortRows(selectedRows, query.orderBy);
59
73
  const slicedRows = typeof query.limit === 'number'
60
74
  ? orderedRows.slice((_a = query.offset) !== null && _a !== void 0 ? _a : 0, ((_b = query.offset) !== null && _b !== void 0 ? _b : 0) + query.limit)
@@ -76,23 +90,23 @@ function getResourceRows(adminforth, resourceId, filters, sort) {
76
90
  return adminforth.resource(resourceId).list(normalizeFilters(filters), undefined, 0, sort);
77
91
  });
78
92
  }
79
- function buildQueryRows(rows, query) {
93
+ function buildQueryRows(rows, query, variables) {
80
94
  var _a, _b;
81
95
  const select = (_a = query.select) !== null && _a !== void 0 ? _a : getDefaultSelect(rows);
82
96
  const groupBy = (_b = query.groupBy) !== null && _b !== void 0 ? _b : [];
83
97
  if (isAggregateQuery(query)) {
84
- return buildGroupedRows(rows, select, groupBy, query.calcs);
98
+ return buildGroupedRows(rows, select, groupBy, variables, query.calcs);
85
99
  }
86
- return rows.map((row) => buildPlainRow(row, select, query.calcs));
100
+ return rows.map((row) => buildPlainRow(row, select, query.calcs, variables));
87
101
  }
88
- function buildGroupedRows(rows, select, groupBy, calcs = []) {
102
+ function buildGroupedRows(rows, select, groupBy, variables, calcs = []) {
89
103
  var _a;
90
104
  const groups = new Map();
91
105
  const effectiveGroupBy = groupBy.length
92
106
  ? groupBy
93
107
  : select.filter(isFieldSelectItem).map((item) => ({ field: item.field, as: item.as, grain: item.grain }));
94
108
  if (!effectiveGroupBy.length) {
95
- const values = calculateGroupValues(rows, select, calcs);
109
+ const values = calculateGroupValues(rows, select, calcs, variables);
96
110
  return Object.keys(values).length ? [values] : [];
97
111
  }
98
112
  for (const row of rows) {
@@ -107,10 +121,10 @@ function buildGroupedRows(rows, select, groupBy, calcs = []) {
107
121
  group.rows.push(row);
108
122
  groups.set(key, group);
109
123
  }
110
- return Array.from(groups.values()).map((group) => (Object.assign(Object.assign({}, group.values), calculateGroupValues(group.rows, select, calcs))));
124
+ return Array.from(groups.values()).map((group) => (Object.assign(Object.assign({}, group.values), calculateGroupValues(group.rows, select, calcs, variables, group.values))));
111
125
  }
112
- function calculateGroupValues(rows, select, calcs) {
113
- const values = {};
126
+ function calculateGroupValues(rows, select, calcs, variables, baseValues = {}) {
127
+ const values = Object.assign({}, baseValues);
114
128
  for (const item of select) {
115
129
  if (isAggregateSelectItem(item)) {
116
130
  const filteredRows = item.filters
@@ -120,11 +134,11 @@ function calculateGroupValues(rows, select, calcs) {
120
134
  }
121
135
  }
122
136
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
123
- values[item.as] = evaluateCalc(item.calc, values);
137
+ values[item.as] = evaluateCalc(item.calc, values, variables);
124
138
  }
125
139
  return values;
126
140
  }
127
- function buildPlainRow(row, select, calcs = []) {
141
+ function buildPlainRow(row, select, calcs = [], variables) {
128
142
  var _a;
129
143
  const values = {};
130
144
  for (const item of select) {
@@ -135,7 +149,7 @@ function buildPlainRow(row, select, calcs = []) {
135
149
  }
136
150
  }
137
151
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
138
- values[item.as] = evaluateCalc(item.calc, values);
152
+ values[item.as] = evaluateCalc(item.calc, values, variables);
139
153
  }
140
154
  return values;
141
155
  }
@@ -174,13 +188,29 @@ function calculateMedian(values) {
174
188
  ? sorted[middle]
175
189
  : (sorted[middle - 1] + sorted[middle]) / 2;
176
190
  }
177
- function evaluateCalc(calc, values) {
178
- const expression = calc.replace(CALC_IDENTIFIER_RE, (name) => String(toFiniteNumber(values[name])));
191
+ function evaluateCalc(calc, values, variables) {
192
+ const expression = calc
193
+ .replace(LOOKUP_CALL_RE, (_match, path, keyField, defaultValue) => {
194
+ var _a;
195
+ const map = resolveVariablePath(variables, path);
196
+ const key = String((_a = values[keyField]) !== null && _a !== void 0 ? _a : '');
197
+ return String(toFiniteNumber(isRecord(map) && Object.prototype.hasOwnProperty.call(map, key)
198
+ ? map[key]
199
+ : Number(defaultValue)));
200
+ })
201
+ .replace(CALC_IDENTIFIER_RE, (name) => String(toFiniteNumber(values[name])));
179
202
  if (!SAFE_CALC_EXPRESSION_RE.test(expression)) {
180
203
  throw new Error(`Unsupported calc expression: ${calc}`);
181
204
  }
182
205
  return Function(`"use strict"; return (${expression});`)();
183
206
  }
207
+ function resolveVariablePath(variables, path) {
208
+ return path
209
+ .replace(VARIABLE_PATH_PREFIX_RE, '')
210
+ .split('.')
211
+ .filter(Boolean)
212
+ .reduce((current, segment) => isRecord(current) ? current[segment] : undefined, variables);
213
+ }
184
214
  function sortRows(rows, orderBy = []) {
185
215
  if (!orderBy.length) {
186
216
  return rows;
@@ -1,12 +1,35 @@
1
- import type { IHttpServer } from 'adminforth';
2
- import { DashboardApiResponseSchema, SlugRequestSchema } from '../schema/api.js';
3
- import type { DashboardRecord } from '../services/dashboardConfigService.js';
1
+ import type { AdminUser, IHttpServer } from 'adminforth';
2
+ import { normalizeDashboardConfig } from '../custom/model/dashboard.types.js';
3
+ import type { DashboardConfig, DashboardWidgetConfig } from '../custom/model/dashboard.types.js';
4
+ import {
5
+ DashboardApiResponseSchema,
6
+ DashboardConfigZodSchema,
7
+ SetDashboardConfigRequestSchema,
8
+ SlugRequestSchema,
9
+ } from '../schema/api.js';
10
+ import type { DashboardWidgetConfigValidationError } from '../schema/widget.js';
11
+ import type { DashboardRecord, PersistedDashboardResponse } from '../services/dashboardConfigService.js';
4
12
  import { buildDashboardResponse } from '../services/dashboardConfigService.js';
5
13
 
6
14
  type DashboardEndpointsContext = {
15
+ canEditDashboard: (adminUser: AdminUser) => boolean;
7
16
  getDashboardRecord: (slug: string) => Promise<DashboardRecord | null>;
17
+ persistDashboardConfig: (
18
+ dashboard: DashboardRecord,
19
+ config: DashboardConfig,
20
+ ) => Promise<PersistedDashboardResponse>;
21
+ validateDashboardWidgetApiConfig: (
22
+ widget: DashboardWidgetConfig,
23
+ ) => DashboardWidgetConfigValidationError[];
8
24
  };
9
25
 
26
+ function formatDashboardConfigValidationErrors(error: { issues: { path: PropertyKey[], message: string }[] }) {
27
+ return error.issues.map((issue) => ({
28
+ field: issue.path.length ? issue.path.map(String).join('.') : 'config',
29
+ message: issue.message,
30
+ }));
31
+ }
32
+
10
33
  export function registerDashboardEndpoints(
11
34
  server: IHttpServer,
12
35
  ctx: DashboardEndpointsContext,
@@ -29,4 +52,54 @@ export function registerDashboardEndpoints(
29
52
  return buildDashboardResponse(dashboard);
30
53
  },
31
54
  });
55
+
56
+ server.endpoint({
57
+ method: 'POST',
58
+ path: '/dashboard/set_dashboard_config',
59
+ description: 'Replaces one dashboard configuration, including groups and widgets. Superadmin only.',
60
+ request_schema: SetDashboardConfigRequestSchema,
61
+ response_schema: DashboardApiResponseSchema,
62
+ handler: async ({ body, adminUser, response }) => {
63
+ if (!ctx.canEditDashboard(adminUser)) {
64
+ response.setStatus(403);
65
+ return { error: 'Dashboard edit is not allowed' };
66
+ }
67
+
68
+ const slug = String(body?.slug || 'default');
69
+ const dashboard = await ctx.getDashboardRecord(slug);
70
+
71
+ if (!dashboard) {
72
+ response.setStatus(404);
73
+ return { error: 'Dashboard not found' };
74
+ }
75
+
76
+ const normalizedConfig = normalizeDashboardConfig(body?.config);
77
+ const parsedConfig = DashboardConfigZodSchema.safeParse(normalizedConfig);
78
+
79
+ if (!parsedConfig.success) {
80
+ response.setStatus(422);
81
+ return {
82
+ error: 'Invalid dashboard config',
83
+ validationErrors: formatDashboardConfigValidationErrors(parsedConfig.error),
84
+ };
85
+ }
86
+
87
+ const widgetValidationErrors = parsedConfig.data.widgets.flatMap((widget, index) => (
88
+ ctx.validateDashboardWidgetApiConfig(widget as DashboardWidgetConfig).map((error) => ({
89
+ ...error,
90
+ field: `widgets.${index}.${error.field}`,
91
+ }))
92
+ ));
93
+
94
+ if (widgetValidationErrors.length) {
95
+ response.setStatus(422);
96
+ return {
97
+ error: 'Invalid dashboard config',
98
+ validationErrors: widgetValidationErrors,
99
+ };
100
+ }
101
+
102
+ return ctx.persistDashboardConfig(dashboard, parsedConfig.data as DashboardConfig);
103
+ },
104
+ });
32
105
  }
@@ -3,7 +3,7 @@ import { randomUUID } from 'crypto';
3
3
  import {
4
4
  normalizeDashboardWidgetConfig,
5
5
  } from '../custom/model/dashboard.types.js';
6
- import type { DashboardConfig, DashboardWidgetConfig } from '../custom/model/dashboard.types.js';
6
+ import type { DashboardConfig, DashboardVariables, DashboardWidgetConfig } from '../custom/model/dashboard.types.js';
7
7
  import {
8
8
  DashboardApiResponseSchema,
9
9
  DashboardWidgetDataResponseSchema,
@@ -31,7 +31,10 @@ type WidgetEndpointsContext = {
31
31
  ) => DashboardWidgetConfigValidationError[];
32
32
  getWidgetData: (
33
33
  widget: DashboardWidgetConfig,
34
- options?: { pagination?: { page: number, pageSize: number } },
34
+ options?: {
35
+ pagination?: { page: number, pageSize: number },
36
+ variables?: DashboardVariables,
37
+ },
35
38
  ) => Promise<unknown>;
36
39
  };
37
40
 
@@ -301,6 +304,7 @@ export function registerWidgetEndpoints(
301
304
  widget,
302
305
  data: await ctx.getWidgetData(widget, {
303
306
  pagination: body?.pagination,
307
+ variables: widget.variables,
304
308
  }),
305
309
  };
306
310
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/dashboard",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
package/schema/api.ts CHANGED
@@ -50,6 +50,11 @@ export const SlugRequestZodSchema = z.object({
50
50
  slug: z.string().optional(),
51
51
  }).strict()
52
52
 
53
+ export const SetDashboardConfigRequestZodSchema = z.object({
54
+ slug: z.string().optional(),
55
+ config: z.record(z.string(), z.unknown()),
56
+ }).strict()
57
+
53
58
  export const GroupIdRequestZodSchema = z.object({
54
59
  slug: z.string().optional(),
55
60
  groupId: z.string(),
@@ -100,6 +105,7 @@ export const DashboardResponseSchema = toAdminForthJsonSchema(DashboardResponseZ
100
105
  export const DashboardApiResponseSchema = toAdminForthJsonSchema(DashboardApiResponseZodSchema)
101
106
  export const DashboardWidgetDataResponseSchema = toAdminForthJsonSchema(DashboardWidgetDataResponseZodSchema)
102
107
  export const SlugRequestSchema = toAdminForthJsonSchema(SlugRequestZodSchema)
108
+ export const SetDashboardConfigRequestSchema = toAdminForthJsonSchema(SetDashboardConfigRequestZodSchema)
103
109
  export const GroupIdRequestSchema = toAdminForthJsonSchema(GroupIdRequestZodSchema)
104
110
  export const MoveGroupRequestSchema = toAdminForthJsonSchema(MoveGroupRequestZodSchema)
105
111
  export const SetGroupConfigRequestSchema = toAdminForthJsonSchema(SetGroupConfigRequestZodSchema)
package/schema/widget.ts CHANGED
@@ -145,6 +145,7 @@ const QueryCalcItemSchema = z.object({
145
145
  }).strict()
146
146
 
147
147
  const FormattingConfigSchema = z.record(z.string(), z.unknown())
148
+ const VariablesConfigSchema = z.record(z.string(), z.unknown())
148
149
 
149
150
  export const QueryConfigSchema = z.object({
150
151
  resource: z.string(),
@@ -170,12 +171,14 @@ const FunnelQueryStepSchema = z.object({
170
171
 
171
172
  export const FunnelQueryConfigSchema = z.object({
172
173
  steps: z.array(FunnelQueryStepSchema).min(1),
174
+ calcs: z.array(QueryCalcItemSchema).optional(),
173
175
  }).strict()
174
176
 
175
177
  const WidgetBaseSchema = z.object({
176
178
  id: z.string().optional(),
177
179
  group_id: z.string().optional(),
178
180
  label: z.string().optional(),
181
+ variables: VariablesConfigSchema.optional(),
179
182
  size: DashboardWidgetSizeSchema.optional(),
180
183
  width: z.number().positive('Width must be greater than 0').optional(),
181
184
  height: z.number().positive('Height must be greater than 0').optional(),
@@ -224,8 +227,8 @@ const BarChartSchema = ChartBaseSchema.extend({
224
227
  const StackedBarChartSchema = ChartBaseSchema.extend({
225
228
  type: z.literal('stacked_bar'),
226
229
  x: ChartFieldRefSchema,
227
- y: ChartFieldRefSchema,
228
- series: ChartSeriesRefSchema,
230
+ y: z.union([ChartFieldRefSchema, z.array(ChartFieldRefSchema).min(1)]),
231
+ series: ChartSeriesRefSchema.optional(),
229
232
  colors: z.array(z.string()).optional(),
230
233
  })
231
234
 
@@ -327,11 +330,11 @@ const ChartWidgetTargetConfigSchema = WidgetBaseSchema.extend({
327
330
  const isFunnelChart = widget.chart.type === 'funnel'
328
331
  const isFunnelQuery = 'steps' in widget.query
329
332
 
330
- if (isFunnelChart !== isFunnelQuery) {
333
+ if (isFunnelChart && !isFunnelQuery) {
331
334
  ctx.addIssue({
332
335
  code: z.ZodIssueCode.custom,
333
336
  path: ['query'],
334
- message: 'Funnel charts must use steps query, other charts must use resource query',
337
+ message: 'Funnel charts must use steps query',
335
338
  })
336
339
  }
337
340
  })
@@ -8,6 +8,7 @@ import type {
8
8
  import type {
9
9
  DashboardWidgetConfig,
10
10
  DashboardWidgetData,
11
+ DashboardVariables,
11
12
  FilterExpression,
12
13
  FunnelQueryConfig,
13
14
  QueryAggregateOperation,
@@ -26,6 +27,7 @@ export type DashboardWidgetDataOptions = {
26
27
  page: number;
27
28
  pageSize: number;
28
29
  };
30
+ variables?: DashboardVariables;
29
31
  };
30
32
 
31
33
  type DashboardWidgetFilters =
@@ -40,6 +42,8 @@ type QueryRowGroup = {
40
42
 
41
43
  const NOW_MINUS_RE = /^(\d+)([dhw])$/;
42
44
  const CALC_IDENTIFIER_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;
45
+ const LOOKUP_CALL_RE = /lookup\(\s*(\$variables(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
46
+ const VARIABLE_PATH_PREFIX_RE = /^\$variables\.?/;
43
47
  const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s]+$/;
44
48
 
45
49
  export type WidgetDataService = {
@@ -56,8 +60,8 @@ export async function getWidgetData(
56
60
  }
57
61
 
58
62
  const data = 'steps' in widget.query
59
- ? await getFunnelWidgetData(adminforth, widget.query)
60
- : await getQueryWidgetData(adminforth, widget.query);
63
+ ? await getFunnelWidgetData(adminforth, widget.query, options.variables ?? {})
64
+ : await getQueryWidgetData(adminforth, widget.query, options.variables ?? {});
61
65
 
62
66
  if (widget.target !== 'table' || !options.pagination) {
63
67
  return data;
@@ -82,20 +86,32 @@ export async function getWidgetData(
82
86
  async function getFunnelWidgetData(
83
87
  adminforth: IAdminForth,
84
88
  query: FunnelQueryConfig,
89
+ variables: DashboardVariables,
85
90
  ): Promise<DashboardWidgetData> {
86
91
  const rows = await Promise.all(query.steps.map(async (step) => {
87
92
  const valueField = step.metric.as;
88
93
  const sourceRows = await getResourceRows(adminforth, step.resource, step.filters);
89
94
 
90
- return {
95
+ const row: Record<string, unknown> = {
91
96
  name: step.name,
97
+ resource: step.resource,
92
98
  [valueField]: calculateAggregate(sourceRows, step.metric),
93
99
  };
100
+
101
+ for (const calc of query.calcs ?? []) {
102
+ row[calc.as] = evaluateCalc(calc.calc, row, variables);
103
+ }
104
+
105
+ return row;
94
106
  }));
95
107
 
96
108
  return {
97
109
  kind: 'aggregate',
98
- columns: ['name', ...Array.from(new Set(query.steps.map((step) => step.metric.as)))],
110
+ columns: [
111
+ 'name',
112
+ ...Array.from(new Set(query.steps.map((step) => step.metric.as))),
113
+ ...Array.from(new Set((query.calcs ?? []).map((calc) => calc.as))),
114
+ ],
99
115
  rows,
100
116
  };
101
117
  }
@@ -103,9 +119,10 @@ async function getFunnelWidgetData(
103
119
  async function getQueryWidgetData(
104
120
  adminforth: IAdminForth,
105
121
  query: QueryConfig,
122
+ variables: DashboardVariables,
106
123
  ): Promise<DashboardWidgetData> {
107
124
  const rows = await getResourceRows(adminforth, query.resource, query.filters, getBackendSort(query.orderBy));
108
- const selectedRows = buildQueryRows(rows, query);
125
+ const selectedRows = buildQueryRows(rows, query, variables);
109
126
  const orderedRows = sortRows(selectedRows, query.orderBy);
110
127
  const slicedRows = typeof query.limit === 'number'
111
128
  ? orderedRows.slice(query.offset ?? 0, (query.offset ?? 0) + query.limit)
@@ -144,21 +161,22 @@ async function getResourceRows(
144
161
  );
145
162
  }
146
163
 
147
- function buildQueryRows(rows: Record<string, unknown>[], query: QueryConfig) {
164
+ function buildQueryRows(rows: Record<string, unknown>[], query: QueryConfig, variables: DashboardVariables) {
148
165
  const select = query.select ?? getDefaultSelect(rows);
149
166
  const groupBy = query.groupBy ?? [];
150
167
 
151
168
  if (isAggregateQuery(query)) {
152
- return buildGroupedRows(rows, select, groupBy, query.calcs);
169
+ return buildGroupedRows(rows, select, groupBy, variables, query.calcs);
153
170
  }
154
171
 
155
- return rows.map((row) => buildPlainRow(row, select, query.calcs));
172
+ return rows.map((row) => buildPlainRow(row, select, query.calcs, variables));
156
173
  }
157
174
 
158
175
  function buildGroupedRows(
159
176
  rows: Record<string, unknown>[],
160
177
  select: QuerySelectItem[],
161
178
  groupBy: QueryGroupByItem[],
179
+ variables: DashboardVariables,
162
180
  calcs: QueryCalcSelectItem[] = [],
163
181
  ) {
164
182
  const groups = new Map<string, QueryRowGroup>();
@@ -167,7 +185,7 @@ function buildGroupedRows(
167
185
  : select.filter(isFieldSelectItem).map((item) => ({ field: item.field, as: item.as, grain: item.grain }));
168
186
 
169
187
  if (!effectiveGroupBy.length) {
170
- const values = calculateGroupValues(rows, select, calcs);
188
+ const values = calculateGroupValues(rows, select, calcs, variables);
171
189
  return Object.keys(values).length ? [values] : [];
172
190
  }
173
191
 
@@ -188,7 +206,7 @@ function buildGroupedRows(
188
206
 
189
207
  return Array.from(groups.values()).map((group) => ({
190
208
  ...group.values,
191
- ...calculateGroupValues(group.rows, select, calcs),
209
+ ...calculateGroupValues(group.rows, select, calcs, variables, group.values),
192
210
  }));
193
211
  }
194
212
 
@@ -196,8 +214,10 @@ function calculateGroupValues(
196
214
  rows: Record<string, unknown>[],
197
215
  select: QuerySelectItem[],
198
216
  calcs: QueryCalcSelectItem[],
217
+ variables: DashboardVariables,
218
+ baseValues: Record<string, unknown> = {},
199
219
  ) {
200
- const values: Record<string, unknown> = {};
220
+ const values: Record<string, unknown> = { ...baseValues };
201
221
 
202
222
  for (const item of select) {
203
223
  if (isAggregateSelectItem(item)) {
@@ -210,7 +230,7 @@ function calculateGroupValues(
210
230
  }
211
231
 
212
232
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
213
- values[item.as] = evaluateCalc(item.calc, values);
233
+ values[item.as] = evaluateCalc(item.calc, values, variables);
214
234
  }
215
235
 
216
236
  return values;
@@ -220,6 +240,7 @@ function buildPlainRow(
220
240
  row: Record<string, unknown>,
221
241
  select: QuerySelectItem[],
222
242
  calcs: QueryCalcSelectItem[] = [],
243
+ variables: DashboardVariables,
223
244
  ) {
224
245
  const values: Record<string, unknown> = {};
225
246
 
@@ -232,7 +253,7 @@ function buildPlainRow(
232
253
  }
233
254
 
234
255
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
235
- values[item.as] = evaluateCalc(item.calc, values);
256
+ values[item.as] = evaluateCalc(item.calc, values, variables);
236
257
  }
237
258
 
238
259
  return values;
@@ -282,8 +303,17 @@ function calculateMedian(values: number[]) {
282
303
  : (sorted[middle - 1] + sorted[middle]) / 2;
283
304
  }
284
305
 
285
- function evaluateCalc(calc: string, values: Record<string, unknown>) {
286
- const expression = calc.replace(CALC_IDENTIFIER_RE, (name) => String(toFiniteNumber(values[name])));
306
+ function evaluateCalc(calc: string, values: Record<string, unknown>, variables: DashboardVariables) {
307
+ const expression = calc
308
+ .replace(LOOKUP_CALL_RE, (_match, path: string, keyField: string, defaultValue: string) => {
309
+ const map = resolveVariablePath(variables, path);
310
+ const key = String(values[keyField] ?? '');
311
+
312
+ return String(toFiniteNumber(isRecord(map) && Object.prototype.hasOwnProperty.call(map, key)
313
+ ? map[key]
314
+ : Number(defaultValue)));
315
+ })
316
+ .replace(CALC_IDENTIFIER_RE, (name) => String(toFiniteNumber(values[name])));
287
317
 
288
318
  if (!SAFE_CALC_EXPRESSION_RE.test(expression)) {
289
319
  throw new Error(`Unsupported calc expression: ${calc}`);
@@ -292,6 +322,14 @@ function evaluateCalc(calc: string, values: Record<string, unknown>) {
292
322
  return Function(`"use strict"; return (${expression});`)();
293
323
  }
294
324
 
325
+ function resolveVariablePath(variables: DashboardVariables, path: string) {
326
+ return path
327
+ .replace(VARIABLE_PATH_PREFIX_RE, '')
328
+ .split('.')
329
+ .filter(Boolean)
330
+ .reduce<unknown>((current, segment) => isRecord(current) ? current[segment] : undefined, variables);
331
+ }
332
+
295
333
  function sortRows(rows: Record<string, unknown>[], orderBy: QueryOrderByItem[] = []) {
296
334
  if (!orderBy.length) {
297
335
  return rows;