@adminforth/dashboard 1.8.0 → 1.10.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 (39) hide show
  1. package/README.md +81 -55
  2. package/custom/api/dashboardApi.ts +73 -36
  3. package/custom/model/dashboard.types.ts +6 -13
  4. package/custom/runtime/DashboardRuntime.vue +26 -22
  5. package/custom/skills/adminforth-dashboard/SKILL.md +13 -20
  6. package/dist/custom/api/dashboardApi.d.ts +24 -18
  7. package/dist/custom/api/dashboardApi.js +42 -18
  8. package/dist/custom/api/dashboardApi.ts +73 -36
  9. package/dist/custom/model/dashboard.types.d.ts +0 -5
  10. package/dist/custom/model/dashboard.types.ts +6 -13
  11. package/dist/custom/queries/useDashboardConfig.d.ts +20 -120
  12. package/dist/custom/queries/useWidgetData.d.ts +20 -120
  13. package/dist/custom/runtime/DashboardRuntime.vue +26 -22
  14. package/dist/custom/skills/adminforth-dashboard/SKILL.md +13 -20
  15. package/dist/endpoint/groups.js +22 -20
  16. package/dist/endpoint/widgets.js +28 -26
  17. package/dist/schema/api.d.ts +230 -3936
  18. package/dist/schema/api.js +7 -12
  19. package/dist/schema/widget.d.ts +20 -200
  20. package/dist/schema/widgets/charts.d.ts +24 -240
  21. package/dist/schema/widgets/common.d.ts +2 -20
  22. package/dist/schema/widgets/common.js +1 -10
  23. package/dist/schema/widgets/gauge-card.d.ts +2 -20
  24. package/dist/schema/widgets/kpi-card.d.ts +2 -20
  25. package/dist/schema/widgets/pivot-table.d.ts +2 -20
  26. package/dist/schema/widgets/table.d.ts +2 -20
  27. package/dist/services/calc-evaluator.d.ts +2 -0
  28. package/dist/services/calc-evaluator.js +54 -0
  29. package/dist/services/dashboardFilterService.d.ts +5 -0
  30. package/dist/services/dashboardFilterService.js +125 -0
  31. package/dist/services/widgetDataService.js +15 -168
  32. package/endpoint/groups.ts +22 -20
  33. package/endpoint/widgets.ts +28 -26
  34. package/package.json +2 -1
  35. package/schema/api.ts +7 -12
  36. package/schema/widgets/common.ts +1 -11
  37. package/services/calc-evaluator.ts +71 -0
  38. package/services/dashboardFilterService.ts +162 -0
  39. package/services/widgetDataService.ts +26 -213
@@ -21,7 +21,7 @@ import {
21
21
  ConfigurePivotTableWidgetRequestSchema,
22
22
  ConfigureStackedBarChartWidgetRequestSchema,
23
23
  ConfigureTableWidgetRequestSchema,
24
- DashboardApiResponseSchema,
24
+ DashboardMutationResponseSchema,
25
25
  DashboardWidgetDataResponseSchema,
26
26
  GroupIdRequestSchema,
27
27
  MoveWidgetRequestSchema,
@@ -117,11 +117,11 @@ function registerConfigureWidgetEndpoint(
117
117
  path: options.path,
118
118
  description: options.description,
119
119
  request_schema: options.requestSchema,
120
- response_schema: DashboardApiResponseSchema,
120
+ response_schema: DashboardMutationResponseSchema,
121
121
  handler: async ({ body, adminUser, response }) => {
122
122
  if (!ctx.canEditDashboard(adminUser)) {
123
123
  response.setStatus(403);
124
- return { error: 'Dashboard edit is not allowed' };
124
+ return { ok: false, error: 'Dashboard edit is not allowed' };
125
125
  }
126
126
 
127
127
  const request = body as ConfigureWidgetRequest;
@@ -134,15 +134,15 @@ function registerConfigureWidgetEndpoint(
134
134
 
135
135
  if (!updatedDashboard) {
136
136
  response.setStatus(404);
137
- return { error: 'Dashboard not found' };
137
+ return { ok: false, error: 'Dashboard not found' };
138
138
  }
139
139
 
140
140
  if (mutationError) {
141
141
  response.setStatus(404);
142
- return { error: mutationError };
142
+ return { ok: false, error: mutationError };
143
143
  }
144
144
 
145
- return updatedDashboard;
145
+ return { ok: true };
146
146
  },
147
147
  });
148
148
  }
@@ -156,14 +156,15 @@ export function registerWidgetEndpoints(
156
156
  path: '/dashboard/add_dashboard_widget',
157
157
  description: 'Adds a new empty widget to a dashboard group. Superadmin only.',
158
158
  request_schema: GroupIdRequestSchema,
159
- response_schema: DashboardApiResponseSchema,
159
+ response_schema: DashboardMutationResponseSchema,
160
160
  handler: async ({ body, adminUser, response }) => {
161
161
  if (!ctx.canEditDashboard(adminUser)) {
162
162
  response.setStatus(403);
163
- return { error: 'Dashboard edit is not allowed' };
163
+ return { ok: false, error: 'Dashboard edit is not allowed' };
164
164
  }
165
165
 
166
166
  let mutationError: string | null = null;
167
+ let widgetId: string | null = null;
167
168
  const updatedDashboard = await ctx.updateDashboardConfig(body.slug, (config) => {
168
169
  const group = config.groups.find((item) => item.id === body.groupId);
169
170
 
@@ -181,6 +182,7 @@ export function registerWidgetEndpoints(
181
182
  order: nextOrder,
182
183
  target: 'empty',
183
184
  };
185
+ widgetId = widget.id;
184
186
 
185
187
  return {
186
188
  ...config,
@@ -190,15 +192,15 @@ export function registerWidgetEndpoints(
190
192
 
191
193
  if (!updatedDashboard) {
192
194
  response.setStatus(404);
193
- return { error: 'Dashboard not found' };
195
+ return { ok: false, error: 'Dashboard not found' };
194
196
  }
195
197
 
196
198
  if (mutationError) {
197
199
  response.setStatus(404);
198
- return { error: mutationError };
200
+ return { ok: false, error: mutationError };
199
201
  }
200
202
 
201
- return updatedDashboard;
203
+ return { ok: true, widgetId };
202
204
  },
203
205
  });
204
206
 
@@ -207,11 +209,11 @@ export function registerWidgetEndpoints(
207
209
  path: '/dashboard/move_dashboard_widget',
208
210
  description: 'Moves a dashboard widget up or down inside its group. Superadmin only.',
209
211
  request_schema: MoveWidgetRequestSchema,
210
- response_schema: DashboardApiResponseSchema,
212
+ response_schema: DashboardMutationResponseSchema,
211
213
  handler: async ({ body, adminUser, response }) => {
212
214
  if (!ctx.canEditDashboard(adminUser)) {
213
215
  response.setStatus(403);
214
- return { error: 'Dashboard edit is not allowed' };
216
+ return { ok: false, error: 'Dashboard edit is not allowed' };
215
217
  }
216
218
 
217
219
  let mutationError: string | null = null;
@@ -249,15 +251,15 @@ export function registerWidgetEndpoints(
249
251
 
250
252
  if (!updatedDashboard) {
251
253
  response.setStatus(404);
252
- return { error: 'Dashboard not found' };
254
+ return { ok: false, error: 'Dashboard not found' };
253
255
  }
254
256
 
255
257
  if (mutationError) {
256
258
  response.setStatus(404);
257
- return { error: mutationError };
259
+ return { ok: false, error: mutationError };
258
260
  }
259
261
 
260
- return updatedDashboard;
262
+ return { ok: true };
261
263
  },
262
264
  });
263
265
 
@@ -266,11 +268,11 @@ export function registerWidgetEndpoints(
266
268
  path: '/dashboard/remove_dashboard_widget',
267
269
  description: 'Removes one dashboard widget by id. Superadmin only.',
268
270
  request_schema: WidgetIdRequestSchema,
269
- response_schema: DashboardApiResponseSchema,
271
+ response_schema: DashboardMutationResponseSchema,
270
272
  handler: async ({ body, adminUser, response }) => {
271
273
  if (!ctx.canEditDashboard(adminUser)) {
272
274
  response.setStatus(403);
273
- return { error: 'Dashboard edit is not allowed' };
275
+ return { ok: false, error: 'Dashboard edit is not allowed' };
274
276
  }
275
277
 
276
278
  let mutationError: string | null = null;
@@ -290,15 +292,15 @@ export function registerWidgetEndpoints(
290
292
 
291
293
  if (!updatedDashboard) {
292
294
  response.setStatus(404);
293
- return { error: 'Dashboard not found' };
295
+ return { ok: false, error: 'Dashboard not found' };
294
296
  }
295
297
 
296
298
  if (mutationError) {
297
299
  response.setStatus(404);
298
- return { error: mutationError };
300
+ return { ok: false, error: mutationError };
299
301
  }
300
302
 
301
- return updatedDashboard;
303
+ return { ok: true };
302
304
  },
303
305
  });
304
306
 
@@ -308,11 +310,11 @@ export function registerWidgetEndpoints(
308
310
  path: '/dashboard/set_widget_config',
309
311
  description: 'Replaces editable JSON configuration for a dashboard widget while preserving widget id, group id, and order. Superadmin only.',
310
312
  request_schema: SetWidgetConfigRequestSchema,
311
- response_schema: DashboardApiResponseSchema,
313
+ response_schema: DashboardMutationResponseSchema,
312
314
  handler: async ({ body, adminUser, response }) => {
313
315
  if (!ctx.canEditDashboard(adminUser)) {
314
316
  response.setStatus(403);
315
- return { error: 'Dashboard edit is not allowed' };
317
+ return { ok: false, error: 'Dashboard edit is not allowed' };
316
318
  }
317
319
 
318
320
  const request = body as ConfigureWidgetRequest;
@@ -320,15 +322,15 @@ export function registerWidgetEndpoints(
320
322
 
321
323
  if (!updatedDashboard) {
322
324
  response.setStatus(404);
323
- return { error: 'Dashboard not found' };
325
+ return { ok: false, error: 'Dashboard not found' };
324
326
  }
325
327
 
326
328
  if (mutationError) {
327
329
  response.setStatus(404);
328
- return { error: mutationError };
330
+ return { ok: false, error: mutationError };
329
331
  }
330
332
 
331
- return updatedDashboard;
333
+ return { ok: true };
332
334
  },
333
335
  });
334
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/dashboard",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -56,6 +56,7 @@
56
56
  }
57
57
  ],
58
58
  "dependencies": {
59
+ "expr-eval-fork": "^3.0.3",
59
60
  "vue": "^3.5.34",
60
61
  "vue-router": "^5.0.7",
61
62
  "yaml": "^2.9.0",
package/schema/api.ts CHANGED
@@ -203,19 +203,14 @@ export const ConfigureHistogramChartWidgetRequestZodSchema = configureWidgetRequ
203
203
  export const ConfigureFunnelChartWidgetRequestZodSchema = configureWidgetRequestSchema(ConfigurableFunnelChartWidgetConfigSchema)
204
204
  export const ConfigurePivotTableWidgetRequestZodSchema = configureWidgetRequestSchema(ConfigurablePivotTableWidgetConfigSchema)
205
205
 
206
- export const DashboardMutationResponseZodSchema = z.union([
207
- z.object({
208
- ok: z.literal(true),
209
- slug: z.string(),
210
- widgetId: z.string().optional(),
211
- groupId: z.string().optional(),
212
- target: z.string().optional(),
213
- revision: z.number().optional(),
214
- }).strict(),
215
- DashboardErrorResponseZodSchema,
216
- ])
206
+ export const DashboardMutationResponseZodSchema = z.object({
207
+ ok: z.boolean(),
208
+ error: z.string().optional(),
209
+ groupId: z.string().optional(),
210
+ widgetId: z.string().optional(),
211
+ }).strict()
217
212
 
218
- export const DashboardApiResponseSchema = toJSONSchema(DashboardApiResponseZodSchema, { target: 'draft-07' })
213
+ export const DashboardApiResponseSchema = toJSONSchema(z.unknown(), { target: 'draft-07' })
219
214
  export const DashboardWidgetDataResponseSchema = toJSONSchema(DashboardWidgetDataResponseZodSchema, { target: 'draft-07' })
220
215
  export const SlugRequestSchema = toJSONSchema(SlugRequestZodSchema, { target: 'draft-07' })
221
216
  export const GroupIdRequestSchema = toJSONSchema(GroupIdRequestZodSchema, { target: 'draft-07' })
@@ -160,13 +160,6 @@ const ResourceQueryConfigSchema = z.object({
160
160
  formatting: z.record(z.string(), z.unknown()).optional(),
161
161
  }).strict()
162
162
 
163
- const StepsQueryMetricStepSchema = z.object({
164
- name: z.string(),
165
- resource: z.string(),
166
- metric: QueryAggregateSelectItemSchema,
167
- filters: FilterExpressionSchema.optional(),
168
- }).strict()
169
-
170
163
  const StepsQuerySelectStepSchema = z.object({
171
164
  name: z.string(),
172
165
  resource: z.string(),
@@ -178,10 +171,7 @@ export const QueryConfigSchema = z.union([
178
171
  ResourceQueryConfigSchema,
179
172
  z.object({
180
173
  source: z.literal('steps'),
181
- steps: z.array(z.union([
182
- StepsQueryMetricStepSchema,
183
- StepsQuerySelectStepSchema,
184
- ])).min(1),
174
+ steps: z.array(StepsQuerySelectStepSchema).min(1),
185
175
  calcs: z.array(QueryCalcItemSchema).optional(),
186
176
  order_by: z.array(QueryOrderByItemSchema).optional(),
187
177
  limit: z.number().int().positive().optional(),
@@ -0,0 +1,71 @@
1
+ import { Parser } from 'expr-eval-fork';
2
+ import type { DashboardVariables } from '../custom/model/dashboard.types.js';
3
+
4
+ const LOOKUP_VARIABLE_PATH_RE = /lookup\(\s*\$variables((?:\.[a-zA-Z_][a-zA-Z0-9_]*)+)\s*,/g;
5
+ const CALC_PARSER_OPTIONS = {
6
+ allowMemberAccess: false,
7
+ operators: {
8
+ assignment: false,
9
+ concatenate: false,
10
+ conditional: true,
11
+ comparison: true,
12
+ fndef: false,
13
+ in: false,
14
+ logical: true,
15
+ random: false,
16
+ },
17
+ } as const;
18
+
19
+ export function evaluateCalc(
20
+ calc: string,
21
+ values: Record<string, unknown>,
22
+ variables: DashboardVariables = {},
23
+ ) {
24
+ const parser = createCalcParser(variables);
25
+
26
+ return parser.parse(normalizeLookupPaths(calc)).evaluate(normalizeCalcValues(values));
27
+ }
28
+
29
+ function createCalcParser(variables: DashboardVariables) {
30
+ const parser = new Parser(CALC_PARSER_OPTIONS);
31
+
32
+ parser.functions.lookup = (path: string | number, key: string | number, defaultValue = 0) => {
33
+ const map = resolveVariablePath(variables, String(path));
34
+ const value = isRecord(map) && Object.prototype.hasOwnProperty.call(map, String(key))
35
+ ? map[String(key)]
36
+ : defaultValue;
37
+
38
+ return toFiniteNumber(value);
39
+ };
40
+
41
+ return parser;
42
+ }
43
+
44
+ function normalizeLookupPaths(calc: string) {
45
+ return calc.replace(LOOKUP_VARIABLE_PATH_RE, (_match, path: string) => {
46
+ return `lookup("${path.replace(/^\./, '')}",`;
47
+ });
48
+ }
49
+
50
+ function normalizeCalcValues(values: Record<string, unknown>) {
51
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [
52
+ key,
53
+ typeof value === 'string' ? value : toFiniteNumber(value),
54
+ ]));
55
+ }
56
+
57
+ function toFiniteNumber(value: unknown) {
58
+ const numberValue = typeof value === 'number' ? value : Number(value);
59
+ return Number.isFinite(numberValue) ? numberValue : 0;
60
+ }
61
+
62
+ function resolveVariablePath(variables: DashboardVariables, path: string) {
63
+ return path
64
+ .split('.')
65
+ .filter(Boolean)
66
+ .reduce<unknown>((current, segment) => isRecord(current) ? current[segment] : undefined, variables);
67
+ }
68
+
69
+ function isRecord(value: unknown): value is Record<string, any> {
70
+ return typeof value === 'object' && value !== null;
71
+ }
@@ -0,0 +1,162 @@
1
+ import { Filters } from 'adminforth';
2
+ import type {
3
+ IAdminForthAndOrFilter,
4
+ IAdminForthSingleFilter,
5
+ } from 'adminforth';
6
+ import type { FilterExpression } from '../custom/model/dashboard.types.js';
7
+
8
+ export type DashboardQueryFilters =
9
+ | IAdminForthSingleFilter
10
+ | IAdminForthAndOrFilter
11
+ | Array<IAdminForthSingleFilter | IAdminForthAndOrFilter>;
12
+
13
+ const RELATIVE_DURATION_RE = /^(\d+)(h|d|w|mo|y)$/;
14
+
15
+ const FILTER_OPERATORS = {
16
+ eq: Filters.EQ,
17
+ neq: Filters.NEQ,
18
+ gt: Filters.GT,
19
+ gte: Filters.GTE,
20
+ lt: Filters.LT,
21
+ lte: Filters.LTE,
22
+ in: Filters.IN,
23
+ not_in: Filters.NOT_IN,
24
+ like: Filters.LIKE,
25
+ ilike: Filters.ILIKE,
26
+ } as const;
27
+
28
+ export function getAdminForthFilters(filters: FilterExpression | DashboardQueryFilters | undefined): DashboardQueryFilters {
29
+ if (Array.isArray(filters)) {
30
+ return filters.map((filter) => isDashboardFilterExpression(filter)
31
+ ? toAdminForthFilter(filter)
32
+ : filter);
33
+ }
34
+
35
+ if (isDashboardFilterExpression(filters)) {
36
+ return toAdminForthFilter(filters);
37
+ }
38
+
39
+ if (filters) {
40
+ return filters;
41
+ }
42
+
43
+ return [];
44
+ }
45
+
46
+ export function mergeFilters(...filters: Array<FilterExpression | DashboardQueryFilters | undefined>) {
47
+ const merged: Array<IAdminForthSingleFilter | IAdminForthAndOrFilter> = [];
48
+
49
+ for (const filter of filters) {
50
+ const normalized = getAdminForthFilters(filter);
51
+
52
+ if (Array.isArray(normalized)) {
53
+ merged.push(...normalized);
54
+ continue;
55
+ }
56
+
57
+ if (normalized) {
58
+ merged.push(normalized);
59
+ }
60
+ }
61
+
62
+ if (!merged.length) {
63
+ return [] as DashboardQueryFilters;
64
+ }
65
+
66
+ return merged.length === 1 ? merged[0] : merged;
67
+ }
68
+
69
+ function isDashboardFilterExpression(value: unknown): value is FilterExpression {
70
+ if (Array.isArray(value)) {
71
+ return true;
72
+ }
73
+
74
+ if (!isRecord(value)) {
75
+ return false;
76
+ }
77
+
78
+ return 'and' in value
79
+ || 'or' in value
80
+ || 'eq' in value
81
+ || 'neq' in value
82
+ || 'gt' in value
83
+ || 'gte' in value
84
+ || 'lt' in value
85
+ || 'lte' in value
86
+ || 'in' in value
87
+ || 'not_in' in value
88
+ || 'like' in value
89
+ || 'ilike' in value;
90
+ }
91
+
92
+ function toAdminForthFilter(filter: FilterExpression): IAdminForthSingleFilter | IAdminForthAndOrFilter {
93
+ if (Array.isArray(filter)) {
94
+ return Filters.AND(filter.map((item) => toAdminForthFilter(item)));
95
+ }
96
+
97
+ if ('and' in filter) {
98
+ return Filters.AND(filter.and.map((item) => toAdminForthFilter(item)));
99
+ }
100
+
101
+ if ('or' in filter) {
102
+ return Filters.OR(filter.or.map((item) => toAdminForthFilter(item)));
103
+ }
104
+
105
+ for (const [operator, createFilter] of Object.entries(FILTER_OPERATORS)) {
106
+ if (Object.prototype.hasOwnProperty.call(filter, operator)) {
107
+ return createFilter(filter.field, resolveFilterValue(filter[operator as keyof typeof FILTER_OPERATORS]));
108
+ }
109
+ }
110
+
111
+ return Filters.AND([]);
112
+ }
113
+
114
+ function resolveFilterValue(value: unknown): unknown {
115
+ if (Array.isArray(value)) {
116
+ return value.map((item) => resolveFilterValue(item));
117
+ }
118
+
119
+ if (!isRecord(value)) {
120
+ return value;
121
+ }
122
+
123
+ if (value.now === true) {
124
+ return new Date().toISOString();
125
+ }
126
+
127
+ if (typeof value.now_minus === 'string') {
128
+ return subtractDuration(new Date(), value.now_minus).toISOString();
129
+ }
130
+
131
+ return value;
132
+ }
133
+
134
+ function subtractDuration(now: Date, duration: string) {
135
+ const match = duration.match(RELATIVE_DURATION_RE);
136
+
137
+ if (!match) {
138
+ throw new Error(`Unsupported relative date duration: ${duration}`);
139
+ }
140
+
141
+ const amount = Number(match[1]);
142
+ const unit = match[2];
143
+ const date = new Date(now);
144
+
145
+ if (unit === 'h') {
146
+ date.setUTCHours(date.getUTCHours() - amount);
147
+ } else if (unit === 'd') {
148
+ date.setUTCDate(date.getUTCDate() - amount);
149
+ } else if (unit === 'w') {
150
+ date.setUTCDate(date.getUTCDate() - amount * 7);
151
+ } else if (unit === 'mo') {
152
+ date.setUTCMonth(date.getUTCMonth() - amount);
153
+ } else if (unit === 'y') {
154
+ date.setUTCFullYear(date.getUTCFullYear() - amount);
155
+ }
156
+
157
+ return date;
158
+ }
159
+
160
+ function isRecord(value: unknown): value is Record<string, any> {
161
+ return typeof value === 'object' && value !== null;
162
+ }