diff --git a/.changeset/page-type-enforce-or-remove.md b/.changeset/page-type-enforce-or-remove.md new file mode 100644 index 0000000000..5c1d1ca5f2 --- /dev/null +++ b/.changeset/page-type-enforce-or-remove.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): remove unrendered roadmap page types from PageTypeSchema (enforce-or-remove) + +`PageTypeSchema` advertised six page types that never shipped a renderer — +`dashboard`, `form`, `record_detail`, `record_review`, `overview`, `blank`. +Authoring one passed schema validation but broke at runtime ("Unknown component +type"), a false affordance that's especially dangerous when templates are +AI-authored. Per ADR-0049 (enforce-or-remove), the enum is now the *live* set +(`record`, `home`, `app`, `utility`, `list`) — authoring a removed type now +fails fast at parse instead of silently at render. The removed types are tracked +in the new `PAGE_TYPE_ROADMAP` export and re-enter the enum only when a renderer +ships. A `page-type-liveness` gate test asserts the enum never re-grows a +roadmap type. + +The `recordReview`/`blankLayout` config schemas and fields are retained but +`@deprecated` (their page types are no longer authorizable) to avoid breaking +downstream imports; they will be removed in a coordinated follow-up. The +`variables` page field is documented `@experimental` — its state container is +wired but no consumer reads/writes it end-to-end yet. diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 4f436bd8f5..59882aae27 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { PageSchema, + PAGE_TYPE_ROADMAP, PageComponentSchema, PageRegionSchema, PageTypeSchema, @@ -199,12 +200,8 @@ describe('PageSchema', () => { expect(page.isDefault).toBe(true); }); - it('should accept different page types', () => { - const types: Array = [ - 'record', 'home', 'app', 'utility', - 'dashboard', 'list', 'form', 'record_detail', 'overview', - ]; - + it('should accept the live page types', () => { + const types: Array = ['record', 'home', 'app', 'utility', 'list']; types.forEach(type => { const page = PageSchema.parse({ name: 'test_page', @@ -214,29 +211,18 @@ describe('PageSchema', () => { }); expect(page.type).toBe(type); }); + }); - // record_review requires recordReview config - const reviewPage = PageSchema.parse({ - name: 'test_page', - label: 'Test Page', - type: 'record_review', - regions: [], - recordReview: { - object: 'case', - actions: [{ label: 'Approve', type: 'approve' }], - }, - }); - expect(reviewPage.type).toBe('record_review'); - - // blank requires blankLayout config - const blankPage = PageSchema.parse({ - name: 'test_page', - label: 'Test Page', - type: 'blank', - regions: [], - blankLayout: { items: [] }, - }); - expect(blankPage.type).toBe('blank'); + it('should reject roadmap page types that have no renderer (enforce-or-remove)', () => { + // dashboard / form / record_detail / record_review / overview / blank were + // removed from PageTypeSchema — authoring one used to pass validation then + // break at runtime ("Unknown component type"). The enum is now the live set. + for (const type of PAGE_TYPE_ROADMAP) { + expect( + () => PageSchema.parse({ name: 'test_page', label: 'Test Page', type, regions: [] }), + `roadmap type "${type}" must be rejected until it ships a renderer`, + ).toThrow(); + } }); it('should accept record page', () => { @@ -483,13 +469,13 @@ describe('PageTypeSchema', () => { }); }); - it('should accept all interface page types', () => { - const types = [ - 'dashboard', 'list', 'form', 'record_detail', 'record_review', 'overview', 'blank', - ]; - - types.forEach(type => { - expect(() => PageTypeSchema.parse(type)).not.toThrow(); + it('should accept the live interface page type and reject roadmap ones', () => { + // `list` is the only live interface page type. The rest were declared for + // "roadmap parity" but never rendered — removed from PageTypeSchema + // (enforce-or-remove, tracked in PAGE_TYPE_ROADMAP). + expect(() => PageTypeSchema.parse('list')).not.toThrow(); + PAGE_TYPE_ROADMAP.forEach(type => { + expect(() => PageTypeSchema.parse(type), `roadmap type "${type}"`).toThrow(); }); }); @@ -587,25 +573,24 @@ describe('RecordReviewConfigSchema', () => { // PageSchema with page types // --------------------------------------------------------------------------- describe('PageSchema with page types', () => { - it('should accept minimal interface-style page', () => { + it('should accept a minimal live page', () => { const page: Page = PageSchema.parse({ name: 'page_overview', label: 'Overview', - type: 'blank', - blankLayout: { items: [] }, + type: 'home', regions: [], }); expect(page.name).toBe('page_overview'); - expect(page.type).toBe('blank'); + expect(page.type).toBe('home'); expect(page.template).toBe('default'); }); - it('should accept dashboard page', () => { + it('should accept an app page with components', () => { const page = PageSchema.parse({ name: 'page_dashboard', label: 'Dashboard', - type: 'dashboard', + type: 'app', regions: [ { name: 'main', @@ -616,36 +601,15 @@ describe('PageSchema with page types', () => { ], }); - expect(page.type).toBe('dashboard'); + expect(page.type).toBe('app'); 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', - blankLayout: { items: [] }, + type: 'home', variables: [ { name: 'selectedId', type: 'string' }, { name: 'showArchived', type: 'boolean', defaultValue: false }, @@ -656,47 +620,11 @@ describe('PageSchema with page types', () => { expect(page.variables).toHaveLength(2); }); - it('should accept all interface page types', () => { - const basicTypes = [ - 'dashboard', 'list', 'form', 'record_detail', 'overview', - ]; - - basicTypes.forEach(type => { - expect(() => PageSchema.parse({ - name: 'test_page', - label: 'Test', - type, - regions: [], - })).not.toThrow(); - }); - - // record_review requires recordReview config - expect(() => PageSchema.parse({ - name: 'test_page', - label: 'Test', - type: 'record_review', - regions: [], - recordReview: { - object: 'case', - actions: [{ label: 'Approve', type: 'approve' }], - }, - })).not.toThrow(); - - // blank requires blankLayout config - expect(() => PageSchema.parse({ - name: 'test_page', - label: 'Test', - type: 'blank', - regions: [], - blankLayout: { items: [] }, - })).not.toThrow(); - }); - it('should accept page with icon', () => { const page = PageSchema.parse({ name: 'page_with_icon', label: 'Dashboard', - type: 'dashboard', + type: 'home', icon: 'bar-chart', regions: [], }); @@ -839,7 +767,7 @@ describe('BlankPageLayoutSchema', () => { const page = PageSchema.parse({ name: 'blank_canvas', label: 'Canvas', - type: 'blank', + type: 'home', regions: [ { name: 'main', @@ -888,7 +816,7 @@ describe('PageVariableSchema record_id type', () => { const page = PageSchema.parse({ name: 'blank_picker', label: 'Picker Page', - type: 'blank', + type: 'home', blankLayout: { items: [] }, variables: [ { name: 'selected_id', type: 'record_id', source: 'account_picker' }, @@ -926,7 +854,7 @@ describe('Page end-to-end', () => { const page = PageSchema.parse({ name: 'page_overview', label: 'Overview', - type: 'dashboard', + type: 'home', object: 'order', regions: [ { @@ -967,7 +895,7 @@ describe('Page end-to-end', () => { const page = PageSchema.parse({ name: 'page_review', label: 'Review Queue', - type: 'record_review', + type: 'home', object: 'order', recordReview: { object: 'order', @@ -1135,7 +1063,7 @@ describe('PageSchema with interfaceConfig', () => { const page = PageSchema.parse({ name: 'sales_dashboard', label: 'Sales Dashboard', - type: 'dashboard', + type: 'home', interfaceConfig: { appearance: { showDescription: false, @@ -1239,65 +1167,24 @@ describe('RecordReviewConfigSchema - Negative Validation', () => { }); // ============================================================================ -// Issue #5: PageSchema conditional validation (superRefine) +// PageSchema cross-field requirements — none by design (enforce-or-remove) // ============================================================================ -describe('PageSchema - conditional validation', () => { - it('should reject record_review page without recordReview config', () => { - expect(() => PageSchema.parse({ - name: 'review_page', - label: 'Review', - type: 'record_review', - regions: [], - })).toThrow(); - }); - - it('should accept record_review page with recordReview config', () => { - expect(() => PageSchema.parse({ - name: 'review_page', - label: 'Review', - type: 'record_review', - regions: [], - recordReview: { - object: 'order', - actions: [{ label: 'Approve', type: 'approve' }], - }, - })).not.toThrow(); - }); - - it('should reject blank page without blankLayout config', () => { +describe('PageSchema - no cross-field requirements', () => { + // The old superRefine required `recordReview`/`blankLayout` for the + // record_review/blank types and `slots` for kind:'slotted'. All three are + // gone: record_review/blank are unrendered roadmap types removed from the + // enum (PAGE_TYPE_ROADMAP), and an empty slotted page validly renders the + // synthesized default. Each was a "required-but-unauthorable field blocks the + // Studio create form" trap. + it('parses a minimal page of each live type with no extra config', () => { + for (const type of ['record', 'home', 'app', 'utility', 'list'] as const) { + expect(() => PageSchema.parse({ name: 'test_page', label: 'P', type, regions: [] })).not.toThrow(); + } + }); + + it('parses a slotted record page with no slots', () => { expect(() => PageSchema.parse({ - name: 'blank_page', - label: 'Blank', - type: 'blank', - regions: [], - })).toThrow(); - }); - - it('should accept blank page with blankLayout config', () => { - expect(() => PageSchema.parse({ - name: 'blank_page', - label: 'Blank', - type: 'blank', - regions: [], - blankLayout: { items: [] }, - })).not.toThrow(); - }); - - it('should not require recordReview for non-record_review types', () => { - expect(() => PageSchema.parse({ - name: 'list_page', - label: 'List', - type: 'list', - regions: [], - })).not.toThrow(); - }); - - it('should not require blankLayout for non-blank types', () => { - expect(() => PageSchema.parse({ - name: 'dashboard_page', - label: 'Dashboard', - type: 'dashboard', - regions: [], + name: 'test_page', label: 'P', type: 'record', kind: 'slotted', regions: [], })).not.toThrow(); }); }); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 95f7f53ceb..25339cd844 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -179,11 +179,16 @@ export const BlankPageLayoutSchema = lazySchema(() => z.object({ * `utility` is a floating utility panel (e.g. notes, phone), `blank` is a free-form canvas * for custom composition. They serve distinct layout purposes. * - * **Implementation status:** only `record`, `home`, `app`, `utility`, and `list` have - * dedicated renderers today; the remaining interface types (`dashboard`, `form`, - * `record_detail`, `record_review`, `overview`, `blank`) are declared for roadmap parity - * but currently fall back to the list renderer. The authoring form only offers the - * implemented set (see `page.form.ts`) to avoid presenting dead options. + * **Liveness (ADR-0049 enforce-or-remove):** only types with a dedicated + * renderer are authorizable. `record`, `home`, `app`, `utility`, and `list` are + * live. Types once declared for "roadmap parity" but never given a renderer + * (`dashboard`, `form`, `record_detail`, `record_review`, `overview`, `blank`) + * have been REMOVED from this enum — a schema-valid-but-unrendered page type is + * a false affordance: it passes validation, then breaks at runtime ("Unknown + * component type"), which is especially dangerous when templates are AI-authored. + * They are tracked in {@link PAGE_TYPE_ROADMAP} and re-enter the enum only when a + * renderer ships. The `page-type-liveness` gate test asserts the enum never + * re-grows a roadmap type. */ export const PageTypeSchema = lazySchema(() => z.enum([ // Platform page types (Salesforce FlexiPage style) — region/component composition @@ -191,17 +196,27 @@ export const PageTypeSchema = lazySchema(() => z.enum([ '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) — data-driven surfaces. - // NOTE: grid/kanban/calendar/gallery/timeline are NOT here — they are visualizations - // of a `list` page (interfaceConfig.appearance.allowedVisualizations), not page types. + // Interface page type (Airtable parity). NOTE: grid/kanban/calendar/gallery/ + // timeline are NOT page types — they are visualizations of a `list` page + // (interfaceConfig.appearance.allowedVisualizations). 'list', // Record list/grid surface with switchable visualizations + quick actions - 'dashboard', // [roadmap] KPI summary with charts/metrics - 'form', // [roadmap] Data entry form - 'record_detail', // [roadmap] Auto-generated single record field display - 'record_review', // [roadmap] Sequential record review/approval - 'overview', // [roadmap] Interface-level navigation/landing hub - 'blank', // [roadmap] Free-form canvas for custom composition -]).describe('Page type — the page KIND (platform or interface). Visualizations of a list page live in interfaceConfig, not here.')); +]).describe('Page type — the page KIND. Only types with a dedicated renderer are authorizable; visualizations of a list page live in interfaceConfig, not here.')); + +/** + * Page types declared in the past for "roadmap parity" but removed from + * {@link PageTypeSchema} because they never shipped a renderer (authoring one + * produced a broken page at runtime). Kept here so the intent isn't lost: when a + * renderer lands, move the type back into the enum (and, for high-risk surfaces, + * add a liveness proof). ADR-0049 enforce-or-remove / spec liveness gate. + */ +export const PAGE_TYPE_ROADMAP = [ + 'dashboard', // KPI summary with charts/metrics + 'form', // Data entry form + 'record_detail', // Auto-generated single record field display + 'record_review', // Sequential record review/approval (config: RecordReviewConfigSchema) + 'overview', // Interface-level navigation/landing hub + 'blank', // Free-form canvas (config: BlankPageLayoutSchema) +] as const; /** * Record Review Config Schema @@ -322,19 +337,34 @@ export const PageSchema = lazySchema(() => z.object({ /** Page Type */ type: PageTypeSchema.default('record').describe('Page type'), - /** Page State Definitions */ - variables: z.array(PageVariableSchema).optional().describe('Local page state variables'), + /** + * Page-local state variables. + * + * @experimental The state container is wired (the runtime mounts a + * `PageVariablesProvider` + `usePageVariables` hook with default-value init), + * but the end-to-end loop is not proven: no shipped element writes a variable + * (e.g. `element:record_picker` → `source`) and page variables are not yet + * injected into the `visible`/binding expression context. Authoring variables + * is therefore mostly inert today — treat as a preview surface until a + * consumer + an example/proof land. + */ + variables: z.array(PageVariableSchema).optional().describe('Local page state variables (experimental — see schema doc)'), /** Context */ object: z.string().optional().describe('Bound object (for Record pages)'), - /** Record Review Configuration (only for record_review pages) */ + /** @deprecated Config for the `record_review` page type, which is not yet + * rendered and has been removed from {@link PageTypeSchema} (see + * {@link PAGE_TYPE_ROADMAP}). Retained as an optional field for back-compat; + * slated for removal once the type ships a renderer or consumers drop it. */ recordReview: RecordReviewConfigSchema.optional() - .describe('Record review configuration (required when type is "record_review")'), + .describe('@deprecated Record review configuration (record_review type is roadmap, not authorizable)'), - /** Blank Page Layout (only for blank pages) */ + /** @deprecated Config for the `blank` page type, which is not yet rendered and + * has been removed from {@link PageTypeSchema} (see {@link PAGE_TYPE_ROADMAP}). + * Retained as an optional field for back-compat; slated for removal. */ blankLayout: BlankPageLayoutSchema.optional() - .describe('Free-form grid layout for blank pages (used when type is "blank")'), + .describe('@deprecated Free-form grid layout (blank type is roadmap, not authorizable)'), /** Layout Template */ template: z.string().default('default').describe('Layout template name (e.g. "header-sidebar-main")'), @@ -400,28 +430,13 @@ export const PageSchema = lazySchema(() => z.object({ tabs: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), discussion: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(), }).optional().describe('Slot override map for slotted pages'), -}).superRefine((data, ctx) => { - if (data.type === 'record_review' && !data.recordReview) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['recordReview'], - message: 'recordReview is required when type is "record_review"', - }); - } - if (data.type === 'blank' && !data.blankLayout) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['blankLayout'], - message: 'blankLayout is required when type is "blank"', - }); - } - // NOTE: `kind: 'slotted'` does NOT require `slots`. A slotted page with no - // overrides renders the synthesized default layout (every slot falls through - // to the default) — the natural starting point: create the slotted shell, - // then add slot overrides in the editor. Requiring `slots` up front made the - // Studio "New Page" form a dead-end the moment you picked "slotted" (the form - // can't author a slot map), the same trap as the old required `regions`. })); +// PageSchema has no cross-field (`superRefine`) requirements by design. It once +// required `recordReview`/`blankLayout` for the `record_review`/`blank` types +// (both removed — unrendered roadmap, see PAGE_TYPE_ROADMAP) and `slots` for +// `kind: 'slotted'` (dropped — an empty slotted page validly renders the +// synthesized default). Each of those was a "required-but-unauthorable field +// blocks the Studio create form" trap; none survives. export type Page = z.infer; /** Authoring input for {@link Page} — defaulted fields are optional. */