@adminforth/dashboard 1.4.2 → 1.6.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 (60) hide show
  1. package/custom/api/dashboardApi.ts +137 -5
  2. package/custom/model/dashboard.types.ts +32 -22
  3. package/custom/runtime/DashboardRuntime.vue +2 -3
  4. package/custom/skills/adminforth-dashboard/SKILL.md +165 -179
  5. package/custom/widgets/KpiCardWidget.vue +172 -9
  6. package/custom/widgets/chart/ChartWidget.vue +5 -5
  7. package/custom/widgets/registry.ts +4 -4
  8. package/dist/custom/api/dashboardApi.d.ts +46 -2
  9. package/dist/custom/api/dashboardApi.js +90 -5
  10. package/dist/custom/api/dashboardApi.ts +137 -5
  11. package/dist/custom/model/dashboard.types.d.ts +30 -14
  12. package/dist/custom/model/dashboard.types.js +2 -2
  13. package/dist/custom/model/dashboard.types.ts +32 -22
  14. package/dist/custom/queries/useDashboardConfig.d.ts +106 -104
  15. package/dist/custom/queries/useWidgetData.d.ts +106 -104
  16. package/dist/custom/runtime/DashboardRuntime.vue +2 -3
  17. package/dist/custom/skills/adminforth-dashboard/SKILL.md +165 -179
  18. package/dist/custom/widgets/KpiCardWidget.vue +172 -9
  19. package/dist/custom/widgets/chart/ChartWidget.vue +5 -5
  20. package/dist/custom/widgets/registry.js +4 -4
  21. package/dist/custom/widgets/registry.ts +4 -4
  22. package/dist/endpoint/dashboard.d.ts +2 -4
  23. package/dist/endpoint/dashboard.js +1 -21
  24. package/dist/endpoint/groups.d.ts +1 -0
  25. package/dist/endpoint/groups.js +61 -48
  26. package/dist/endpoint/widgets.d.ts +1 -0
  27. package/dist/endpoint/widgets.js +167 -64
  28. package/dist/schema/api.d.ts +11710 -2785
  29. package/dist/schema/api.js +118 -26
  30. package/dist/schema/widget.d.ts +425 -1980
  31. package/dist/schema/widget.js +13 -374
  32. package/dist/schema/widgets/charts.d.ts +1689 -0
  33. package/dist/schema/widgets/charts.js +92 -0
  34. package/dist/schema/widgets/common.d.ts +275 -0
  35. package/dist/schema/widgets/common.js +171 -0
  36. package/dist/schema/widgets/gauge-card.d.ts +172 -0
  37. package/dist/schema/widgets/gauge-card.js +28 -0
  38. package/dist/schema/widgets/kpi-card.d.ts +212 -0
  39. package/dist/schema/widgets/kpi-card.js +43 -0
  40. package/dist/schema/widgets/pivot-table.d.ts +196 -0
  41. package/dist/schema/widgets/pivot-table.js +17 -0
  42. package/dist/schema/widgets/table.d.ts +130 -0
  43. package/dist/schema/widgets/table.js +12 -0
  44. package/dist/services/dashboardConfigService.d.ts +4 -0
  45. package/dist/services/dashboardConfigService.js +46 -0
  46. package/dist/services/widgetDataService.js +96 -2
  47. package/endpoint/dashboard.ts +2 -33
  48. package/endpoint/groups.ts +91 -72
  49. package/endpoint/widgets.ts +260 -87
  50. package/package.json +1 -1
  51. package/schema/api.ts +148 -28
  52. package/schema/widget.ts +43 -425
  53. package/schema/widgets/charts.ts +113 -0
  54. package/schema/widgets/common.ts +194 -0
  55. package/schema/widgets/gauge-card.ts +34 -0
  56. package/schema/widgets/kpi-card.ts +49 -0
  57. package/schema/widgets/pivot-table.ts +24 -0
  58. package/schema/widgets/table.ts +18 -0
  59. package/services/dashboardConfigService.ts +73 -0
  60. package/services/widgetDataService.ts +129 -3
@@ -0,0 +1,130 @@
1
+ import { z } from 'zod';
2
+ export declare const TableViewConfigSchema: z.ZodObject<{
3
+ columns: z.ZodOptional<z.ZodArray<z.ZodObject<{
4
+ field: z.ZodString;
5
+ label: z.ZodOptional<z.ZodString>;
6
+ format: z.ZodOptional<z.ZodEnum<{
7
+ number: "number";
8
+ integer: "integer";
9
+ compact_number: "compact_number";
10
+ currency: "currency";
11
+ percent: "percent";
12
+ percent_delta: "percent_delta";
13
+ number_delta: "number_delta";
14
+ currency_delta: "currency_delta";
15
+ }>>;
16
+ }, z.core.$strict>>>;
17
+ pagination: z.ZodOptional<z.ZodBoolean>;
18
+ page_size: z.ZodOptional<z.ZodNumber>;
19
+ }, z.core.$strict>;
20
+ export declare const TableWidgetConfigSchema: z.ZodObject<{
21
+ id: z.ZodString;
22
+ group_id: z.ZodString;
23
+ order: z.ZodNumber;
24
+ label: z.ZodOptional<z.ZodString>;
25
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
26
+ size: z.ZodOptional<z.ZodEnum<{
27
+ small: "small";
28
+ medium: "medium";
29
+ large: "large";
30
+ wide: "wide";
31
+ full: "full";
32
+ }>>;
33
+ width: z.ZodOptional<z.ZodNumber>;
34
+ height: z.ZodOptional<z.ZodNumber>;
35
+ min_width: z.ZodOptional<z.ZodNumber>;
36
+ max_width: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
37
+ target: z.ZodLiteral<"table">;
38
+ table: z.ZodOptional<z.ZodObject<{
39
+ columns: z.ZodOptional<z.ZodArray<z.ZodObject<{
40
+ field: z.ZodString;
41
+ label: z.ZodOptional<z.ZodString>;
42
+ format: z.ZodOptional<z.ZodEnum<{
43
+ number: "number";
44
+ integer: "integer";
45
+ compact_number: "compact_number";
46
+ currency: "currency";
47
+ percent: "percent";
48
+ percent_delta: "percent_delta";
49
+ number_delta: "number_delta";
50
+ currency_delta: "currency_delta";
51
+ }>>;
52
+ }, z.core.$strict>>>;
53
+ pagination: z.ZodOptional<z.ZodBoolean>;
54
+ page_size: z.ZodOptional<z.ZodNumber>;
55
+ }, z.core.$strict>>;
56
+ query: z.ZodObject<{
57
+ resource: z.ZodString;
58
+ select: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
59
+ field: z.ZodString;
60
+ as: z.ZodOptional<z.ZodString>;
61
+ grain: z.ZodOptional<z.ZodEnum<{
62
+ day: "day";
63
+ week: "week";
64
+ month: "month";
65
+ year: "year";
66
+ }>>;
67
+ }, z.core.$strict>, z.ZodObject<{
68
+ agg: z.ZodEnum<{
69
+ sum: "sum";
70
+ count: "count";
71
+ count_distinct: "count_distinct";
72
+ avg: "avg";
73
+ min: "min";
74
+ max: "max";
75
+ median: "median";
76
+ }>;
77
+ field: z.ZodOptional<z.ZodString>;
78
+ as: z.ZodString;
79
+ filters: z.ZodOptional<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
80
+ }, z.core.$strict>, z.ZodObject<{
81
+ calc: z.ZodString;
82
+ as: z.ZodString;
83
+ }, z.core.$strict>]>>>;
84
+ sparkline: z.ZodOptional<z.ZodObject<{
85
+ field: z.ZodString;
86
+ grain: z.ZodEnum<{
87
+ day: "day";
88
+ week: "week";
89
+ month: "month";
90
+ year: "year";
91
+ }>;
92
+ as: z.ZodString;
93
+ fill_missing: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
94
+ }, z.core.$strict>>;
95
+ filters: z.ZodOptional<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
96
+ group_by: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
97
+ field: z.ZodString;
98
+ as: z.ZodOptional<z.ZodString>;
99
+ grain: z.ZodOptional<z.ZodEnum<{
100
+ day: "day";
101
+ week: "week";
102
+ month: "month";
103
+ year: "year";
104
+ }>>;
105
+ timezone: z.ZodOptional<z.ZodString>;
106
+ }, z.core.$strict>]>>>;
107
+ order_by: z.ZodOptional<z.ZodArray<z.ZodObject<{
108
+ field: z.ZodString;
109
+ direction: z.ZodOptional<z.ZodEnum<{
110
+ asc: "asc";
111
+ desc: "desc";
112
+ }>>;
113
+ }, z.core.$strict>>>;
114
+ limit: z.ZodOptional<z.ZodNumber>;
115
+ offset: z.ZodOptional<z.ZodNumber>;
116
+ bucket: z.ZodOptional<z.ZodObject<{
117
+ field: z.ZodString;
118
+ buckets: z.ZodArray<z.ZodObject<{
119
+ label: z.ZodString;
120
+ min: z.ZodOptional<z.ZodNumber>;
121
+ max: z.ZodOptional<z.ZodNumber>;
122
+ }, z.core.$strict>>;
123
+ }, z.core.$strict>>;
124
+ calcs: z.ZodOptional<z.ZodArray<z.ZodObject<{
125
+ calc: z.ZodString;
126
+ as: z.ZodString;
127
+ }, z.core.$strict>>>;
128
+ formatting: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
129
+ }, z.core.$strict>;
130
+ }, z.core.$strict>;
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ import { ChartFieldRefSchema, QueryConfigSchema, WidgetBaseSchema, } from './common.js';
3
+ export const TableViewConfigSchema = z.object({
4
+ columns: z.array(ChartFieldRefSchema).optional(),
5
+ pagination: z.boolean().optional(),
6
+ page_size: z.number().int().positive().optional(),
7
+ }).strict();
8
+ export const TableWidgetConfigSchema = WidgetBaseSchema.extend({
9
+ target: z.literal('table'),
10
+ table: TableViewConfigSchema.optional(),
11
+ query: QueryConfigSchema,
12
+ });
@@ -15,11 +15,15 @@ export type PersistedDashboardResponse = {
15
15
  revision: number;
16
16
  config: DashboardConfig;
17
17
  };
18
+ type DashboardConfigMutator = (config: DashboardConfig, dashboard: DashboardRecord) => DashboardConfig | null | Promise<DashboardConfig | null>;
18
19
  export declare function getDashboardRecord(adminforth: IAdminForth, dashboardConfigsResourceId: string, slug: string): Promise<DashboardRecord | null>;
19
20
  export declare function persistDashboardConfig(adminforth: IAdminForth, dashboardConfigsResourceId: string, dashboard: DashboardRecord, config: DashboardConfig): Promise<PersistedDashboardResponse>;
21
+ export declare function updateDashboardConfig(adminforth: IAdminForth, dashboardConfigsResourceId: string, slug: string, mutateConfig: DashboardConfigMutator): Promise<PersistedDashboardResponse | null>;
20
22
  export type DashboardConfigService = {
21
23
  getDashboardRecord: (slug: string) => Promise<DashboardRecord | null>;
22
24
  parseStoredDashboardConfig: typeof parseStoredDashboardConfig;
23
25
  persistDashboardConfig: (dashboard: DashboardRecord, config: DashboardConfig) => Promise<PersistedDashboardResponse>;
26
+ updateDashboardConfig: (slug: string, mutateConfig: DashboardConfigMutator) => Promise<PersistedDashboardResponse | null>;
24
27
  };
25
28
  export declare function createDashboardConfigService(adminforth: IAdminForth, dashboardConfigsResourceId: string): DashboardConfigService;
29
+ export {};
@@ -14,6 +14,29 @@ export function parseStoredDashboardConfig(config) {
14
14
  const parsedConfig = typeof config === 'string' ? JSON.parse(config) : config;
15
15
  return DashboardConfigZodSchema.parse(parsedConfig);
16
16
  }
17
+ const dashboardConfigUpdateQueues = new Map();
18
+ function runDashboardConfigUpdateQueued(dashboardSlug, callback) {
19
+ return __awaiter(this, void 0, void 0, function* () {
20
+ var _a;
21
+ const previousUpdate = (_a = dashboardConfigUpdateQueues.get(dashboardSlug)) !== null && _a !== void 0 ? _a : Promise.resolve();
22
+ let releaseCurrentUpdate;
23
+ const currentUpdate = new Promise((resolve) => {
24
+ releaseCurrentUpdate = resolve;
25
+ });
26
+ const queuedUpdate = previousUpdate.then(() => currentUpdate, () => currentUpdate);
27
+ dashboardConfigUpdateQueues.set(dashboardSlug, queuedUpdate);
28
+ yield previousUpdate.catch(() => undefined);
29
+ try {
30
+ return yield callback();
31
+ }
32
+ finally {
33
+ releaseCurrentUpdate();
34
+ if (dashboardConfigUpdateQueues.get(dashboardSlug) === queuedUpdate) {
35
+ dashboardConfigUpdateQueues.delete(dashboardSlug);
36
+ }
37
+ }
38
+ });
39
+ }
17
40
  function normalizeDashboardOrder(config) {
18
41
  var _a;
19
42
  const widgetsByGroupId = new Map();
@@ -55,10 +78,33 @@ export function persistDashboardConfig(adminforth, dashboardConfigsResourceId, d
55
78
  };
56
79
  });
57
80
  }
81
+ export function updateDashboardConfig(adminforth, dashboardConfigsResourceId, slug, mutateConfig) {
82
+ return __awaiter(this, void 0, void 0, function* () {
83
+ return runDashboardConfigUpdateQueued(slug, () => __awaiter(this, void 0, void 0, function* () {
84
+ const dashboard = yield getDashboardRecord(adminforth, dashboardConfigsResourceId, slug);
85
+ if (!dashboard) {
86
+ return null;
87
+ }
88
+ const config = parseStoredDashboardConfig(dashboard.config);
89
+ const nextConfig = yield mutateConfig(config, dashboard);
90
+ if (nextConfig === null) {
91
+ return {
92
+ id: dashboard.id,
93
+ slug: dashboard.slug,
94
+ label: dashboard.label,
95
+ revision: dashboard.revision,
96
+ config,
97
+ };
98
+ }
99
+ return persistDashboardConfig(adminforth, dashboardConfigsResourceId, dashboard, nextConfig);
100
+ }));
101
+ });
102
+ }
58
103
  export function createDashboardConfigService(adminforth, dashboardConfigsResourceId) {
59
104
  return {
60
105
  getDashboardRecord: (slug) => getDashboardRecord(adminforth, dashboardConfigsResourceId, slug),
61
106
  parseStoredDashboardConfig,
62
107
  persistDashboardConfig: (dashboard, config) => persistDashboardConfig(adminforth, dashboardConfigsResourceId, dashboard, config),
108
+ updateDashboardConfig: (slug, mutateConfig) => updateDashboardConfig(adminforth, dashboardConfigsResourceId, slug, mutateConfig),
63
109
  };
64
110
  }
@@ -11,7 +11,8 @@ import { Filters, Sorts } from 'adminforth';
11
11
  const CALC_IDENTIFIER_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;
12
12
  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;
13
13
  const VARIABLE_PATH_PREFIX_RE = /^\$variables\.?/;
14
- const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s]+$/;
14
+ const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s?:<>=!]+$/;
15
+ const RELATIVE_DURATION_RE = /^(\d+)(h|d|w|mo|y)$/;
15
16
  const FILTER_OPERATORS = {
16
17
  eq: Filters.EQ,
17
18
  neq: Filters.NEQ,
@@ -78,6 +79,10 @@ function getFunnelWidgetData(adminforth, query, variables) {
78
79
  function getQueryWidgetData(adminforth, query, variables) {
79
80
  return __awaiter(this, void 0, void 0, function* () {
80
81
  var _a, _b, _c;
82
+ const metricSelect = getSingleAggregateMetricSelect(query);
83
+ if (metricSelect) {
84
+ return getMetricWidgetData(adminforth, query, metricSelect);
85
+ }
81
86
  const selectedRows = isAggregateQuery(query)
82
87
  ? yield buildAggregateQueryRows(adminforth, query, variables)
83
88
  : buildPlainQueryRows(yield getResourceRows(adminforth, query.resource, query.filters, getBackendSort(query.order_by)), query, variables);
@@ -97,6 +102,43 @@ function getQueryWidgetData(adminforth, query, variables) {
97
102
  };
98
103
  });
99
104
  }
105
+ function getMetricWidgetData(adminforth, query, metric) {
106
+ return __awaiter(this, void 0, void 0, function* () {
107
+ var _a;
108
+ const [currentValues = {}] = yield getAggregateRows(adminforth, query.resource, query.filters, [metric], []);
109
+ const values = {
110
+ [metric.as]: (_a = currentValues[metric.as]) !== null && _a !== void 0 ? _a : 0,
111
+ };
112
+ const rows = query.sparkline
113
+ ? yield getMetricSparklineRows(adminforth, query, metric, getAdminForthFilters(query.filters))
114
+ : [values];
115
+ const columns = Array.from(new Set([
116
+ metric.as,
117
+ ...(query.sparkline ? [query.sparkline.as] : []),
118
+ ]));
119
+ return {
120
+ kind: 'aggregate',
121
+ columns,
122
+ rows,
123
+ values,
124
+ };
125
+ });
126
+ }
127
+ function getMetricSparklineRows(adminforth, query, metric, filters) {
128
+ return __awaiter(this, void 0, void 0, function* () {
129
+ const sparkline = query.sparkline;
130
+ const groupBy = [{
131
+ field: sparkline.field,
132
+ as: sparkline.as,
133
+ grain: sparkline.grain,
134
+ }];
135
+ const rows = yield getAggregateRows(adminforth, query.resource, filters, [metric], groupBy);
136
+ return rows.map((row) => {
137
+ var _a;
138
+ return (Object.assign(Object.assign({}, (_a = query.sparkline) === null || _a === void 0 ? void 0 : _a.fill_missing), row));
139
+ });
140
+ });
141
+ }
100
142
  function getResourceRows(adminforth, resourceId, filters, sort) {
101
143
  return __awaiter(this, void 0, void 0, function* () {
102
144
  return adminforth.resource(resourceId).list(getAdminForthFilters(filters), undefined, 0, sort);
@@ -238,6 +280,18 @@ function isAggregateQuery(query) {
238
280
  return Boolean(((_a = query.group_by) === null || _a === void 0 ? void 0 : _a.length)
239
281
  || ((_b = query.select) === null || _b === void 0 ? void 0 : _b.some((item) => isAggregateSelectItem(item))));
240
282
  }
283
+ function getSingleAggregateMetricSelect(query) {
284
+ var _a, _b;
285
+ if ((_a = query.group_by) === null || _a === void 0 ? void 0 : _a.length) {
286
+ return undefined;
287
+ }
288
+ const select = (_b = query.select) !== null && _b !== void 0 ? _b : [];
289
+ const aggregateItems = select.filter(isAggregateSelectItem);
290
+ if (aggregateItems.length !== 1 || aggregateItems.length !== select.length) {
291
+ return undefined;
292
+ }
293
+ return aggregateItems[0];
294
+ }
241
295
  function isFieldSelectItem(item) {
242
296
  return 'field' in item && !('agg' in item);
243
297
  }
@@ -454,11 +508,51 @@ function toAdminForthFilter(filter) {
454
508
  }
455
509
  for (const [operator, createFilter] of Object.entries(FILTER_OPERATORS)) {
456
510
  if (Object.prototype.hasOwnProperty.call(filter, operator)) {
457
- return createFilter(filter.field, filter[operator]);
511
+ return createFilter(filter.field, resolveFilterValue(filter[operator]));
458
512
  }
459
513
  }
460
514
  return Filters.AND([]);
461
515
  }
516
+ function resolveFilterValue(value) {
517
+ if (Array.isArray(value)) {
518
+ return value.map((item) => resolveFilterValue(item));
519
+ }
520
+ if (!isRecord(value)) {
521
+ return value;
522
+ }
523
+ if (value.now === true) {
524
+ return new Date().toISOString();
525
+ }
526
+ if (typeof value.now_minus === 'string') {
527
+ return subtractDuration(new Date(), value.now_minus).toISOString();
528
+ }
529
+ return value;
530
+ }
531
+ function subtractDuration(now, duration) {
532
+ const match = duration.match(RELATIVE_DURATION_RE);
533
+ if (!match) {
534
+ throw new Error(`Unsupported relative date duration: ${duration}`);
535
+ }
536
+ const amount = Number(match[1]);
537
+ const unit = match[2];
538
+ const date = new Date(now);
539
+ if (unit === 'h') {
540
+ date.setUTCHours(date.getUTCHours() - amount);
541
+ }
542
+ else if (unit === 'd') {
543
+ date.setUTCDate(date.getUTCDate() - amount);
544
+ }
545
+ else if (unit === 'w') {
546
+ date.setUTCDate(date.getUTCDate() - amount * 7);
547
+ }
548
+ else if (unit === 'mo') {
549
+ date.setUTCMonth(date.getUTCMonth() - amount);
550
+ }
551
+ else if (unit === 'y') {
552
+ date.setUTCFullYear(date.getUTCFullYear() - amount);
553
+ }
554
+ return date;
555
+ }
462
556
  function toFiniteNumber(value) {
463
557
  const numberValue = typeof value === 'number' ? value : Number(value);
464
558
  return Number.isFinite(numberValue) ? numberValue : 0;
@@ -1,20 +1,14 @@
1
- import type { AdminUser, IHttpServer } from 'adminforth';
1
+ import type { IHttpServer } from 'adminforth';
2
2
  import type { DashboardConfig } from '../custom/model/dashboard.types.js';
3
3
  import {
4
4
  DashboardApiResponseSchema,
5
- SetDashboardConfigRequestSchema,
6
5
  SlugRequestSchema,
7
6
  } from '../schema/api.js';
8
- import type { DashboardRecord, PersistedDashboardResponse } from '../services/dashboardConfigService.js';
7
+ import type { DashboardRecord } from '../services/dashboardConfigService.js';
9
8
 
10
9
  type DashboardEndpointsContext = {
11
- canEditDashboard: (adminUser: AdminUser) => boolean;
12
10
  getDashboardRecord: (slug: string) => Promise<DashboardRecord | null>;
13
11
  parseStoredDashboardConfig: (config: unknown) => DashboardConfig;
14
- persistDashboardConfig: (
15
- dashboard: DashboardRecord,
16
- config: DashboardConfig,
17
- ) => Promise<PersistedDashboardResponse>;
18
12
  };
19
13
 
20
14
  export function registerDashboardEndpoints(
@@ -44,29 +38,4 @@ export function registerDashboardEndpoints(
44
38
  };
45
39
  },
46
40
  });
47
-
48
- server.endpoint({
49
- method: 'POST',
50
- path: '/dashboard/set_dashboard_config',
51
- description: 'Replaces one dashboard configuration, including groups and widgets. Superadmin only.',
52
- request_schema: SetDashboardConfigRequestSchema,
53
- response_schema: DashboardApiResponseSchema,
54
- handler: async ({ body, adminUser, response }) => {
55
- if (!ctx.canEditDashboard(adminUser)) {
56
- response.setStatus(403);
57
- return { error: 'Dashboard edit is not allowed' };
58
- }
59
-
60
- const dashboard = await ctx.getDashboardRecord(body.slug);
61
-
62
- if (!dashboard) {
63
- response.setStatus(404);
64
- return { error: 'Dashboard not found' };
65
- }
66
-
67
- const config = body.config as DashboardConfig;
68
-
69
- return ctx.persistDashboardConfig(dashboard, config);
70
- },
71
- });
72
41
  }
@@ -22,6 +22,10 @@ type GroupEndpointsContext = {
22
22
  dashboard: DashboardRecord,
23
23
  config: DashboardConfig,
24
24
  ) => Promise<PersistedDashboardResponse>;
25
+ updateDashboardConfig: (
26
+ slug: string,
27
+ mutateConfig: (config: DashboardConfig, dashboard: DashboardRecord) => DashboardConfig | null | Promise<DashboardConfig | null>,
28
+ ) => Promise<PersistedDashboardResponse | null>;
25
29
  };
26
30
 
27
31
  export function registerGroupEndpoints(
@@ -40,26 +44,26 @@ export function registerGroupEndpoints(
40
44
  return { error: 'Dashboard edit is not allowed' };
41
45
  }
42
46
 
43
- const dashboard = await ctx.getDashboardRecord(body.slug);
47
+ const updatedDashboard = await ctx.updateDashboardConfig(body.slug, (config) => {
48
+ const nextOrder = config.groups.length + 1;
49
+ const group: DashboardGroupConfig = {
50
+ id: `group_${randomUUID()}`,
51
+ label: 'New group',
52
+ order: nextOrder,
53
+ };
54
+
55
+ return {
56
+ ...config,
57
+ groups: [...config.groups, group],
58
+ };
59
+ });
44
60
 
45
- if (!dashboard) {
61
+ if (!updatedDashboard) {
46
62
  response.setStatus(404);
47
63
  return { error: 'Dashboard not found' };
48
64
  }
49
65
 
50
- const config = ctx.parseStoredDashboardConfig(dashboard.config);
51
- const nextOrder = config.groups.length + 1;
52
-
53
- const group: DashboardGroupConfig = {
54
- id: `group_${randomUUID()}`,
55
- label: 'New group',
56
- order: nextOrder,
57
- };
58
-
59
- return ctx.persistDashboardConfig(dashboard, {
60
- ...config,
61
- groups: [...config.groups, group],
62
- });
66
+ return updatedDashboard;
63
67
  },
64
68
  });
65
69
 
@@ -76,33 +80,40 @@ export function registerGroupEndpoints(
76
80
  }
77
81
 
78
82
  const groupId = body.groupId;
79
- const dashboard = await ctx.getDashboardRecord(body.slug);
83
+ let mutationError: string | null = null;
84
+ const updatedDashboard = await ctx.updateDashboardConfig(body.slug, (config) => {
85
+ const group = config.groups.find((item) => item.id === groupId);
86
+
87
+ if (!group) {
88
+ mutationError = 'Dashboard group not found';
89
+ return null;
90
+ }
91
+
92
+ const nextGroup: DashboardGroupConfig = {
93
+ ...(body.config as EditableDashboardGroupConfig),
94
+ id: group.id,
95
+ order: group.order,
96
+ };
80
97
 
81
- if (!dashboard) {
98
+ return {
99
+ ...config,
100
+ groups: config.groups.map((item) => item.id === groupId
101
+ ? nextGroup
102
+ : item),
103
+ };
104
+ });
105
+
106
+ if (!updatedDashboard) {
82
107
  response.setStatus(404);
83
108
  return { error: 'Dashboard not found' };
84
109
  }
85
110
 
86
- const config = ctx.parseStoredDashboardConfig(dashboard.config);
87
- const group = config.groups.find((item) => item.id === groupId);
88
-
89
- if (!group) {
111
+ if (mutationError) {
90
112
  response.setStatus(404);
91
- return { error: 'Dashboard group not found' };
113
+ return { error: mutationError };
92
114
  }
93
115
 
94
- const nextGroup: DashboardGroupConfig = {
95
- ...(body.config as EditableDashboardGroupConfig),
96
- id: group.id,
97
- order: group.order,
98
- };
99
-
100
- return ctx.persistDashboardConfig(dashboard, {
101
- ...config,
102
- groups: config.groups.map((item) => item.id === groupId
103
- ? nextGroup
104
- : item),
105
- });
116
+ return updatedDashboard;
106
117
  },
107
118
  });
108
119
 
@@ -118,42 +129,43 @@ export function registerGroupEndpoints(
118
129
  return { error: 'Dashboard edit is not allowed' };
119
130
  }
120
131
 
121
- const dashboard = await ctx.getDashboardRecord(body.slug);
132
+ let mutationError: string | null = null;
133
+ const updatedDashboard = await ctx.updateDashboardConfig(body.slug, (config) => {
134
+ const sortedGroups = [...config.groups].sort((a, b) => a.order - b.order);
135
+ const currentIndex = sortedGroups.findIndex((group) => group.id === body.groupId);
122
136
 
123
- if (!dashboard) {
124
- response.setStatus(404);
125
- return { error: 'Dashboard not found' };
126
- }
137
+ if (currentIndex === -1) {
138
+ mutationError = 'Dashboard group not found';
139
+ return null;
140
+ }
127
141
 
128
- const config = ctx.parseStoredDashboardConfig(dashboard.config);
129
- const sortedGroups = [...config.groups].sort((a, b) => a.order - b.order);
130
- const currentIndex = sortedGroups.findIndex((group) => group.id === body.groupId);
142
+ const targetIndex = body.direction === 'up' ? currentIndex - 1 : currentIndex + 1;
131
143
 
132
- if (currentIndex === -1) {
133
- response.setStatus(404);
134
- return { error: 'Dashboard group not found' };
135
- }
144
+ if (targetIndex < 0 || targetIndex >= sortedGroups.length) {
145
+ return null;
146
+ }
136
147
 
137
- const targetIndex = body.direction === 'up' ? currentIndex - 1 : currentIndex + 1;
148
+ const reorderedGroups = [...sortedGroups];
149
+ const [group] = reorderedGroups.splice(currentIndex, 1);
150
+ reorderedGroups.splice(targetIndex, 0, group);
138
151
 
139
- if (targetIndex < 0 || targetIndex >= sortedGroups.length) {
140
152
  return {
141
- id: dashboard.id,
142
- slug: dashboard.slug,
143
- label: dashboard.label,
144
- revision: dashboard.revision,
145
- config: ctx.parseStoredDashboardConfig(dashboard.config),
153
+ ...config,
154
+ groups: reorderedGroups,
146
155
  };
156
+ });
157
+
158
+ if (!updatedDashboard) {
159
+ response.setStatus(404);
160
+ return { error: 'Dashboard not found' };
147
161
  }
148
162
 
149
- const reorderedGroups = [...sortedGroups];
150
- const [group] = reorderedGroups.splice(currentIndex, 1);
151
- reorderedGroups.splice(targetIndex, 0, group);
163
+ if (mutationError) {
164
+ response.setStatus(404);
165
+ return { error: mutationError };
166
+ }
152
167
 
153
- return ctx.persistDashboardConfig(dashboard, {
154
- ...config,
155
- groups: reorderedGroups,
156
- });
168
+ return updatedDashboard;
157
169
  },
158
170
  });
159
171
 
@@ -170,26 +182,33 @@ export function registerGroupEndpoints(
170
182
  }
171
183
 
172
184
  const groupId = body.groupId;
173
- const dashboard = await ctx.getDashboardRecord(body.slug);
185
+ let mutationError: string | null = null;
186
+ const updatedDashboard = await ctx.updateDashboardConfig(body.slug, (config) => {
187
+ const nextGroups = config.groups.filter((group) => group.id !== groupId);
188
+
189
+ if (nextGroups.length === config.groups.length) {
190
+ mutationError = 'Dashboard group not found';
191
+ return null;
192
+ }
193
+
194
+ return {
195
+ ...config,
196
+ groups: nextGroups,
197
+ widgets: config.widgets.filter((widget) => widget.group_id !== groupId),
198
+ };
199
+ });
174
200
 
175
- if (!dashboard) {
201
+ if (!updatedDashboard) {
176
202
  response.setStatus(404);
177
203
  return { error: 'Dashboard not found' };
178
204
  }
179
205
 
180
- const config = ctx.parseStoredDashboardConfig(dashboard.config);
181
- const nextGroups = config.groups.filter((group) => group.id !== groupId);
182
-
183
- if (nextGroups.length === config.groups.length) {
206
+ if (mutationError) {
184
207
  response.setStatus(404);
185
- return { error: 'Dashboard group not found' };
208
+ return { error: mutationError };
186
209
  }
187
210
 
188
- return ctx.persistDashboardConfig(dashboard, {
189
- ...config,
190
- groups: nextGroups,
191
- widgets: config.widgets.filter((widget) => widget.group_id !== groupId),
192
- });
211
+ return updatedDashboard;
193
212
  },
194
213
  });
195
214