Skip to content

Commit cfd5ac4

Browse files
os-zhuangclaude
andauthored
fix(spec): remove unrendered roadmap page types (enforce-or-remove) (#2265)
PageTypeSchema advertised 6 page types with no renderer (dashboard, form, record_detail, record_review, overview, blank). Authoring one passed validation then broke at runtime ("Unknown component type") — a false affordance, worse because templates are AI-authored. Per ADR-0049 the enum is now the live set (record/home/app/utility/list); removed types live in a new PAGE_TYPE_ROADMAP export and a gate test stops the enum re-growing them. The record_review/blank config schemas + fields are kept @deprecated (avoid breaking downstream imports, coordinated removal to follow); `variables` is marked @experimental. Found by dogfooding the Studio page designer (blank → "Unknown component type"). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 359c0aa commit cfd5ac4

3 files changed

Lines changed: 129 additions & 205 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): remove unrendered roadmap page types from PageTypeSchema (enforce-or-remove)
6+
7+
`PageTypeSchema` advertised six page types that never shipped a renderer —
8+
`dashboard`, `form`, `record_detail`, `record_review`, `overview`, `blank`.
9+
Authoring one passed schema validation but broke at runtime ("Unknown component
10+
type"), a false affordance that's especially dangerous when templates are
11+
AI-authored. Per ADR-0049 (enforce-or-remove), the enum is now the *live* set
12+
(`record`, `home`, `app`, `utility`, `list`) — authoring a removed type now
13+
fails fast at parse instead of silently at render. The removed types are tracked
14+
in the new `PAGE_TYPE_ROADMAP` export and re-enter the enum only when a renderer
15+
ships. A `page-type-liveness` gate test asserts the enum never re-grows a
16+
roadmap type.
17+
18+
The `recordReview`/`blankLayout` config schemas and fields are retained but
19+
`@deprecated` (their page types are no longer authorizable) to avoid breaking
20+
downstream imports; they will be removed in a coordinated follow-up. The
21+
`variables` page field is documented `@experimental` — its state container is
22+
wired but no consumer reads/writes it end-to-end yet.

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

Lines changed: 50 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import {
33
PageSchema,
4+
PAGE_TYPE_ROADMAP,
45
PageComponentSchema,
56
PageRegionSchema,
67
PageTypeSchema,
@@ -199,12 +200,8 @@ describe('PageSchema', () => {
199200
expect(page.isDefault).toBe(true);
200201
});
201202

202-
it('should accept different page types', () => {
203-
const types: Array<Page['type']> = [
204-
'record', 'home', 'app', 'utility',
205-
'dashboard', 'list', 'form', 'record_detail', 'overview',
206-
];
207-
203+
it('should accept the live page types', () => {
204+
const types: Array<Page['type']> = ['record', 'home', 'app', 'utility', 'list'];
208205
types.forEach(type => {
209206
const page = PageSchema.parse({
210207
name: 'test_page',
@@ -214,29 +211,18 @@ describe('PageSchema', () => {
214211
});
215212
expect(page.type).toBe(type);
216213
});
214+
});
217215

218-
// record_review requires recordReview config
219-
const reviewPage = PageSchema.parse({
220-
name: 'test_page',
221-
label: 'Test Page',
222-
type: 'record_review',
223-
regions: [],
224-
recordReview: {
225-
object: 'case',
226-
actions: [{ label: 'Approve', type: 'approve' }],
227-
},
228-
});
229-
expect(reviewPage.type).toBe('record_review');
230-
231-
// blank requires blankLayout config
232-
const blankPage = PageSchema.parse({
233-
name: 'test_page',
234-
label: 'Test Page',
235-
type: 'blank',
236-
regions: [],
237-
blankLayout: { items: [] },
238-
});
239-
expect(blankPage.type).toBe('blank');
216+
it('should reject roadmap page types that have no renderer (enforce-or-remove)', () => {
217+
// dashboard / form / record_detail / record_review / overview / blank were
218+
// removed from PageTypeSchema — authoring one used to pass validation then
219+
// break at runtime ("Unknown component type"). The enum is now the live set.
220+
for (const type of PAGE_TYPE_ROADMAP) {
221+
expect(
222+
() => PageSchema.parse({ name: 'test_page', label: 'Test Page', type, regions: [] }),
223+
`roadmap type "${type}" must be rejected until it ships a renderer`,
224+
).toThrow();
225+
}
240226
});
241227

242228
it('should accept record page', () => {
@@ -483,13 +469,13 @@ describe('PageTypeSchema', () => {
483469
});
484470
});
485471

486-
it('should accept all interface page types', () => {
487-
const types = [
488-
'dashboard', 'list', 'form', 'record_detail', 'record_review', 'overview', 'blank',
489-
];
490-
491-
types.forEach(type => {
492-
expect(() => PageTypeSchema.parse(type)).not.toThrow();
472+
it('should accept the live interface page type and reject roadmap ones', () => {
473+
// `list` is the only live interface page type. The rest were declared for
474+
// "roadmap parity" but never rendered — removed from PageTypeSchema
475+
// (enforce-or-remove, tracked in PAGE_TYPE_ROADMAP).
476+
expect(() => PageTypeSchema.parse('list')).not.toThrow();
477+
PAGE_TYPE_ROADMAP.forEach(type => {
478+
expect(() => PageTypeSchema.parse(type), `roadmap type "${type}"`).toThrow();
493479
});
494480
});
495481

@@ -587,25 +573,24 @@ describe('RecordReviewConfigSchema', () => {
587573
// PageSchema with page types
588574
// ---------------------------------------------------------------------------
589575
describe('PageSchema with page types', () => {
590-
it('should accept minimal interface-style page', () => {
576+
it('should accept a minimal live page', () => {
591577
const page: Page = PageSchema.parse({
592578
name: 'page_overview',
593579
label: 'Overview',
594-
type: 'blank',
595-
blankLayout: { items: [] },
580+
type: 'home',
596581
regions: [],
597582
});
598583

599584
expect(page.name).toBe('page_overview');
600-
expect(page.type).toBe('blank');
585+
expect(page.type).toBe('home');
601586
expect(page.template).toBe('default');
602587
});
603588

604-
it('should accept dashboard page', () => {
589+
it('should accept an app page with components', () => {
605590
const page = PageSchema.parse({
606591
name: 'page_dashboard',
607592
label: 'Dashboard',
608-
type: 'dashboard',
593+
type: 'app',
609594
regions: [
610595
{
611596
name: 'main',
@@ -616,36 +601,15 @@ describe('PageSchema with page types', () => {
616601
],
617602
});
618603

619-
expect(page.type).toBe('dashboard');
604+
expect(page.type).toBe('app');
620605
expect(page.regions[0].components).toHaveLength(1);
621606
});
622607

623-
it('should accept record_review page with config', () => {
624-
const page = PageSchema.parse({
625-
name: 'page_review',
626-
label: 'Review Queue',
627-
type: 'record_review',
628-
object: 'order',
629-
recordReview: {
630-
object: 'order',
631-
actions: [
632-
{ label: 'Approve', type: 'approve', field: 'status', value: 'approved' },
633-
{ label: 'Reject', type: 'reject', field: 'status', value: 'rejected' },
634-
],
635-
},
636-
regions: [],
637-
});
638-
639-
expect(page.type).toBe('record_review');
640-
expect(page.recordReview?.actions).toHaveLength(2);
641-
});
642-
643608
it('should accept page with variables', () => {
644609
const page = PageSchema.parse({
645610
name: 'page_filtered',
646611
label: 'Filtered View',
647-
type: 'blank',
648-
blankLayout: { items: [] },
612+
type: 'home',
649613
variables: [
650614
{ name: 'selectedId', type: 'string' },
651615
{ name: 'showArchived', type: 'boolean', defaultValue: false },
@@ -656,47 +620,11 @@ describe('PageSchema with page types', () => {
656620
expect(page.variables).toHaveLength(2);
657621
});
658622

659-
it('should accept all interface page types', () => {
660-
const basicTypes = [
661-
'dashboard', 'list', 'form', 'record_detail', 'overview',
662-
];
663-
664-
basicTypes.forEach(type => {
665-
expect(() => PageSchema.parse({
666-
name: 'test_page',
667-
label: 'Test',
668-
type,
669-
regions: [],
670-
})).not.toThrow();
671-
});
672-
673-
// record_review requires recordReview config
674-
expect(() => PageSchema.parse({
675-
name: 'test_page',
676-
label: 'Test',
677-
type: 'record_review',
678-
regions: [],
679-
recordReview: {
680-
object: 'case',
681-
actions: [{ label: 'Approve', type: 'approve' }],
682-
},
683-
})).not.toThrow();
684-
685-
// blank requires blankLayout config
686-
expect(() => PageSchema.parse({
687-
name: 'test_page',
688-
label: 'Test',
689-
type: 'blank',
690-
regions: [],
691-
blankLayout: { items: [] },
692-
})).not.toThrow();
693-
});
694-
695623
it('should accept page with icon', () => {
696624
const page = PageSchema.parse({
697625
name: 'page_with_icon',
698626
label: 'Dashboard',
699-
type: 'dashboard',
627+
type: 'home',
700628
icon: 'bar-chart',
701629
regions: [],
702630
});
@@ -839,7 +767,7 @@ describe('BlankPageLayoutSchema', () => {
839767
const page = PageSchema.parse({
840768
name: 'blank_canvas',
841769
label: 'Canvas',
842-
type: 'blank',
770+
type: 'home',
843771
regions: [
844772
{
845773
name: 'main',
@@ -888,7 +816,7 @@ describe('PageVariableSchema record_id type', () => {
888816
const page = PageSchema.parse({
889817
name: 'blank_picker',
890818
label: 'Picker Page',
891-
type: 'blank',
819+
type: 'home',
892820
blankLayout: { items: [] },
893821
variables: [
894822
{ name: 'selected_id', type: 'record_id', source: 'account_picker' },
@@ -926,7 +854,7 @@ describe('Page end-to-end', () => {
926854
const page = PageSchema.parse({
927855
name: 'page_overview',
928856
label: 'Overview',
929-
type: 'dashboard',
857+
type: 'home',
930858
object: 'order',
931859
regions: [
932860
{
@@ -967,7 +895,7 @@ describe('Page end-to-end', () => {
967895
const page = PageSchema.parse({
968896
name: 'page_review',
969897
label: 'Review Queue',
970-
type: 'record_review',
898+
type: 'home',
971899
object: 'order',
972900
recordReview: {
973901
object: 'order',
@@ -1135,7 +1063,7 @@ describe('PageSchema with interfaceConfig', () => {
11351063
const page = PageSchema.parse({
11361064
name: 'sales_dashboard',
11371065
label: 'Sales Dashboard',
1138-
type: 'dashboard',
1066+
type: 'home',
11391067
interfaceConfig: {
11401068
appearance: {
11411069
showDescription: false,
@@ -1239,65 +1167,24 @@ describe('RecordReviewConfigSchema - Negative Validation', () => {
12391167
});
12401168

12411169
// ============================================================================
1242-
// Issue #5: PageSchema conditional validation (superRefine)
1170+
// PageSchema cross-field requirements — none by design (enforce-or-remove)
12431171
// ============================================================================
1244-
describe('PageSchema - conditional validation', () => {
1245-
it('should reject record_review page without recordReview config', () => {
1246-
expect(() => PageSchema.parse({
1247-
name: 'review_page',
1248-
label: 'Review',
1249-
type: 'record_review',
1250-
regions: [],
1251-
})).toThrow();
1252-
});
1253-
1254-
it('should accept record_review page with recordReview config', () => {
1255-
expect(() => PageSchema.parse({
1256-
name: 'review_page',
1257-
label: 'Review',
1258-
type: 'record_review',
1259-
regions: [],
1260-
recordReview: {
1261-
object: 'order',
1262-
actions: [{ label: 'Approve', type: 'approve' }],
1263-
},
1264-
})).not.toThrow();
1265-
});
1266-
1267-
it('should reject blank page without blankLayout config', () => {
1172+
describe('PageSchema - no cross-field requirements', () => {
1173+
// The old superRefine required `recordReview`/`blankLayout` for the
1174+
// record_review/blank types and `slots` for kind:'slotted'. All three are
1175+
// gone: record_review/blank are unrendered roadmap types removed from the
1176+
// enum (PAGE_TYPE_ROADMAP), and an empty slotted page validly renders the
1177+
// synthesized default. Each was a "required-but-unauthorable field blocks the
1178+
// Studio create form" trap.
1179+
it('parses a minimal page of each live type with no extra config', () => {
1180+
for (const type of ['record', 'home', 'app', 'utility', 'list'] as const) {
1181+
expect(() => PageSchema.parse({ name: 'test_page', label: 'P', type, regions: [] })).not.toThrow();
1182+
}
1183+
});
1184+
1185+
it('parses a slotted record page with no slots', () => {
12681186
expect(() => PageSchema.parse({
1269-
name: 'blank_page',
1270-
label: 'Blank',
1271-
type: 'blank',
1272-
regions: [],
1273-
})).toThrow();
1274-
});
1275-
1276-
it('should accept blank page with blankLayout config', () => {
1277-
expect(() => PageSchema.parse({
1278-
name: 'blank_page',
1279-
label: 'Blank',
1280-
type: 'blank',
1281-
regions: [],
1282-
blankLayout: { items: [] },
1283-
})).not.toThrow();
1284-
});
1285-
1286-
it('should not require recordReview for non-record_review types', () => {
1287-
expect(() => PageSchema.parse({
1288-
name: 'list_page',
1289-
label: 'List',
1290-
type: 'list',
1291-
regions: [],
1292-
})).not.toThrow();
1293-
});
1294-
1295-
it('should not require blankLayout for non-blank types', () => {
1296-
expect(() => PageSchema.parse({
1297-
name: 'dashboard_page',
1298-
label: 'Dashboard',
1299-
type: 'dashboard',
1300-
regions: [],
1187+
name: 'test_page', label: 'P', type: 'record', kind: 'slotted', regions: [],
13011188
})).not.toThrow();
13021189
});
13031190
});

0 commit comments

Comments
 (0)