Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/spec/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

236 changes: 236 additions & 0 deletions packages/spec/src/ui/view.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Comment on lines +88 to +103

CopilotAIJan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is missing for the edge case where an api provider has neither read nor write configured. Consider adding a test case to verify this scenario is handled appropriately (either by validation rejection or documented as acceptable). Additionally, consider testing an api provider with only write configured, which could be a valid use case for write-only forms.

Copilot uses AI. Check for mistakes.

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 = {
Expand DownExpand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
49 changes: 49 additions & 0 deletions packages/spec/src/ui/view.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
import { z } from 'zod';

/**
* HTTP Method Enum
*/
export const HttpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);

CopilotAIJan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This HTTP method enum duplicates the existing HttpMethod schema in packages/spec/src/system/api.zod.ts:6. To maintain consistency and avoid duplication, consider importing and reusing the existing HttpMethod schema instead of defining a new HttpMethodSchema. The existing schema is already exported from the System namespace and uses the same enum values.

Copilot uses AI. Check for mistakes.

/**
* 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'),

CopilotAIJan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of z.unknown() is inconsistent with the rest of the codebase. Throughout this repository, z.any() is used for flexible/dynamic values (as seen in packages/spec/src/api/contract.zod.ts:29, packages/spec/src/data/field.zod.ts:90, and numerous other files). While z.unknown() is technically more type-safe, changing this pattern in isolation creates inconsistency. Consider using z.any() for params to align with established conventions.

Copilot uses AI. Check for mistakes.
body: z.unknown().optional().describe('Request body for POST/PUT/PATCH'),

CopilotAIJan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of z.unknown() is inconsistent with the rest of the codebase. Throughout this repository, z.any() is used for flexible/dynamic values (as seen in packages/spec/src/data/workflow.zod.ts:81, packages/spec/src/api/contract.zod.ts:11, and numerous other files). While z.unknown() is technically more type-safe, changing this pattern in isolation creates inconsistency. Consider using z.any() for body to align with established conventions.

Copilot uses AI. Check for mistakes.
});

/**
* 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'),

CopilotAIJan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The object field should include snake_case validation to ensure consistency with the naming convention for machine names throughout the codebase. According to the established pattern (e.g., packages/spec/src/data/dataset.zod.ts:29), object names should be validated with .regex(/^[a-z_][a-z0-9_]*$/) to enforce the snake_case convention for data values.

Copilot generated this review using guidance from repository custom instructions.
}),
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({
Comment on lines +31 to +36

CopilotAIJan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The api provider allows both read and write to be optional, which means an api provider could be configured without any endpoints. This could lead to runtime issues. Consider adding validation to ensure at least one of read or write is provided when using the api provider, using Zod's .refine() method or making at least the read field required for meaningful API data sources.

Suggested change
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({
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)'),
})
.refine(
(data)=>data.read!==undefined||data.write!==undefined,
{
message: 'api provider requires at least one of "read" or "write"',
}
),
z.object({

Copilot uses AI. Check for mistakes.
provider: z.literal('value'),
items: z.array(z.unknown()).describe('Static data array'),

CopilotAIJan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of z.unknown() is inconsistent with the rest of the codebase. Throughout this repository, z.any() is used for array items with dynamic data (as seen in packages/spec/src/data/dataset.zod.ts:56 and numerous other files). While z.unknown() is technically more type-safe, changing this pattern in isolation creates inconsistency. Consider using z.array(z.any()) for items to align with established conventions.

Copilot uses AI. Check for mistakes.
}),
]);

/**
* Kanban Settings
*/
Expand DownExpand Up@@ -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)'),
Expand DownExpand Up@@ -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
});
Expand All@@ -93,3 +139,6 @@ export type View = z.infer<typeof ViewSchema>;
export type ListView = z.infer<typeof ListViewSchema>;
export type FormView = z.infer<typeof FormViewSchema>;
export type FormSection = z.infer<typeof FormSectionSchema>;
export type ViewData = z.infer<typeof ViewDataSchema>;
export type HttpRequest = z.infer<typeof HttpRequestSchema>;
export type HttpMethod = z.infer<typeof HttpMethodSchema>;
Loading