Skip to content

Commit ce07979

Browse files
Copilothotlong
andcommitted
Replace z.any() in UI schemas, add cross-reference validation for seed data and navigation, add negative validation tests
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent c781175 commit ce07979

8 files changed

Lines changed: 420 additions & 7 deletions

File tree

packages/spec/src/stack.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,3 +847,116 @@ describe('defineStack - Map Format Support', () => {
847847
expect(result.views![0].list?.type).toBe('grid');
848848
});
849849
});
850+
851+
// ============================================================================
852+
// Negative / Inverse Validation Tests — Cross-Reference
853+
// ============================================================================
854+
855+
describe('defineStack - Seed Data Cross-Reference Validation', () => {
856+
const baseManifest = {
857+
id: 'com.example.test',
858+
name: 'test-project',
859+
version: '1.0.0',
860+
type: 'app' as const,
861+
};
862+
863+
it('should detect seed data referencing undefined object', () => {
864+
const config = {
865+
manifest: baseManifest,
866+
objects: [
867+
{ name: 'account', fields: { name: { type: 'text' } } },
868+
],
869+
data: [
870+
{ object: 'ghost_object', records: [{ name: 'Test' }] },
871+
],
872+
};
873+
expect(() => defineStack(config)).toThrow('ghost_object');
874+
expect(() => defineStack(config)).toThrow('cross-reference validation failed');
875+
});
876+
877+
it('should pass when seed data references defined object', () => {
878+
const config = {
879+
manifest: baseManifest,
880+
objects: [
881+
{ name: 'account', fields: { name: { type: 'text' } } },
882+
],
883+
data: [
884+
{ object: 'account', records: [{ name: 'Acme Corp' }] },
885+
],
886+
};
887+
expect(() => defineStack(config)).not.toThrow();
888+
});
889+
});
890+
891+
describe('defineStack - Navigation Cross-Reference Validation', () => {
892+
const baseManifest = {
893+
id: 'com.example.test',
894+
name: 'test-project',
895+
version: '1.0.0',
896+
type: 'app' as const,
897+
};
898+
899+
it('should detect navigation referencing undefined object', () => {
900+
const config = {
901+
manifest: baseManifest,
902+
objects: [
903+
{ name: 'task', fields: { title: { type: 'text' } } },
904+
],
905+
apps: [
906+
{
907+
name: 'my_app',
908+
label: 'My App',
909+
navigation: [
910+
{ id: 'nav_missing', type: 'object' as const, label: 'Missing', objectName: 'nonexistent_object' },
911+
],
912+
},
913+
],
914+
};
915+
expect(() => defineStack(config)).toThrow('nonexistent_object');
916+
});
917+
918+
it('should detect navigation referencing undefined dashboard', () => {
919+
const config = {
920+
manifest: baseManifest,
921+
objects: [
922+
{ name: 'task', fields: { title: { type: 'text' } } },
923+
],
924+
dashboards: [
925+
{ name: 'sales_dashboard', label: 'Sales', widgets: [] },
926+
],
927+
apps: [
928+
{
929+
name: 'my_app',
930+
label: 'My App',
931+
navigation: [
932+
{ id: 'nav_ghost', type: 'dashboard' as const, label: 'Missing', dashboardName: 'ghost_dashboard' },
933+
],
934+
},
935+
],
936+
};
937+
expect(() => defineStack(config)).toThrow('ghost_dashboard');
938+
});
939+
940+
it('should pass when all navigation references are valid', () => {
941+
const config = {
942+
manifest: baseManifest,
943+
objects: [
944+
{ name: 'task', fields: { title: { type: 'text' } } },
945+
],
946+
dashboards: [
947+
{ name: 'task_overview', label: 'Overview', widgets: [] },
948+
],
949+
apps: [
950+
{
951+
name: 'my_app',
952+
label: 'My App',
953+
navigation: [
954+
{ id: 'nav_tasks', type: 'object' as const, label: 'Tasks', objectName: 'task' },
955+
{ id: 'nav_overview', type: 'dashboard' as const, label: 'Overview', dashboardName: 'task_overview' },
956+
],
957+
},
958+
],
959+
};
960+
expect(() => defineStack(config)).not.toThrow();
961+
});
962+
});

packages/spec/src/stack.zod.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,74 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
332332
}
333333
}
334334

335+
// Validate seed data → object references
336+
if (config.data) {
337+
for (const dataset of config.data) {
338+
if (dataset.object && !objectNames.has(dataset.object)) {
339+
errors.push(
340+
`Seed data references object '${dataset.object}' which is not defined in objects.`,
341+
);
342+
}
343+
}
344+
}
345+
346+
// Validate app navigation → object/dashboard/page/report references
347+
if (config.apps) {
348+
const dashboardNames = new Set<string>();
349+
if (config.dashboards) {
350+
for (const d of config.dashboards) {
351+
dashboardNames.add(d.name);
352+
}
353+
}
354+
const pageNames = new Set<string>();
355+
if (config.pages) {
356+
for (const p of config.pages) {
357+
pageNames.add(p.name);
358+
}
359+
}
360+
const reportNames = new Set<string>();
361+
if (config.reports) {
362+
for (const r of config.reports) {
363+
reportNames.add(r.name);
364+
}
365+
}
366+
367+
for (const app of config.apps) {
368+
if (!app.navigation) continue;
369+
const checkNavItems = (items: unknown[], appName: string) => {
370+
for (const item of items) {
371+
if (!item || typeof item !== 'object') continue;
372+
const nav = item as Record<string, unknown>;
373+
if (nav.type === 'object' && typeof nav.objectName === 'string' && !objectNames.has(nav.objectName)) {
374+
errors.push(
375+
`App '${appName}' navigation references object '${nav.objectName}' which is not defined in objects.`,
376+
);
377+
}
378+
if (nav.type === 'dashboard' && typeof nav.dashboardName === 'string' && dashboardNames.size > 0 && !dashboardNames.has(nav.dashboardName)) {
379+
errors.push(
380+
`App '${appName}' navigation references dashboard '${nav.dashboardName}' which is not defined in dashboards.`,
381+
);
382+
}
383+
if (nav.type === 'page' && typeof nav.pageName === 'string' && pageNames.size > 0 && !pageNames.has(nav.pageName)) {
384+
errors.push(
385+
`App '${appName}' navigation references page '${nav.pageName}' which is not defined in pages.`,
386+
);
387+
}
388+
if (nav.type === 'report' && typeof nav.reportName === 'string' && reportNames.size > 0 && !reportNames.has(nav.reportName)) {
389+
errors.push(
390+
`App '${appName}' navigation references report '${nav.reportName}' which is not defined in reports.`,
391+
);
392+
}
393+
// Recurse into group children
394+
if (nav.type === 'group' && Array.isArray(nav.children)) {
395+
checkNavItems(nav.children, appName);
396+
}
397+
}
398+
};
399+
checkNavItems(app.navigation, app.name);
400+
}
401+
}
402+
335403
return errors;
336404
}
337405

packages/spec/src/ui/component.zod.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { z } from 'zod';
4+
import { FilterConditionSchema } from '../data/filter.zod';
45
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
56
import { FeedItemType, FeedFilterMode } from '../data/feed.zod';
67

@@ -184,7 +185,7 @@ export const ElementNumberPropsSchema = z.object({
184185
field: z.string().optional().describe('Field to aggregate'),
185186
aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max'])
186187
.describe('Aggregation function'),
187-
filter: z.any().optional().describe('Filter criteria'),
188+
filter: FilterConditionSchema.optional().describe('Filter criteria'),
188189
format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'),
189190
prefix: z.string().optional().describe('Prefix text (e.g. "$")'),
190191
suffix: z.string().optional().describe('Suffix text (e.g. "%")'),
@@ -247,7 +248,7 @@ export const ElementRecordPickerPropsSchema = z.object({
247248
object: z.string().describe('Object to pick records from'),
248249
displayField: z.string().describe('Field to display as the record label'),
249250
searchFields: z.array(z.string()).optional().describe('Fields to search against'),
250-
filter: z.any().optional().describe('Filter criteria for available records'),
251+
filter: FilterConditionSchema.optional().describe('Filter criteria for available records'),
251252
multiple: z.boolean().optional().default(false).describe('Allow multiple record selection'),
252253
targetVariable: z.string().optional().describe('Page variable to bind selected record ID(s)'),
253254
placeholder: I18nLabelSchema.optional().describe('Placeholder text'),

packages/spec/src/ui/dashboard.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,3 +1597,91 @@ describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => {
15971597
expect(dashboard.widgets[2].measures).toHaveLength(3);
15981598
});
15991599
});
1600+
1601+
// ============================================================================
1602+
// Negative / Inverse Validation Tests
1603+
// ============================================================================
1604+
1605+
describe('DashboardWidgetSchema - Negative Validation', () => {
1606+
it('should reject widget without layout', () => {
1607+
expect(() => DashboardWidgetSchema.parse({
1608+
title: 'Bad Widget',
1609+
type: 'metric',
1610+
})).toThrow();
1611+
});
1612+
1613+
it('should reject widget with invalid type enum', () => {
1614+
expect(() => DashboardWidgetSchema.parse({
1615+
type: 'nonexistent_chart',
1616+
layout: { x: 0, y: 0, w: 4, h: 2 },
1617+
})).toThrow();
1618+
});
1619+
1620+
it('should reject widget with non-numeric layout values', () => {
1621+
expect(() => DashboardWidgetSchema.parse({
1622+
type: 'metric',
1623+
layout: { x: 'a', y: 0, w: 4, h: 2 },
1624+
})).toThrow();
1625+
});
1626+
1627+
it('should reject widget with incomplete layout', () => {
1628+
expect(() => DashboardWidgetSchema.parse({
1629+
type: 'metric',
1630+
layout: { x: 0, y: 0 },
1631+
})).toThrow();
1632+
});
1633+
1634+
it('should reject widget with invalid aggregate enum', () => {
1635+
expect(() => DashboardWidgetSchema.parse({
1636+
type: 'metric',
1637+
aggregate: 'median',
1638+
layout: { x: 0, y: 0, w: 4, h: 2 },
1639+
})).toThrow();
1640+
});
1641+
});
1642+
1643+
describe('DashboardSchema - Negative Validation', () => {
1644+
it('should reject dashboard without name', () => {
1645+
expect(() => DashboardSchema.parse({
1646+
label: 'No Name Dashboard',
1647+
widgets: [],
1648+
})).toThrow();
1649+
});
1650+
1651+
it('should reject dashboard without label', () => {
1652+
expect(() => DashboardSchema.parse({
1653+
name: 'no_label',
1654+
widgets: [],
1655+
})).toThrow();
1656+
});
1657+
1658+
it('should reject dashboard without widgets', () => {
1659+
expect(() => DashboardSchema.parse({
1660+
name: 'no_widgets',
1661+
label: 'Missing Widgets',
1662+
})).toThrow();
1663+
});
1664+
1665+
it('should reject dashboard with camelCase name', () => {
1666+
expect(() => DashboardSchema.parse({
1667+
name: 'salesDashboard',
1668+
label: 'Bad Name',
1669+
widgets: [],
1670+
})).toThrow();
1671+
});
1672+
});
1673+
1674+
describe('GlobalFilterSchema - Negative Validation', () => {
1675+
it('should reject filter without field', () => {
1676+
expect(() => GlobalFilterSchema.parse({
1677+
label: 'No Field',
1678+
})).toThrow();
1679+
});
1680+
1681+
it('should reject filter with invalid type enum', () => {
1682+
expect(() => GlobalFilterSchema.parse({
1683+
field: 'status',
1684+
type: 'invalid_type',
1685+
})).toThrow();
1686+
});
1687+
});

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,15 @@ export const GlobalFilterSchema = z.object({
186186

187187
/** Static options for select/lookup filters */
188188
options: z.array(z.object({
189-
value: z.any(),
189+
value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'),
190190
label: I18nLabelSchema,
191191
})).optional().describe('Static filter options'),
192192

193193
/** Dynamic data binding for filter options */
194194
optionsFrom: GlobalFilterOptionsFromSchema.optional().describe('Dynamic filter options from object'),
195195

196196
/** Default filter value */
197-
defaultValue: z.any().optional().describe('Default filter value'),
197+
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Default filter value'),
198198

199199
/** Filter application scope */
200200
scope: z.enum(['dashboard', 'widget']).default('dashboard').describe('Filter application scope'),

0 commit comments

Comments
 (0)