Skip to content

Commit 8f78604

Browse files
Copilothotlong
andcommitted
Add ViewFilterRuleSchema, unify filter types in view/page/component, add example-level strict validation tests
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 2a60554 commit 8f78604

6 files changed

Lines changed: 283 additions & 10 deletions

File tree

ROADMAP.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ This strategy ensures rapid iteration while maintaining a clear path to producti
9999
| Service Contracts | 27 |
100100
| Contracts Implemented | 13 (52%) |
101101
| Test Files | 229 |
102-
| Tests Passing | 6,445 / 6,445 |
102+
| Tests Passing | 6,456 / 6,456 |
103103
| `@deprecated` Items | 3 |
104104
| Protocol Domains | 15 (Data, UI, AI, API, Automation, Cloud, Contracts, Identity, Integration, Kernel, QA, Security, Shared, Studio, System) |
105105

@@ -108,12 +108,15 @@ This strategy ensures rapid iteration while maintaining a clear path to producti
108108
| Item | Status | Details |
109109
|:---|:---:|:---|
110110
| `defineStack()` strict by default || `strict: true` default since v3.0.2, validates schemas + cross-references |
111-
| `z.any()` elimination in UI protocol || All `filter` fields → `FilterConditionSchema`, all `value` fields → typed unions |
111+
| `z.any()` elimination in UI protocol || All `filter` fields → `FilterConditionSchema` or `ViewFilterRuleSchema`, all `value` fields → typed unions |
112+
| Filter format unification || MongoDB-style filters use `FilterConditionSchema`, declarative view/tab filters use `ViewFilterRuleSchema``z.array(z.unknown())` eliminated |
112113
| Seed data → object cross-reference || `validateCrossReferences` detects seed data referencing undefined objects |
113-
| Navigation → object/dashboard/page/report cross-reference || App navigation items validated against defined metadata |
114-
| Negative validation tests (dashboard, page, report) || Missing required fields, invalid enums, type violations all covered |
114+
| Navigation → object/dashboard/page/report cross-reference || App navigation items validated against defined metadata (recursive group support) |
115+
| Negative validation tests (dashboard, page, report, view) || Missing required fields, invalid enums, type violations, cross-reference errors all covered |
116+
| Example-level strict validation tests || Todo-style and CRM-style full app configs validated in strict mode |
117+
| SSOT: types from Zod (`z.infer`) || 135 UI types derived via `z.infer`, zero duplicate interfaces in `.zod.ts` files |
115118
| `z.any()` in data/filter.zod.ts (8 instances) | ✅ Justified | Runtime comparison operators (`$eq`, `$ne`, `$in`, `$nin`) accept any value type |
116-
| `z.unknown()` hardening in remaining schemas | 🟡 | `z.unknown()` used for extensible config/metadata fields — stricter composite schemas planned |
119+
| `z.unknown()` in extensibility fields | ✅ Justified | `properties`, `children`, `context`, `options`, `body` — inherently dynamic extensibility points |
117120
| DashboardWidget discriminated union by type | 🔴 | Planned — chart/metric/pivot subtypes with type-specific required fields |
118121
| CI lint rule rejecting new `z.any()` | 🔴 | Planned — eslint or custom lint rule to block `z.any()` additions |
119122

packages/spec/src/stack.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,3 +960,176 @@ describe('defineStack - Navigation Cross-Reference Validation', () => {
960960
expect(() => defineStack(config)).not.toThrow();
961961
});
962962
});
963+
964+
// ============================================================================
965+
// Example-Level Strict Validation — mirrors examples/app-todo & examples/app-crm
966+
// ============================================================================
967+
968+
describe('defineStack - Example-Level Strict Validation', () => {
969+
it('should validate a Todo-style app config (strict mode)', () => {
970+
const todoConfig = {
971+
manifest: {
972+
id: 'com.example.todo',
973+
namespace: 'todo',
974+
version: '2.0.0',
975+
type: 'app' as const,
976+
name: 'Todo Manager',
977+
description: 'A comprehensive Todo app',
978+
},
979+
objects: [
980+
{
981+
name: 'task',
982+
label: 'Task',
983+
fields: {
984+
subject: { type: 'text', label: 'Subject', required: true },
985+
status: { type: 'select', label: 'Status', options: [
986+
{ value: 'not_started', label: 'Not Started' },
987+
{ value: 'in_progress', label: 'In Progress' },
988+
{ value: 'completed', label: 'Completed' },
989+
]},
990+
priority: { type: 'select', label: 'Priority', options: [
991+
{ value: 'low', label: 'Low' },
992+
{ value: 'normal', label: 'Normal' },
993+
{ value: 'high', label: 'High' },
994+
]},
995+
category: { type: 'text', label: 'Category' },
996+
due_date: { type: 'date', label: 'Due Date' },
997+
},
998+
},
999+
],
1000+
data: [
1001+
{
1002+
object: 'task',
1003+
mode: 'upsert' as const,
1004+
externalId: 'subject',
1005+
records: [
1006+
{ subject: 'Learn ObjectStack', status: 'completed', priority: 'high', category: 'Work' },
1007+
{ subject: 'Build a cool app', status: 'in_progress', priority: 'normal', category: 'Work' },
1008+
],
1009+
},
1010+
],
1011+
dashboards: [
1012+
{
1013+
name: 'task_overview',
1014+
label: 'Task Overview',
1015+
widgets: [
1016+
{ title: 'Total Tasks', type: 'metric', object: 'task', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } },
1017+
{ title: 'By Status', type: 'pie', object: 'task', categoryField: 'status', aggregate: 'count', layout: { x: 3, y: 0, w: 6, h: 4 } },
1018+
],
1019+
},
1020+
],
1021+
apps: [
1022+
{
1023+
name: 'todo_app',
1024+
label: 'Todo Manager',
1025+
navigation: [
1026+
{ id: 'nav_tasks', type: 'object' as const, label: 'Tasks', objectName: 'task' },
1027+
{ id: 'nav_dashboard', type: 'dashboard' as const, label: 'Overview', dashboardName: 'task_overview' },
1028+
],
1029+
},
1030+
],
1031+
};
1032+
expect(() => defineStack(todoConfig, { strict: true })).not.toThrow();
1033+
});
1034+
1035+
it('should validate a CRM-style app config with seed data and reports (strict mode)', () => {
1036+
const crmConfig = {
1037+
manifest: {
1038+
id: 'com.example.crm',
1039+
namespace: 'crm',
1040+
version: '1.0.0',
1041+
type: 'app' as const,
1042+
name: 'Sales CRM',
1043+
description: 'Complete sales management solution',
1044+
},
1045+
objects: [
1046+
{
1047+
name: 'account',
1048+
label: 'Account',
1049+
fields: {
1050+
name: { type: 'text', label: 'Name', required: true },
1051+
industry: { type: 'text', label: 'Industry' },
1052+
annual_revenue: { type: 'number', label: 'Annual Revenue' },
1053+
},
1054+
},
1055+
{
1056+
name: 'opportunity',
1057+
label: 'Opportunity',
1058+
fields: {
1059+
name: { type: 'text', label: 'Name', required: true },
1060+
amount: { type: 'currency', label: 'Amount' },
1061+
stage: { type: 'select', label: 'Stage', options: [
1062+
{ value: 'prospecting', label: 'Prospecting' },
1063+
{ value: 'negotiation', label: 'Negotiation' },
1064+
{ value: 'closed_won', label: 'Closed Won' },
1065+
]},
1066+
},
1067+
},
1068+
],
1069+
data: [
1070+
{
1071+
object: 'account',
1072+
mode: 'upsert' as const,
1073+
externalId: 'name',
1074+
records: [
1075+
{ name: 'Acme Corp', industry: 'technology', annual_revenue: 5000000 },
1076+
],
1077+
},
1078+
],
1079+
reports: [
1080+
{
1081+
name: 'pipeline_report',
1082+
label: 'Pipeline Report',
1083+
objectName: 'opportunity',
1084+
type: 'summary' as const,
1085+
columns: [
1086+
{ field: 'name' },
1087+
{ field: 'amount', aggregate: 'sum' as const },
1088+
],
1089+
groupingsDown: [{ field: 'stage' }],
1090+
},
1091+
],
1092+
dashboards: [
1093+
{
1094+
name: 'sales_overview',
1095+
label: 'Sales Overview',
1096+
widgets: [
1097+
{ title: 'Pipeline Value', type: 'metric', object: 'opportunity', valueField: 'amount', aggregate: 'sum', layout: { x: 0, y: 0, w: 4, h: 2 } },
1098+
],
1099+
},
1100+
],
1101+
apps: [
1102+
{
1103+
name: 'sales_crm',
1104+
label: 'Sales CRM',
1105+
icon: 'briefcase',
1106+
navigation: [
1107+
{ id: 'nav_accounts', type: 'object' as const, label: 'Accounts', objectName: 'account' },
1108+
{ id: 'nav_opportunities', type: 'object' as const, label: 'Opportunities', objectName: 'opportunity' },
1109+
{ id: 'nav_dashboard', type: 'dashboard' as const, label: 'Sales Overview', dashboardName: 'sales_overview' },
1110+
{ id: 'nav_report', type: 'report' as const, label: 'Pipeline', reportName: 'pipeline_report' },
1111+
],
1112+
},
1113+
],
1114+
};
1115+
expect(() => defineStack(crmConfig, { strict: true })).not.toThrow();
1116+
});
1117+
1118+
it('should reject CRM config with seed data referencing non-existent object', () => {
1119+
const badConfig = {
1120+
manifest: {
1121+
id: 'com.example.crm',
1122+
name: 'crm',
1123+
version: '1.0.0',
1124+
type: 'app' as const,
1125+
},
1126+
objects: [
1127+
{ name: 'account', fields: { name: { type: 'text' } } },
1128+
],
1129+
data: [
1130+
{ object: 'contact', records: [{ name: 'John' }] },
1131+
],
1132+
};
1133+
expect(() => defineStack(badConfig, { strict: true })).toThrow('contact');
1134+
});
1135+
});

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { z } from 'zod';
44
import { FilterConditionSchema } from '../data/filter.zod';
5+
import { ViewFilterRuleSchema } from './view.zod';
56
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
67
import { FeedItemType, FeedFilterMode } from '../data/feed.zod';
78

@@ -77,7 +78,7 @@ export const RecordRelatedListProps = z.object({
7778
}))
7879
]).optional().describe('Sort order for related records'),
7980
limit: z.number().int().positive().default(5).describe('Number of records to display initially'),
80-
filter: z.array(z.unknown()).optional().describe('Additional filter criteria for related records'),
81+
filter: z.array(ViewFilterRuleSchema).optional().describe('Additional filter criteria for related records'),
8182
title: I18nLabelSchema.optional().describe('Custom title for the related list'),
8283
showViewAll: z.boolean().default(true).describe('Show "View All" link to see all related records'),
8384
actions: z.array(z.string()).optional().describe('Action IDs available for related records'),

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
UserActionsConfigSchema,
1111
AppearanceConfigSchema,
1212
ViewTabSchema,
13+
ViewFilterRuleSchema,
1314
AddRecordConfigSchema,
1415
} from './view.zod';
1516

@@ -213,7 +214,7 @@ export const InterfacePageConfigSchema = z.object({
213214
/** Data binding */
214215
source: z.string().optional().describe('Source object name for the page'),
215216
levels: z.number().int().min(1).optional().describe('Number of hierarchy levels to display'),
216-
filterBy: z.array(z.unknown()).optional().describe('Page-level filter criteria'),
217+
filterBy: z.array(ViewFilterRuleSchema).optional().describe('Page-level filter criteria'),
217218

218219
/** Appearance */
219220
appearance: AppearanceConfigSchema.optional().describe('Appearance and visualization configuration'),

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
UserActionsConfigSchema,
2727
AppearanceConfigSchema,
2828
ViewTabSchema,
29+
ViewFilterRuleSchema,
2930
AddRecordConfigSchema,
3031
type View,
3132
type ListView,
@@ -2300,3 +2301,71 @@ describe('ListViewSchema — Airtable Interface parity fields', () => {
23002301
expect(listView.allowPrinting).toBeUndefined();
23012302
});
23022303
});
2304+
2305+
// ============================================================================
2306+
// ViewFilterRuleSchema Tests
2307+
// ============================================================================
2308+
2309+
describe('ViewFilterRuleSchema', () => {
2310+
it('should accept a filter rule with field, operator, and value', () => {
2311+
const rule = ViewFilterRuleSchema.parse({
2312+
field: 'status',
2313+
operator: 'equals',
2314+
value: 'active',
2315+
});
2316+
expect(rule.field).toBe('status');
2317+
expect(rule.operator).toBe('equals');
2318+
expect(rule.value).toBe('active');
2319+
});
2320+
2321+
it('should accept a unary filter rule without value', () => {
2322+
const rule = ViewFilterRuleSchema.parse({
2323+
field: 'close_date',
2324+
operator: 'this_quarter',
2325+
});
2326+
expect(rule.value).toBeUndefined();
2327+
});
2328+
2329+
it('should accept boolean and number filter values', () => {
2330+
expect(() => ViewFilterRuleSchema.parse({ field: 'archived', operator: 'equals', value: false })).not.toThrow();
2331+
expect(() => ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gte', value: 1000 })).not.toThrow();
2332+
});
2333+
2334+
it('should accept array filter values (for IN operator)', () => {
2335+
expect(() => ViewFilterRuleSchema.parse({
2336+
field: 'status',
2337+
operator: 'in',
2338+
value: ['active', 'pending'],
2339+
})).not.toThrow();
2340+
});
2341+
2342+
it('should reject filter rule without field', () => {
2343+
expect(() => ViewFilterRuleSchema.parse({ operator: 'equals', value: 'x' })).toThrow();
2344+
});
2345+
2346+
it('should reject filter rule without operator', () => {
2347+
expect(() => ViewFilterRuleSchema.parse({ field: 'status', value: 'x' })).toThrow();
2348+
});
2349+
});
2350+
2351+
describe('ListViewSchema filter field', () => {
2352+
it('should accept typed filter array', () => {
2353+
const view = ListViewSchema.parse({
2354+
type: 'grid',
2355+
columns: ['name', 'status'],
2356+
filter: [
2357+
{ field: 'status', operator: 'equals', value: 'active' },
2358+
{ field: 'archived', operator: 'equals', value: false },
2359+
],
2360+
});
2361+
expect(view.filter).toHaveLength(2);
2362+
});
2363+
2364+
it('should reject filter with non-object entries', () => {
2365+
expect(() => ListViewSchema.parse({
2366+
type: 'grid',
2367+
columns: ['name'],
2368+
filter: ['invalid_string'],
2369+
})).toThrow();
2370+
});
2371+
});

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,31 @@ export const ViewDataSchema = z.discriminatedUnion('provider', [
4444
}),
4545
]);
4646

47+
/**
48+
* View Filter Rule Schema
49+
* Standardized filter condition used in list views, tabs, and page-level filters.
50+
* Uses a declarative array-of-objects format: [{ field, operator, value }].
51+
*
52+
* @example
53+
* ```ts
54+
* filter: [
55+
* { field: 'status', operator: 'equals', value: 'active' },
56+
* { field: 'close_date', operator: 'this_quarter' },
57+
* ]
58+
* ```
59+
*/
60+
export const ViewFilterRuleSchema = z.object({
61+
/** Field name to filter on */
62+
field: z.string().describe('Field name to filter on'),
63+
/** Filter operator */
64+
operator: z.string().describe('Filter operator (e.g. equals, not_equals, contains, this_quarter)'),
65+
/** Filter value (optional for unary operators like is_null, this_quarter) */
66+
value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))])
67+
.optional().describe('Filter value'),
68+
}).describe('View filter rule');
69+
70+
export type ViewFilterRule = z.infer<typeof ViewFilterRuleSchema>;
71+
4772
/**
4873
* Column Summary Function Schema
4974
* Aggregation function for column footer (Airtable-style column summaries)
@@ -231,7 +256,7 @@ export const ViewTabSchema = z.object({
231256
label: I18nLabelSchema.optional().describe('Display label'),
232257
icon: z.string().optional().describe('Tab icon name'),
233258
view: z.string().optional().describe('Referenced list view name from listViews'),
234-
filter: z.array(z.unknown()).optional().describe('Tab-specific filter criteria'),
259+
filter: z.array(ViewFilterRuleSchema).optional().describe('Tab-specific filter criteria'),
235260
order: z.number().int().min(0).optional().describe('Tab display order'),
236261
pinned: z.boolean().default(false).describe('Pin tab (cannot be removed by users)'),
237262
isDefault: z.boolean().default(false).describe('Set as the default active tab'),
@@ -360,7 +385,7 @@ export const ListViewSchema = z.object({
360385
z.array(z.string()), // Legacy: simple field names
361386
z.array(ListColumnSchema), // Enhanced: detailed column config
362387
]).describe('Fields to display as columns'),
363-
filter: z.array(z.unknown()).optional().describe('Filter criteria (JSON Rules)'),
388+
filter: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'),
364389
sort: z.union([
365390
z.string(), //Legacy "field desc"
366391
z.array(z.object({
@@ -378,7 +403,8 @@ export const ListViewSchema = z.object({
378403
field: z.string().describe('Field name to filter by'),
379404
label: z.string().optional().describe('Display label for the chip'),
380405
operator: z.enum(['equals', 'not_equals', 'contains', 'in', 'is_null', 'is_not_null']).default('equals').describe('Filter operator'),
381-
value: z.unknown().optional().describe('Preset filter value'),
406+
value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))])
407+
.optional().describe('Preset filter value'),
382408
})).optional().describe('One-click filter chips for quick record filtering'),
383409

384410
/** Grid Features */

0 commit comments

Comments
 (0)