diff --git a/.changeset/8466-calendar-color-allday-fields.md b/.changeset/8466-calendar-color-allday-fields.md new file mode 100644 index 0000000000..dda117b34f --- /dev/null +++ b/.changeset/8466-calendar-color-allday-fields.md @@ -0,0 +1,59 @@ +--- +'@object-ui/types': minor +--- + +Declare the two flat calendar field-name keys the renderer already reads and the +package README already teaches — `colorField` and `allDayField` on +`ObjectCalendarSchema` (objectstack-ai/objectui#8466). + +`ObjectCalendar.tsx`'s `getCalendarConfig` reads FIVE flat keys off the node, and +`packages/plugin-calendar/README.md` teaches all five in one sentence — "point +`titleField` / `startDateField` / `endDateField` / `allDayField` / `colorField` +at your own fields when they differ." Only three of the five were declared. The +other two reached the renderer through `BaseSchema`'s `[key: string]: any` on the +TypeScript face and its `.passthrough()` on the zod mirror: admitted, never +examined. A misspelling therefore left the calendar silently colourless while +every published gate passed. + +`colorField` is derived from the spec's `CalendarConfig`, the same type the +`calendar` block carries, so the flat spelling cannot drift from the block +spelling — the pattern objectstack-ai/objectui#6051 established for +`ObjectGanttSchema.colorField` on this same file, for the same key name and the +same mechanism. `allDayField` is objectui-local and has no `CalendarConfig` twin +to derive from, so it is declared as `string`; it is load-bearing in the renderer +since objectstack-ai/objectui#8026. + +Declaring `allDayField` widens no accept set, which is why Commandment #0.1 is +not engaged. Measured on `@objectstack/spec` 17.3.0: +`ComponentPropsMap['object-calendar']` refuses ALL FIVE flat keys with +`unrecognized_keys` — `titleField`, `startDateField` and `endDateField` included, +and those three have shipped declared here for releases. The flat face is +objectui's own lane, kept deliberately: the mirror's `.passthrough()` names this +very key as its reason. Under an index signature and a `.passthrough()` that +already admit any value, a declaration cannot widen anything — it only narrows. + +Nor is `allDayField` a new precedent here: `CalendarViewSchema` — a sibling +calendar interface in the same plugin, whose renderer reads the same five flat +keys — has shipped all five of these keys declared, `allDayField` included, on +both faces. Two interfaces, two renderers, ONE flat vocabulary: +`ObjectCalendarSchema` was the odd one out, and the accompanying pin keeps that +shared vocabulary from drifting apart. + +Both members are optional on both faces, so no node that omits them changes +verdict, and nothing that validated before is refused now. What does change +verdict is a WRONG-TYPED value at a correctly spelled key, which the index +signature and `.passthrough()` used to admit unexamined: `colorField: 0xff0000` +and `allDayField: true` — the field-name-versus-value confusion these keys invite +— are now refused at authoring time and through `safeValidateSchema`, the path +the CLI's `validate` / `check` take. That is why this is a `minor` and not a +`patch`. + +Neither key is added to `plugin-calendar`'s registration `inputs`, deliberately: +the forward direction of `apps/console`'s registry/spec parity gate refuses an +`inputs` entry the spec props schema does not accept, and all five flat keys are +refused there. The three sibling keys are absent from `inputs` for the same +reason. + +The `BaseSchema` index-signature ceiling measured by +objectstack-ai/objectui#7927 is unchanged — a MISSPELLED key is still admitted on +both faces, and the accompanying pin asserts that rather than claiming otherwise. diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index fcc26a3d92..0097191725 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -268,7 +268,10 @@ import type { ObjectCalendarSchema } from '@object-ui/types'; // What this annotation buys, and what it does not - measured, objectui#7925. // It type-checks the VALUES of the declared keys: `defaultView: 'agenda'` and // `titleField: 42` are both compile errors, and `check:doc-snippets` re-runs -// that check on every commit. It does NOT check key NAMES - this interface +// that check on every commit. Since objectui#8466 that cover reaches all five +// flat field-name keys - `allDayField` and `colorField` were reachable only +// through `BaseSchema`'s index signature until then, so this block is also +// what proves they are declared. It does NOT check key NAMES - this interface // extends `BaseSchema`, whose `[key: string]: any` admits any spelling, so a // misspelt key still compiles clean. Read the block as type-checked values, // never as a guarded key set. @@ -278,6 +281,8 @@ const schema: ObjectCalendarSchema = { titleField: 'name', startDateField: 'startDate', endDateField: 'endDate', + allDayField: 'isAllDay', + colorField: 'statusColor', defaultView: 'month' }; ``` diff --git a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts new file mode 100644 index 0000000000..2dce231dac --- /dev/null +++ b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts @@ -0,0 +1,422 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#8466 — `ObjectCalendarSchema.colorField` and + * `ObjectCalendarSchema.allDayField` declared, on both faces. + * + * ## The defect + * + * `ObjectCalendar.tsx`'s `getCalendarConfig` reads FIVE flat field-name keys off + * the node, and `plugin-calendar/README.md` teaches all five in one sentence — + * "point `titleField` / `startDateField` / `endDateField` / `allDayField` / + * `colorField` at your own fields when they differ." Only THREE of the five were + * declared. The other two reached the renderer through `BaseSchema`'s + * `[key: string]: any` on the TS side and its `.passthrough()` on the mirror: + * admitted, never examined by either published face. A misspelling therefore + * left the calendar silently colourless while every published gate passed. + * + * ## Why BOTH keys, and the measurement that decided the second one + * + * The two keys look asymmetric and are not. `colorField` IS a spec key — but + * only inside the NESTED `calendar` block (`CalendarConfigSchema`). At the FLAT + * position, which is the position this interface declares, + * `ComponentPropsMap['object-calendar']` refuses `colorField` and `allDayField` + * IDENTICALLY, with the same `unrecognized_keys` diagnostic — and it refuses + * `titleField`, `startDateField` and `endDateField` the same way, all three of + * which have shipped DECLARED here for releases. + * + * So the "declaring `allDayField` widens past the contract" objection, if it + * held, would condemn three shipped members too. It does not hold, and the + * reason is the direction of travel: the flat face is objectui's own lane, taken + * deliberately (`zod/objectql.zod.ts` keeps `.passthrough()` naming this very key + * — "the renderers grow config knobs ahead of the protocol (calendar's + * `allDayField`, for one), and stripping them here would silently disable a + * shipped capability"). Under an index signature and a `.passthrough()` that + * ALREADY admit any value, a declaration cannot widen anything; it only NARROWS, + * by adding value validation where there was none. Commandment #0.1 bans the + * lenient direction, and this is the strict one. + * + * That whole argument rests on measurements, so this file PINS them — the spec's + * five refusals and its accepting controls — rather than restating them in prose + * that could rot when the spec moves. + * + * ## The boundary this card does NOT cross + * + * Neither key is added to `plugin-calendar`'s registration `inputs`, and that is + * load-bearing, not an oversight: the FORWARD direction of + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` refuses an + * `inputs` entry the spec props schema does not accept, so declaring these there + * would redden the merge queue. The three sibling flat keys are absent from + * `inputs` for exactly the same reason. Pinned below. + * + * ## What declaring buys — and what it does NOT, measured not assumed + * + * objectui#7927 measured the ceiling: `BaseSchema` ends in `[key: string]: any`, + * so no annotation here can catch a MISSPELLED key. `colourField` stays admitted + * on both faces, and the control assertions below PIN that, so nobody reads this + * file as claiming more than it does. What the ceiling does not cap is the VALUE + * dimension, and that is the half this file pins — on the TS face through + * `@ts-expect-error` directives that go UNUSED (TS2578, a hard type-check + * failure) the moment their member is deleted, and on the mirror through + * refusals that land ON the key and reach `safeValidateSchema`, the path the + * CLI's `validate` / `check` take. + * + * ## Instruments, borrowed from `kanban-calendar-filter-sort-8174.test.ts` + * + * Membership is asserted on the mirror's OWN `.shape`, never on parse acceptance + * (under `.passthrough()` acceptance cannot tell "declared" from "admitted + * unexamined"). Type-level pins use invariant equality, so a member that fell + * back to the index signature reads as `any` and therefore as a failure. And + * every claim carries a CONTROL asserted to hold the opposite verdict, so no + * assertion can pass vacuously. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { CalendarConfigSchema, ComponentPropsMap } from '@objectstack/spec/ui'; + +import { ObjectCalendarSchema, safeValidateSchema } from '../zod/index.zod'; +import type { ObjectCalendarSchema as TsObjectCalendarSchema } from '../objectql'; +import type { CalendarViewSchema as TsCalendarViewSchema } from '../complex'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); + +const CALENDAR_READER = 'packages/plugin-calendar/src/ObjectCalendar.tsx'; +const CALENDAR_REGISTRATION = 'packages/plugin-calendar/src/index.tsx'; +const CALENDAR_README = 'packages/plugin-calendar/README.md'; + +/** The five flat field-name keys `getCalendarConfig` reads and the README teaches. */ +const FLAT_KEYS = ['titleField', 'startDateField', 'endDateField', 'allDayField', 'colorField'] as const; +/** The three that were already declared — the precedent the two new ones join. */ +const ALREADY_DECLARED = ['titleField', 'startDateField', 'endDateField'] as const; +/** The two this card declares. */ +const NEWLY_DECLARED = ['colorField', 'allDayField'] as const; + +/** + * A key the renderer never reads and neither face declares. Non-vacuity control + * for every "declared" assertion: it must stay `any` on the TS face and out of + * the mirror shape, while still being ADMITTED — the objectui#7927 ceiling, + * pinned rather than claimed away. + */ +const CONTROL_KEY = 'swatchField'; +/** A declared-and-read control for the off-disk read census. */ +const READ_CONTROL_KEY = 'objectName'; +/** The misspelling the ceiling still admits. Pinned, not fixed here. */ +const MISSPELLING = 'colourField'; + +const CALENDAR_NODE = { type: 'object-calendar', objectName: 'event' } as const; + +/* ── Type-level pins (invariant equality, house form) ─────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** The canonical `any` detector: only `any` absorbs `1 &` down to something `0` extends. */ +type IsAny = 0 extends (1 & T) ? true : false; +/** An object with no keys is assignable to `Pick` only when `K` is optional on `T`. */ +type IsOptional = Record extends Pick ? true : false; + +// `colorField` is DERIVED from the spec's `CalendarConfig`, so it resolves to +// that member's type. Delete the member and the indexed access falls back to +// `[key: string]: any`, making `IsAny` true and this `Equal` false. +export type _ColorFieldIsString = Expect>; +export type _ColorFieldIsNotAny = Expect, false>>; +export type _ColorFieldIsOptional = Expect>; +export type _AllDayFieldIsString = Expect>; +export type _AllDayFieldIsNotAny = Expect, false>>; +export type _AllDayFieldIsOptional = Expect>; +// The control key is undeclared, exactly as the two above were before this card. +// Same instrument, opposite verdict — which is what makes the six lines above +// readings rather than a type that says `true` for everything. +export type _ControlKeyFallsThrough = Expect>; +// objectui#7927's ceiling, pinned rather than claimed away. +export type _MisspellingStillAdmitted = Expect>; + +// The SIBLING interface in the same plugin — served by its OWN renderer, which +// reads the same five flat keys — already declares all five. A member that fell +// back to the index signature reads as `any` and fails these, so these five +// lines are what would catch the shared flat vocabulary drifting apart. +type Declared = Equal, false>; +const siblingPins: [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +] = [true, true, true, true, true]; +// Control: the same instrument returns the OPPOSITE verdict for a key the +// sibling does not declare either, so the five above are readings. +export type _SiblingControlFallsThrough = Expect>; + +// The TS face ACCEPTS the documented shape… +const calendarLiteral: TsObjectCalendarSchema = { + ...CALENDAR_NODE, + colorField: 'status_colour', + allDayField: 'is_all_day', +}; + +// …and REFUSES wrong-typed values the index signature used to admit. Each +// directive goes unused — TS2578, a hard failure — if its member is deleted. +// @ts-expect-error — `colorField` names a FIELD, so it is a string, not the colour itself +const calendarBadColorField: TsObjectCalendarSchema = { ...CALENDAR_NODE, colorField: 0xff0000 }; +// @ts-expect-error — `allDayField` names a FIELD; a boolean is the VALUE, the confusion this declaration catches +const calendarBadAllDayField: TsObjectCalendarSchema = { ...CALENDAR_NODE, allDayField: true }; + +/* ── Off-disk derivations ─────────────────────────────────────────────────── */ + +function readRepo(rel: string): string { + return readFileSync(join(REPO_ROOT, rel), 'utf8'); +} + +/** Every `schema.KEY` read in a renderer, off disk. */ +function rendererReads(rel: string): Set { + return new Set([...readRepo(rel).matchAll(/\bschema\.([A-Za-z_$][\w$]*)/g)].map((m) => m[1])); +} + +function shapeKeys(schema: unknown): string[] { + return Object.keys((schema as { shape: Record }).shape); +} + +/** + * ⚠️ `ComponentPropsMap` entries are read through `Record` on + * purpose — here, and again at the `unrecognized_keys` census below. Both raise + * an `@typescript-eslint/no-explicit-any` WARNING, never an error (eslint over + * this file: exit 0, 0 errors, exactly these 2 warnings), and both are KEPT. + * `_def` is a zod INTERNAL for which the spec publishes no type, so a + * hand-written shape for it would be a local ASSERTION about a third-party + * runtime that nothing re-checks — it would go stale in silence, which is the + * exact failure mode this file exists to catch. The sibling instrument file + * these were borrowed from (`kanban-calendar-filter-sort-8174.test.ts`) carries + * three of the same warnings for the same reason. + * + * ⛔ An `eslint-disable` is not the remedy — THIS COMMENT is, because it records + * WHY. A directive only silences, and `eslint.config.js` sets + * `reportUnusedDisableDirectives: 'error'` on every linted path, so a directive + * left behind after the cast is ever retyped stops being a silencer and becomes + * a hard error. + */ +function specShapeKeys(type: string): string[] { + const entry = (ComponentPropsMap as unknown as Record)[type]; + const def = entry._def; + const shape = typeof def.shape === 'function' ? def.shape() : def.shape; + return Object.keys(shape); +} + +/* ── The reads: the fact the declarations record ──────────────────────────── */ + +describe('objectui#8466 — the renderer reads these keys, which is what the declarations record', () => { + it('`getCalendarConfig` reads all five flat keys off the node', () => { + const src = readRepo(CALENDAR_READER); + for (const key of FLAT_KEYS) { + expect(src, `${CALENDAR_READER} no longer reads the flat ${key}`).toContain(`(schema as any).${key}`); + } + }); + + it('…and `allDayField` is LOAD-BEARING, not merely resolved (objectui#8026)', () => { + // The premise that removed triage's "declaring an inert key would be worse" + // objection. If the renderer ever stops honouring the key, this reddens + // BEFORE anyone trusts the declaration to mean something. + const src = readRepo(CALENDAR_READER); + expect(src).toContain('allDayField'); + // It is in the config memo's dependency list, which is what makes an + // authored change reach the screen. + expect(src).toMatch(/useMemo\(\(\) => getCalendarConfig\(schema\), \[[\s\S]*?allDayField[\s\S]*?\]\)/); + }); + + it('the reads census returns a firing control, so the verdicts above are readings', () => { + const reads = rendererReads(CALENDAR_READER); + expect(reads.has(READ_CONTROL_KEY)).toBe(true); + expect(reads.has(CONTROL_KEY)).toBe(false); + }); + + it('the README still teaches all five in one sentence, which is what makes them authorable', () => { + // The card's second half: the published prose. If this sentence is ever + // rewritten, the declaration set it justifies has to be revisited. + const readme = readRepo(CALENDAR_README); + for (const key of FLAT_KEYS) expect(readme).toContain(`\`${key}\``); + expect(readme).toContain('at your own\nfields when they differ.'); + }); +}); + +/* ── The spec face: the measurement that decided `allDayField` ────────────── */ + +describe('objectui#8466 — the spec refuses ALL FIVE flat keys, which is why declaring widens nothing', () => { + it('`ComponentPropsMap["object-calendar"]` declares none of the five at top level', () => { + const declared = specShapeKeys('object-calendar'); + for (const key of FLAT_KEYS) { + expect(declared, `spec now declares the flat ${key}; revisit this card's reasoning`).not.toContain(key); + } + // Non-vacuity: the same extraction returns the keys the spec certainly does + // declare at this position, so the five absences are readings. + expect(declared).toContain(READ_CONTROL_KEY); + expect(declared).toContain('calendar'); + expect(declared).toContain('defaultView'); + }); + + it('…and refuses each of the five IDENTICALLY, with `unrecognized_keys`', () => { + // This is the measurement that resolves the `colorField` / `allDayField` + // asymmetry: at the FLAT position there is none. The three already-shipped + // members are refused by exactly the same diagnostic as the two new ones. + const oc = (ComponentPropsMap as unknown as Record)['object-calendar']; + for (const key of FLAT_KEYS) { + const r = oc.safeParse({ [key]: 'x' }); + expect(r.success, `spec now accepts the flat ${key}`).toBe(false); + expect(r.error.issues.map((i: { code: string }) => i.code)).toContain('unrecognized_keys'); + } + // Both controls fire: keys the spec DOES declare parse green here. + expect(oc.safeParse({ [READ_CONTROL_KEY]: 'event' }).success).toBe(true); + expect(oc.safeParse({ locale: 'en-GB' }).success).toBe(true); + }); + + it('`colorField` IS a spec key — but only in the NESTED block, which is a different position', () => { + // The distinction the whole decision turns on. `CalendarConfigSchema` is a + // strictObject of four keys: it HAS `colorField` and refuses `allDayField` + // by name. That asymmetry is real nested, and absent flat. + expect(Object.keys(CalendarConfigSchema.shape)).toEqual([ + 'startDateField', + 'endDateField', + 'titleField', + 'colorField', + ]); + const nested = CalendarConfigSchema.safeParse({ startDateField: 's', allDayField: 'x' }); + expect(nested.success).toBe(false); + if (!nested.success) { + expect(nested.error.issues.map((i) => i.code)).toContain('unrecognized_keys'); + } + // Firing control: the same parse with a declared member is green. + expect(CalendarConfigSchema.safeParse({ startDateField: 's', colorField: 'c' }).success).toBe(true); + }); +}); + +/* ── The sibling interface: two renderers, one flat vocabulary ────────────── */ + +describe('objectui#8466 — `calendar-view` already declared all five, and the two must not fork', () => { + it('ONE renderer serves both `object-calendar` and `calendar`', () => { + // `ObjectCalendarRenderer` is registered twice — under `object-calendar` + // and under `calendar`, and the same `getCalendarConfig` reads the same five + // keys off both. ⛔ NOT under `calendar-view`: that element has its OWN + // renderer (`calendar-view-renderer.tsx`, which imports neither + // `ObjectCalendarRenderer` nor `getCalendarConfig`) and reads the five flat + // keys off `schema` itself. So the flat vocabulary spans THREE registered + // type names across TWO renderers, which is what makes a drift between the + // two interfaces a real defect rather than a tidiness point. + const src = readRepo(CALENDAR_REGISTRATION); + expect(src).toContain("ComponentRegistry.register('object-calendar', ObjectCalendarRenderer"); + expect(src).toContain("ComponentRegistry.register('calendar', ObjectCalendarRenderer"); + }); + + it('the sibling `CalendarViewSchema` declares all five — including the two this card adds', () => { + // Measured, and the reason declaring `allDayField` is not a new precedent: + // this package has ALREADY shipped it declared on a published interface, for + // a sibling renderer that reads exactly the same five flat keys. + // `ObjectCalendarSchema` was the odd one out, not the pioneer. + const declaredOnSibling: Record<(typeof FLAT_KEYS)[number], true> = { + titleField: true, + startDateField: true, + endDateField: true, + allDayField: true, + colorField: true, + }; + expect(Object.keys(declaredOnSibling).sort()).toEqual([...FLAT_KEYS].sort()); + expect(siblingPins).toHaveLength(5); + }); +}); + +/* ── The boundary: `inputs` stays clear of the flat face ──────────────────── */ + +describe('objectui#8466 — the registration `inputs` deliberately declares NO flat key', () => { + it('none of the five is an `inputs` entry, so the parity gate stays green', () => { + // `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` FORWARD + // direction: a block may not declare a top-level input the spec refuses. + // All five are refused, so all five must stay out of `inputs`. + const src = readRepo(CALENDAR_REGISTRATION); + for (const key of FLAT_KEYS) { + expect(src, `${key} became an inputs entry; the parity gate's forward direction will refuse it`) + .not.toMatch(new RegExp(`name:\\s*'${key}'`)); + } + // Firing control: keys that ARE inputs entries are found by the same regex. + expect(src).toMatch(/name:\s*'objectName'/); + expect(src).toMatch(/name:\s*'defaultView'/); + }); +}); + +/* ── The zod mirror ───────────────────────────────────────────────────────── */ + +describe('objectui#8466 — the mirror declares what the interface declares', () => { + it('membership, read off the mirror shape (acceptance cannot tell declared from admitted)', () => { + const keys = shapeKeys(ObjectCalendarSchema); + for (const key of [...ALREADY_DECLARED, ...NEWLY_DECLARED]) expect(keys).toContain(key); + // The control stays out, which keeps the assertions above from being + // satisfied by a shape that simply contains everything. + expect(keys).not.toContain(CONTROL_KEY); + expect(keys).not.toContain(MISSPELLING); + }); + + it('accepts the documented shape, and the values SURVIVE the parse', () => { + const node = { ...CALENDAR_NODE, colorField: 'status_colour', allDayField: 'is_all_day' }; + const r = ObjectCalendarSchema.safeParse(node); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + if (r.success) { + expect((r.data as Record).colorField).toBe('status_colour'); + expect((r.data as Record).allDayField).toBe('is_all_day'); + } + // …and through the published union entry point, so the right arm is reached. + expect(safeValidateSchema(node).success).toBe(true); + }); + + it('both members stay OPTIONAL: the node without them parses green — this adds no requiredness', () => { + expect(ObjectCalendarSchema.safeParse(CALENDAR_NODE).success).toBe(true); + expect(safeValidateSchema(CALENDAR_NODE).success).toBe(true); + }); + + it.each([ + ['colorField', 0xff0000], + ['colorField', { hex: '#ff0000' }], + // The confusion the declaration catches: the FLAG rather than the FIELD NAME. + ['allDayField', true], + ['allDayField', ['is_all_day']], + ] as const)('refuses a wrong-typed `%s` (%j) AT the key — the verdict declaring MOVES', (key, value) => { + // Before this card every one of these rode `.passthrough()` unexamined. + const r = ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [key]: value }); + expect(r.success).toBe(false); + if (!r.success) { + // The refusal must land ON the key, not merely somewhere in the node — + // which is what distinguishes value validation from an unrelated refusal. + const paths = r.error.issues.map((i) => (i.path ?? []).join('.')); + expect(paths, JSON.stringify(paths)).toContain(key); + } + // …and the same refusal through the path the CLI's `validate` / `check` reach. + expect(safeValidateSchema({ ...CALENDAR_NODE, [key]: value }).success).toBe(false); + }); + + it('the objectui#7927 ceiling is UNCHANGED: a misspelled key is still admitted', () => { + // This card buys value validation, not misspelling detection. Pinning the + // ceiling is what stops the change being read as more than it is — and + // turns red if `.passthrough()` is ever tightened, which would be #7927's + // job and would need this file revisited. + expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [MISSPELLING]: 'x' }).success).toBe(true); + expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [CONTROL_KEY]: 'x' }).success).toBe(true); + // Even a wrong-TYPED misspelling rides through, which is the sharp edge: + // the value validation above reaches only the spelling that is declared. + expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [MISSPELLING]: true }).success).toBe(true); + }); +}); + +/* ── Keep the type-level consts referenced (they are the pins) ─────────────── */ + +describe('objectui#8466 — the TS face accepts the documented node', () => { + it('the accepted literal carries the values it was authored with', () => { + expect(calendarLiteral.colorField).toBe('status_colour'); + expect(calendarLiteral.allDayField).toBe('is_all_day'); + // The refused literals exist only so their `@ts-expect-error` directives do; + // referencing them keeps `noUnusedLocals` off this file's back. + expect([calendarBadColorField, calendarBadAllDayField]).toHaveLength(2); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index d260d8ad5e..525afff22d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -98,6 +98,7 @@ import type { TimelineConfig, NavigationConfig, GanttConfig as SpecGanttConfig, + CalendarConfig as SpecCalendarConfig, } from '@objectstack/spec/ui'; /** @@ -2765,6 +2766,60 @@ export interface ObjectCalendarSchema extends BaseSchema { endDateField?: string; /** Field for event title */ titleField?: string; + /** + * Record field carrying the event's colour — any CSS colour or a semantic + * palette name, typically a server-computed status colour. Resolved PER + * RECORD by `plugin-calendar/src/ObjectCalendar.tsx`, which falls back to the + * record's own `color` value and then to the platform default, so an authored + * value that never arrives is invisible rather than loud. + * + * DERIVED from {@link CalendarConfig}, the same type the `calendar` block + * carries, so the flat spelling cannot drift from the block spelling. That is + * verbatim the pattern objectui#6051 established for + * {@link ObjectGanttSchema.colorField} — the same key name, the same + * mechanism, on this same file — and the reason this member is an indexed + * access rather than a fresh `string`. + * + * Undeclared here until objectui#8466, so an authored value reached the + * renderer only through {@link BaseSchema}'s `[key: string]: any` — admitted, + * never examined — while `plugin-calendar/README.md` taught it as authorable. + */ + colorField?: SpecCalendarConfig['colorField']; + /** + * Record field carrying the all-day flag. LOAD-BEARING since objectui#8026: + * the events pass in `plugin-calendar/src/ObjectCalendar.tsx` reads it and a + * change to the authored key genuinely changes what is drawn. It is also in + * that component's `getCalendarConfig` memo dependency list, which is what + * makes the change reach the screen. + * + * objectui-LOCAL, and the one member here with no {@link CalendarConfig} twin + * to derive from: `@objectstack/spec`'s `CalendarConfigSchema` is a + * `strictObject` of four keys and refuses this one BY NAME with an + * `unrecognized_keys` diagnostic. That is the class this package's mirror + * already names out loud, where `.passthrough()` is kept explicitly for this + * key — "the renderers grow config knobs ahead of the protocol (calendar's + * `allDayField`, for one), and stripping them here would silently disable a + * shipped capability" (`zod/objectql.zod.ts`). + * + * ⛔ Declaring it widens NO accept set, which is why Commandment #0.1 is not + * engaged. Measured on spec 17.3.0: `ComponentPropsMap['object-calendar']` + * refuses ALL FIVE flat field-name keys with `unrecognized_keys` — including + * {@link ObjectCalendarSchema.titleField}, + * {@link ObjectCalendarSchema.startDateField} and + * {@link ObjectCalendarSchema.endDateField} above, which have shipped + * DECLARED for releases. The flat face is objectui's own lane, taken whole; + * this key is its fifth member, not a new dialect. And under `BaseSchema`'s + * index signature the value was already `any`, so declaring only NARROWS. + * + * ⭐ Nor is it a new precedent: {@link CalendarViewSchema} — a sibling + * calendar interface in the same plugin, whose OWN renderer + * (`plugin-calendar/src/calendar-view-renderer.tsx`) reads the same five flat + * keys — has shipped all five declared, `allDayField` included, on both + * faces. Two interfaces, two renderers, one flat vocabulary: this interface + * was the odd one out, not the pioneer, and the pin keeps that shared + * vocabulary from drifting apart. + */ + allDayField?: string; /** * Default view mode — the renderer's rendered set. `'agenda'` was retired * (objectui#5784, following #5740): `CalendarView` renders no agenda view, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 4db28ef62a..e4a7f8f901 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1078,6 +1078,27 @@ export const ObjectCalendarSchema = BaseSchema.extend({ startDateField: z.string().optional().describe('Start date field'), endDateField: z.string().optional().describe('End date field'), titleField: z.string().optional().describe('Title field'), + // objectui#8466 — the last two members of the FLAT field-name face, which + // `ObjectCalendar.tsx`'s `getCalendarConfig` reads bare off the node and + // which `plugin-calendar/README.md` teaches as authorable. Neither published + // face of this package named them: they rode `BaseSchema`'s `[key: string]: + // any` on the TS side and its `.passthrough()` here — admitted, never + // examined, so a misspelling left the calendar silently colourless while + // every published gate passed. + // + // Mirrored at the SAME requiredness as `../objectql.ts` (both optional) so + // the zod-mirror-parity ratchet stays at zero drift for this pair, exactly as + // the `filter`/`sort` pair above. + // + // ⛔ Neither key is added to `plugin-calendar`'s registration `inputs`, and + // that asymmetry is deliberate: `ComponentPropsMap['object-calendar']` + // refuses all five flat keys with `unrecognized_keys`, so declaring them + // THERE would redden the FORWARD direction of + // `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`. The flat + // face is objectui's own lane — `titleField`/`startDateField`/`endDateField` + // have shipped declared here, and absent from `inputs`, for releases. + colorField: z.string().optional().describe('Field carrying the per-record event colour — a CSS colour or a semantic palette name'), + allDayField: z.string().optional().describe("Field carrying the all-day flag — objectui-local (the spec's CalendarConfigSchema refuses it by name); LOAD-BEARING since objectui#8026"), defaultView: z.enum(['month', 'week', 'day']).optional().describe("Default view — 'month' | 'week' | 'day', the renderer's rendered set ('agenda' was retired: objectui#5784)"), // objectui#8174 — the two query keys `ObjectCalendar.tsx` lowers onto its own // `dataSource.find` (`$filter: schema.filter`, diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index de0cb2891d..4ffec286e9 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -1013,7 +1013,7 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `save`, `storedPage`, which the example never declares', }, - 'packages/types/src/objectql.ts:1607 ObjectFormSchema': { + 'packages/types/src/objectql.ts:1608 ObjectFormSchema': { card: null, codes: [1005, 1109], reason: