From e5a2edeea140da8b6c13747111c4090212a30651 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 02:23:17 +0000 Subject: [PATCH] feat(spec): calendar in allowedVisualizations requires calendar.startDateField (#13817) Cross-field refinement on the list-view schema, attached at all three doors (ListViewSchema, ObjectListViewSchema, the flattened runtime overlay arm): 'calendar' in appearance.allowedVisualizations with no calendar: block now refuses loudly at parse, naming calendar.startDateField and both remedies. titleField on CalendarConfigSchema moves required -> optional per the ruled 'only startDateField is load-bearing' measurement (ADR-0079 title fallback, measured in objectui ObjectCalendar.tsx). Regenerated reference docs. Ruled on #13748 (2026-08-31, director batch #19, option A); spec half of the two-half fix, objectui#7029 is the runtime half. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- ...r-visualization-requires-startdatefield.md | 36 ++++++ content/docs/references/api/protocol.mdx | 4 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/view.mdx | 18 +-- packages/spec/src/ui/view.test.ts | 109 +++++++++++++++++- packages/spec/src/ui/view.zod.ts | 93 ++++++++++++--- 6 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 .changeset/calendar-visualization-requires-startdatefield.md diff --git a/.changeset/calendar-visualization-requires-startdatefield.md b/.changeset/calendar-visualization-requires-startdatefield.md new file mode 100644 index 0000000000..fb1582dfa5 --- /dev/null +++ b/.changeset/calendar-visualization-requires-startdatefield.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): calendar in `appearance.allowedVisualizations` now requires `calendar.startDateField` on list views (#13817) + +**BREAKING** accept-set narrowing on `ListViewSchema` (and its two derived +doors, `ObjectListViewSchema` and the flattened `ViewMetadataSchema` list +overlay), shipped as `minor` under the repo's launch-window convention for +breaking changes. Ruled on #13748 (2026-08-31, option A — fix both halves); +this is the spec half, objectui#7029 is the runtime half. + +A view declaring `appearance.allowedVisualizations: [... 'calendar']` with no +`calendar:` block used to parse clean. Downstream (measured on #13748): the +calendar toggle rendered and was clickable, objectui invented +`startDateField: 'due_date'`, and the renderer landed every record without +that field on "today" — a plausible-looking, fully wrong screen, with the +renderer's own refusal screen unreachable because the synthesized config +always looked complete. The parse now **rejects loudly**, naming +`calendar.startDateField`, why the date has no truthful fallback, and both +remedies (declare the block, or drop `'calendar'` from the whitelist). + +In the same stroke `CalendarConfigSchema.titleField` moves required → +**optional**: only `startDateField` is load-bearing — the renderer resolves a +missing title through the ADR-0079 record display-name chain (measured in +objectui `ObjectCalendar.tsx`: an explicit `titleField` wins only "when +present"). Keeping it required would have made the new cross-field gate +demand more than the renderer reads, which the ruling forbids. Every +previously-valid calendar block stays valid. + +Scope: `calendar` only — the measured defect. Whether `timeline` or another +visualization has the same shape is a separate finding to measure first (the +ruling says so in those words); a scope pin test asserts the requirement does +not leak to `timeline`. + + diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index b2ba553f9e..bddc560233 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1627,7 +1627,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | @@ -1712,7 +1712,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 5cd6520cb5..de59cf7060 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -374,7 +374,7 @@ const result = ApiMethod.parse(data); | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 074a21b371..5001e8d77f 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -105,7 +105,7 @@ Appearance and visualization configuration | :--- | :--- | :--- | :--- | | **startDateField** | `string` | ✅ | Field providing the event start date/time | | **endDateField** | `string` | optional | Field providing the event end date/time (defaults to a single-day event) | -| **titleField** | `string` | ✅ | Field displayed as the event title | +| **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field whose value determines the event color | @@ -772,7 +772,7 @@ Map view configuration | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | @@ -914,7 +914,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **startDateField** | `string` | ✅ | Field providing the event start date/time | | **endDateField** | `string` | optional | Field providing the event end date/time (defaults to a single-day event) | -| **titleField** | `string` | ✅ | Field displayed as the event title | +| **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field whose value determines the event color | ### Nested Shape: `ListView.gantt` @@ -1167,7 +1167,7 @@ Tab configuration for multi-tab view interface | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | @@ -1300,7 +1300,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **startDateField** | `string` | ✅ | Field providing the event start date/time | | **endDateField** | `string` | optional | Field providing the event end date/time (defaults to a single-day event) | -| **titleField** | `string` | ✅ | Field displayed as the event title | +| **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field whose value determines the event color | ### Nested Shape: `ObjectListView.gantt` @@ -1753,7 +1753,7 @@ Tab configuration for multi-tab view interface | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | @@ -1838,7 +1838,7 @@ Tab configuration for multi-tab view interface | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | @@ -2079,7 +2079,7 @@ This schema accepts one of the following structures: | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | @@ -2255,7 +2255,7 @@ This schema accepts one of the following structures: | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index dcba4c6987..4c1310c9f9 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -180,15 +180,28 @@ describe('KanbanConfigSchema', () => { }); describe('CalendarConfigSchema', () => { - it('should accept minimal calendar config', () => { + // [#13817] The minimal block is `startDateField` ALONE — `titleField` is + // optional because the renderer resolves a missing title through the + // ADR-0079 display-name chain (measured: objectui ObjectCalendar.tsx uses an + // explicit titleField only "when present"). The date has no such fallback, + // so it is the one required key. + it('should accept minimal calendar config — `startDateField` alone', () => { const config = { startDateField: 'start_date', - titleField: 'subject', }; expect(() => CalendarConfigSchema.parse(config)).not.toThrow(); }); + it('REFUSES a calendar config with no `startDateField`', () => { + const r = CalendarConfigSchema.safeParse({ titleField: 'subject' }); + expect(r.success).toBe(false); + expect( + r.success === false + && r.error.issues.some((i) => i.path.join('.') === 'startDateField'), + ).toBe(true); + }); + it('should accept full calendar config', () => { const config = { startDateField: 'start_date', @@ -3843,3 +3856,95 @@ describe("ListViewSchema — the `page` view type (#13216)", () => { expect(VisualizationTypeSchema.safeParse('page').success).toBe(false); }); }); + +// ============================================================================ +// [#13817] `allowedVisualizations` ⇄ `calendar` — calendar in the switcher +// requires `calendar.startDateField` (ruled on #13748, option A; spec half) +// ============================================================================ + +describe("ListViewSchema — calendar in `appearance.allowedVisualizations` requires the `calendar:` block (#13817)", () => { + /** Walk `invalid_union` wrappers and return every issue, nested arms included. */ + const flattenUnionIssues = (issues: z.ZodIssue[]): z.ZodIssue[] => + issues.flatMap((i) => { + const nested = (i as unknown as { errors?: z.ZodIssue[][] }).errors; + return i.code === 'invalid_union' && Array.isArray(nested) + ? [i, ...flattenUnionIssues(nested.flat())] + : [i]; + }); + + // The same three doors the page-mount check runs at — the check is attached + // at three separate points for the same zod-4 reason, and a missing + // re-attachment is invisible (the door keeps accepting, which reads as "no + // rule violated" rather than "no rule ran"). The overlay door is + // `PUT /api/v1/meta/view`, the one a Studio tenant or an MCP/AI author has. + describe.each(viewDoorsCarryingPageMountCheck)('%s', (_label, parse) => { + it("REFUSES 'calendar' in allowedVisualizations with no `calendar:` block, naming `calendar.startDateField`", () => { + const r = parse({ + type: 'grid', + columns: ['name'], + appearance: { allowedVisualizations: ['grid', 'calendar'] }, + }); + expect(r.success).toBe(false); + const issue = (r as { error: z.ZodError }).error.issues + .find((i) => i.path.join('.').endsWith('calendar')); + expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined(); + expect(issue!.message).toContain('calendar: { startDateField:'); + expect(issue!.message).toContain('allowedVisualizations'); + }); + + it('REFUSES a `calendar:` block that lacks `startDateField` when calendar is allowed', () => { + const r = parse({ + type: 'grid', + columns: ['name'], + appearance: { allowedVisualizations: ['grid', 'calendar'] }, + calendar: { titleField: 'subject' }, + }); + expect(r.success).toBe(false); + // The block schema itself owns this refusal (`startDateField` is its one + // required key), so the issue lands inside the block. At the overlay + // door the union wraps a shape failure in `invalid_union` with the + // per-arm truth nested one level down (a refinement-only failure + // surfaces flat, a shape failure does not — the documented behavior + // `diagnoseViewMetadata` exists to unwrap), so search nested arms too. + const flat = flattenUnionIssues((r as { error: z.ZodError }).error.issues); + const issue = flat.find((i) => i.path.join('.').endsWith('calendar.startDateField')); + expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined(); + }); + + it('accepts calendar-in-switcher with `calendar: { startDateField }` — nothing more required', () => { + const r = parse({ + type: 'grid', + columns: ['name'], + appearance: { allowedVisualizations: ['grid', 'calendar'] }, + calendar: { startDateField: 'due_date' }, + }); + expect(r.success, r.success === false ? JSON.stringify((r as unknown as { error: z.ZodError }).error.issues) : '').toBe(true); + }); + + it("accepts a switcher WITHOUT 'calendar' and no block — unchanged", () => { + const r = parse({ + type: 'grid', + columns: ['name'], + appearance: { allowedVisualizations: ['grid', 'kanban'] }, + }); + expect(r.success).toBe(true); + }); + + it('accepts a view with no `appearance` at all — unchanged', () => { + const r = parse({ type: 'grid', columns: ['name'] }); + expect(r.success).toBe(true); + }); + }); + + // ⚠️ Scope pin: `calendar` only — the measured defect (#13748). A timeline + // switcher entry with no `timeline:` block still parses; whether it has the + // same shape is a separate finding to measure first, not a rider here. + it("does NOT extend the requirement to 'timeline' — unmeasured, out of scope", () => { + const r = ListViewSchema.safeParse({ + type: 'grid', + columns: ['name'], + appearance: { allowedVisualizations: ['grid', 'timeline'] }, + }); + expect(r.success).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index f32a0881d5..fc0485f035 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1166,6 +1166,17 @@ export const ListChartConfigSchema = lazySchema(() => strictObject({ /** * Calendar Settings + * + * [#13817] `startDateField` is the ONLY required key — measured against the + * one renderer (objectui `plugin-calendar/src/ObjectCalendar.tsx`): an explicit + * `titleField` wins when present, and in its absence the event title resolves + * through the ADR-0079 record display-name chain (`titleFormat` → + * `displayNameField` → type-aware derivation → `"Untitled"`). Requiring + * `titleField` here would demand more than the renderer reads — the exact + * shape the #13748 ruling forbids ("do not require more than the renderer + * actually needs"). The date has no such fallback: without `startDateField` + * there is nothing truthful to place events by, which is what + * {@link checkListViewCalendarVisualization} enforces cross-field. */ export const CalendarConfigSchema = lazySchema(() => strictObject({ surface: 'this calendar configuration', @@ -1173,7 +1184,7 @@ export const CalendarConfigSchema = lazySchema(() => strictObject({ }, { startDateField: z.string().describe('Field providing the event start date/time'), endDateField: z.string().optional().describe('Field providing the event end date/time (defaults to a single-day event)'), - titleField: z.string().describe('Field displayed as the event title'), + titleField: z.string().optional().describe('Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain)'), colorField: z.string().optional().describe('Field whose value determines the event color'), })); @@ -1522,6 +1533,56 @@ function checkListViewPageMount( } } +const VIEW_CALENDAR_ALLOWED_NEEDS_START_DATE = + "`appearance.allowedVisualizations` includes 'calendar', so end users can switch this view to a " + + 'calendar — but no `calendar:` block says which field supplies the event date. There is no ' + + 'truthful fallback: a date guessed by the renderer lands every record without that field on ' + + '"today", a plausible-looking, fully wrong screen. Declare `calendar: { startDateField: ' + + "'' }` — only `startDateField` is required; the event title falls back to the " + + "record display name (ADR-0079) — or remove 'calendar' from `allowedVisualizations`."; + +/** + * [#13817] The `appearance.allowedVisualizations` ⇄ `calendar` binding check — + * ruled on #13748 (2026-08-31, option A "fix both halves"; this is the spec + * half, objectui#7029 is the runtime half). + * + * A view declaring `appearance.allowedVisualizations: [... 'calendar']` with no + * `calendar:` block used to parse clean; the switcher then rendered a clickable + * calendar toggle, objectui invented `startDateField: 'due_date'`, and every + * record without that field piled onto "today" (measured on hotcrm pinned at + * `@objectstack/* 17.1.0` — all 9 leave requests on one cell). The renderer's + * own refusal screen was unreachable because the synthesized config always + * looked complete. Loud-over-silent: refuse at parse, naming the missing key. + * + * Only the block's `startDateField` is load-bearing — see the measurement note + * on {@link CalendarConfigSchema} (it is that schema's one required key, so + * requiring the block IS requiring `calendar.startDateField` and nothing more). + * + * ⚠️ Scope: `calendar` only — the measured defect. Whether `timeline` or + * another visualization has the same shape is a separate finding to measure + * first, not a rider here (the #13748 ruling says so in those words). + * + * Attached with `.superRefine` at the same three doors as + * {@link checkListViewPageMount}, for the same zod-4 reason (refinements block + * `.omit()`/key-overwriting `.extend()`, so derived shapes re-attach): the + * authoring terminal, `objects[].listViews.*`, and the flattened runtime + * overlay arm — the door a Studio tenant or an MCP/AI author writes through. + */ +function checkListViewCalendarVisualization( + view: { appearance?: { allowedVisualizations?: unknown } | null; calendar?: unknown }, + ctx: z.RefinementCtx, +): void { + const allowed = view.appearance?.allowedVisualizations; + if (!Array.isArray(allowed) || !allowed.includes('calendar')) return; + if (view.calendar === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['calendar'], + message: VIEW_CALENDAR_ALLOWED_NEEDS_START_DATE, + }); + } +} + /** * List View Schema (Expanded) * Defines how a collection of records is displayed to the user. @@ -1874,12 +1935,15 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** * List View Schema (Expanded) — {@link ListViewShapeSchema} plus the - * `type: 'page'` ⇄ `pageName` binding check. See that shape for why the two are - * separate bindings, and {@link checkListViewPageMount} for what the check - * refuses. + * `type: 'page'` ⇄ `pageName` binding check and the + * `allowedVisualizations` ⇄ `calendar` binding check. See that shape for why + * shape and checks are separate bindings, and {@link checkListViewPageMount} / + * {@link checkListViewCalendarVisualization} for what each check refuses. */ export const ListViewSchema = lazySchema(() => - ListViewShapeSchema.superRefine(checkListViewPageMount)); + ListViewShapeSchema + .superRefine(checkListViewPageMount) + .superRefine(checkListViewCalendarVisualization)); /** * [#12868] Form-view select option — {@link SelectOptionSchema} minus the @@ -3455,11 +3519,13 @@ export const ObjectListViewSchema = lazySchema(() => ListViewShapeSchema.omit({ userFilters: true }) .extend({ userFilters: ObjectUserFiltersSchema.optional() }) // Derived from the UNREFINED shape (zod 4 refuses `.omit()` on a refined - // object), so the binding check is re-attached here rather than inherited. - // Dropping this line would leave `objects[].listViews.*` — the ADR-0047 - // authoring surface — as the one door where a `page` view with no - // `pageName` parses clean. - .superRefine(checkListViewPageMount)); + // object), so the binding checks are re-attached here rather than + // inherited. Dropping these lines would leave `objects[].listViews.*` — + // the ADR-0047 authoring surface — as the one door where a `page` view + // with no `pageName`, or a calendar-enabled view with no `calendar:` + // block, parses clean. + .superRefine(checkListViewPageMount) + .superRefine(checkListViewCalendarVisualization)); /** * [#4001/#7741] The wrap remedy, ONE prose source for two doors: the container's @@ -4365,13 +4431,14 @@ const ListViewOverlayWireSchema = lazySchema(() => // [#13216] Built from {@link ListViewShapeSchema}, not {@link ListViewSchema}: // `flattenedViewOverlayFields()` re-declares `name` and `label`, and zod 4 // refuses to overwrite a key on an object that carries refinements. So the - // binding check is re-attached AFTER `.strip()` instead of inherited — this + // binding checks are re-attached AFTER `.strip()` instead of inherited — this // is the runtime write door (`PUT /api/v1/meta/view`), the one an MCP/AI - // author reaches, so it is the last place the refusal may go missing. + // author reaches, so it is the last place the refusals may go missing. // `viewDoorsCarryingPageMountCheck` in `view.test.ts` fails if any of the // three attachment points is dropped. ListViewShapeSchema.extend(flattenedViewOverlayFields()).strip() - .superRefine(checkListViewPageMount), + .superRefine(checkListViewPageMount) + .superRefine(checkListViewCalendarVisualization), ); /**