diff --git a/.changeset/calendar-readme-schema-keys-5045.md b/.changeset/calendar-readme-schema-keys-5045.md new file mode 100644 index 000000000..eaf0ed083 --- /dev/null +++ b/.changeset/calendar-readme-schema-keys-5045.md @@ -0,0 +1,26 @@ +--- +'@object-ui/plugin-calendar': patch +--- + +`README.md`'s "Schema API / CalendarView" block described a `CalendarViewSchema` +that does not exist. Measured against the interface itself +(`packages/types/src/complex.ts`) and its zod mirror: `events` — the schema's +only required key besides `type` — was published as `events?`, so a reader +following the README omits it and TypeScript rejects the node; `defaultDate` was +`string` where the schema says `string | Date`; and `onDateClick` was listed as a +schema key when it is a `CalendarViewProps` **component** prop, sending readers +to a different package's surface for a key `calendar-view` does not have (the +schema's key is `onDateChange`). The block also listed 6 of the schema's 13 keys +with nothing saying it was a summary (objectui#5045). + +The block now carries the requiredness the schema declares, names itself a +partial summary of `CalendarViewSchema`, and adds the author-facing +`defaultView` / `view` / `views` / `editable` / `date`. It also states plainly +what the registered `calendar-view` renderer actually reads — it builds events +from the node's `data` array and drops an authored `events` key (objectui#4433) — +so the corrected requiredness does not itself become a new wrong instruction. + +This is a documentation fix to a file `plugin-calendar` publishes to npm, which +is why it carries a version: the npm landing page only picks up the correction +on a release. No behaviour, export, type, or `dist` byte changes. The pin test +added alongside it publishes nothing. diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index f41fe647a..16b2dce16 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -148,19 +148,44 @@ ComponentRegistry.register('my-calendar', ObjectCalendarRenderer); ### CalendarView -Display a monthly calendar with events: +Display a monthly calendar with events. This is a **partial summary**: the full +contract is `CalendarViewSchema` in `@object-ui/types`, which declares 13 keys of +its own on top of the common `BaseSchema` keys (`className`, `id`, `data`, +`visible`, ...). ```typescript { type: 'calendar-view', - events?: CalendarEvent[], - defaultDate?: string, // ISO date string - onEventClick?: (event) => void, - onDateClick?: (date) => void, - className?: string + events: CalendarEvent[], // REQUIRED — the only required key besides `type` + defaultView?: CalendarViewMode, // 'month' | 'week' | 'day' | 'agenda' (default 'month') + view?: CalendarViewMode, // controlled + views?: CalendarViewMode[], // default ['month', 'week', 'day'] + defaultDate?: string | Date, + date?: string | Date, // controlled + editable?: boolean, // default false + onEventClick?: (event: CalendarEvent) => void, + onDateChange?: (date: Date) => void, + onViewChange?: (view: CalendarViewMode) => void, + className?: string // from `BaseSchema`, not `CalendarViewSchema` } ``` +`onEventCreate` and `onEventUpdate` complete the 13; see `CalendarViewSchema` for +their signatures. `onDateClick` is **not** on this schema — it is a +`CalendarViewProps` component prop (see [Drag-and-Drop](#drag-and-drop) and +[Click-to-Create](#click-to-create)). + +> **The type is not the renderer.** `CalendarViewSchema` is the shape +> `@object-ui/types` publishes for this node, not a description of what the +> registered `calendar-view` renderer reads. That renderer builds its events from +> the node's `data` array plus the `titleField` / `startDateField` / +> `endDateField` / `colorField` / `allDayField` inputs, and **drops an authored +> `events` key** (objectui#4433) — so the type's one required key does nothing +> when written as JSON. Of the keys above it also reads `view` and `className`; +> the handlers only ever arrive from a React host +> (``), because JSON cannot carry a +> function. + ### Calendar Event Structure The authored event shape, declared by `@object-ui/types`: @@ -226,9 +251,9 @@ const schema = { console.log('Event clicked:', event); // Open event details modal }, - onDateClick: (date) => { - console.log('Date clicked:', date); - // Create new event + onDateChange: (date) => { + console.log('Visible date changed:', date); + // React to the calendar moving to another month/week/day } }; ``` diff --git a/packages/plugin-calendar/src/readme-calendar-view-schema.test.ts b/packages/plugin-calendar/src/readme-calendar-view-schema.test.ts new file mode 100644 index 000000000..eb669209a --- /dev/null +++ b/packages/plugin-calendar/src/readme-calendar-view-schema.test.ts @@ -0,0 +1,179 @@ +/** + * 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. + */ + +/** + * `packages/plugin-calendar/README.md`'s "Schema API -> CalendarView" fence + * describes real `CalendarViewSchema` keys, with the requiredness the schema + * actually declares (objectui#5045). + * + * ## Why this file exists + * + * The fence had drifted from the type in four separate ways at once, and every + * one of them was reader-facing on the npm landing page: + * + * - `events?` — the schema's ONLY required key besides `type`, published as + * optional. A reader following the README omits it and TypeScript rejects + * the node; + * - `defaultDate?: string` — the schema says `string | Date`; + * - `onDateClick` — not on `CalendarViewSchema` at all. It is a + * `CalendarViewProps` prop, i.e. a different package's *component* surface, + * so the README sent readers to a key the schema does not have; + * - six keys of thirteen listed, with nothing saying the list was partial. + * + * Nothing was holding the fence to the type, so all four drifted silently and + * were found by reading. That is the failure mode `readme-registration-keys` + * in `@object-ui/layout` names for the same class of defect: "someone read it" + * is not a mechanism. This card's fix was another hand-correction; this pin is + * what stops the next one being needed. + * + * ## Exhaustiveness is deliberately NOT asserted + * + * The fence is a declared PARTIAL summary — it names the author-facing keys and + * points at `CalendarViewSchema` for the rest. A pin demanding all thirteen + * would convert an editorial choice into a gate and force every future schema + * key into the README. What is asserted is the direction that misleads a + * reader: a key the README names must EXIST, and must carry the requiredness + * the schema gives it. + * + * ## Both sides are parsed from source, never restated here + * + * Hardcoding the key list in this file would reproduce the defect one layer up + * — the README was itself a confident hand-written restatement of the schema. + * So `CalendarViewSchema` and `BaseSchema` are read out of + * `packages/types/src/*.ts` on every run, and a red test here always means "fix + * the README (or the schema)", never "update the test". A moved or renamed + * interface fails LOUDLY rather than vacuously passing an empty key set. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Walk up to the workspace root, so the type source is found by repo layout. */ +function repoRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i += 1) { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir; + dir = resolve(dir, '..'); + } + throw new Error('repo root (pnpm-workspace.yaml) not found from this test file'); +} + +const ROOT = repoRoot(); +const README = join(ROOT, 'packages/plugin-calendar/README.md'); + +interface Key { + optional: boolean; +} + +/** + * The own (not inherited) property declarations of a TS interface, by brace + * matching rather than a line regex — the bodies here carry JSDoc blocks and + * function types with their own braces and semicolons. + */ +function interfaceKeys(file: string, name: string): Map { + const src = readFileSync(file, 'utf8'); + const opener = new RegExp(`export interface ${name}\\b[^{]*\\{`).exec(src); + if (!opener) throw new Error(`interface ${name} not found in ${file}`); + + let i = opener.index + opener[0].length; + const start = i; + for (let depth = 1; depth > 0; i += 1) { + if (i >= src.length) throw new Error(`unterminated interface ${name} in ${file}`); + if (src[i] === '{') depth += 1; + else if (src[i] === '}') depth -= 1; + } + + const body = src + .slice(start, i - 1) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + + const keys = new Map(); + let depth = 0; + let buf = ''; + const flush = () => { + const m = /^(?:readonly\s+)?([A-Za-z_$][\w$]*)(\?)?\s*:/.exec(buf.trim()); + if (m) keys.set(m[1], { optional: Boolean(m[2]) }); + buf = ''; + }; + for (const ch of body) { + if ('{(['.includes(ch)) depth += 1; + if ('})]'.includes(ch)) depth -= 1; + if ((ch === ';' || ch === '\n') && depth === 0) flush(); + else buf += ch; + } + flush(); + + if (keys.size === 0) throw new Error(`parsed zero keys out of ${name} in ${file}`); + return keys; +} + +/** The "Schema API -> CalendarView" fence, and the keys it publishes. */ +function readmeFence(): { keys: Map; ownCountClaim: number } { + const src = readFileSync(README, 'utf8'); + const heading = src.indexOf('\n### CalendarView\n'); + if (heading < 0) throw new Error('"### CalendarView" heading not found in the README'); + + const section = src.slice(heading, src.indexOf('\n### ', heading + 1)); + const fence = /```typescript\n([\s\S]*?)\n```/.exec(section); + if (!fence) throw new Error('no typescript fence under "### CalendarView"'); + + const keys = new Map(); + for (const line of fence[1].split('\n')) { + const m = /^ {2}([A-Za-z_$][\w$]*)(\?)?:/.exec(line); + if (m) keys.set(m[1], { optional: Boolean(m[2]) }); + } + if (keys.size === 0) throw new Error('parsed zero keys out of the README fence'); + + const claim = /declares (\d+) keys of\s+its own/.exec(section); + if (!claim) throw new Error('the fence no longer states how many keys the schema declares'); + + return { keys, ownCountClaim: Number(claim[1]) }; +} + +const TYPES = join(ROOT, 'packages/types/src'); +const own = interfaceKeys(join(TYPES, 'complex.ts'), 'CalendarViewSchema'); +const base = interfaceKeys(join(TYPES, 'base.ts'), 'BaseSchema'); +const fence = readmeFence(); + +describe('plugin-calendar README: "Schema API -> CalendarView"', () => { + it('names only keys `CalendarViewSchema` or `BaseSchema` actually declares', () => { + const phantom = [...fence.keys.keys()].filter((k) => !own.has(k) && !base.has(k)); + expect( + phantom, + `README publishes ${JSON.stringify(phantom)} as \`calendar-view\` schema keys, but ` + + 'neither `CalendarViewSchema` nor `BaseSchema` declares them. `onDateClick` is the ' + + 'original offender: a `CalendarViewProps` component prop, not a schema key.', + ).toEqual([]); + }); + + it('gives every key the requiredness the schema declares', () => { + const wrong: string[] = []; + for (const [key, { optional }] of fence.keys) { + const declared = own.get(key) ?? base.get(key); + if (declared && declared.optional !== optional) { + wrong.push(`${key}: README says ${optional ? 'optional' : 'required'}, schema says ${declared.optional ? 'optional' : 'required'}`); + } + } + expect( + wrong, + 'The README must not restate the schema\'s requiredness incorrectly — `events` is the ' + + 'schema\'s only required key besides `type`, and was published as `events?`.', + ).toEqual([]); + }); + + it('states the number of keys `CalendarViewSchema` declares, and states it correctly', () => { + expect( + fence.ownCountClaim, + 'The README tells readers how many keys the full schema has. That figure is prose and ' + + 'drifts like any other; it is held to the interface here.', + ).toBe(own.size); + }); +}); diff --git a/packages/plugin-calendar/tsconfig.test.json b/packages/plugin-calendar/tsconfig.test.json index a98ee894b..99d7e7aac 100644 --- a/packages/plugin-calendar/tsconfig.test.json +++ b/packages/plugin-calendar/tsconfig.test.json @@ -13,8 +13,13 @@ // a global augmentation, not an import, and `@testing-library/jest-dom` does // not live under `@types/` — so it is never picked up automatically and has // to be named here. Naming `types` at all switches off automatic `@types/*` - // inclusion, which is fine: nothing in these tests touches Node globals. - "types": ["@testing-library/jest-dom"], + // inclusion, which is why `node` is named alongside it: + // `readme-calendar-view-schema.test.ts` reads this package's README and the + // `@object-ui/types` sources off disk to hold the published schema docs to + // the schema (objectui#5045). Same reason `packages/layout/tsconfig.test.json` + // names it, and same reason it stays OUT of `tsconfig.json`: package SOURCE + // ships to browsers and must not compile against Node APIs. + "types": ["@testing-library/jest-dom", "node"], // Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and // `@objectstack/spec` resolve through the workspace dependency's built // `.d.ts` instead of pulling sibling sources in as program inputs (TS6059).