diff --git a/.changeset/17054-calendar-config-all-day-field.md b/.changeset/17054-calendar-config-all-day-field.md new file mode 100644 index 0000000000..7c968a670a --- /dev/null +++ b/.changeset/17054-calendar-config-all-day-field.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +--- + +`CalendarConfigSchema` now declares **`allDayField`** — the fifth field binding on a calendar config, and the one key the rest of this package already published as a member while the schema refused it by name. + +**The trap this closes.** The `object-calendar` door refuses a flat `allDayField` and prescribes, verbatim: *"Write this as a key of the `calendar` config object instead — `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`."* That block's `calendar` prop `.describe()` publishes the same five-key shape, and it ships to `content/docs/references/ui/component.mdx`. An author who followed the prescription on a stored view was refused a **second** time, by a different schema with a different message — `Unrecognized key(s) on this calendar configuration: allDayField` — and neither message said the key was not a member at all, so the natural next move was to assume a typo and try more spellings. + +**Why the schema was the wrong half, measured rather than assumed.** The key is honoured, not inert. At the objectui pin this repo builds against, `ListView`'s `collectViewFields` reads `calendar.allDayField` into the fetch projection and its calendar branch forwards the authored block onto the `object-calendar` node, where `getCalendarConfig` resolves it; objectui then made it load-bearing in the render itself. Trimming the prescription instead would have left a shipped capability with no protocol carrier — and the mirror that carries it today keeps `.passthrough()` explicitly so the key is not stripped, which means a later hardening there would silently drop it. + +**What is authorable, and what still is not.** + +```ts +// accepted +calendar: { startDateField: 'start_date', endDateField: 'end_date', + titleField: 'subject', colorField: 'status', allDayField: 'is_all_day' } + +// still refused — one key per concept, not a second authorable spelling +{ type: 'object-calendar', allDayField: 'is_all_day' } +``` + +`allDayField` **names a boolean field, not a value**: a record whose flag is true draws as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit it and the renderer's existing inference is untouched — an event with no end date draws as all-day — so every calendar that never authored the key renders exactly as before. + +**The opening is one key wide.** `defaultView` stays refused on this config: it is the renderer's initial view mode, a UI preference rather than a field binding, and it already has its own declared home as an `object-calendar` component prop. Unknown keys are refused in the same shape as before, and `startDateField` is still required. + +Purely additive: nothing that parsed before is refused now, and no key is renamed or removed. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index efe38cc223..8d5b2cbea3 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1641,7 +1641,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; … }` | 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 | @@ -1726,7 +1726,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; … }` | 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 f76e1a4a7f..5100b42d5c 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -375,7 +375,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; … }` | 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 9394710c28..40450f447a 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -107,6 +107,7 @@ Appearance and visualization configuration | **endDateField** | `string` | optional | Field providing the event end date/time (defaults to a single-day event) | | **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 to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | +| **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | --- @@ -799,7 +800,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; … }` | 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 | @@ -943,6 +944,7 @@ View filter rule | **endDateField** | `string` | optional | Field providing the event end date/time (defaults to a single-day event) | | **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 to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | +| **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | ### Nested Shape: `ListView.gantt` @@ -1204,7 +1206,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; … }` | 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 | @@ -1339,6 +1341,7 @@ View filter rule | **endDateField** | `string` | optional | Field providing the event end date/time (defaults to a single-day event) | | **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 to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | +| **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | ### Nested Shape: `ObjectListView.gantt` @@ -1800,7 +1803,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; … }` | 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 | @@ -1885,7 +1888,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; … }` | 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 | @@ -2126,7 +2129,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; … }` | 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 | @@ -2302,7 +2305,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; … }` | 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/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 625ae5cfcc..7c63716cd7 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -167,6 +167,7 @@ "ui/BulkActionParam:placeholder", "ui/BulkActionParam:required", "ui/BulkActionParam:type", + "ui/CalendarConfig:allDayField", "ui/CalendarConfig:colorField", "ui/CalendarConfig:endDateField", "ui/CalendarConfig:startDateField", diff --git a/packages/spec/src/ui/calendar-config-allday-prescription-17054.test.ts b/packages/spec/src/ui/calendar-config-allday-prescription-17054.test.ts new file mode 100644 index 0000000000..57db78d4e0 --- /dev/null +++ b/packages/spec/src/ui/calendar-config-allday-prescription-17054.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest'; + +import { CalendarConfigSchema, ListViewSchema } from './view.zod'; +import { ComponentPropsMap } from './component.zod'; + +/** + * [#17054] Two faces of THIS package disagreed about whether `allDayField` is a + * member of the calendar config, and the disagreement was a class-(c) authoring + * trap: the `object-calendar` door refused a flat `allDayField` and prescribed + * `calendar: { startDateField, endDateField, titleField, colorField, + * allDayField }`, a shape `CalendarConfigSchema` refused BY NAME. An author who + * followed the prescription on a stored view was refused a second time, by a + * different schema, with a different message, and nothing in either message + * said the key was not a member at all — so the natural next move is to assume + * a typo and try more spellings. + * + * The round measured that the key is honoured, not inert, so the schema was the + * wrong half: at the objectui pin `53ded82b` this repo builds against, + * `ListView`'s `collectViewFields` reads `calendar.allDayField` into the fetch + * projection and its calendar branch forwards the authored block onto the + * `object-calendar` node, where `getCalendarConfig` resolves it; objectui#8026 + * makes it load-bearing in the render. `CalendarConfigSchema` now declares it. + * + * ⭐ Both directions are pinned. The first block proves the prescribed shape is + * accepted; the second proves what the widening did NOT cost — the flat + * spelling is still refused, `defaultView` (the neighbouring objectui-LOCAL + * knob, a UI preference rather than a field binding) is still refused, and an + * unknown key is still refused in the same shape as before. + */ + +/** The four keys that were declared before this card, in the prescription's own order. */ +const FOUR = { + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'subject', + colorField: 'status', +} as const; + +/** A list view that parses clean apart from whatever the case under test adds. */ +const baseView = (calendar: Record) => ({ + type: 'calendar' as const, + name: 'my_cal', + columns: ['subject'], + calendar, +}); + +const objectCalendar = ComponentPropsMap['object-calendar']; + +/** The `object-calendar` refusal that carries the flat-key prescription. */ +const flatRefusalMessage = (): string => { + const r = objectCalendar.safeParse({ objectName: 'events', allDayField: 'is_all_day' }); + expect(r.success).toBe(false); + const issue = r.success === false + ? r.error.issues.find((i) => i.code === 'unrecognized_keys') + : undefined; + expect(issue).toBeDefined(); + return String(issue?.message ?? ''); +}; + +describe('[#17054] the `object-calendar` prescription names only keys `CalendarConfigSchema` accepts', () => { + /** + * ⭐ The pin for the DEFECT CLASS, not for one key. It reads the key list out + * of the prescription the runtime actually prints and asks the config schema + * to accept each one, so any future edit that makes the diagnostic name a + * non-member goes red here — including a key nobody has thought of yet. + */ + it('every key the prescription names inside `calendar: { … }` is accepted by CalendarConfigSchema', () => { + const message = flatRefusalMessage(); + const named = /calendar:\s*\{([^}]*)\}/.exec(message); + expect(named, `the prescription no longer spells a \`calendar: { … }\` shape: ${message}`).not.toBeNull(); + + const keys = String(named?.[1] ?? '') + .split(',') + .map((k) => k.trim()) + .filter((k) => k.length > 0); + // Control: the extraction found a real list, not an empty one that would + // make the assertion below vacuously true. + expect(keys.length).toBeGreaterThanOrEqual(5); + expect(keys).toContain('allDayField'); + + for (const key of keys) { + const r = CalendarConfigSchema.safeParse({ startDateField: 'start_date', [key]: 'some_field' }); + expect( + r.success, + `the prescription names \`${key}\`, which CalendarConfigSchema refuses`, + ).toBe(true); + } + }); + + it('ACCEPTS the prescribed shape verbatim — the four declared keys plus `allDayField`', () => { + const r = CalendarConfigSchema.safeParse({ ...FOUR, allDayField: 'is_all_day' }); + expect(r.success).toBe(true); + }); + + it('ACCEPTS the prescribed shape through the stored-view door, where the second refusal used to land', () => { + const r = ListViewSchema.safeParse(baseView({ ...FOUR, allDayField: 'is_all_day' })); + expect(r.success).toBe(true); + }); + + it('control: the same view without `allDayField` parses too — the door was never the problem', () => { + const r = ListViewSchema.safeParse(baseView({ ...FOUR })); + expect(r.success).toBe(true); + }); +}); + +describe('[#17054] what the widening did NOT open', () => { + /** + * The flat spelling stays refused. `allDayField` became a member of the + * `calendar` config object, ⛔ not a second authorable spelling on the block + * (one key per concept, Prime Directive #12) — and the refusal keeps carrying + * the prescription that now points somewhere real. + */ + it('REFUSES a flat `allDayField` on `object-calendar`, still, and still prescribes the nested shape', () => { + const r = objectCalendar.safeParse({ objectName: 'events', allDayField: 'is_all_day' }); + expect(r.success).toBe(false); + const issue = r.success === false + ? r.error.issues.find((i) => i.code === 'unrecognized_keys') + : undefined; + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['allDayField']); + expect(issue?.message).toContain('Write this as a key of the `calendar` config object instead'); + }); + + /** + * `defaultView` is the neighbour that proves the opening is one key wide. It + * is honoured by the same renderer and declared in objectui's own + * sanctioned-local list, and it stays refused here because it is a UI + * preference, not a field binding — and it already has a declared spec home + * as an `object-calendar` component prop. + */ + it('REFUSES `defaultView` on the calendar config, still — the opening is exactly one key wide', () => { + const r = CalendarConfigSchema.safeParse({ ...FOUR, defaultView: 'month' }); + expect(r.success).toBe(false); + const issue = r.success === false + ? r.error.issues.find((i) => i.code === 'unrecognized_keys') + : undefined; + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['defaultView']); + }); + + it('REFUSES an unknown key on the calendar config, still, in the same shape as before', () => { + const r = CalendarConfigSchema.safeParse({ ...FOUR, bogusKeyXy: 'x' }); + expect(r.success).toBe(false); + const issue = r.success === false + ? r.error.issues.find((i) => i.code === 'unrecognized_keys') + : undefined; + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['bogusKeyXy']); + expect(issue?.message).toContain('Unrecognized key(s) on this calendar configuration'); + }); + + it('REFUSES an unknown key through the stored-view door, reported at `calendar`', () => { + const r = ListViewSchema.safeParse(baseView({ ...FOUR, bogusKeyXy: 'x' })); + expect(r.success).toBe(false); + const issue = r.success === false + ? r.error.issues.find((i) => i.code === 'unrecognized_keys') + : undefined; + expect(issue?.path.join('.')).toBe('calendar'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['bogusKeyXy']); + }); + + it('still REQUIRES `startDateField` — `allDayField` alone is not a calendar binding', () => { + const r = CalendarConfigSchema.safeParse({ allDayField: 'is_all_day' }); + expect(r.success).toBe(false); + expect( + r.success === false && r.error.issues.some((i) => i.path.join('.') === 'startDateField'), + ).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 721c3930ef..3ecc02a254 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1365,6 +1365,34 @@ export const CalendarConfigSchema = lazySchema(() => strictObject({ endDateField: z.string().optional().describe('Field providing the event end date/time (defaults to a single-day event)'), 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 to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value'), + /** + * [#17054] The fifth binding, and the one this schema was missing while two + * other faces of this same package already published it as a member: the + * `object-calendar` flat-key prescription names it verbatim + * (`OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE` in `ui/component.zod.ts`) and that + * block's `calendar` prop `.describe()` spells the config as + * `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` + * — text that ships to `content/docs/references/ui/component.mdx`. An author + * who followed the prescription on a STORED VIEW was refused here by name. + * + * Declared rather than trimmed because the renderer honours it: at the + * objectui pin `53ded82b` this repo builds against, `ListView`'s + * `collectViewFields` reads `calendar.allDayField` into the fetch projection + * (`plugin-list/src/ListView.tsx`) and its calendar branch forwards the + * authored block onto the `object-calendar` node, where `getCalendarConfig` + * resolves it; objectui#8026 then makes it load-bearing in the render itself. + * It is a FIELD BINDING like its four neighbours, which is what separates it + * from `defaultView` — the renderer's initial view mode, a UI preference with + * its own declared home as an `object-calendar` component prop and no + * business on a field-binding config. + * + * ⛔ No default field name. An undeclared `allDayField` leaves the renderer's + * existing inference untouched (an event with no end date draws as all-day); + * a DECLARED one is absolute — a record whose flag is absent or false is not + * all-day, because letting the inference overrule a declared key is this + * card's own defect inverted. + */ + allDayField: z.string().optional().describe('Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day'), })); /**