@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,194 @@
1
+ import { z } from 'zod'
2
+
3
+ export const DashboardWidgetSizeSchema = z.enum([
4
+ 'small',
5
+ 'medium',
6
+ 'large',
7
+ 'wide',
8
+ 'full',
9
+ ])
10
+
11
+ export const ValueFormatSchema = z.enum([
12
+ 'number',
13
+ 'integer',
14
+ 'compact_number',
15
+ 'currency',
16
+ 'percent',
17
+ 'percent_delta',
18
+ 'number_delta',
19
+ 'currency_delta',
20
+ ]).optional()
21
+
22
+ export const VariablesConfigSchema = z.record(z.string(), z.unknown())
23
+
24
+ export const ChartFieldRefSchema = z.object({
25
+ field: z.string(),
26
+ label: z.string().optional(),
27
+ format: ValueFormatSchema,
28
+ }).strict()
29
+
30
+ const RelativeDateValueSchema = z.union([
31
+ z.object({
32
+ now: z.literal(true),
33
+ }).strict(),
34
+ z.object({
35
+ now_minus: z.string().regex(/^\d+(h|d|w|mo|y)$/),
36
+ }).strict(),
37
+ ])
38
+
39
+ const FilterValueSchema: z.ZodType = z.lazy(() => z.union([
40
+ RelativeDateValueSchema,
41
+ z.string(),
42
+ z.number(),
43
+ z.boolean(),
44
+ z.null(),
45
+ z.array(FilterValueSchema),
46
+ z.record(z.string(), FilterValueSchema),
47
+ ]))
48
+
49
+ export const FilterExpressionSchema: z.ZodType = z.lazy(() => z.union([
50
+ z.array(FilterExpressionSchema),
51
+ z.object({
52
+ and: z.array(FilterExpressionSchema),
53
+ }).strict(),
54
+ z.object({
55
+ or: z.array(FilterExpressionSchema),
56
+ }).strict(),
57
+ z.object({
58
+ field: z.string(),
59
+ eq: FilterValueSchema.optional(),
60
+ neq: FilterValueSchema.optional(),
61
+ gt: FilterValueSchema.optional(),
62
+ gte: FilterValueSchema.optional(),
63
+ lt: FilterValueSchema.optional(),
64
+ lte: FilterValueSchema.optional(),
65
+ in: z.array(FilterValueSchema).optional(),
66
+ not_in: z.array(FilterValueSchema).optional(),
67
+ like: FilterValueSchema.optional(),
68
+ ilike: FilterValueSchema.optional(),
69
+ }).strict(),
70
+ ]))
71
+
72
+ export const QueryAggregateOperationSchema = z.enum([
73
+ 'sum',
74
+ 'count',
75
+ 'count_distinct',
76
+ 'avg',
77
+ 'min',
78
+ 'max',
79
+ 'median',
80
+ ])
81
+
82
+ export const QueryFieldSelectItemSchema = z.object({
83
+ field: z.string(),
84
+ as: z.string().optional(),
85
+ grain: z.enum(['day', 'week', 'month', 'year']).optional(),
86
+ }).strict()
87
+
88
+ export const QueryAggregateSelectItemSchema = z.object({
89
+ agg: QueryAggregateOperationSchema,
90
+ field: z.string().optional(),
91
+ as: z.string(),
92
+ filters: FilterExpressionSchema.optional(),
93
+ }).strict().superRefine((item, ctx) => {
94
+ if (!['count'].includes(item.agg) && !item.field) {
95
+ ctx.addIssue({
96
+ code: 'custom',
97
+ path: ['field'],
98
+ message: `field is required for ${item.agg}`,
99
+ })
100
+ }
101
+ })
102
+
103
+ export const QueryCalcSelectItemSchema = z.object({
104
+ calc: z.string(),
105
+ as: z.string(),
106
+ }).strict()
107
+
108
+ export const QuerySelectItemSchema = z.union([
109
+ QueryFieldSelectItemSchema,
110
+ QueryAggregateSelectItemSchema,
111
+ QueryCalcSelectItemSchema,
112
+ ])
113
+
114
+ export const QueryGroupByItemSchema = z.union([
115
+ z.string(),
116
+ z.object({
117
+ field: z.string(),
118
+ as: z.string().optional(),
119
+ grain: z.enum(['day', 'week', 'month', 'year']).optional(),
120
+ timezone: z.string().optional(),
121
+ }).strict(),
122
+ ])
123
+
124
+ export const QueryOrderByItemSchema = z.object({
125
+ field: z.string(),
126
+ direction: z.enum(['asc', 'desc']).optional(),
127
+ }).strict()
128
+
129
+ const BucketConfigSchema = z.object({
130
+ field: z.string(),
131
+ buckets: z.array(z.object({
132
+ label: z.string(),
133
+ min: z.number().optional(),
134
+ max: z.number().optional(),
135
+ }).strict()),
136
+ }).strict()
137
+
138
+ export const QueryCalcItemSchema = z.object({
139
+ calc: z.string(),
140
+ as: z.string(),
141
+ }).strict()
142
+
143
+ export const QueryConfigSchema = z.object({
144
+ resource: z.string(),
145
+ select: z.array(QuerySelectItemSchema).optional(),
146
+ sparkline: z.object({
147
+ field: z.string(),
148
+ grain: z.enum(['day', 'week', 'month', 'year']),
149
+ as: z.string(),
150
+ fill_missing: z.record(z.string(), z.unknown()).optional(),
151
+ }).strict().optional(),
152
+ filters: FilterExpressionSchema.optional(),
153
+ group_by: z.array(QueryGroupByItemSchema).optional(),
154
+ order_by: z.array(QueryOrderByItemSchema).optional(),
155
+ limit: z.number().int().positive().optional(),
156
+ offset: z.number().int().nonnegative().optional(),
157
+ bucket: BucketConfigSchema.optional(),
158
+ calcs: z.array(QueryCalcItemSchema).optional(),
159
+ formatting: z.record(z.string(), z.unknown()).optional(),
160
+ }).strict()
161
+
162
+ const FunnelQueryStepSchema = z.object({
163
+ name: z.string(),
164
+ resource: z.string(),
165
+ metric: QueryAggregateSelectItemSchema,
166
+ filters: FilterExpressionSchema.optional(),
167
+ }).strict()
168
+
169
+ export const FunnelQueryConfigSchema = z.object({
170
+ steps: z.array(FunnelQueryStepSchema).min(1),
171
+ calcs: z.array(QueryCalcItemSchema).optional(),
172
+ }).strict()
173
+
174
+ export const WidgetPersistedFieldsSchema = z.object({
175
+ id: z.string(),
176
+ group_id: z.string(),
177
+ order: z.number(),
178
+ }).strict()
179
+
180
+ export const WidgetEditableBaseSchema = z.object({
181
+ label: z.string().optional(),
182
+ variables: VariablesConfigSchema.optional(),
183
+ size: DashboardWidgetSizeSchema.optional(),
184
+ width: z.number().positive('Width must be greater than 0').optional(),
185
+ height: z.number().positive('Height must be greater than 0').optional(),
186
+ min_width: z.number().nonnegative('Min width must be a non-negative number').optional(),
187
+ max_width: z.number().nonnegative('Max width must be a non-negative number').nullable().optional(),
188
+ }).strict()
189
+
190
+ export const WidgetBaseSchema = WidgetPersistedFieldsSchema.merge(WidgetEditableBaseSchema)
191
+
192
+ export const EmptyWidgetConfigSchema = WidgetBaseSchema.extend({
193
+ target: z.literal('empty'),
194
+ })
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ QueryConfigSchema,
4
+ ValueFormatSchema,
5
+ WidgetBaseSchema,
6
+ } from './common.js'
7
+
8
+ export const GaugeCardViewConfigSchema = z.object({
9
+ title: z.string().optional(),
10
+ value: z.object({
11
+ field: z.string(),
12
+ format: ValueFormatSchema,
13
+ prefix: z.string().optional(),
14
+ suffix: z.string().optional(),
15
+ }).strict(),
16
+ target: z.object({
17
+ value: z.number().optional(),
18
+ field: z.string().optional(),
19
+ label: z.string().optional(),
20
+ }).strict().optional(),
21
+ progress: z.object({
22
+ value_field: z.string(),
23
+ target_value: z.number().optional(),
24
+ target_field: z.string().optional(),
25
+ format: ValueFormatSchema,
26
+ }).strict().optional(),
27
+ color: z.string().optional(),
28
+ }).strict()
29
+
30
+ export const GaugeCardWidgetConfigSchema = WidgetBaseSchema.extend({
31
+ target: z.literal('gauge_card'),
32
+ card: GaugeCardViewConfigSchema,
33
+ query: QueryConfigSchema,
34
+ })
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ QueryConfigSchema,
4
+ ValueFormatSchema,
5
+ WidgetBaseSchema,
6
+ } from './common.js'
7
+
8
+ export const KpiCardViewConfigSchema = z.object({
9
+ title: z.string().optional(),
10
+ value: z.object({
11
+ field: z.string(),
12
+ format: ValueFormatSchema,
13
+ prefix: z.string().optional(),
14
+ suffix: z.string().optional(),
15
+ }).strict(),
16
+ subtitle: z.object({
17
+ text: z.string().optional(),
18
+ field: z.string().optional(),
19
+ }).strict().optional(),
20
+ comparison: z.object({
21
+ field: z.string(),
22
+ format: ValueFormatSchema,
23
+ positive_is_good: z.boolean().optional(),
24
+ compact: z.object({
25
+ show: z.boolean().optional(),
26
+ template: z.string().optional(),
27
+ }).strict().optional(),
28
+ tooltip: z.object({
29
+ label: z.string().optional(),
30
+ template: z.string().optional(),
31
+ }).strict().optional(),
32
+ }).strict().optional(),
33
+ sparkline: z.object({
34
+ type: z.enum(['line']).optional(),
35
+ field: z.string(),
36
+ x: z.string(),
37
+ show_axes: z.boolean().optional(),
38
+ show_labels: z.boolean().optional(),
39
+ fill: z.object({
40
+ type: z.enum(['gradient', 'solid']).optional(),
41
+ }).strict().optional(),
42
+ }).strict().optional(),
43
+ }).strict()
44
+
45
+ export const KpiCardWidgetConfigSchema = WidgetBaseSchema.extend({
46
+ target: z.literal('kpi_card'),
47
+ card: KpiCardViewConfigSchema,
48
+ query: QueryConfigSchema,
49
+ })
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ ChartFieldRefSchema,
4
+ QueryConfigSchema,
5
+ ValueFormatSchema,
6
+ WidgetBaseSchema,
7
+ } from './common.js'
8
+
9
+ export const PivotTableViewConfigSchema = z.object({
10
+ rows: z.array(ChartFieldRefSchema).min(1),
11
+ columns: z.array(ChartFieldRefSchema).optional(),
12
+ values: z.array(z.object({
13
+ field: z.string(),
14
+ label: z.string().optional(),
15
+ format: ValueFormatSchema,
16
+ aggregation: z.enum(['sum', 'count', 'avg', 'min', 'max']).optional(),
17
+ }).strict()).min(1),
18
+ }).strict()
19
+
20
+ export const PivotTableWidgetConfigSchema = WidgetBaseSchema.extend({
21
+ target: z.literal('pivot_table'),
22
+ pivot: PivotTableViewConfigSchema,
23
+ query: QueryConfigSchema,
24
+ })
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ ChartFieldRefSchema,
4
+ QueryConfigSchema,
5
+ WidgetBaseSchema,
6
+ } from './common.js'
7
+
8
+ export const TableViewConfigSchema = z.object({
9
+ columns: z.array(ChartFieldRefSchema).optional(),
10
+ pagination: z.boolean().optional(),
11
+ page_size: z.number().int().positive().optional(),
12
+ }).strict()
13
+
14
+ export const TableWidgetConfigSchema = WidgetBaseSchema.extend({
15
+ target: z.literal('table'),
16
+ table: TableViewConfigSchema.optional(),
17
+ query: QueryConfigSchema,
18
+ })
@@ -26,6 +26,39 @@ export type PersistedDashboardResponse = {
26
26
  config: DashboardConfig;
27
27
  };
28
28
 
29
+ type DashboardConfigMutator = (
30
+ config: DashboardConfig,
31
+ dashboard: DashboardRecord,
32
+ ) => DashboardConfig | null | Promise<DashboardConfig | null>;
33
+
34
+ const dashboardConfigUpdateQueues = new Map<string, Promise<void>>();
35
+
36
+ async function runDashboardConfigUpdateQueued<T>(
37
+ dashboardSlug: string,
38
+ callback: () => Promise<T>,
39
+ ): Promise<T> {
40
+ const previousUpdate = dashboardConfigUpdateQueues.get(dashboardSlug) ?? Promise.resolve();
41
+ let releaseCurrentUpdate!: () => void;
42
+ const currentUpdate = new Promise<void>((resolve) => {
43
+ releaseCurrentUpdate = resolve;
44
+ });
45
+ const queuedUpdate = previousUpdate.then(() => currentUpdate, () => currentUpdate);
46
+
47
+ dashboardConfigUpdateQueues.set(dashboardSlug, queuedUpdate);
48
+
49
+ await previousUpdate.catch(() => undefined);
50
+
51
+ try {
52
+ return await callback();
53
+ } finally {
54
+ releaseCurrentUpdate();
55
+
56
+ if (dashboardConfigUpdateQueues.get(dashboardSlug) === queuedUpdate) {
57
+ dashboardConfigUpdateQueues.delete(dashboardSlug);
58
+ }
59
+ }
60
+ }
61
+
29
62
  function normalizeDashboardOrder(config: DashboardConfig): DashboardConfig {
30
63
  const widgetsByGroupId = new Map<string, DashboardWidgetConfig[]>();
31
64
 
@@ -91,6 +124,36 @@ export async function persistDashboardConfig(
91
124
  };
92
125
  }
93
126
 
127
+ export async function updateDashboardConfig(
128
+ adminforth: IAdminForth,
129
+ dashboardConfigsResourceId: string,
130
+ slug: string,
131
+ mutateConfig: DashboardConfigMutator,
132
+ ): Promise<PersistedDashboardResponse | null> {
133
+ return runDashboardConfigUpdateQueued(slug, async () => {
134
+ const dashboard = await getDashboardRecord(adminforth, dashboardConfigsResourceId, slug);
135
+
136
+ if (!dashboard) {
137
+ return null;
138
+ }
139
+
140
+ const config = parseStoredDashboardConfig(dashboard.config);
141
+ const nextConfig = await mutateConfig(config, dashboard);
142
+
143
+ if (nextConfig === null) {
144
+ return {
145
+ id: dashboard.id,
146
+ slug: dashboard.slug,
147
+ label: dashboard.label,
148
+ revision: dashboard.revision,
149
+ config,
150
+ };
151
+ }
152
+
153
+ return persistDashboardConfig(adminforth, dashboardConfigsResourceId, dashboard, nextConfig);
154
+ });
155
+ }
156
+
94
157
  export type DashboardConfigService = {
95
158
  getDashboardRecord: (slug: string) => Promise<DashboardRecord | null>;
96
159
  parseStoredDashboardConfig: typeof parseStoredDashboardConfig;
@@ -98,6 +161,10 @@ export type DashboardConfigService = {
98
161
  dashboard: DashboardRecord,
99
162
  config: DashboardConfig,
100
163
  ) => Promise<PersistedDashboardResponse>;
164
+ updateDashboardConfig: (
165
+ slug: string,
166
+ mutateConfig: DashboardConfigMutator,
167
+ ) => Promise<PersistedDashboardResponse | null>;
101
168
  };
102
169
 
103
170
  export function createDashboardConfigService(
@@ -113,5 +180,11 @@ export function createDashboardConfigService(
113
180
  dashboard,
114
181
  config,
115
182
  ),
183
+ updateDashboardConfig: (slug, mutateConfig) => updateDashboardConfig(
184
+ adminforth,
185
+ dashboardConfigsResourceId,
186
+ slug,
187
+ mutateConfig,
188
+ ),
116
189
  };
117
190
  }
@@ -70,7 +70,8 @@ type EffectiveGroupByItem = {
70
70
  const CALC_IDENTIFIER_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;
71
71
  const LOOKUP_CALL_RE = /lookup\(\s*(\$variables(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
72
72
  const VARIABLE_PATH_PREFIX_RE = /^\$variables\.?/;
73
- const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s]+$/;
73
+ const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s?:<>=!]+$/;
74
+ const RELATIVE_DURATION_RE = /^(\d+)(h|d|w|mo|y)$/;
74
75
  const FILTER_OPERATORS = {
75
76
  eq: Filters.EQ,
76
77
  neq: Filters.NEQ,
@@ -165,6 +166,12 @@ async function getQueryWidgetData(
165
166
  query: QueryConfig,
166
167
  variables: DashboardVariables,
167
168
  ): Promise<DashboardWidgetData> {
169
+ const metricSelect = getSingleAggregateMetricSelect(query);
170
+
171
+ if (metricSelect) {
172
+ return getMetricWidgetData(adminforth, query, metricSelect);
173
+ }
174
+
168
175
  const selectedRows = isAggregateQuery(query)
169
176
  ? await buildAggregateQueryRows(adminforth, query, variables)
170
177
  : buildPlainQueryRows(
@@ -196,6 +203,64 @@ async function getQueryWidgetData(
196
203
  };
197
204
  }
198
205
 
206
+ async function getMetricWidgetData(
207
+ adminforth: IAdminForth,
208
+ query: QueryConfig,
209
+ metric: QueryAggregateSelectItem,
210
+ ): Promise<DashboardWidgetData> {
211
+ const [currentValues = {}] = await getAggregateRows(
212
+ adminforth,
213
+ query.resource,
214
+ query.filters,
215
+ [metric],
216
+ [],
217
+ );
218
+ const values: Record<string, unknown> = {
219
+ [metric.as]: currentValues[metric.as] ?? 0,
220
+ };
221
+
222
+ const rows = query.sparkline
223
+ ? await getMetricSparklineRows(adminforth, query, metric, getAdminForthFilters(query.filters))
224
+ : [values];
225
+ const columns = Array.from(new Set([
226
+ metric.as,
227
+ ...(query.sparkline ? [query.sparkline.as] : []),
228
+ ]));
229
+
230
+ return {
231
+ kind: 'aggregate',
232
+ columns,
233
+ rows,
234
+ values,
235
+ };
236
+ }
237
+
238
+ async function getMetricSparklineRows(
239
+ adminforth: IAdminForth,
240
+ query: QueryConfig,
241
+ metric: QueryAggregateSelectItem,
242
+ filters: DashboardWidgetFilters,
243
+ ) {
244
+ const sparkline = query.sparkline!;
245
+ const groupBy = [{
246
+ field: sparkline.field,
247
+ as: sparkline.as,
248
+ grain: sparkline.grain,
249
+ }];
250
+ const rows = await getAggregateRows(
251
+ adminforth,
252
+ query.resource,
253
+ filters,
254
+ [metric],
255
+ groupBy,
256
+ );
257
+
258
+ return rows.map((row) => ({
259
+ ...query.sparkline?.fill_missing,
260
+ ...row,
261
+ }));
262
+ }
263
+
199
264
  async function getResourceRows(
200
265
  adminforth: IAdminForth,
201
266
  resourceId: string,
@@ -237,7 +302,7 @@ async function buildAggregateQueryRows(
237
302
  async function getAggregateRows(
238
303
  adminforth: IAdminForth,
239
304
  resourceId: string,
240
- baseFilters: FilterExpression | undefined,
305
+ baseFilters: FilterExpression | DashboardWidgetFilters | undefined,
241
306
  select: QueryAggregateSelectItem[],
242
307
  groupBy: EffectiveGroupByItem[],
243
308
  ) {
@@ -405,6 +470,21 @@ function isAggregateQuery(query: QueryConfig) {
405
470
  );
406
471
  }
407
472
 
473
+ function getSingleAggregateMetricSelect(query: QueryConfig) {
474
+ if (query.group_by?.length) {
475
+ return undefined;
476
+ }
477
+
478
+ const select = query.select ?? [];
479
+ const aggregateItems = select.filter(isAggregateSelectItem);
480
+
481
+ if (aggregateItems.length !== 1 || aggregateItems.length !== select.length) {
482
+ return undefined;
483
+ }
484
+
485
+ return aggregateItems[0];
486
+ }
487
+
408
488
  function isFieldSelectItem(item: QuerySelectItem): item is QueryFieldSelectItem {
409
489
  return 'field' in item && !('agg' in item);
410
490
  }
@@ -678,13 +758,59 @@ function toAdminForthFilter(filter: FilterExpression): IAdminForthSingleFilter |
678
758
 
679
759
  for (const [operator, createFilter] of Object.entries(FILTER_OPERATORS)) {
680
760
  if (Object.prototype.hasOwnProperty.call(filter, operator)) {
681
- return createFilter(filter.field, filter[operator as keyof typeof FILTER_OPERATORS]);
761
+ return createFilter(filter.field, resolveFilterValue(filter[operator as keyof typeof FILTER_OPERATORS]));
682
762
  }
683
763
  }
684
764
 
685
765
  return Filters.AND([]);
686
766
  }
687
767
 
768
+ function resolveFilterValue(value: unknown): unknown {
769
+ if (Array.isArray(value)) {
770
+ return value.map((item) => resolveFilterValue(item));
771
+ }
772
+
773
+ if (!isRecord(value)) {
774
+ return value;
775
+ }
776
+
777
+ if (value.now === true) {
778
+ return new Date().toISOString();
779
+ }
780
+
781
+ if (typeof value.now_minus === 'string') {
782
+ return subtractDuration(new Date(), value.now_minus).toISOString();
783
+ }
784
+
785
+ return value;
786
+ }
787
+
788
+ function subtractDuration(now: Date, duration: string) {
789
+ const match = duration.match(RELATIVE_DURATION_RE);
790
+
791
+ if (!match) {
792
+ throw new Error(`Unsupported relative date duration: ${duration}`);
793
+ }
794
+
795
+ const amount = Number(match[1]);
796
+ const unit = match[2];
797
+ const date = new Date(now);
798
+
799
+ if (unit === 'h') {
800
+ date.setUTCHours(date.getUTCHours() - amount);
801
+ } else if (unit === 'd') {
802
+ date.setUTCDate(date.getUTCDate() - amount);
803
+ } else if (unit === 'w') {
804
+ date.setUTCDate(date.getUTCDate() - amount * 7);
805
+ } else if (unit === 'mo') {
806
+ date.setUTCMonth(date.getUTCMonth() - amount);
807
+ } else if (unit === 'y') {
808
+ date.setUTCFullYear(date.getUTCFullYear() - amount);
809
+ }
810
+
811
+ return date;
812
+ }
813
+
688
814
  function toFiniteNumber(value: unknown) {
689
815
  const numberValue = typeof value === 'number' ? value : Number(value);
690
816
  return Number.isFinite(numberValue) ? numberValue : 0;