Skip to content

Commit 6c45ef7

Browse files
authored
Merge pull request #696 from objectstack-ai/copilot/enhance-ui-protocol
2 parents 6a010eb + 334ca87 commit 6c45ef7

13 files changed

Lines changed: 1159 additions & 33 deletions

ROADMAP.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@ These are the backbone of ObjectStack's enterprise capabilities.
7373
| Packages (total) | 27 |
7474
| Apps | 2 (Studio, Docs) |
7575
| Examples | 4 (Todo, CRM, Host, BI Plugin) |
76-
| Zod Schema Files | 176 |
76+
| Zod Schema Files | 177 |
7777
| Exported Schemas | 1,100+ |
7878
| `.describe()` Annotations | 7,111+ |
7979
| Service Contracts | 25 |
8080
| Contracts Implemented | 11 (44%) |
81-
| Test Files | 197 |
82-
| Tests Passing | 5,363 / 5,363 |
81+
| Test Files | 199 |
82+
| Tests Passing | 5,468 / 5,468 |
8383
| `@deprecated` Items | 3 |
8484
| Protocol Domains | 15 (Data, UI, AI, API, Automation, Cloud, Contracts, Identity, Integration, Kernel, QA, Security, Shared, Studio, System) |
8585

@@ -129,7 +129,7 @@ The following renames are planned for packages that implement core service contr
129129

130130
- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document
131131
- [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions
132-
- [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
132+
- [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
133133
- [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
134134
- [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook
135135
- [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
142142
- [x] **QA Protocol** — Testing framework schemas
143143
- [x] **Studio Protocol** — Plugin extension schemas
144144
- [x] **Contracts** — 25 service interfaces with full method signatures
145-
- [x] **Stack Definition**`defineStack()`, `defineView()`, `defineApp()`, `defineFlow()`, `defineAgent()` helpers
145+
- [x] **Stack Definition**`defineStack()`, `defineView()`, `defineApp()`, `defineInterface()`, `defineFlow()`, `defineAgent()` helpers
146146
- [x] **Error Map** — Custom Zod error messages with `objectStackErrorMap`
147147
- [x] **DX Utilities**`safeParsePretty()`, `formatZodError()`, `suggestFieldType()`
148148

@@ -356,13 +356,13 @@ The following renames are planned for packages that implement core service contr
356356

357357
> See [Airtable Interface Gap Analysis](docs/design/airtable-interface-gap-analysis.md) for the full evaluation.
358358
359-
#### Phase A: Interface Foundation (v3.2)
359+
#### Phase A: Interface Foundation (v3.2)
360360

361-
- [ ] `InterfaceSchema` — Self-contained, shareable, multi-page application surface (`src/ui/interface.zod.ts`)
362-
- [ ] `RecordReviewConfigSchema` — Sequential record review/approval page type with navigation and actions
363-
- [ ] Content elements — `element:text`, `element:number`, `element:image`, `element:divider` as `PageComponentType` extensions
364-
- [ ] Per-element data binding — `dataSource` property on `PageComponentInstanceSchema` for multi-object pages
365-
- [ ] Element props — `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema`
361+
- [x] `InterfaceSchema` — Self-contained, shareable, multi-page application surface (`src/ui/interface.zod.ts`)
362+
- [x] `RecordReviewConfigSchema` — Sequential record review/approval page type with navigation and actions
363+
- [x] Content elements — `element:text`, `element:number`, `element:image`, `element:divider` as `PageComponentType` extensions
364+
- [x] Per-element data binding — `dataSource` property on `PageComponentSchema` for multi-object pages
365+
- [x] Element props — `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema`
366366

367367
#### Phase B: Element Library & Builder (v3.3)
368368

docs/design/airtable-interface-gap-analysis.md

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> **Author:** ObjectStack Core Team
44
> **Created:** 2026-02-16
5-
> **Status:** Proposal
5+
> **Status:** Phase A Implemented
66
> **Target Version:** v3.2 – v4.0
77
88
---
@@ -58,13 +58,13 @@ ties them together — specifically:
5858

5959
| Area | Airtable | ObjectStack |
6060
|:---|:---|:---|
61-
| **Interface as a first-class entity** | ✅ Multi-page app per base | 🟡 App + Page exist separately |
61+
| **Interface as a first-class entity** | ✅ Multi-page app per base | `InterfaceSchema` + `InterfaceNavItemSchema` in App navigation |
6262
| **Drag-and-drop element canvas** | ✅ Free-form element placement | 🟡 Region-based composition |
63-
| **Record Review workflow** | ✅ Built-in record-by-record review | ❌ Not modeled |
64-
| **Element-level data binding** | ✅ Each element binds to any table/view | 🟡 Page-level object binding |
65-
| **Shareable interface URLs** | ✅ Public/private share links | ❌ Not modeled |
66-
| **Interface-level permissions** | ✅ Per-interface user access | 🟡 App-level permissions only |
67-
| **Embeddable interfaces** | ✅ iframe embed codes | ❌ Not modeled |
63+
| **Record Review workflow** | ✅ Built-in record-by-record review | `RecordReviewConfigSchema` in `PageSchema` |
64+
| **Element-level data binding** | ✅ Each element binds to any table/view | `ElementDataSourceSchema` per component |
65+
| **Shareable interface URLs** | ✅ Public/private share links | ❌ Not modeled (Phase C) |
66+
| **Interface-level permissions** | ✅ Per-interface user access | `assignedRoles` on `InterfaceSchema` |
67+
| **Embeddable interfaces** | ✅ iframe embed codes | ❌ Not modeled (Phase C) |
6868

6969
This document proposes specific schema additions and a phased roadmap to close these gaps while
7070
preserving ObjectStack's superior extensibility and enterprise capabilities.
@@ -562,17 +562,22 @@ export const EmbedConfigSchema = z.object({
562562

563563
## 7. Implementation Road Map
564564

565-
### 7.1 Phase A: Interface Foundation (v3.2 — Q3 2026)
565+
### 7.1 Phase A: Interface Foundation (v3.2 — Q3 2026)
566566

567567
> **Goal:** Establish the "Interface" abstraction as a first-class protocol entity.
568568
569-
- [ ] Define `InterfaceSchema` in `src/ui/interface.zod.ts`
570-
- [ ] Add `RecordReviewConfigSchema` to `PageSchema` types
571-
- [ ] Add content elements to `PageComponentType` (`element:text`, `element:number`, `element:image`, `element:divider`)
572-
- [ ] Add `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` to component props
573-
- [ ] Add `dataSource` property to `PageComponentInstanceSchema` for per-element data binding
574-
- [ ] Write comprehensive tests for all new schemas
575-
- [ ] Update `src/ui/index.ts` exports
569+
- [x] Define `InterfaceSchema` in `src/ui/interface.zod.ts`
570+
- [x] Add `RecordReviewConfigSchema` to `PageSchema` types
571+
- [x] Add content elements to `PageComponentType` (`element:text`, `element:number`, `element:image`, `element:divider`)
572+
- [x] Add `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` to component props
573+
- [x] Add `dataSource` property to `PageComponentSchema` for per-element data binding
574+
- [x] Write comprehensive tests for all new schemas
575+
- [x] Update `src/ui/index.ts` exports
576+
- [x] Merge `InterfacePageSchema` into `PageSchema` — unified `PageTypeSchema` with 16 types
577+
- [x] Extract shared `SortItemSchema` to `shared/enums.zod.ts`
578+
- [x] Export `defineInterface()` from root index.ts
579+
- [x] Add `InterfaceNavItemSchema` to `AppSchema` navigation for App↔Interface bridging
580+
- [x] Disambiguate overlapping page types (`record`/`record_detail`, `home`/`overview`) in `PageTypeSchema` docs
576581
- [ ] Generate JSON Schema for new types
577582

578583
**Estimated effort:** 2–3 weeks
@@ -644,6 +649,12 @@ export const EmbedConfigSchema = z.object({
644649
| 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 |
645650
| 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 |
646651
| 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 |
652+
| 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 |
653+
| 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 |
654+
| 8 | `InterfaceBrandingSchema` extends `AppBrandingSchema` | 2 of 3 fields (`primaryColor`, `logo`) were identical. Using `.extend()` adds only `coverImage`, avoiding property divergence. | 2026-02-16 |
655+
| 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 |
656+
| 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 |
657+
| 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 |
647658

648659
---
649660

packages/spec/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export * from './stack.zod';
7878
// DX Helper Functions (re-exported for convenience)
7979
export { defineView } from './ui/view.zod';
8080
export { defineApp } from './ui/app.zod';
81+
export { defineInterface } from './ui/interface.zod';
8182
export { defineFlow } from './automation/flow.zod';
8283
export { defineAgent } from './ai/agent.zod';
8384

packages/spec/src/shared/enums.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
22
import {
33
AggregationFunctionEnum,
44
SortDirectionEnum,
5+
SortItemSchema,
56
MutationEventEnum,
67
IsolationLevelEnum,
78
CacheStrategyEnum,
@@ -46,6 +47,26 @@ describe('SortDirectionEnum', () => {
4647
});
4748
});
4849

50+
describe('SortItemSchema', () => {
51+
it('should accept valid sort item', () => {
52+
const result = SortItemSchema.parse({ field: 'created_at', order: 'desc' });
53+
expect(result.field).toBe('created_at');
54+
expect(result.order).toBe('desc');
55+
});
56+
57+
it('should reject without field', () => {
58+
expect(() => SortItemSchema.parse({ order: 'asc' })).toThrow();
59+
});
60+
61+
it('should reject without order', () => {
62+
expect(() => SortItemSchema.parse({ field: 'name' })).toThrow();
63+
});
64+
65+
it('should reject invalid order direction', () => {
66+
expect(() => SortItemSchema.parse({ field: 'name', order: 'up' })).toThrow();
67+
});
68+
});
69+
4970
describe('MutationEventEnum', () => {
5071
it('should accept all mutation events', () => {
5172
const valid = ['insert', 'update', 'delete', 'upsert'];

packages/spec/src/shared/enums.zod.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export const SortDirectionEnum = z.enum(['asc', 'desc'])
1818
.describe('Sort order direction');
1919
export type SortDirection = z.infer<typeof SortDirectionEnum>;
2020

21+
/** Reusable sort item — field + direction pair used across views, data sources, filters */
22+
export const SortItemSchema = z.object({
23+
field: z.string().describe('Field name to sort by'),
24+
order: SortDirectionEnum.describe('Sort direction'),
25+
}).describe('Sort field and direction pair');
26+
export type SortItem = z.infer<typeof SortItemSchema>;
27+
2128
/** CRUD mutation events used across hook, validation, object CDC */
2229
export const MutationEventEnum = z.enum([
2330
'insert', 'update', 'delete', 'upsert',

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DashboardNavItemSchema,
88
PageNavItemSchema,
99
UrlNavItemSchema,
10+
InterfaceNavItemSchema,
1011
GroupNavItemSchema,
1112
defineApp,
1213
type App,
@@ -127,6 +128,53 @@ describe('UrlNavItemSchema', () => {
127128
});
128129
});
129130

131+
describe('InterfaceNavItemSchema', () => {
132+
it('should accept interface nav item with just interfaceName', () => {
133+
const navItem = {
134+
id: 'nav_order_review',
135+
label: 'Order Review',
136+
type: 'interface' as const,
137+
interfaceName: 'order_review',
138+
};
139+
140+
const result = InterfaceNavItemSchema.parse(navItem);
141+
expect(result.interfaceName).toBe('order_review');
142+
expect(result.pageName).toBeUndefined();
143+
});
144+
145+
it('should accept interface nav item with pageName', () => {
146+
const navItem = {
147+
id: 'nav_sales_dashboard',
148+
label: 'Sales Dashboard',
149+
icon: 'layout-dashboard',
150+
type: 'interface' as const,
151+
interfaceName: 'sales_portal',
152+
pageName: 'page_dashboard',
153+
};
154+
155+
const result = InterfaceNavItemSchema.parse(navItem);
156+
expect(result.interfaceName).toBe('sales_portal');
157+
expect(result.pageName).toBe('page_dashboard');
158+
});
159+
160+
it('should work in NavigationItemSchema union', () => {
161+
expect(() => NavigationItemSchema.parse({
162+
id: 'nav_interface',
163+
label: 'Interface',
164+
type: 'interface',
165+
interfaceName: 'my_interface',
166+
})).not.toThrow();
167+
});
168+
169+
it('should reject without interfaceName', () => {
170+
expect(() => InterfaceNavItemSchema.parse({
171+
id: 'nav_missing',
172+
label: 'Missing',
173+
type: 'interface',
174+
})).toThrow();
175+
});
176+
});
177+
130178
describe('GroupNavItemSchema', () => {
131179
it('should accept group nav item', () => {
132180
const navItem = {
@@ -459,6 +507,39 @@ describe('AppSchema', () => {
459507

460508
expect(() => AppSchema.parse(hrApp)).not.toThrow();
461509
});
510+
511+
it('should accept app with interface navigation items', () => {
512+
const app: App = {
513+
name: 'data_platform',
514+
label: 'Data Platform',
515+
navigation: [
516+
{
517+
id: 'nav_home',
518+
label: 'Home',
519+
icon: 'home',
520+
type: 'dashboard',
521+
dashboardName: 'main_dashboard',
522+
},
523+
{
524+
id: 'nav_order_review',
525+
label: 'Order Review',
526+
icon: 'clipboard-check',
527+
type: 'interface',
528+
interfaceName: 'order_review',
529+
},
530+
{
531+
id: 'nav_sales_portal',
532+
label: 'Sales Portal',
533+
icon: 'layout-dashboard',
534+
type: 'interface',
535+
interfaceName: 'sales_portal',
536+
pageName: 'page_dashboard',
537+
},
538+
],
539+
};
540+
541+
expect(() => AppSchema.parse(app)).not.toThrow();
542+
});
462543
});
463544
});
464545

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,18 @@ export const UrlNavItemSchema = BaseNavItemSchema.extend({
7878
});
7979

8080
/**
81-
* 5. Group Navigation Item
81+
* 5. Interface Navigation Item
82+
* Navigates to a specific Interface (self-contained multi-page surface).
83+
* Bridges AppSchema (navigation container) with InterfaceSchema (content surface).
84+
*/
85+
export const InterfaceNavItemSchema = BaseNavItemSchema.extend({
86+
type: z.literal('interface'),
87+
interfaceName: z.string().describe('Target interface name (snake_case)'),
88+
pageName: z.string().optional().describe('Specific page within the interface to open'),
89+
});
90+
91+
/**
92+
* 6. Group Navigation Item
8293
* A container for child navigation items (Sub-menu).
8394
* Does not perform navigation itself.
8495
*/
@@ -101,6 +112,7 @@ export const NavigationItemSchema: z.ZodType<any> = z.lazy(() =>
101112
DashboardNavItemSchema,
102113
PageNavItemSchema,
103114
UrlNavItemSchema,
115+
InterfaceNavItemSchema,
104116
GroupNavItemSchema.extend({
105117
children: z.array(NavigationItemSchema).describe('Child navigation items'),
106118
})
@@ -256,4 +268,5 @@ export type ObjectNavItem = z.infer<typeof ObjectNavItemSchema>;
256268
export type DashboardNavItem = z.infer<typeof DashboardNavItemSchema>;
257269
export type PageNavItem = z.infer<typeof PageNavItemSchema>;
258270
export type UrlNavItem = z.infer<typeof UrlNavItemSchema>;
271+
export type InterfaceNavItem = z.infer<typeof InterfaceNavItemSchema>;
259272
export type GroupNavItem = z.infer<typeof GroupNavItemSchema> & { children: NavigationItem[] };

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,45 @@ export const AIChatWindowProps = z.object({
128128
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
129129
});
130130

131+
/**
132+
* ----------------------------------------------------------------------
133+
* 3. Content Element Components (Airtable Interface Parity)
134+
* ----------------------------------------------------------------------
135+
*/
136+
137+
export const ElementTextPropsSchema = z.object({
138+
content: z.string().describe('Text or Markdown content'),
139+
variant: z.enum(['heading', 'subheading', 'body', 'caption'])
140+
.optional().default('body').describe('Text style variant'),
141+
align: z.enum(['left', 'center', 'right'])
142+
.optional().default('left').describe('Text alignment'),
143+
/** ARIA accessibility */
144+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
145+
});
146+
147+
export const ElementNumberPropsSchema = z.object({
148+
object: z.string().describe('Source object'),
149+
field: z.string().optional().describe('Field to aggregate'),
150+
aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max'])
151+
.describe('Aggregation function'),
152+
filter: z.any().optional().describe('Filter criteria'),
153+
format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'),
154+
prefix: z.string().optional().describe('Prefix text (e.g. "$")'),
155+
suffix: z.string().optional().describe('Suffix text (e.g. "%")'),
156+
/** ARIA accessibility */
157+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
158+
});
159+
160+
export const ElementImagePropsSchema = z.object({
161+
src: z.string().describe('Image URL or attachment field'),
162+
alt: z.string().optional().describe('Alt text for accessibility'),
163+
fit: z.enum(['cover', 'contain', 'fill'])
164+
.optional().default('cover').describe('Image object-fit mode'),
165+
height: z.number().optional().describe('Fixed height in pixels'),
166+
/** ARIA accessibility */
167+
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
168+
});
169+
131170
/**
132171
* ----------------------------------------------------------------------
133172
* Component Props Map
@@ -164,7 +203,13 @@ export const ComponentPropsMap = {
164203

165204
// AI
166205
'ai:chat_window': AIChatWindowProps,
167-
'ai:suggestion': z.object({ context: z.string().optional() })
206+
'ai:suggestion': z.object({ context: z.string().optional() }),
207+
208+
// Content Elements
209+
'element:text': ElementTextPropsSchema,
210+
'element:number': ElementNumberPropsSchema,
211+
'element:image': ElementImagePropsSchema,
212+
'element:divider': EmptyProps,
168213
} as const;
169214

170215
/**

packages/spec/src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ export * from './keyboard.zod';
2828
export * from './animation.zod';
2929
export * from './notification.zod';
3030
export * from './dnd.zod';
31+
export * from './interface.zod';

0 commit comments

Comments
 (0)