@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.
- package/README.md +80 -11
- package/custom/api/dashboardApi.ts +4 -0
- package/custom/model/dashboard.types.ts +16 -8
- package/custom/skills/adminforth-dashboard/SKILL.md +108 -1
- package/custom/widgets/chart/ChartWidget.vue +20 -3
- package/dist/custom/api/dashboardApi.d.ts +1 -0
- package/dist/custom/api/dashboardApi.js +5 -0
- package/dist/custom/api/dashboardApi.ts +4 -0
- package/dist/custom/model/dashboard.types.d.ts +3 -0
- package/dist/custom/model/dashboard.types.js +19 -5
- package/dist/custom/model/dashboard.types.ts +16 -8
- package/dist/custom/queries/useDashboardConfig.d.ts +20 -0
- package/dist/custom/queries/useWidgetData.d.ts +20 -0
- package/dist/custom/skills/adminforth-dashboard/SKILL.md +108 -1
- package/dist/custom/widgets/chart/ChartWidget.vue +20 -3
- package/dist/endpoint/dashboard.d.ts +7 -2
- package/dist/endpoint/dashboard.js +45 -1
- package/dist/endpoint/widgets.d.ts +2 -1
- package/dist/endpoint/widgets.js +1 -0
- package/dist/schema/api.d.ts +109 -16
- package/dist/schema/api.js +5 -0
- package/dist/schema/widget.d.ts +72 -12
- package/dist/schema/widget.js +7 -4
- package/dist/services/widgetDataService.d.ts +2 -1
- package/dist/services/widgetDataService.js +50 -20
- package/endpoint/dashboard.ts +76 -3
- package/endpoint/widgets.ts +6 -2
- package/package.json +1 -1
- package/schema/api.ts +6 -0
- package/schema/widget.ts +7 -4
- package/services/widgetDataService.ts +53 -15
package/dist/schema/widget.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
|
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;
|
package/endpoint/dashboard.ts
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
|
-
import type { IHttpServer } from 'adminforth';
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
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
|
}
|
package/endpoint/widgets.ts
CHANGED
|
@@ -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?: {
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
|
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;
|