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
17 changes: 11 additions & 6 deletions docs/design/airtable-interface-gap-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -576,6 +576,8 @@ export const EmbedConfigSchema = z.object({
- [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
Expand Down Expand Up @@ -650,6 +652,9 @@ export const EmbedConfigSchema = z.object({
| 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 |

---

Expand Down
81 changes: 81 additions & 0 deletions packages/spec/src/ui/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DashboardNavItemSchema,
PageNavItemSchema,
UrlNavItemSchema,
InterfaceNavItemSchema,
GroupNavItemSchema,
defineApp,
type App,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
});
});
});

Expand Down
15 changes: 14 additions & 1 deletion packages/spec/src/ui/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -101,6 +112,7 @@ export const NavigationItemSchema: z.ZodType<any> = z.lazy(() =>
DashboardNavItemSchema,
PageNavItemSchema,
UrlNavItemSchema,
InterfaceNavItemSchema,
GroupNavItemSchema.extend({
children: z.array(NavigationItemSchema).describe('Child navigation items'),
})
Expand Down Expand Up @@ -256,4 +268,5 @@ export type ObjectNavItem = z.infer<typeof ObjectNavItemSchema>;
export type DashboardNavItem = z.infer<typeof DashboardNavItemSchema>;
export type PageNavItem = z.infer<typeof PageNavItemSchema>;
export type UrlNavItem = z.infer<typeof UrlNavItemSchema>;
export type InterfaceNavItem = z.infer<typeof InterfaceNavItemSchema>;
export type GroupNavItem = z.infer<typeof GroupNavItemSchema> & { children: NavigationItem[] };
31 changes: 21 additions & 10 deletions packages/spec/src/ui/page.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,26 @@ export const PageVariableSchema = z.object({

/**
* Page Type Schema
* Unified page type enum covering both platform pages (record, home, app, utility)
* and Airtable-inspired interface page types (dashboard, grid, kanban, etc.).
* 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
'record', // Record detail page (Salesforce FlexiPage)
'home', // Home/landing page
'app', // App-level page
'utility', // Utility panel
// 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
Expand All @@ -118,10 +129,10 @@ export const PageTypeSchema = z.enum([
'calendar', // Date-based scheduling
'timeline', // Gantt-like project timeline
'form', // Data entry form
'record_detail', // Single record deep-dive
'record_detail', // Auto-generated single record field display
'record_review', // Sequential record review/approval
'overview', // Landing/navigation hub
'blank', // Free-form canvas
'overview', // Interface-level navigation/landing hub
'blank', // Free-form canvas for custom composition
]).describe('Page type — platform or interface page types');

/**
Expand Down