Skip to content

Commit df0d477

Browse files
authored
Merge pull request #95 from objectstack-ai/copilot/add-view-data-schema
2 parents 5d6009d + 3fee845 commit df0d477

File tree

3 files changed

+287
-2
lines changed

3 files changed

+287
-2
lines changed

packages/spec/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,122 @@ import {
77
KanbanConfigSchema,
88
CalendarConfigSchema,
99
GanttConfigSchema,
10+
ViewDataSchema,
11+
HttpRequestSchema,
12+
HttpMethodSchema,
1013
type View,
1114
type ListView,
1215
type FormView,
16+
type ViewData,
17+
type HttpRequest,
1318
} from './view.zod';
1419

20+
describe('HttpMethodSchema', () => {
21+
it('should accept valid HTTP methods', () => {
22+
const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
23+
24+
methods.forEach(method => {
25+
expect(() => HttpMethodSchema.parse(method)).not.toThrow();
26+
});
27+
});
28+
29+
it('should reject invalid HTTP methods', () => {
30+
expect(() => HttpMethodSchema.parse('INVALID')).toThrow();
31+
});
32+
});
33+
34+
describe('HttpRequestSchema', () => {
35+
it('should accept minimal HTTP request config', () => {
36+
const request: HttpRequest = {
37+
url: '/api/data',
38+
};
39+
40+
const result = HttpRequestSchema.parse(request);
41+
expect(result.method).toBe('GET');
42+
});
43+
44+
it('should accept full HTTP request config', () => {
45+
const request: HttpRequest = {
46+
url: '/api/data',
47+
method: 'POST',
48+
headers: { 'Content-Type': 'application/json' },
49+
params: { filter: 'active' },
50+
body: { name: 'test' },
51+
};
52+
53+
expect(() => HttpRequestSchema.parse(request)).not.toThrow();
54+
});
55+
});
56+
57+
describe('ViewDataSchema', () => {
58+
it('should accept object provider with object name', () => {
59+
const data: ViewData = {
60+
provider: 'object',
61+
object: 'account',
62+
};
63+
64+
expect(() => ViewDataSchema.parse(data)).not.toThrow();
65+
});
66+
67+
it('should require object name for object provider', () => {
68+
const data = {
69+
provider: 'object',
70+
};
71+
72+
expect(() => ViewDataSchema.parse(data)).toThrow();
73+
});
74+
75+
it('should accept api provider with read configuration', () => {
76+
const data: ViewData = {
77+
provider: 'api',
78+
read: {
79+
url: '/api/accounts',
80+
method: 'GET',
81+
params: { status: 'active' },
82+
},
83+
};
84+
85+
expect(() => ViewDataSchema.parse(data)).not.toThrow();
86+
});
87+
88+
it('should accept api provider with read and write configurations', () => {
89+
const data: ViewData = {
90+
provider: 'api',
91+
read: {
92+
url: '/api/accounts',
93+
method: 'GET',
94+
},
95+
write: {
96+
url: '/api/accounts',
97+
method: 'POST',
98+
headers: { 'Content-Type': 'application/json' },
99+
},
100+
};
101+
102+
expect(() => ViewDataSchema.parse(data)).not.toThrow();
103+
});
104+
105+
it('should accept value provider with static items', () => {
106+
const data: ViewData = {
107+
provider: 'value',
108+
items: [
109+
{ id: 1, name: 'Item 1' },
110+
{ id: 2, name: 'Item 2' },
111+
],
112+
};
113+
114+
expect(() => ViewDataSchema.parse(data)).not.toThrow();
115+
});
116+
117+
it('should require items for value provider', () => {
118+
const data = {
119+
provider: 'value',
120+
};
121+
122+
expect(() => ViewDataSchema.parse(data)).toThrow();
123+
});
124+
});
125+
15126
describe('KanbanConfigSchema', () => {
16127
it('should accept minimal kanban config', () => {
17128
const config = {
@@ -192,6 +303,72 @@ describe('ListViewSchema', () => {
192303

193304
expect(() => ListViewSchema.parse(namedView)).not.toThrow();
194305
});
306+
307+
it('should accept list view with object provider', () => {
308+
const listView: ListView = {
309+
type: 'grid',
310+
columns: ['name', 'email'],
311+
data: {
312+
provider: 'object',
313+
object: 'contact',
314+
},
315+
};
316+
317+
expect(() => ListViewSchema.parse(listView)).not.toThrow();
318+
});
319+
320+
it('should accept list view with api provider', () => {
321+
const listView: ListView = {
322+
type: 'grid',
323+
columns: ['name', 'email', 'phone'],
324+
data: {
325+
provider: 'api',
326+
read: {
327+
url: '/api/contacts',
328+
method: 'GET',
329+
},
330+
},
331+
};
332+
333+
expect(() => ListViewSchema.parse(listView)).not.toThrow();
334+
});
335+
336+
it('should accept list view with value provider', () => {
337+
const listView: ListView = {
338+
type: 'grid',
339+
columns: ['name', 'status'],
340+
data: {
341+
provider: 'value',
342+
items: [
343+
{ name: 'Task 1', status: 'Open' },
344+
{ name: 'Task 2', status: 'Closed' },
345+
],
346+
},
347+
};
348+
349+
expect(() => ListViewSchema.parse(listView)).not.toThrow();
350+
});
351+
352+
it('should accept kanban view with custom api data source', () => {
353+
const kanbanView: ListView = {
354+
type: 'kanban',
355+
columns: ['name', 'owner', 'amount'],
356+
data: {
357+
provider: 'api',
358+
read: {
359+
url: '/api/opportunities',
360+
params: { view: 'pipeline' },
361+
},
362+
},
363+
kanban: {
364+
groupByField: 'stage',
365+
summarizeField: 'amount',
366+
columns: ['name', 'owner', 'close_date'],
367+
},
368+
};
369+
370+
expect(() => ListViewSchema.parse(kanbanView)).not.toThrow();
371+
});
195372
});
196373

197374
describe('FormSectionSchema', () => {
@@ -322,6 +499,65 @@ describe('FormViewSchema', () => {
322499

323500
expect(() => FormViewSchema.parse(wizardView)).not.toThrow();
324501
});
502+
503+
it('should accept form view with object provider', () => {
504+
const formView: FormView = {
505+
type: 'simple',
506+
data: {
507+
provider: 'object',
508+
object: 'account',
509+
},
510+
sections: [
511+
{
512+
label: 'Account Information',
513+
fields: ['name', 'industry', 'revenue'],
514+
},
515+
],
516+
};
517+
518+
expect(() => FormViewSchema.parse(formView)).not.toThrow();
519+
});
520+
521+
it('should accept form view with api provider', () => {
522+
const formView: FormView = {
523+
type: 'simple',
524+
data: {
525+
provider: 'api',
526+
read: {
527+
url: '/api/accounts/:id',
528+
method: 'GET',
529+
},
530+
write: {
531+
url: '/api/accounts/:id',
532+
method: 'PUT',
533+
},
534+
},
535+
sections: [
536+
{
537+
fields: ['name', 'email', 'phone'],
538+
},
539+
],
540+
};
541+
542+
expect(() => FormViewSchema.parse(formView)).not.toThrow();
543+
});
544+
545+
it('should accept form view with value provider', () => {
546+
const formView: FormView = {
547+
type: 'simple',
548+
data: {
549+
provider: 'value',
550+
items: [{ name: 'Default Account', type: 'Customer' }],
551+
},
552+
sections: [
553+
{
554+
fields: ['name', 'type'],
555+
},
556+
],
557+
};
558+
559+
expect(() => FormViewSchema.parse(formView)).not.toThrow();
560+
});
325561
});
326562

327563
describe('ViewSchema', () => {

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
import { z } from 'zod';
22

3+
/**
4+
* HTTP Method Enum
5+
*/
6+
export const HttpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
7+
8+
/**
9+
* HTTP Request Configuration for API Provider
10+
*/
11+
export const HttpRequestSchema = z.object({
12+
url: z.string().describe('API endpoint URL'),
13+
method: HttpMethodSchema.optional().default('GET').describe('HTTP method'),
14+
headers: z.record(z.string()).optional().describe('Custom HTTP headers'),
15+
params: z.record(z.unknown()).optional().describe('Query parameters'),
16+
body: z.unknown().optional().describe('Request body for POST/PUT/PATCH'),
17+
});
18+
19+
/**
20+
* View Data Source Configuration
21+
* Supports three modes:
22+
* 1. 'object': Standard Protocol - Auto-connects to ObjectStack Metadata and Data APIs
23+
* 2. 'api': Custom API - Explicitly provided API URLs
24+
* 3. 'value': Static Data - Hardcoded data array
25+
*/
26+
export const ViewDataSchema = z.discriminatedUnion('provider', [
27+
z.object({
28+
provider: z.literal('object'),
29+
object: z.string().describe('Target object name'),
30+
}),
31+
z.object({
32+
provider: z.literal('api'),
33+
read: HttpRequestSchema.optional().describe('Configuration for fetching data'),
34+
write: HttpRequestSchema.optional().describe('Configuration for submitting data (for forms/editable tables)'),
35+
}),
36+
z.object({
37+
provider: z.literal('value'),
38+
items: z.array(z.unknown()).describe('Static data array'),
39+
}),
40+
]);
41+
342
/**
443
* Kanban Settings
544
*/
@@ -38,6 +77,9 @@ export const ListViewSchema = z.object({
3877
label: z.string().optional(), // Display label override
3978
type: z.enum(['grid', 'kanban', 'calendar', 'gantt', 'map']).default('grid'),
4079

80+
/** Data Source Configuration */
81+
data: ViewDataSchema.optional().describe('Data source configuration (defaults to "object" provider)'),
82+
4183
/** Shared Query Config */
4284
columns: z.array(z.string()).describe('Fields to display as columns'),
4385
filter: z.array(z.any()).optional().describe('Filter criteria (JSON Rules)'),
@@ -74,6 +116,10 @@ export const FormSectionSchema = z.object({
74116
*/
75117
export const FormViewSchema = z.object({
76118
type: z.enum(['simple', 'tabbed', 'wizard']).default('simple'),
119+
120+
/** Data Source Configuration */
121+
data: ViewDataSchema.optional().describe('Data source configuration (defaults to "object" provider)'),
122+
77123
sections: z.array(FormSectionSchema).optional(), // For simple layout
78124
groups: z.array(FormSectionSchema).optional(), // Legacy support -> alias to sections
79125
});
@@ -93,3 +139,6 @@ export type View = z.infer<typeof ViewSchema>;
93139
export type ListView = z.infer<typeof ListViewSchema>;
94140
export type FormView = z.infer<typeof FormViewSchema>;
95141
export type FormSection = z.infer<typeof FormSectionSchema>;
142+
export type ViewData = z.infer<typeof ViewDataSchema>;
143+
export type HttpRequest = z.infer<typeof HttpRequestSchema>;
144+
export type HttpMethod = z.infer<typeof HttpMethodSchema>;

0 commit comments

Comments
 (0)