diff --git a/packages/spec/package-lock.json b/packages/spec/package-lock.json index 5e9e01f61c..43ffd24174 100644 --- a/packages/spec/package-lock.json +++ b/packages/spec/package-lock.json @@ -1,12 +1,12 @@ { "name": "@objectstack/spec", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@objectstack/spec", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { "zod": "^3.22.4" diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 104b157437..94641ff126 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -7,11 +7,122 @@ import { KanbanConfigSchema, CalendarConfigSchema, GanttConfigSchema, + ViewDataSchema, + HttpRequestSchema, + HttpMethodSchema, type View, type ListView, type FormView, + type ViewData, + type HttpRequest, } from './view.zod'; +describe('HttpMethodSchema', () => { + it('should accept valid HTTP methods', () => { + const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; + + methods.forEach(method => { + expect(() => HttpMethodSchema.parse(method)).not.toThrow(); + }); + }); + + it('should reject invalid HTTP methods', () => { + expect(() => HttpMethodSchema.parse('INVALID')).toThrow(); + }); +}); + +describe('HttpRequestSchema', () => { + it('should accept minimal HTTP request config', () => { + const request: HttpRequest = { + url: '/api/data', + }; + + const result = HttpRequestSchema.parse(request); + expect(result.method).toBe('GET'); + }); + + it('should accept full HTTP request config', () => { + const request: HttpRequest = { + url: '/api/data', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + params: { filter: 'active' }, + body: { name: 'test' }, + }; + + expect(() => HttpRequestSchema.parse(request)).not.toThrow(); + }); +}); + +describe('ViewDataSchema', () => { + it('should accept object provider with object name', () => { + const data: ViewData = { + provider: 'object', + object: 'account', + }; + + expect(() => ViewDataSchema.parse(data)).not.toThrow(); + }); + + it('should require object name for object provider', () => { + const data = { + provider: 'object', + }; + + expect(() => ViewDataSchema.parse(data)).toThrow(); + }); + + it('should accept api provider with read configuration', () => { + const data: ViewData = { + provider: 'api', + read: { + url: '/api/accounts', + method: 'GET', + params: { status: 'active' }, + }, + }; + + expect(() => ViewDataSchema.parse(data)).not.toThrow(); + }); + + it('should accept api provider with read and write configurations', () => { + const data: ViewData = { + provider: 'api', + read: { + url: '/api/accounts', + method: 'GET', + }, + write: { + url: '/api/accounts', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + }; + + expect(() => ViewDataSchema.parse(data)).not.toThrow(); + }); + + it('should accept value provider with static items', () => { + const data: ViewData = { + provider: 'value', + items: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ], + }; + + expect(() => ViewDataSchema.parse(data)).not.toThrow(); + }); + + it('should require items for value provider', () => { + const data = { + provider: 'value', + }; + + expect(() => ViewDataSchema.parse(data)).toThrow(); + }); +}); + describe('KanbanConfigSchema', () => { it('should accept minimal kanban config', () => { const config = { @@ -192,6 +303,72 @@ describe('ListViewSchema', () => { expect(() => ListViewSchema.parse(namedView)).not.toThrow(); }); + + it('should accept list view with object provider', () => { + const listView: ListView = { + type: 'grid', + columns: ['name', 'email'], + data: { + provider: 'object', + object: 'contact', + }, + }; + + expect(() => ListViewSchema.parse(listView)).not.toThrow(); + }); + + it('should accept list view with api provider', () => { + const listView: ListView = { + type: 'grid', + columns: ['name', 'email', 'phone'], + data: { + provider: 'api', + read: { + url: '/api/contacts', + method: 'GET', + }, + }, + }; + + expect(() => ListViewSchema.parse(listView)).not.toThrow(); + }); + + it('should accept list view with value provider', () => { + const listView: ListView = { + type: 'grid', + columns: ['name', 'status'], + data: { + provider: 'value', + items: [ + { name: 'Task 1', status: 'Open' }, + { name: 'Task 2', status: 'Closed' }, + ], + }, + }; + + expect(() => ListViewSchema.parse(listView)).not.toThrow(); + }); + + it('should accept kanban view with custom api data source', () => { + const kanbanView: ListView = { + type: 'kanban', + columns: ['name', 'owner', 'amount'], + data: { + provider: 'api', + read: { + url: '/api/opportunities', + params: { view: 'pipeline' }, + }, + }, + kanban: { + groupByField: 'stage', + summarizeField: 'amount', + columns: ['name', 'owner', 'close_date'], + }, + }; + + expect(() => ListViewSchema.parse(kanbanView)).not.toThrow(); + }); }); describe('FormSectionSchema', () => { @@ -322,6 +499,65 @@ describe('FormViewSchema', () => { expect(() => FormViewSchema.parse(wizardView)).not.toThrow(); }); + + it('should accept form view with object provider', () => { + const formView: FormView = { + type: 'simple', + data: { + provider: 'object', + object: 'account', + }, + sections: [ + { + label: 'Account Information', + fields: ['name', 'industry', 'revenue'], + }, + ], + }; + + expect(() => FormViewSchema.parse(formView)).not.toThrow(); + }); + + it('should accept form view with api provider', () => { + const formView: FormView = { + type: 'simple', + data: { + provider: 'api', + read: { + url: '/api/accounts/:id', + method: 'GET', + }, + write: { + url: '/api/accounts/:id', + method: 'PUT', + }, + }, + sections: [ + { + fields: ['name', 'email', 'phone'], + }, + ], + }; + + expect(() => FormViewSchema.parse(formView)).not.toThrow(); + }); + + it('should accept form view with value provider', () => { + const formView: FormView = { + type: 'simple', + data: { + provider: 'value', + items: [{ name: 'Default Account', type: 'Customer' }], + }, + sections: [ + { + fields: ['name', 'type'], + }, + ], + }; + + expect(() => FormViewSchema.parse(formView)).not.toThrow(); + }); }); describe('ViewSchema', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 8c6c0b35bd..14f754a5e5 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1,5 +1,44 @@ import { z } from 'zod'; +/** + * HTTP Method Enum + */ +export const HttpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * HTTP Request Configuration for API Provider + */ +export const HttpRequestSchema = z.object({ + url: z.string().describe('API endpoint URL'), + method: HttpMethodSchema.optional().default('GET').describe('HTTP method'), + headers: z.record(z.string()).optional().describe('Custom HTTP headers'), + params: z.record(z.unknown()).optional().describe('Query parameters'), + body: z.unknown().optional().describe('Request body for POST/PUT/PATCH'), +}); + +/** + * View Data Source Configuration + * Supports three modes: + * 1. 'object': Standard Protocol - Auto-connects to ObjectStack Metadata and Data APIs + * 2. 'api': Custom API - Explicitly provided API URLs + * 3. 'value': Static Data - Hardcoded data array + */ +export const ViewDataSchema = z.discriminatedUnion('provider', [ + z.object({ + provider: z.literal('object'), + object: z.string().describe('Target object name'), + }), + z.object({ + provider: z.literal('api'), + read: HttpRequestSchema.optional().describe('Configuration for fetching data'), + write: HttpRequestSchema.optional().describe('Configuration for submitting data (for forms/editable tables)'), + }), + z.object({ + provider: z.literal('value'), + items: z.array(z.unknown()).describe('Static data array'), + }), +]); + /** * Kanban Settings */ @@ -38,6 +77,9 @@ export const ListViewSchema = z.object({ label: z.string().optional(), // Display label override type: z.enum(['grid', 'kanban', 'calendar', 'gantt', 'map']).default('grid'), + /** Data Source Configuration */ + data: ViewDataSchema.optional().describe('Data source configuration (defaults to "object" provider)'), + /** Shared Query Config */ columns: z.array(z.string()).describe('Fields to display as columns'), filter: z.array(z.any()).optional().describe('Filter criteria (JSON Rules)'), @@ -74,6 +116,10 @@ export const FormSectionSchema = z.object({ */ export const FormViewSchema = z.object({ type: z.enum(['simple', 'tabbed', 'wizard']).default('simple'), + + /** Data Source Configuration */ + data: ViewDataSchema.optional().describe('Data source configuration (defaults to "object" provider)'), + sections: z.array(FormSectionSchema).optional(), // For simple layout groups: z.array(FormSectionSchema).optional(), // Legacy support -> alias to sections }); @@ -93,3 +139,6 @@ export type View = z.infer; export type ListView = z.infer; export type FormView = z.infer; export type FormSection = z.infer; +export type ViewData = z.infer; +export type HttpRequest = z.infer; +export type HttpMethod = z.infer;