diff --git a/ROADMAP.md b/ROADMAP.md index fa47a0efc..ba1738dad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,13 +73,13 @@ These are the backbone of ObjectStack's enterprise capabilities. | Packages (total) | 27 | | Apps | 2 (Studio, Docs) | | Examples | 4 (Todo, CRM, Host, BI Plugin) | -| Zod Schema Files | 176 | +| Zod Schema Files | 177 | | Exported Schemas | 1,100+ | | `.describe()` Annotations | 7,111+ | | Service Contracts | 25 | | Contracts Implemented | 11 (44%) | -| Test Files | 197 | -| Tests Passing | 5,363 / 5,363 | +| Test Files | 199 | +| Tests Passing | 5,468 / 5,468 | | `@deprecated` Items | 3 | | Protocol Domains | 15 (Data, UI, AI, API, Automation, Cloud, Contracts, Identity, Integration, Kernel, QA, Security, Shared, Studio, System) | @@ -129,7 +129,7 @@ The following renames are planned for packages that implement core service contr - [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document - [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions -- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page, Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n +- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Interface, Content Elements - [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation, Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services - [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook - [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development @@ -142,7 +142,7 @@ The following renames are planned for packages that implement core service contr - [x] **QA Protocol** — Testing framework schemas - [x] **Studio Protocol** — Plugin extension schemas - [x] **Contracts** — 25 service interfaces with full method signatures -- [x] **Stack Definition** — `defineStack()`, `defineView()`, `defineApp()`, `defineFlow()`, `defineAgent()` helpers +- [x] **Stack Definition** — `defineStack()`, `defineView()`, `defineApp()`, `defineInterface()`, `defineFlow()`, `defineAgent()` helpers - [x] **Error Map** — Custom Zod error messages with `objectStackErrorMap` - [x] **DX Utilities** — `safeParsePretty()`, `formatZodError()`, `suggestFieldType()` @@ -356,13 +356,13 @@ The following renames are planned for packages that implement core service contr > See [Airtable Interface Gap Analysis](docs/design/airtable-interface-gap-analysis.md) for the full evaluation. -#### Phase A: Interface Foundation (v3.2) +#### Phase A: Interface Foundation (v3.2) ✅ -- [ ] `InterfaceSchema` — Self-contained, shareable, multi-page application surface (`src/ui/interface.zod.ts`) -- [ ] `RecordReviewConfigSchema` — Sequential record review/approval page type with navigation and actions -- [ ] Content elements — `element:text`, `element:number`, `element:image`, `element:divider` as `PageComponentType` extensions -- [ ] Per-element data binding — `dataSource` property on `PageComponentInstanceSchema` for multi-object pages -- [ ] Element props — `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` +- [x] `InterfaceSchema` — Self-contained, shareable, multi-page application surface (`src/ui/interface.zod.ts`) +- [x] `RecordReviewConfigSchema` — Sequential record review/approval page type with navigation and actions +- [x] Content elements — `element:text`, `element:number`, `element:image`, `element:divider` as `PageComponentType` extensions +- [x] Per-element data binding — `dataSource` property on `PageComponentSchema` for multi-object pages +- [x] Element props — `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` #### Phase B: Element Library & Builder (v3.3) diff --git a/docs/design/airtable-interface-gap-analysis.md b/docs/design/airtable-interface-gap-analysis.md index 74a12281c..3dbc4d751 100644 --- a/docs/design/airtable-interface-gap-analysis.md +++ b/docs/design/airtable-interface-gap-analysis.md @@ -2,7 +2,7 @@ > **Author:** ObjectStack Core Team > **Created:** 2026-02-16 -> **Status:** Proposal +> **Status:** Phase A Implemented > **Target Version:** v3.2 – v4.0 --- @@ -58,13 +58,13 @@ ties them together — specifically: | Area | Airtable | ObjectStack | |:---|:---|:---| -| **Interface as a first-class entity** | ✅ Multi-page app per base | 🟡 App + Page exist separately | +| **Interface as a first-class entity** | ✅ Multi-page app per base | ✅ `InterfaceSchema` + `InterfaceNavItemSchema` in App navigation | | **Drag-and-drop element canvas** | ✅ Free-form element placement | 🟡 Region-based composition | -| **Record Review workflow** | ✅ Built-in record-by-record review | ❌ Not modeled | -| **Element-level data binding** | ✅ Each element binds to any table/view | 🟡 Page-level object binding | -| **Shareable interface URLs** | ✅ Public/private share links | ❌ Not modeled | -| **Interface-level permissions** | ✅ Per-interface user access | 🟡 App-level permissions only | -| **Embeddable interfaces** | ✅ iframe embed codes | ❌ Not modeled | +| **Record Review workflow** | ✅ Built-in record-by-record review | ✅ `RecordReviewConfigSchema` in `PageSchema` | +| **Element-level data binding** | ✅ Each element binds to any table/view | ✅ `ElementDataSourceSchema` per component | +| **Shareable interface URLs** | ✅ Public/private share links | ❌ Not modeled (Phase C) | +| **Interface-level permissions** | ✅ Per-interface user access | ✅ `assignedRoles` on `InterfaceSchema` | +| **Embeddable interfaces** | ✅ iframe embed codes | ❌ Not modeled (Phase C) | This document proposes specific schema additions and a phased roadmap to close these gaps while preserving ObjectStack's superior extensibility and enterprise capabilities. @@ -562,17 +562,22 @@ export const EmbedConfigSchema = z.object({ ## 7. Implementation Road Map -### 7.1 Phase A: Interface Foundation (v3.2 — Q3 2026) +### 7.1 Phase A: Interface Foundation (v3.2 — Q3 2026) ✅ > **Goal:** Establish the "Interface" abstraction as a first-class protocol entity. -- [ ] Define `InterfaceSchema` in `src/ui/interface.zod.ts` -- [ ] Add `RecordReviewConfigSchema` to `PageSchema` types -- [ ] Add content elements to `PageComponentType` (`element:text`, `element:number`, `element:image`, `element:divider`) -- [ ] Add `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` to component props -- [ ] Add `dataSource` property to `PageComponentInstanceSchema` for per-element data binding -- [ ] Write comprehensive tests for all new schemas -- [ ] Update `src/ui/index.ts` exports +- [x] Define `InterfaceSchema` in `src/ui/interface.zod.ts` +- [x] Add `RecordReviewConfigSchema` to `PageSchema` types +- [x] Add content elements to `PageComponentType` (`element:text`, `element:number`, `element:image`, `element:divider`) +- [x] Add `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` to component props +- [x] Add `dataSource` property to `PageComponentSchema` for per-element data binding +- [x] Write comprehensive tests for all new schemas +- [x] Update `src/ui/index.ts` exports +- [x] Merge `InterfacePageSchema` into `PageSchema` — unified `PageTypeSchema` with 16 types +- [x] Extract shared `SortItemSchema` to `shared/enums.zod.ts` +- [x] Export `defineInterface()` from root index.ts +- [x] Add `InterfaceNavItemSchema` to `AppSchema` navigation for App↔Interface bridging +- [x] Disambiguate overlapping page types (`record`/`record_detail`, `home`/`overview`) in `PageTypeSchema` docs - [ ] Generate JSON Schema for new types **Estimated effort:** 2–3 weeks @@ -644,6 +649,12 @@ export const EmbedConfigSchema = z.object({ | 3 | Phase sharing/embedding to v4.0 | Requires security infrastructure (RLS, share tokens, origin validation) that depends on service implementations in v3.x | 2026-02-16 | | 4 | Keep `RecordReviewConfig` as part of `PageSchema` rather than a new view type | Record Review is a page layout pattern, not a data visualization (view). It combines record display with workflow actions. | 2026-02-16 | | 5 | Support per-element `dataSource` instead of page-level-only binding | Critical for dashboards and overview pages that aggregate data from multiple objects | 2026-02-16 | +| 6 | Merge `InterfacePageSchema` into `PageSchema` | 7 of 9 properties were identical. Unified `PageTypeSchema` with 16 types (4 platform + 12 interface) eliminates duplication while preserving both use cases. `InterfaceSchema.pages` now references `PageSchema` directly. | 2026-02-16 | +| 7 | Extract shared `SortItemSchema` to `shared/enums.zod.ts` | Sort item pattern `{ field, order }` was defined inline in 4+ schemas (ElementDataSource, RecordReview, ListView, RecordRelatedList). Shared schema ensures consistency and reduces duplication. | 2026-02-16 | +| 8 | `InterfaceBrandingSchema` extends `AppBrandingSchema` | 2 of 3 fields (`primaryColor`, `logo`) were identical. Using `.extend()` adds only `coverImage`, avoiding property divergence. | 2026-02-16 | +| 9 | Keep `InterfaceSchema` and `AppSchema` separate — do NOT merge | **App** = navigation container (menu tree, routing, mobile nav). **Interface** = content surface (ordered pages, data binding, role-specific views). Merging would conflate navigation topology with page composition. An App can embed multiple Interfaces via `InterfaceNavItemSchema`. This mirrors Salesforce App/FlexiPage and Airtable Base/Interface separation. | 2026-02-16 | +| 10 | Add `InterfaceNavItemSchema` to bridge App↔Interface | `AppSchema.navigation` lacked a way to reference Interfaces. Added `type: 'interface'` nav item with `interfaceName` and optional `pageName` to enable App→Interface navigation without merging the schemas. | 2026-02-16 | +| 11 | Keep all 16 page types — no merge, disambiguate in docs | Reviewed overlapping pairs: `record` vs `record_detail` (component-based layout vs auto-generated field display), `home` vs `overview` (platform landing vs interface navigation hub), `app`/`utility`/`blank` (distinct layout contexts). Each serves a different use case at a different abstraction level. Added disambiguation comments to `PageTypeSchema`. | 2026-02-16 | --- diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 0ffecaceb..b635a1a59 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -78,6 +78,7 @@ export * from './stack.zod'; // DX Helper Functions (re-exported for convenience) export { defineView } from './ui/view.zod'; export { defineApp } from './ui/app.zod'; +export { defineInterface } from './ui/interface.zod'; export { defineFlow } from './automation/flow.zod'; export { defineAgent } from './ai/agent.zod'; diff --git a/packages/spec/src/shared/enums.test.ts b/packages/spec/src/shared/enums.test.ts index dafa50a04..fa3861ff7 100644 --- a/packages/spec/src/shared/enums.test.ts +++ b/packages/spec/src/shared/enums.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { AggregationFunctionEnum, SortDirectionEnum, + SortItemSchema, MutationEventEnum, IsolationLevelEnum, CacheStrategyEnum, @@ -46,6 +47,26 @@ describe('SortDirectionEnum', () => { }); }); +describe('SortItemSchema', () => { + it('should accept valid sort item', () => { + const result = SortItemSchema.parse({ field: 'created_at', order: 'desc' }); + expect(result.field).toBe('created_at'); + expect(result.order).toBe('desc'); + }); + + it('should reject without field', () => { + expect(() => SortItemSchema.parse({ order: 'asc' })).toThrow(); + }); + + it('should reject without order', () => { + expect(() => SortItemSchema.parse({ field: 'name' })).toThrow(); + }); + + it('should reject invalid order direction', () => { + expect(() => SortItemSchema.parse({ field: 'name', order: 'up' })).toThrow(); + }); +}); + describe('MutationEventEnum', () => { it('should accept all mutation events', () => { const valid = ['insert', 'update', 'delete', 'upsert']; diff --git a/packages/spec/src/shared/enums.zod.ts b/packages/spec/src/shared/enums.zod.ts index f9fabee4d..d945f2d79 100644 --- a/packages/spec/src/shared/enums.zod.ts +++ b/packages/spec/src/shared/enums.zod.ts @@ -18,6 +18,13 @@ export const SortDirectionEnum = z.enum(['asc', 'desc']) .describe('Sort order direction'); export type SortDirection = z.infer; +/** Reusable sort item — field + direction pair used across views, data sources, filters */ +export const SortItemSchema = z.object({ + field: z.string().describe('Field name to sort by'), + order: SortDirectionEnum.describe('Sort direction'), +}).describe('Sort field and direction pair'); +export type SortItem = z.infer; + /** CRUD mutation events used across hook, validation, object CDC */ export const MutationEventEnum = z.enum([ 'insert', 'update', 'delete', 'upsert', diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 06d561732..c612138d4 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -7,6 +7,7 @@ import { DashboardNavItemSchema, PageNavItemSchema, UrlNavItemSchema, + InterfaceNavItemSchema, GroupNavItemSchema, defineApp, type App, @@ -127,6 +128,53 @@ describe('UrlNavItemSchema', () => { }); }); +describe('InterfaceNavItemSchema', () => { + it('should accept interface nav item with just interfaceName', () => { + const navItem = { + id: 'nav_order_review', + label: 'Order Review', + type: 'interface' as const, + interfaceName: 'order_review', + }; + + const result = InterfaceNavItemSchema.parse(navItem); + expect(result.interfaceName).toBe('order_review'); + expect(result.pageName).toBeUndefined(); + }); + + it('should accept interface nav item with pageName', () => { + const navItem = { + id: 'nav_sales_dashboard', + label: 'Sales Dashboard', + icon: 'layout-dashboard', + type: 'interface' as const, + interfaceName: 'sales_portal', + pageName: 'page_dashboard', + }; + + const result = InterfaceNavItemSchema.parse(navItem); + expect(result.interfaceName).toBe('sales_portal'); + expect(result.pageName).toBe('page_dashboard'); + }); + + it('should work in NavigationItemSchema union', () => { + expect(() => NavigationItemSchema.parse({ + id: 'nav_interface', + label: 'Interface', + type: 'interface', + interfaceName: 'my_interface', + })).not.toThrow(); + }); + + it('should reject without interfaceName', () => { + expect(() => InterfaceNavItemSchema.parse({ + id: 'nav_missing', + label: 'Missing', + type: 'interface', + })).toThrow(); + }); +}); + describe('GroupNavItemSchema', () => { it('should accept group nav item', () => { const navItem = { @@ -459,6 +507,39 @@ describe('AppSchema', () => { expect(() => AppSchema.parse(hrApp)).not.toThrow(); }); + + it('should accept app with interface navigation items', () => { + const app: App = { + name: 'data_platform', + label: 'Data Platform', + navigation: [ + { + id: 'nav_home', + label: 'Home', + icon: 'home', + type: 'dashboard', + dashboardName: 'main_dashboard', + }, + { + id: 'nav_order_review', + label: 'Order Review', + icon: 'clipboard-check', + type: 'interface', + interfaceName: 'order_review', + }, + { + id: 'nav_sales_portal', + label: 'Sales Portal', + icon: 'layout-dashboard', + type: 'interface', + interfaceName: 'sales_portal', + pageName: 'page_dashboard', + }, + ], + }; + + expect(() => AppSchema.parse(app)).not.toThrow(); + }); }); }); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 55ba26f44..f1d2592b1 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -78,7 +78,18 @@ export const UrlNavItemSchema = BaseNavItemSchema.extend({ }); /** - * 5. Group Navigation Item + * 5. Interface Navigation Item + * Navigates to a specific Interface (self-contained multi-page surface). + * Bridges AppSchema (navigation container) with InterfaceSchema (content surface). + */ +export const InterfaceNavItemSchema = BaseNavItemSchema.extend({ + type: z.literal('interface'), + interfaceName: z.string().describe('Target interface name (snake_case)'), + pageName: z.string().optional().describe('Specific page within the interface to open'), +}); + +/** + * 6. Group Navigation Item * A container for child navigation items (Sub-menu). * Does not perform navigation itself. */ @@ -101,6 +112,7 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => DashboardNavItemSchema, PageNavItemSchema, UrlNavItemSchema, + InterfaceNavItemSchema, GroupNavItemSchema.extend({ children: z.array(NavigationItemSchema).describe('Child navigation items'), }) @@ -256,4 +268,5 @@ export type ObjectNavItem = z.infer; export type DashboardNavItem = z.infer; export type PageNavItem = z.infer; export type UrlNavItem = z.infer; +export type InterfaceNavItem = z.infer; export type GroupNavItem = z.infer & { children: NavigationItem[] }; diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d4931a600..bb91f1daf 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -128,6 +128,45 @@ export const AIChatWindowProps = z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); +/** + * ---------------------------------------------------------------------- + * 3. Content Element Components (Airtable Interface Parity) + * ---------------------------------------------------------------------- + */ + +export const ElementTextPropsSchema = z.object({ + content: z.string().describe('Text or Markdown content'), + variant: z.enum(['heading', 'subheading', 'body', 'caption']) + .optional().default('body').describe('Text style variant'), + align: z.enum(['left', 'center', 'right']) + .optional().default('left').describe('Text alignment'), + /** ARIA accessibility */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + +export const ElementNumberPropsSchema = z.object({ + object: z.string().describe('Source object'), + field: z.string().optional().describe('Field to aggregate'), + aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']) + .describe('Aggregation function'), + filter: z.any().optional().describe('Filter criteria'), + format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'), + prefix: z.string().optional().describe('Prefix text (e.g. "$")'), + suffix: z.string().optional().describe('Suffix text (e.g. "%")'), + /** ARIA accessibility */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + +export const ElementImagePropsSchema = z.object({ + src: z.string().describe('Image URL or attachment field'), + alt: z.string().optional().describe('Alt text for accessibility'), + fit: z.enum(['cover', 'contain', 'fill']) + .optional().default('cover').describe('Image object-fit mode'), + height: z.number().optional().describe('Fixed height in pixels'), + /** ARIA accessibility */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + /** * ---------------------------------------------------------------------- * Component Props Map @@ -164,7 +203,13 @@ export const ComponentPropsMap = { // AI 'ai:chat_window': AIChatWindowProps, - 'ai:suggestion': z.object({ context: z.string().optional() }) + 'ai:suggestion': z.object({ context: z.string().optional() }), + + // Content Elements + 'element:text': ElementTextPropsSchema, + 'element:number': ElementNumberPropsSchema, + 'element:image': ElementImagePropsSchema, + 'element:divider': EmptyProps, } as const; /** diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index a6e6b2248..45ca21ad2 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -28,3 +28,4 @@ export * from './keyboard.zod'; export * from './animation.zod'; export * from './notification.zod'; export * from './dnd.zod'; +export * from './interface.zod'; diff --git a/packages/spec/src/ui/interface.test.ts b/packages/spec/src/ui/interface.test.ts new file mode 100644 index 000000000..84881655f --- /dev/null +++ b/packages/spec/src/ui/interface.test.ts @@ -0,0 +1,749 @@ +import { describe, it, expect } from 'vitest'; +import { + InterfaceSchema, + InterfaceBrandingSchema, + defineInterface, + type Interface, +} from './interface.zod'; +import { + PageSchema, + PageTypeSchema, + PageComponentSchema, + RecordReviewConfigSchema, + ElementDataSourceSchema, + type Page, + type ElementDataSource, + type RecordReviewConfig, +} from './page.zod'; +import { + ElementTextPropsSchema, + ElementNumberPropsSchema, + ElementImagePropsSchema, + ComponentPropsMap, +} from './component.zod'; + +// --------------------------------------------------------------------------- +// PageTypeSchema — unified page types (platform + interface) +// --------------------------------------------------------------------------- +describe('PageTypeSchema', () => { + it('should accept all platform page types', () => { + const types = ['record', 'home', 'app', 'utility']; + types.forEach(type => { + expect(() => PageTypeSchema.parse(type)).not.toThrow(); + }); + }); + + it('should accept all interface page types', () => { + const types = [ + 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', + 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', + ]; + + types.forEach(type => { + expect(() => PageTypeSchema.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid page type', () => { + expect(() => PageTypeSchema.parse('invalid')).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// RecordReviewConfigSchema +// --------------------------------------------------------------------------- +describe('RecordReviewConfigSchema', () => { + it('should accept minimal review config', () => { + const config: RecordReviewConfig = RecordReviewConfigSchema.parse({ + object: 'order', + actions: [ + { label: 'Approve', type: 'approve' }, + ], + }); + + expect(config.object).toBe('order'); + expect(config.actions).toHaveLength(1); + expect(config.navigation).toBe('sequential'); + expect(config.showProgress).toBe(true); + }); + + it('should accept full review config', () => { + const config = RecordReviewConfigSchema.parse({ + object: 'invoice', + filter: { status: 'pending' }, + sort: [{ field: 'created_at', order: 'desc' }], + displayFields: ['amount', 'vendor', 'description'], + actions: [ + { label: 'Approve', type: 'approve', field: 'status', value: 'approved', nextRecord: true }, + { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, + { label: 'Skip', type: 'skip', nextRecord: true }, + { label: 'Flag', type: 'custom', field: 'flagged', value: true }, + ], + navigation: 'filtered', + showProgress: false, + }); + + expect(config.actions).toHaveLength(4); + expect(config.navigation).toBe('filtered'); + expect(config.showProgress).toBe(false); + expect(config.displayFields).toEqual(['amount', 'vendor', 'description']); + }); + + it('should reject review config without object', () => { + expect(() => RecordReviewConfigSchema.parse({ + actions: [{ label: 'Approve', type: 'approve' }], + })).toThrow(); + }); + + it('should reject review config without actions', () => { + expect(() => RecordReviewConfigSchema.parse({ + object: 'order', + })).toThrow(); + }); + + it('should accept all action types', () => { + const types = ['approve', 'reject', 'skip', 'custom'] as const; + + types.forEach(type => { + expect(() => RecordReviewConfigSchema.parse({ + object: 'order', + actions: [{ label: 'Action', type }], + })).not.toThrow(); + }); + }); + + it('should accept all navigation modes', () => { + const modes = ['sequential', 'random', 'filtered'] as const; + + modes.forEach(navigation => { + const config = RecordReviewConfigSchema.parse({ + object: 'order', + actions: [{ label: 'Ok', type: 'approve' }], + navigation, + }); + expect(config.navigation).toBe(navigation); + }); + }); +}); + +// --------------------------------------------------------------------------- +// InterfaceBrandingSchema +// --------------------------------------------------------------------------- +describe('InterfaceBrandingSchema', () => { + it('should accept empty branding', () => { + expect(() => InterfaceBrandingSchema.parse({})).not.toThrow(); + }); + + it('should accept full branding config', () => { + const branding = InterfaceBrandingSchema.parse({ + primaryColor: '#0070F3', + logo: '/assets/logo.png', + coverImage: '/assets/cover.jpg', + }); + + expect(branding.primaryColor).toBe('#0070F3'); + expect(branding.logo).toBe('/assets/logo.png'); + expect(branding.coverImage).toBe('/assets/cover.jpg'); + }); +}); + +// --------------------------------------------------------------------------- +// PageSchema — interface page types (merged from InterfacePageSchema) +// --------------------------------------------------------------------------- +describe('PageSchema with interface page types', () => { + it('should accept minimal interface-style page', () => { + const page: Page = PageSchema.parse({ + name: 'page_overview', + label: 'Overview', + type: 'blank', + regions: [], + }); + + expect(page.name).toBe('page_overview'); + expect(page.type).toBe('blank'); + expect(page.template).toBe('default'); + }); + + it('should accept dashboard page', () => { + const page = PageSchema.parse({ + name: 'page_dashboard', + label: 'Dashboard', + type: 'dashboard', + regions: [ + { + name: 'main', + components: [ + { type: 'element:number', properties: { object: 'order', aggregate: 'count' } }, + ], + }, + ], + }); + + expect(page.type).toBe('dashboard'); + expect(page.regions[0].components).toHaveLength(1); + }); + + it('should accept record_review page with config', () => { + const page = PageSchema.parse({ + name: 'page_review', + label: 'Review Queue', + type: 'record_review', + object: 'order', + recordReview: { + object: 'order', + actions: [ + { label: 'Approve', type: 'approve', field: 'status', value: 'approved' }, + { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, + ], + }, + regions: [], + }); + + expect(page.type).toBe('record_review'); + expect(page.recordReview?.actions).toHaveLength(2); + }); + + it('should accept page with variables', () => { + const page = PageSchema.parse({ + name: 'page_filtered', + label: 'Filtered View', + type: 'blank', + variables: [ + { name: 'selectedId', type: 'string' }, + { name: 'showArchived', type: 'boolean', defaultValue: false }, + ], + regions: [], + }); + + expect(page.variables).toHaveLength(2); + }); + + it('should accept all interface page types', () => { + const types = [ + 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', + 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', + ]; + + types.forEach(type => { + expect(() => PageSchema.parse({ + name: 'test_page', + label: 'Test', + type, + regions: [], + })).not.toThrow(); + }); + }); + + it('should accept page with icon', () => { + const page = PageSchema.parse({ + name: 'page_with_icon', + label: 'Dashboard', + type: 'dashboard', + icon: 'bar-chart', + regions: [], + }); + + expect(page.icon).toBe('bar-chart'); + }); + + it('should accept page with i18n label', () => { + expect(() => PageSchema.parse({ + name: 'i18n_page', + label: { key: 'interface.pages.overview', defaultValue: 'Overview' }, + regions: [], + })).not.toThrow(); + }); + + it('should accept page with ARIA attributes', () => { + expect(() => PageSchema.parse({ + name: 'accessible_page', + label: 'Accessible Page', + regions: [], + aria: { ariaLabel: 'Interface overview page', role: 'main' }, + })).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// InterfaceSchema +// --------------------------------------------------------------------------- +describe('InterfaceSchema', () => { + it('should accept minimal interface', () => { + const iface: Interface = InterfaceSchema.parse({ + name: 'order_review', + label: 'Order Review', + pages: [], + }); + + expect(iface.name).toBe('order_review'); + expect(iface.label).toBe('Order Review'); + expect(iface.pages).toHaveLength(0); + }); + + it('should accept full interface', () => { + const iface = InterfaceSchema.parse({ + name: 'sales_portal', + label: 'Sales Portal', + description: 'Self-service portal for sales reps', + object: 'opportunity', + pages: [ + { + name: 'page_dashboard', + label: 'Dashboard', + type: 'dashboard', + regions: [], + }, + { + name: 'page_pipeline', + label: 'Pipeline', + type: 'kanban', + object: 'opportunity', + regions: [], + }, + ], + homePageName: 'page_dashboard', + branding: { + primaryColor: '#1A73E8', + logo: '/logos/sales.png', + }, + assignedRoles: ['sales_rep', 'sales_manager'], + isDefault: true, + }); + + expect(iface.pages).toHaveLength(2); + expect(iface.homePageName).toBe('page_dashboard'); + expect(iface.assignedRoles).toHaveLength(2); + expect(iface.isDefault).toBe(true); + }); + + it('should validate name format (snake_case)', () => { + expect(() => InterfaceSchema.parse({ + name: 'valid_name', + label: 'Valid', + pages: [], + })).not.toThrow(); + + expect(() => InterfaceSchema.parse({ + name: 'InvalidName', + label: 'Invalid', + pages: [], + })).toThrow(); + + expect(() => InterfaceSchema.parse({ + name: 'invalid-name', + label: 'Invalid', + pages: [], + })).toThrow(); + }); + + it('should reject without required fields', () => { + expect(() => InterfaceSchema.parse({ + label: 'Missing Name', + pages: [], + })).toThrow(); + + expect(() => InterfaceSchema.parse({ + name: 'missing_label', + pages: [], + })).toThrow(); + + expect(() => InterfaceSchema.parse({ + name: 'missing_pages', + label: 'Missing Pages', + })).toThrow(); + }); + + it('should accept interface with ARIA attributes', () => { + expect(() => InterfaceSchema.parse({ + name: 'accessible_interface', + label: 'Accessible Interface', + pages: [], + aria: { ariaLabel: 'Sales portal interface', role: 'application' }, + })).not.toThrow(); + }); + + it('should accept i18n labels', () => { + expect(() => InterfaceSchema.parse({ + name: 'i18n_interface', + label: { key: 'interfaces.review', defaultValue: 'Review' }, + description: { key: 'interfaces.review.desc', defaultValue: 'Review orders' }, + pages: [], + })).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// defineInterface factory +// --------------------------------------------------------------------------- +describe('defineInterface', () => { + it('should create a validated interface', () => { + const iface = defineInterface({ + name: 'hr_portal', + label: 'HR Portal', + pages: [ + { + name: 'page_onboarding', + label: 'Onboarding', + type: 'overview', + regions: [], + }, + ], + }); + + expect(iface.name).toBe('hr_portal'); + expect(iface.pages).toHaveLength(1); + }); + + it('should throw on invalid config', () => { + expect(() => defineInterface({ + name: 'InvalidName', + label: 'Invalid', + pages: [], + })).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Content Elements in PageComponentType +// --------------------------------------------------------------------------- +describe('Content Elements', () => { + it('should accept element:text component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:text', + properties: { content: 'Hello World' }, + })).not.toThrow(); + }); + + it('should accept element:number component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:number', + properties: { object: 'order', aggregate: 'count' }, + })).not.toThrow(); + }); + + it('should accept element:image component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:image', + properties: { src: '/images/banner.jpg' }, + })).not.toThrow(); + }); + + it('should accept element:divider component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:divider', + properties: {}, + })).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// ElementDataSourceSchema (per-element data binding) +// --------------------------------------------------------------------------- +describe('ElementDataSourceSchema', () => { + it('should accept minimal data source', () => { + const ds: ElementDataSource = ElementDataSourceSchema.parse({ + object: 'order', + }); + + expect(ds.object).toBe('order'); + expect(ds.view).toBeUndefined(); + expect(ds.filter).toBeUndefined(); + expect(ds.sort).toBeUndefined(); + expect(ds.limit).toBeUndefined(); + }); + + it('should accept full data source', () => { + const ds = ElementDataSourceSchema.parse({ + object: 'invoice', + view: 'pending_review', + filter: { status: 'pending' }, + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + }); + + expect(ds.object).toBe('invoice'); + expect(ds.view).toBe('pending_review'); + expect(ds.sort).toHaveLength(1); + expect(ds.limit).toBe(50); + }); + + it('should reject without object', () => { + expect(() => ElementDataSourceSchema.parse({})).toThrow(); + }); + + it('should reject invalid sort order', () => { + expect(() => ElementDataSourceSchema.parse({ + object: 'order', + sort: [{ field: 'name', order: 'invalid' }], + })).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// PageComponentSchema dataSource integration +// --------------------------------------------------------------------------- +describe('PageComponent dataSource integration', () => { + it('should accept component with dataSource', () => { + const component = PageComponentSchema.parse({ + type: 'element:number', + properties: { object: 'order', aggregate: 'sum', field: 'total' }, + dataSource: { + object: 'order', + filter: { status: 'completed' }, + limit: 100, + }, + }); + + expect(component.dataSource?.object).toBe('order'); + expect(component.dataSource?.limit).toBe(100); + }); + + it('should accept component without dataSource', () => { + const component = PageComponentSchema.parse({ + type: 'element:text', + properties: { content: 'Static text' }, + }); + + expect(component.dataSource).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Element Props Schemas +// --------------------------------------------------------------------------- +describe('ElementTextPropsSchema', () => { + it('should accept minimal text props', () => { + const props = ElementTextPropsSchema.parse({ content: 'Hello' }); + expect(props.content).toBe('Hello'); + expect(props.variant).toBe('body'); + expect(props.align).toBe('left'); + }); + + it('should accept full text props', () => { + const props = ElementTextPropsSchema.parse({ + content: '# Welcome', + variant: 'heading', + align: 'center', + }); + expect(props.variant).toBe('heading'); + expect(props.align).toBe('center'); + }); + + it('should accept all variants', () => { + const variants = ['heading', 'subheading', 'body', 'caption'] as const; + variants.forEach(variant => { + expect(() => ElementTextPropsSchema.parse({ content: 'Test', variant })).not.toThrow(); + }); + }); + + it('should reject without content', () => { + expect(() => ElementTextPropsSchema.parse({})).toThrow(); + }); +}); + +describe('ElementNumberPropsSchema', () => { + it('should accept minimal number props', () => { + const props = ElementNumberPropsSchema.parse({ + object: 'order', + aggregate: 'count', + }); + expect(props.object).toBe('order'); + expect(props.aggregate).toBe('count'); + expect(props.field).toBeUndefined(); + }); + + it('should accept full number props', () => { + const props = ElementNumberPropsSchema.parse({ + object: 'order', + field: 'amount', + aggregate: 'sum', + filter: { status: 'paid' }, + format: 'currency', + prefix: '$', + suffix: ' USD', + }); + expect(props.format).toBe('currency'); + expect(props.prefix).toBe('$'); + expect(props.suffix).toBe(' USD'); + }); + + it('should accept all aggregate functions', () => { + const aggregates = ['count', 'sum', 'avg', 'min', 'max'] as const; + aggregates.forEach(aggregate => { + expect(() => ElementNumberPropsSchema.parse({ object: 'order', aggregate })).not.toThrow(); + }); + }); + + it('should accept all format options', () => { + const formats = ['number', 'currency', 'percent'] as const; + formats.forEach(format => { + expect(() => ElementNumberPropsSchema.parse({ object: 'order', aggregate: 'count', format })).not.toThrow(); + }); + }); + + it('should reject without required fields', () => { + expect(() => ElementNumberPropsSchema.parse({})).toThrow(); + expect(() => ElementNumberPropsSchema.parse({ object: 'order' })).toThrow(); + }); +}); + +describe('ElementImagePropsSchema', () => { + it('should accept minimal image props', () => { + const props = ElementImagePropsSchema.parse({ src: '/images/hero.jpg' }); + expect(props.src).toBe('/images/hero.jpg'); + expect(props.fit).toBe('cover'); + }); + + it('should accept full image props', () => { + const props = ElementImagePropsSchema.parse({ + src: '/images/banner.png', + alt: 'Company banner', + fit: 'contain', + height: 200, + }); + expect(props.alt).toBe('Company banner'); + expect(props.fit).toBe('contain'); + expect(props.height).toBe(200); + }); + + it('should accept all fit modes', () => { + const fits = ['cover', 'contain', 'fill'] as const; + fits.forEach(fit => { + expect(() => ElementImagePropsSchema.parse({ src: '/img.png', fit })).not.toThrow(); + }); + }); + + it('should reject without src', () => { + expect(() => ElementImagePropsSchema.parse({})).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// ComponentPropsMap content elements +// --------------------------------------------------------------------------- +describe('ComponentPropsMap content elements', () => { + it('should contain element:text', () => { + expect(ComponentPropsMap['element:text']).toBeDefined(); + }); + + it('should contain element:number', () => { + expect(ComponentPropsMap['element:number']).toBeDefined(); + }); + + it('should contain element:image', () => { + expect(ComponentPropsMap['element:image']).toBeDefined(); + }); + + it('should contain element:divider', () => { + expect(ComponentPropsMap['element:divider']).toBeDefined(); + }); + + it('should parse element:text props', () => { + const result = ComponentPropsMap['element:text'].parse({ content: 'Hello' }); + expect(result.content).toBe('Hello'); + }); + + it('should parse element:number props', () => { + const result = ComponentPropsMap['element:number'].parse({ + object: 'order', + aggregate: 'count', + }); + expect(result.object).toBe('order'); + }); + + it('should parse element:image props', () => { + const result = ComponentPropsMap['element:image'].parse({ src: '/img.png' }); + expect(result.src).toBe('/img.png'); + }); + + it('should parse element:divider (empty props)', () => { + expect(() => ComponentPropsMap['element:divider'].parse({})).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end: Full interface with all features +// --------------------------------------------------------------------------- +describe('Interface end-to-end', () => { + it('should accept a complete real-world interface definition', () => { + const iface = defineInterface({ + name: 'order_management', + label: 'Order Management', + description: 'Complete order management interface', + object: 'order', + pages: [ + { + name: 'page_overview', + label: 'Overview', + type: 'dashboard', + regions: [ + { + name: 'main', + components: [ + { + type: 'element:text', + properties: { content: '# Order Dashboard', variant: 'heading' }, + }, + { + type: 'element:number', + properties: { object: 'order', aggregate: 'count' }, + dataSource: { object: 'order', filter: { status: 'pending' } }, + }, + { + type: 'element:number', + properties: { object: 'order', aggregate: 'sum', field: 'total', format: 'currency', prefix: '$' }, + dataSource: { object: 'order', filter: { status: 'completed' } }, + }, + { + type: 'element:divider', + properties: {}, + }, + { + type: 'element:image', + properties: { src: '/images/banner.jpg', alt: 'Order management', fit: 'cover', height: 200 }, + }, + ], + }, + ], + }, + { + name: 'page_review', + label: 'Review Queue', + type: 'record_review', + object: 'order', + recordReview: { + object: 'order', + filter: { status: 'pending_review' }, + sort: [{ field: 'priority', order: 'desc' }], + displayFields: ['customer_name', 'total', 'items_count'], + actions: [ + { label: 'Approve', type: 'approve', field: 'status', value: 'approved' }, + { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, + { label: 'Skip', type: 'skip' }, + ], + navigation: 'sequential', + showProgress: true, + }, + regions: [], + }, + { + name: 'page_grid', + label: 'All Orders', + type: 'grid', + object: 'order', + regions: [], + }, + ], + homePageName: 'page_overview', + branding: { primaryColor: '#2563EB', logo: '/logos/orders.png' }, + assignedRoles: ['order_manager', 'admin'], + isDefault: true, + }); + + expect(iface.name).toBe('order_management'); + expect(iface.pages).toHaveLength(3); + expect(iface.pages[1].recordReview?.actions).toHaveLength(3); + expect(iface.branding?.primaryColor).toBe('#2563EB'); + expect(iface.assignedRoles).toEqual(['order_manager', 'admin']); + }); +}); diff --git a/packages/spec/src/ui/interface.zod.ts b/packages/spec/src/ui/interface.zod.ts new file mode 100644 index 000000000..0ff8b1ff1 --- /dev/null +++ b/packages/spec/src/ui/interface.zod.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { PageSchema } from './page.zod'; +import { AppBrandingSchema } from './app.zod'; + +/** + * Interface Branding Schema + * Visual branding overrides for an interface. + * Extends AppBrandingSchema with interface-specific properties (coverImage). + */ +export const InterfaceBrandingSchema = AppBrandingSchema.extend({ + coverImage: z.string().optional().describe('Cover image URL for the interface landing'), +}); + +/** + * Interface Schema + * A self-contained, shareable, multi-page application surface. + * + * Unlike `AppSchema` (which is a navigation container for the full platform), + * an Interface is a focused, role-specific surface that stitches together + * views, elements, and actions into a cohesive experience. + * + * An App can contain multiple Interfaces. + * + * Pages within an Interface use the unified `PageSchema` with interface page types + * (dashboard, grid, kanban, record_review, etc.). + * + * **NAMING CONVENTION:** + * Interface names must be lowercase snake_case. + * + * @example + * ```ts + * const reviewInterface = defineInterface({ + * name: 'order_review', + * label: 'Order Review', + * object: 'order', + * pages: [ + * { + * name: 'review_queue', + * label: 'Review Queue', + * type: 'record_review', + * object: 'order', + * recordReview: { + * object: 'order', + * actions: [ + * { label: 'Approve', type: 'approve', field: 'status', value: 'approved' }, + * { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, + * ], + * }, + * regions: [], + * }, + * ], + * }); + * ``` + */ +export const InterfaceSchema = z.object({ + name: SnakeCaseIdentifierSchema.describe('Interface unique machine name (lowercase snake_case)'), + label: I18nLabelSchema.describe('Interface display label'), + description: I18nLabelSchema.optional().describe('Interface purpose description'), + + /** Primary object binding */ + object: z.string().optional().describe('Primary object binding (snake_case)'), + + /** Pages — uses the unified PageSchema */ + pages: z.array(PageSchema).describe('Ordered list of pages in this interface'), + + /** Default landing page */ + homePageName: z.string().optional().describe('Default landing page name'), + + /** Visual branding */ + branding: InterfaceBrandingSchema.optional().describe('Visual branding overrides'), + + /** Access control */ + assignedRoles: z.array(z.string()).optional().describe('Roles that can access this interface'), + + /** Default flag */ + isDefault: z.boolean().optional().describe('Whether this is the default interface for the object'), + + /** ARIA accessibility attributes */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + +/** + * Type-safe factory for creating interface definitions. + */ +export function defineInterface(config: z.input): Interface { + return InterfaceSchema.parse(config); +} + +// Type Exports +export type Interface = z.infer; +export type InterfaceInput = z.input; +export type InterfaceBranding = z.infer; diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 895ddf1bd..4d95656c1 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -191,7 +191,11 @@ describe('PageSchema', () => { }); it('should accept different page types', () => { - const types: Array = ['record', 'home', 'app', 'utility']; + const types: Array = [ + 'record', 'home', 'app', 'utility', + 'dashboard', 'grid', 'list', 'gallery', 'kanban', 'calendar', + 'timeline', 'form', 'record_detail', 'record_review', 'overview', 'blank', + ]; types.forEach(type => { const page = PageSchema.parse({ diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index ded64bb4e..6930be0c2 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { SortItemSchema } from '../shared/enums.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ResponsiveConfigSchema } from './responsive.zod'; @@ -28,9 +29,24 @@ export const PageComponentType = z.enum([ // Utility 'global:search', 'global:notifications', 'user:profile', // AI - 'ai:chat_window', 'ai:suggestion' + 'ai:chat_window', 'ai:suggestion', + // Content Elements (Airtable Interface parity) + 'element:text', 'element:number', 'element:image', 'element:divider' ]); +/** + * Element Data Source Schema + * Per-element data binding for multi-object pages. + * Overrides page-level object context so each element can query a different object. + */ +export const ElementDataSourceSchema = z.object({ + object: z.string().describe('Object to query'), + view: z.string().optional().describe('Named view to apply'), + filter: z.any().optional().describe('Additional filter criteria'), + sort: z.array(SortItemSchema).optional().describe('Sort order'), + limit: z.number().int().positive().optional().describe('Max records to display'), +}); + /** * Page Component Schema * A configured instance of a UI component. @@ -62,6 +78,9 @@ export const PageComponentSchema = z.object({ /** Visibility Rule */ visibility: z.string().optional().describe('Visibility filter/formula'), + /** Per-element data binding, overrides page-level object context */ + dataSource: ElementDataSourceSchema.optional().describe('Per-element data binding for multi-object pages'), + /** Responsive layout overrides per breakpoint */ responsive: ResponsiveConfigSchema.optional().describe('Responsive layout configuration'), @@ -79,10 +98,78 @@ export const PageVariableSchema = z.object({ defaultValue: z.unknown().optional(), }); +/** + * Page Type Schema + * Unified page type enum covering both platform pages (Salesforce FlexiPage style) + * and Airtable-inspired interface page types. + * + * **Disambiguation of similar types:** + * - `record` vs `record_detail`: `record` is a component-based layout page (FlexiPage style with regions), + * `record_detail` is a field-display page showing all fields of a single record (Airtable style). + * Use `record` for custom record pages with regions/components, `record_detail` for auto-generated detail views. + * - `home` vs `overview`: `home` is the platform-level landing page (tab landing), + * `overview` is an interface-level navigation hub with links/instructions. + * Use `home` for app-level landing, `overview` for in-interface navigation hubs. + * - `app` vs `utility` vs `blank`: `app` is an app-level page with navigation context, + * `utility` is a floating utility panel (e.g. notes, phone), `blank` is a free-form canvas + * for custom composition. They serve distinct layout purposes. + */ +export const PageTypeSchema = z.enum([ + // Platform page types (Salesforce FlexiPage style) + 'record', // Component-based record layout page with regions + 'home', // Platform-level home/landing page + 'app', // App-level page with navigation context + 'utility', // Floating utility panel (e.g. notes, phone dialer) + // Interface page types (Airtable Interface parity) + 'dashboard', // KPI summary with charts/metrics + 'grid', // Spreadsheet-like data table + 'list', // Record list with quick actions + 'gallery', // Card-based visual browsing + 'kanban', // Status-based board + 'calendar', // Date-based scheduling + 'timeline', // Gantt-like project timeline + 'form', // Data entry form + 'record_detail', // Auto-generated single record field display + 'record_review', // Sequential record review/approval + 'overview', // Interface-level navigation/landing hub + 'blank', // Free-form canvas for custom composition +]).describe('Page type — platform or interface page types'); + +/** + * Record Review Config Schema + * Configuration for a sequential record review/approval page. + * Users navigate through records one-by-one, taking actions (approve/reject/skip). + * Only applicable when page type is 'record_review'. + */ +export const RecordReviewConfigSchema = z.object({ + object: z.string().describe('Target object for review'), + filter: z.any().optional().describe('Filter criteria for review queue'), + sort: z.array(SortItemSchema).optional().describe('Sort order for review queue'), + displayFields: z.array(z.string()).optional() + .describe('Fields to display on the review page'), + actions: z.array(z.object({ + label: z.string().describe('Action button label'), + type: z.enum(['approve', 'reject', 'skip', 'custom']) + .describe('Action type'), + field: z.string().optional() + .describe('Field to update on action'), + value: z.any().optional() + .describe('Value to set on action'), + nextRecord: z.boolean().optional().default(true) + .describe('Auto-advance to next record after action'), + })).describe('Review actions'), + navigation: z.enum(['sequential', 'random', 'filtered']) + .optional().default('sequential') + .describe('Record navigation mode'), + showProgress: z.boolean().optional().default(true) + .describe('Show review progress indicator'), +}); + /** * Page Schema - * Defines a composition of components for a specific context (Record, Home, App). - * Compare to Salesforce FlexiPage. + * Defines a composition of components for a specific context. + * Supports both platform pages (Salesforce FlexiPage style: record, home, app, utility) + * and interface pages (Airtable Interface style: dashboard, grid, kanban, record_review, etc.). * * **NAMING CONVENTION:** * Page names are used in routing and must be lowercase snake_case. @@ -102,15 +189,22 @@ export const PageSchema = z.object({ name: SnakeCaseIdentifierSchema.describe('Page unique name (lowercase snake_case)'), label: I18nLabelSchema, description: I18nLabelSchema.optional(), + + /** Icon (used in interface navigation) */ + icon: z.string().optional().describe('Page icon name'), /** Page Type */ - type: z.enum(['record', 'home', 'app', 'utility']).default('record'), + type: PageTypeSchema.default('record').describe('Page type'), /** Page State Definitions */ variables: z.array(PageVariableSchema).optional().describe('Local page state variables'), /** Context */ object: z.string().optional().describe('Bound object (for Record pages)'), + + /** Record Review Configuration (only for record_review pages) */ + recordReview: RecordReviewConfigSchema.optional() + .describe('Record review configuration (required when type is "record_review")'), /** Layout Template */ template: z.string().default('default').describe('Layout template name (e.g. "header-sidebar-main")'), @@ -127,6 +221,9 @@ export const PageSchema = z.object({ }); export type Page = z.infer; +export type PageType = z.infer; export type PageComponent = z.infer; export type PageRegion = z.infer; export type PageVariable = z.infer; +export type ElementDataSource = z.infer; +export type RecordReviewConfig = z.infer;