-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathdashboard.test.ts
More file actions
166 lines (148 loc) · 7.42 KB
/
Copy pathdashboard.test.ts
File metadata and controls
166 lines (148 loc) · 7.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect } from 'vitest';
import {
DashboardSchema,
DashboardWidgetSchema,
DashboardHeaderSchema,
DashboardHeaderActionSchema,
Dashboard,
WidgetColorVariantSchema,
WidgetActionTypeSchema,
GlobalFilterSchema,
GlobalFilterOptionsFromSchema,
} from './dashboard.zod';
/**
* ADR-0021 single-form: every dashboard widget binds a `dataset` and selects
* `dimensions`/`values` BY NAME. The legacy inline `object` + `categoryField` +
* `valueField` + `aggregate` query was removed in the cutover, so these tests
* cover the dataset shape and the surviving presentation sub-schemas.
*/
describe('DashboardWidgetSchema (dataset-bound)', () => {
it('accepts a KPI/metric widget (dataset + values, no dimensions)', () => {
const w = DashboardWidgetSchema.parse({
id: 'total_pipeline', type: 'metric', dataset: 'sales', values: ['revenue'],
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(w.dataset).toBe('sales');
expect(w.values).toEqual(['revenue']);
});
// Regression (Studio dashboard designer): the designer adds widgets WITHOUT a
// `layout`; the renderer auto-flows them. `layout` must be OPTIONAL — when it
// was required, every designer-authored dashboard failed validation (422 on
// draft save) and Publish stayed disabled even though the widget rendered.
it('accepts a widget with NO layout (auto-flowed; Studio designer omits it)', () => {
const w = DashboardWidgetSchema.parse({
id: 'widget_1', type: 'metric', dataset: 'showcase_task_metrics', values: ['task_count'],
});
expect(w.layout).toBeUndefined();
expect(w.values).toEqual(['task_count']);
});
it('accepts a chart widget with dimensions', () => {
const w = DashboardWidgetSchema.parse({
id: 'by_stage', type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
layout: { x: 0, y: 0, w: 6, h: 4 },
});
expect(w.dimensions).toEqual(['stage']);
});
it('keeps the presentation-scope filter (runtimeFilter) and compareTo', () => {
const w = DashboardWidgetSchema.parse({
id: 'won', type: 'metric', dataset: 'sales', values: ['revenue'],
filter: { stage: 'closed_won' }, compareTo: 'previousPeriod',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(w.filter).toEqual({ stage: 'closed_won' });
expect(w.compareTo).toBe('previousPeriod');
});
it('rejects a widget with no dataset', () => {
expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } })).toThrow();
});
it('rejects a widget with no values', () => {
expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', dataset: 'sales', values: [], layout: { x: 0, y: 0, w: 3, h: 2 } })).toThrow();
});
it('a widget supplying only the removed inline fields is invalid (no dataset)', () => {
expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', object: 'opportunity', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } } as any)).toThrow();
});
it('keeps the runtime capability gates', () => {
const w = DashboardWidgetSchema.parse({
id: 'gated', type: 'metric', dataset: 'sys', values: ['cnt'],
requiresObject: 'sys_package_installation', requiresService: 'analytics',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(w.requiresObject).toBe('sys_package_installation');
expect(w.requiresService).toBe('analytics');
});
});
describe('DashboardSchema', () => {
it('parses a dataset-bound dashboard', () => {
const d = DashboardSchema.parse({
name: 'sales_overview', label: 'Sales Overview',
widgets: [
{ id: 'kpi', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } },
{ id: 'chart', type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], layout: { x: 3, y: 0, w: 6, h: 4 } },
],
});
expect(d.widgets).toHaveLength(2);
});
it('parses a designer-authored dashboard whose widgets omit layout', () => {
const d = DashboardSchema.parse({
name: 'delivery_exec_overview', label: 'Delivery Executive Overview',
widgets: [
{ id: 'widget_1', type: 'metric', dataset: 'showcase_task_metrics', values: ['task_count'] },
{ id: 'widget_2', type: 'donut', dataset: 'showcase_task_metrics', dimensions: ['status'], values: ['task_count'] },
],
});
expect(d.widgets.every((w) => w.layout === undefined)).toBe(true);
});
it('Dashboard.create factory parses + returns a typed dashboard', () => {
const d = Dashboard.create({
name: 'dash_x', label: 'D',
widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } }],
});
expect(d.name).toBe('dash_x');
});
it('supports columns/gap/refresh/dateRange/globalFilters', () => {
const d = DashboardSchema.parse({
name: 'dash_x', label: 'D', columns: 12, gap: 4, refreshInterval: 60,
dateRange: { field: 'close_date', defaultRange: 'this_quarter' },
globalFilters: [{ field: 'owner', type: 'lookup' }],
widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } }],
});
expect(d.columns).toBe(12);
expect(d.globalFilters).toHaveLength(1);
});
});
describe('Dashboard presentation sub-schemas', () => {
it('DashboardHeaderSchema + action', () => {
const h = DashboardHeaderSchema.parse({ showTitle: true, actions: [{ label: 'New', actionUrl: '/new', actionType: 'modal' }] });
expect(h.actions).toHaveLength(1);
expect(DashboardHeaderActionSchema.parse({ label: 'X', actionUrl: '/x' }).label).toBe('X');
});
it('WidgetColorVariantSchema + WidgetActionTypeSchema enums', () => {
expect(WidgetColorVariantSchema.parse('blue')).toBe('blue');
expect(WidgetActionTypeSchema.parse('flow')).toBe('flow');
expect(() => WidgetColorVariantSchema.parse('chartreuse')).toThrow();
});
it('GlobalFilterSchema + GlobalFilterOptionsFromSchema', () => {
const f = GlobalFilterSchema.parse({ field: 'owner', type: 'lookup', optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' }, scope: 'dashboard' });
expect(f.scope).toBe('dashboard');
expect(GlobalFilterOptionsFromSchema.parse({ object: 'user', valueField: 'id', labelField: 'name' }).object).toBe('user');
});
it('GlobalFilterSchema.name — optional stable variable key (framework#2501)', () => {
const named = GlobalFilterSchema.parse({ name: 'region', field: 'sales_region', type: 'select' });
expect(named.name).toBe('region');
// name stays optional — runtime defaults it to `field`.
expect(GlobalFilterSchema.parse({ field: 'region' }).name).toBeUndefined();
});
it('DashboardWidgetSchema.filterBindings — field override / opt-out (framework#2501)', () => {
const w = DashboardWidgetSchema.parse({
id: 'accounts_signed', type: 'line', dataset: 'accounts', values: ['count'],
filterBindings: { dateRange: 'signed_at', region: 'sales_region', status: false },
});
expect(w.filterBindings).toEqual({ dateRange: 'signed_at', region: 'sales_region', status: false });
// Only string (field name) or literal false are valid binding values.
expect(() => DashboardWidgetSchema.parse({
id: 'w_bad', type: 'metric', dataset: 'sales', values: ['revenue'],
filterBindings: { region: true },
})).toThrow();
});
});