From b5c46c16098344b523f6cfea9cb590c5dfdfa203 Mon Sep 17 00:00:00 2001 From: os-justin Date: Mon, 7 Sep 2026 09:55:29 +0000 Subject: [PATCH] feat(plugin-kanban,plugin-calendar): declare the seven scalar spec keys both renderers already honour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ComponentPropsMap` declares 13 top-level keys for `object-kanban` and 9 for `object-calendar`; the registrations published 4 of each. The gap was structural rather than considered — the console registers both blocks with `registerLazy` and `getConfig` is loaded-only, so they sat outside the console's reverse-parity population until objectui#8176 loaded it. Declares the seven whose arm is SCALAR and whose read site changes behaviour: `groupBy`, `cardTitle`, `titleField`, `swimlaneField`, `coverImageField` on the board, and `defaultView` (the spec's three-member enum) + `locale` on the calendar. Both tags of each block now spread ONE shared list, so the two published surfaces cannot drift apart by hand-copy. The seven matching `UNPUBLISHED_EXEMPTIONS` entries are deleted in the same change and both halves of the shrink-only backlog ceiling move 15 -> 8. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- ...1-kanban-calendar-scalar-authoring-keys.md | 16 ++ .../registry-inputs-spec-parity.test.ts | 87 +++++--- ...larKeysAreDeclaredAndHonoured-8201.test.ts | 165 ++++++++++++++ packages/plugin-calendar/src/index.tsx | 95 ++++++-- ...larKeysAreDeclaredAndHonoured-8201.test.ts | 208 ++++++++++++++++++ packages/plugin-kanban/src/index.tsx | 100 ++++++++- 6 files changed, 616 insertions(+), 55 deletions(-) create mode 100644 .changeset/8201-kanban-calendar-scalar-authoring-keys.md create mode 100644 packages/plugin-calendar/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts create mode 100644 packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts diff --git a/.changeset/8201-kanban-calendar-scalar-authoring-keys.md b/.changeset/8201-kanban-calendar-scalar-authoring-keys.md new file mode 100644 index 0000000000..e98c998ec1 --- /dev/null +++ b/.changeset/8201-kanban-calendar-scalar-authoring-keys.md @@ -0,0 +1,16 @@ +--- +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-calendar': patch +--- + +`object-kanban` / `view:kanban` and `object-calendar` / `view:calendar` now DECLARE seven +spec-carried keys their renderers already honoured, so the html tier stops reporting working +metadata as `unknown-prop` (objectui#8201, the backlog objectui#8176 exposed). + +Board: `groupBy`, `cardTitle`, `titleField`, `swimlaneField`, `coverImageField`. +Calendar: `defaultView` (the spec's three-member enum) and `locale`. + +No renderer behaviour changes — every one of these keys was already read and already acted on; +what changes is that authoring tools can now discover them and the save gate agrees with the +validator. Both tags of each block now spread ONE shared `inputs` list, so the two published +surfaces cannot drift apart by hand-copy. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 8c7b5ab51c..3f295be3e2 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -997,8 +997,9 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // ── objectui#8176: the two lazily-registered blocks, judged for the first ── - // time. Sixteen keys, two owners, every one of them A DECLARATION - // SOMEONE OWES (the arm this map's docblock above says must name itself). + // time. EIGHT keys left of the eighteen that entered, and all eight are + // still A DECLARATION SOMEONE OWES (the arm this map's docblock above + // says must name itself) — none of them has become a ruled carve-out. // // ⚠️ IT STARTED AT EIGHTEEN, and the two that left are the ledger doing // its job rather than a correction to it. `object-kanban.filter` and @@ -1027,6 +1028,26 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // `plugin-calendar` registrations (PR objectui#8223). Deleted, not updated — // the same documented exit the two `filter` entries took above, observed here // a second time. + // ⚠️ objectui#8201 took SEVEN more by the same exit, and WHICH seven is the + // slice boundary that card argued rather than an alphabet, so it is + // recorded here: `object-kanban`'s `groupBy`, `cardTitle`, `titleField`, + // `swimlaneField` and `coverImageField`, and `object-calendar`'s + // `defaultView` and `locale`. Every one of the seven is SCALAR-ARMED + // (`string`, or the three-member `enum`), so objectui#8212's tightened + // direction adds nothing to its obligation: `structuredInputs` below is + // keyed off the DECLARED arm, a scalar arm never enters that population, + // no `MEMBER_PINS` entry is owed and `MEMBER_PIN_EXEMPTION_CEILING` does + // not move. That is the MECHANICAL statement of the boundary, which is why + // it was chosen over "by package" — it is checkable rather than tasteful. + // The eight left behind are array/object-armed (`data`, `cardFields`, + // `grouping`, `conditionalFormatting`, `staticData`), coupled to one + // (`object-calendar.loading`, inert unless `data` is authored because + // `ObjectCalendar.tsx` applies it only under `hasExternalData`), or + // ESCALATED (`object-kanban.quickAdd` — measured NOT honoured, since + // `KanbanImpl` gates the control on `quickAdd && onQuickAdd` and nothing on + // the `ObjectKanban` path supplies that runtime slot; whether that is a + // permanent carve-out or a feature gap is a product ruling, so the entry + // stays as-is until the maintainer rules). // ⚠️ One correction that outlives the entry, because the rider note that // stood here asserted the opposite and a future reader would inherit it: // `sort` is NOT a pass-through key, and its member claim is NOT the @@ -1038,34 +1059,20 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // member instead (`field`, `order`, an omitted `order` meaning ascending, // and a member with no usable `field` dropped rather than invented). That // is a SHARPER member claim than a pass-through, not a weaker one. - 'object-kanban.groupBy': - 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', 'object-kanban.data': 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', - 'object-kanban.cardTitle': - 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', - 'object-kanban.titleField': - 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', 'object-kanban.cardFields': 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', - 'object-kanban.swimlaneField': - 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', 'object-kanban.grouping': 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', 'object-kanban.quickAdd': 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', - 'object-kanban.coverImageField': - 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', 'object-kanban.conditionalFormatting': 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', - 'object-calendar.defaultView': - 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', 'object-calendar.data': 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', 'object-calendar.staticData': 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', - 'object-calendar.locale': - 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', 'object-calendar.loading': 'Newly JUDGED rather than newly missing: the console registers this block with `registerLazy`, so it sat outside the population of this gate entirely until objectui#8176 loaded it. A DECLARATION SOMEONE OWES, not a ruled carve-out — nobody has yet asked, per key, against the read sites in the renderer, whether it should be published or carved out. objectui#8201 owns that question and its answer deletes this entry. objectui#8176.', @@ -2893,19 +2900,39 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // direction — greening a fresh divergence on these two blocks by writing a // nineteenth entry instead of declaring the input. // - // Sixteen is the MEASURED backlog today, not a budget: ten undiscoverable - // keys on `object-kanban`, six on `object-calendar`. It was EIGHTEEN when - // these blocks entered the population; objectui#7712's declaration landed - // as objectui#8186 while this branch was open and took its two `filter` - // entries with it, which is the first observed round-trip of the exit this - // ceiling is paired with. Landing objectui#8201 / objectui#8171 lowers it - // again. A new divergence on these blocks is a plain defect and gets - // declared, exactly as it would on any other covered block. + // EIGHT is the MEASURED backlog today, not a budget: five undiscoverable + // keys on `object-kanban`, three on `object-calendar`. The number has come + // down three times and every step was this ceiling's paired EXIT — a + // declaration retiring its own cover — rather than a re-derivation: + // + // 18 these blocks enter the population (objectui#8176) + // 16 objectui#8186 lands objectui#7712's two `filter` declarations + // 15 objectui#8223 lands objectui#8171's `object-calendar.sort` + // 8 objectui#8201 lands its first slice — seven scalar-armed keys + // + // ⚠️ The prose that stood here read "Sixteen … ten on `object-kanban`, six + // on `object-calendar`" while both assertions already read 15: objectui#8223 + // moved the numbers and not the sentence describing them. Corrected in + // passing, and worth a line because a stale count here is exactly the kind + // of drift that makes a reader distrust the assertions instead of the prose. + // + // A new divergence on these blocks is a plain defect and gets declared, + // exactly as it would on any other covered block. + // + // ⚠️ ONE OF THE REMAINING EIGHT MAY NOT LEAVE BY A DECLARATION AT ALL. + // `object-kanban.quickAdd` is measured NOT honoured by this renderer + // (objectui#8201): `KanbanImpl` gates the control on `quickAdd && + // onQuickAdd`, and no producer on the `ObjectKanban` path supplies that + // runtime slot. Its disposition — permanent carve-out, or a feature gap + // whose fix makes the key declarable — is a PRODUCT ruling that card + // escalated rather than guessed, so its entry is unchanged. A future card + // lowering this number on that entry must say which of the two happened. // - // ⚠️ The `toBe` below is EXACT in both directions on purpose, so the card - // that lands a declaration must lower this number in the same change. That - // is a rider objectui#8171's PR objectui#8223 owes if it lands after this - // one — see the note on `object-calendar.sort` in `UNPUBLISHED_EXEMPTIONS`. + // ⚠️ BOTH assertions below carry the ceiling and BOTH must move together. + // The `toBe` is EXACT in both directions on purpose, so the card that lands + // a declaration lowers this number in the same change; lowering only the + // `toBe` would leave `toBeLessThanOrEqual` banking headroom the ratchet + // exists to refuse. const backlog = Object.keys(UNPUBLISHED_EXEMPTIONS).filter((key) => LAZY_REGISTERED_BLOCKS.includes(splitExemptionKey(key)[0]), ); @@ -2914,14 +2941,14 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) 'a new unpublished-key exemption was added on a block objectui#8176 newly ' + 'judged — declare the input at its registration site instead; the ' + 'backlog list is shrink-only', - ).toBeLessThanOrEqual(15); + ).toBeLessThanOrEqual(8); // Lower it here when the owning cards land, so the ceiling keeps ratcheting // rather than banking the headroom their fixes free up. expect( backlog.length, 'the objectui#8176 backlog shrank — lower the ceiling above to match, in ' + 'the same change that declared the input', - ).toBe(15); + ).toBe(8); }); it('the eight tombstoned keys are recognised, not exempted — and not published either', () => { diff --git a/packages/plugin-calendar/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts b/packages/plugin-calendar/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts new file mode 100644 index 0000000000..8fd73218af --- /dev/null +++ b/packages/plugin-calendar/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts @@ -0,0 +1,165 @@ +/** + * 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#8201 — `defaultView` and `locale` are DECLARED authoring surface on + * every tag `ObjectCalendarRenderer` is published under. + * + * ## The defect this pins closed + * + * `@objectstack/spec`'s `ComponentPropsMap['object-calendar']` declares nine + * top-level keys; both registrations published four. `ObjectCalendar.tsx` seeds + * its `view` state from `schema.defaultView`, and `index.tsx` resolves + * `rest.locale` through `Intl.getCanonicalLocales` and forwards it to the + * component's date/time formatting — so both keys changed behaviour while the + * html tier reported them as `unknown-prop` (objectui#6678's shape). + * + * The gap was structural: the console registers this block with `registerLazy` + * and `getConfig` is loaded-only, so it sat outside the console's + * reverse-parity population until objectui#8176 loaded it. + * + * ## The rows + * + * 1-4 are the objectui#7712 house form (html tier per tag PER KEY, its control, + * the registry declaration with `objectName` as non-vacuity, and the spec's key + * verdict with a bogus control on the same `safeParse` call — objectui#8172's + * lesson, since `limit` is taught by four faces and refused BY NAME by this + * same strict map). Row 5 pins that both tags share ONE list object. + * + * Row 6 is specific to `defaultView` and is the reason this key needed care: + * an `enum` arm is judged EXACTLY by the console's parity gate — every declared + * member must be a value the contract accepts — and `agenda` was RETIRED from + * this enum (objectui#5784, pinned by `@object-ui/types`' + * `default-view-agenda-retired.test.ts`). So the declared member list is + * DERIVED from the spec's verdicts here rather than restated, with the retired + * spelling as the control that the derivation is a real reading. + * + * ## What this file does NOT claim + * + * `locale`'s end-to-end honour row already exists and is not duplicated here: + * `object-calendar-renderer.propsContract.test.tsx`'s + * "MUST-NOT-CHANGE: a well-formed `locale` still reaches the component" authors + * `locale: 'de-DE'` on the NODE and asserts it arrives. `defaultView`'s honour + * verdict rests on the read-site measurement recorded in `index.tsx`'s + * `OBJECT_CALENDAR_INPUTS` docblock — its sink is a `useState` lazy initializer + * inside `ObjectCalendar`, so a behaviour row costs a full data-source render. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; +// Module scope, not a hook: this import IS the registration (AGENTS.md's +// test-discipline section). +import '../index'; + +/** The two tags this one renderer is published under. */ +const CALENDAR_TAGS = [ + { label: 'object-calendar', type: 'object-calendar', namespace: 'plugin-calendar' }, + { label: 'view:calendar', type: 'calendar', namespace: 'view' }, +] as const; + +/** The two keys objectui#8201 declared, each with a value its arm admits. */ +const DECLARED_SCALAR_KEYS = [ + { key: 'defaultView', value: 'week' }, + { key: 'locale', value: 'de-DE' }, +] as const; + +/** Every (tag, key) pair, so a red row names both halves. */ +const TAG_KEY_ROWS = CALENDAR_TAGS.flatMap((tag) => + DECLARED_SCALAR_KEYS.map((entry) => ({ ...tag, ...entry })), +); + +/** The `defaultView` spellings this repo has ever taught, retired ones included. */ +const CANDIDATE_VIEWS = ['month', 'week', 'day', 'agenda', 'list', 'year', 'timeline'] as const; + +const declaredInputs = (type: string, namespace?: string): any[] => + ((ComponentRegistry.getConfig(type, namespace) as any)?.inputs ?? []); + +const declaredInputNames = (type: string, namespace?: string): string[] => + declaredInputs(type, namespace).map((i: any) => i.name); + +const liveManifest = () => + manifestFromConfigs( + ComponentRegistry.getKnownTypes().map((type) => { + const meta = ComponentRegistry.getMeta(type); + return { type, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs }; + }) as unknown as Parameters[0], + ); + +const unknownProps = (type: string, props: Record): string[] => + validateTree({ type, objectName: 'event', ...props } as never, liveManifest()) + .diagnostics.filter((d) => d.code === 'unknown-prop') + .map((d) => d.message); + +const calendarSpec = () => (ComponentPropsMap as Record)['object-calendar']; + +const refusedByName = (props: Record): string[] => { + const parsed = calendarSpec().safeParse(props); + return parsed.success ? [] : parsed.error.issues.flatMap((issue: any) => issue.keys ?? []); +}; + +/** Does the contract accept this `defaultView` spelling? */ +const specAcceptsView = (value: string): boolean => + calendarSpec().safeParse({ objectName: 'event', defaultView: value }).success; + +describe('objectui#8201 — object-calendar publishes the scalar keys it reads', () => { + it.each(TAG_KEY_ROWS)('$label — the html tier accepts an authored `$key`', ({ type, key, value }) => { + expect( + unknownProps(type, { [key]: value }), + `<${type}> reports the spec-declared \`${key}\` as unknown while the renderer honours it`, + ).toEqual([]); + }); + + it.each(CALENDAR_TAGS)('$label — control: a genuinely unknown prop is still reported', ({ type }) => { + expect(unknownProps(type, { bogusProp: 'x' })).toEqual([`<${type}> has no prop "bogusProp"`]); + }); + + it.each(TAG_KEY_ROWS)('$label — the registration declares `$key`', ({ type, namespace, key }) => { + const declared = declaredInputNames(type, namespace); + expect(declared, `${type} inputs`).toContain('objectName'); + expect(declared, `${type} inputs`).toContain(key); + }); + + it('the spec accepts both keys together, so this declares rather than widens', () => { + const authored = Object.fromEntries(DECLARED_SCALAR_KEYS.map(({ key, value }) => [key, value])); + const refused = refusedByName({ objectName: 'event', ...authored }); + for (const { key } of DECLARED_SCALAR_KEYS) { + expect(refused, `the spec refuses \`${key}\` by name`).not.toContain(key); + } + // The control for that zero, on the same strict schema. + expect(refusedByName({ objectName: 'event', bogusProp: 'x' })).toContain('bogusProp'); + }); + + it('both tags publish ONE shared list, so a hand-copy cannot drift', () => { + const [a, b] = CALENDAR_TAGS.map(({ type, namespace }) => declaredInputs(type, namespace)); + expect(a.length, 'object-calendar declares no inputs at all').toBeGreaterThan(0); + expect(a.map((i: any) => i.name)).toEqual(b.map((i: any) => i.name)); + expect(a.map((i: any) => i.name)).toEqual( + expect.arrayContaining(DECLARED_SCALAR_KEYS.map(({ key }) => key)), + ); + }); + + it.each(CALENDAR_TAGS)( + '$label — the declared `defaultView` members are DERIVED from the spec, retired spellings excluded', + ({ type, namespace }) => { + const declared = declaredInputs(type, namespace).find((i: any) => i.name === 'defaultView'); + expect(declared, `${type} declares no defaultView input`).toBeTruthy(); + expect(declared.type, 'defaultView must carry the enum arm the gate judges EXACTLY').toBe('enum'); + + const accepted = CANDIDATE_VIEWS.filter(specAcceptsView); + const refused = CANDIDATE_VIEWS.filter((v) => !specAcceptsView(v)); + // Both halves must be non-empty, or "derived from the spec" is vacuous: + // an all-accepting contract would make the first line trivially true. + expect(accepted.length, 'the contract accepted nothing — the probe is broken').toBeGreaterThan(0); + expect(refused, 'the contract refused nothing — this is not a closed enum').toContain('agenda'); + + expect([...declared.enum].sort()).toEqual([...accepted].sort()); + }, + ); +}); diff --git a/packages/plugin-calendar/src/index.tsx b/packages/plugin-calendar/src/index.tsx index 4c62366695..724b54c788 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; +import type { ComponentInput } from '@object-ui/types'; import { ElementDataSourceGate, useSchemaContext, @@ -290,27 +291,95 @@ export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any ); }); +/** + * The authoring surface both `ObjectCalendarRenderer` tags publish, spelled + * ONCE and spread into both registrations (objectui#8201). + * + * ## Why it is shared rather than hand-copied + * + * `object-calendar` and `view:calendar` are the SAME renderer, so a hand-copy + * is the only way the two lists could ever disagree. Sharing removes that + * failure mode structurally rather than by review attention. + * + * ## Why these two keys were added + * + * `@objectstack/spec`'s `ComponentPropsMap['object-calendar']` declares nine + * top-level keys; this list published four. The gap was STRUCTURAL rather than + * considered — the console registers this block with + * `ComponentRegistry.registerLazy` and `getConfig` is loaded-only by design, so + * the block sat outside the console's reverse-parity population entirely until + * objectui#8176 loaded it. objectui#8201 asked the per-key question. + * + * Both keys were measured against a read site that CHANGES BEHAVIOUR, and the + * two arrive through DIFFERENT channels, which is worth spelling out because + * the channel is what makes each honoured: + * + * - `defaultView` — read off the SCHEMA (`ObjectCalendar.tsx` seeds its + * `view` state from `schema.defaultView`), so the calendar opens on the + * authored grid. A narrow viewport downgrades `day` to `month`; the + * author's choice is otherwise honoured on first render. + * - `locale` — read off the PROPS channel this boundary resolves + * (`resolveAuthoredLocale(rest.locale)`), which is where `SchemaRenderer` + * delivers a node's own authored keys. It forwards only a tag + * `Intl.getCanonicalLocales` accepts, and the forwarded value reaches + * `toLocaleDateString` / `toLocaleTimeString` inside the component. Pinned + * already by `object-calendar-renderer.propsContract.test.tsx`'s + * MUST-NOT-CHANGE row, which authors `locale: 'de-DE'` on the NODE. + * + * `defaultView`'s declared members are the spec's own three and nothing else. + * That is load-bearing: this gate judges an `enum` arm EXACTLY (every declared + * member must be a value the contract accepts), and `agenda` was RETIRED from + * this enum (objectui#5784, pinned by `default-view-agenda-retired.test.ts`) — + * so a fourth member copied from an older doc would be a red arm, not a nicety. + * + * ## What declaring them widens, and on what grounds (clause ②) + * + * Declaring an input WIDENS the authoring surface, so the grounds are stated: + * the SPEC already declares both and the RENDERER already honours both — the + * identical grounds objectui#8186 (`filter`) and objectui#8223 (`sort`) cleared + * on. Measured with a control on the same `safeParse` call, because "the spec + * declares it" is the assumption objectui#8172 falsified for `limit`. + * + * ## What is deliberately NOT here yet + * + * Three of the nine keys stay undeclared, each keeping its live entry in + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: + * + * - `data` and `staticData` are array-armed, which objectui#8212 made a + * three-part obligation whose `MEMBER_PINS` entry must be measured at the + * sink rather than assumed. Sliced out with their kanban counterparts. + * - `loading` is scalar, and is held back for a different reason: it is INERT + * on its own. `ObjectCalendar.tsx` applies `externalLoading` only under + * `hasExternalData`, so an authored `loading` does nothing unless `data` is + * authored too — and publishing half of a coupled pair advertises a key an + * author cannot make do anything. It goes out WITH `data`, in the slice + * that measures `data`'s member shape. + * + * The declarations are pinned by + * `__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts`, per tag and per + * key, so removing one from this list reddens a NAMED row rather than a file. + */ +const OBJECT_CALENDAR_INPUTS: ComponentInput[] = [ + { name: 'objectName', type: 'string', required: true }, + { name: 'calendar', type: 'object', description: 'startDateField, endDateField, titleField, colorField' }, + { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the calendar fetches. Lowered to `$filter` on the query.' }, + { name: 'sort', type: 'array', description: 'Sort order in `[{ field, order }]` form, ordering the records the calendar fetches. Lowered to `$orderby` on the query.' }, + { name: 'defaultView', type: 'enum', enum: ['month', 'week', 'day'], description: 'The grid the calendar opens on. A narrow viewport downgrades `day` to `month`; the author’s choice is otherwise honoured on first render.' }, + { name: 'locale', type: 'string', description: 'BCP-47 tag used to format the dates and times this calendar renders. A tag `Intl` refuses is dropped — the same answer as an absent key — rather than passed through to throw out of render.' }, +]; + ComponentRegistry.register('object-calendar', ObjectCalendarRenderer, { namespace: 'plugin-calendar', label: 'Object Calendar', category: 'view', - inputs: [ - { name: 'objectName', type: 'string', required: true }, - { name: 'calendar', type: 'object', description: 'startDateField, endDateField, titleField, colorField' }, - { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the calendar fetches. Lowered to `$filter` on the query.' }, - { name: 'sort', type: 'array', description: 'Sort order in `[{ field, order }]` form, ordering the records the calendar fetches. Lowered to `$orderby` on the query.' }, - ], + inputs: [...OBJECT_CALENDAR_INPUTS], }); ComponentRegistry.register('calendar', ObjectCalendarRenderer, { namespace: 'view', label: 'Calendar View', category: 'view', - // Same renderer as `object-calendar`, therefore the same declared surface. - inputs: [ - { name: 'objectName', type: 'string', required: true }, - { name: 'calendar', type: 'object', description: 'startDateField, endDateField, titleField, colorField' }, - { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the calendar fetches. Lowered to `$filter` on the query.' }, - { name: 'sort', type: 'array', description: 'Sort order in `[{ field, order }]` form, ordering the records the calendar fetches. Lowered to `$orderby` on the query.' }, - ], + // Same renderer as `object-calendar`, therefore the same declared surface — + // now SHARED rather than hand-copied (objectui#8201). + inputs: [...OBJECT_CALENDAR_INPUTS], }); diff --git a/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts b/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts new file mode 100644 index 0000000000..8fd6d1f979 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts @@ -0,0 +1,208 @@ +/** + * 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#8201 — the five SCALAR-armed `object-kanban` keys the renderer + * honours are DECLARED authoring surface on every tag it is published under. + * + * ## The defect this pins closed + * + * `@objectstack/spec`'s `ComponentPropsMap['object-kanban']` declares thirteen + * top-level keys. Both registrations published three of them (four after + * objectui#8186 added `filter`), while `ObjectKanban.tsx` read five more and + * changed behaviour on every one. Since `sdui-parser`'s `validate.ts` reports + * `unknown-prop` for every key no `inputs` entry claims, the html tier told an + * author that spellings that WORK are unknown — objectui#6678's shape, which + * trains authors (AI authors included) to delete working metadata. + * + * The gap was structural rather than considered: the console registers this + * block with `registerLazy` and `getConfig` is loaded-only, so the block sat + * outside the console's reverse-parity population until objectui#8176 loaded + * it. This file is the per-key pin for the five objectui#8201 judged DECLARE. + * + * ## The rows, and what makes each a reading + * + * 1. THE HTML TIER ACCEPTS IT — the real validator over a manifest built from + * the LIVE registry (never a hand-written one that could agree with itself), + * per tag and PER KEY. Splitting the rows per key is what makes this a + * per-registration pin rather than a per-file one: dropping one entry from + * `OBJECT_KANBAN_INPUTS` reddens the two rows that NAME that key, not an + * opaque file failure. + * 2. THE CONTROL, on the same call — a prop the component genuinely does not + * declare is still reported. Without it, row 1's empty array would also be + * produced by a validator that reported nothing at all. + * 3. THE DECLARATION, read straight off the registry, with `objectName` as its + * non-vacuity control. + * 4. THE CONTRACT AGREES — the spec accepts each key (asserted as a KEY + * verdict: the name is absent from `unrecognized_keys`) with a bogus key on + * the same `safeParse` call proving the zero is a verdict and not a vacuous + * read. This row is objectui#8172's lesson made mechanical: `limit` is + * taught by four faces and refused BY NAME by this same strict map, so + * "the spec declares it" is a claim that must be measured, never assumed. + * 5. ONE SHARED LIST — both tags are the same renderer, so the declared + * surfaces must be the same OBJECT, not two lists that happen to agree. + * 6. HONOURED, at the read site — `bucketCardsIntoColumns` is the exported, + * pure sink for `groupBy` and `coverImageField`, so their honour claim is + * asserted here rather than only argued. + * + * ## What this file does NOT claim, stated so nobody reads it as claimed + * + * `cardTitle`, `titleField` and `swimlaneField` carry rows 1-5 but no row 6. + * Their resolution is inline in `ObjectKanban`'s `effectiveData` / + * `effectiveSwimlaneField` memos rather than in an exported resolver, so a + * behaviour row costs a full data-source render. Their honour verdict rests on + * the read-site measurement recorded in `index.tsx`'s `OBJECT_KANBAN_INPUTS` + * docblock, not on an assertion in this file. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; +// Module scope, not a hook: this import IS the registration (AGENTS.md's +// test-discipline section — an unbounded module load must not be billed to a +// bounded window). +import { bucketCardsIntoColumns } from '../index'; +import '../index'; + +/** The two tags this one renderer is published under. */ +const KANBAN_TAGS = [ + { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, + { label: 'view:kanban', type: 'kanban', namespace: 'view' }, +] as const; + +/** + * The five keys objectui#8201 declared, each with a value of the arm the spec + * accepts. All five are `string`-armed on both sides. + */ +const DECLARED_SCALAR_KEYS = [ + { key: 'groupBy', value: 'stage' }, + { key: 'cardTitle', value: 'name' }, + { key: 'titleField', value: 'name' }, + { key: 'swimlaneField', value: 'owner' }, + { key: 'coverImageField', value: 'thumbnail' }, +] as const; + +/** Every (tag, key) pair, so a red row names both halves. */ +const TAG_KEY_ROWS = KANBAN_TAGS.flatMap((tag) => + DECLARED_SCALAR_KEYS.map((entry) => ({ ...tag, ...entry })), +); + +const declaredInputNames = (type: string, namespace?: string): string[] => + ((ComponentRegistry.getConfig(type, namespace) as any)?.inputs ?? []).map((i: any) => i.name); + +/** + * A manifest built the way `gen-manifest.ts` and the JSX-page compiler build + * theirs — from the live registry — so these verdicts are the ones a real + * author gets, not the ones a fixture was written to produce. + */ +const liveManifest = () => + manifestFromConfigs( + ComponentRegistry.getKnownTypes().map((type) => { + const meta = ComponentRegistry.getMeta(type); + return { type, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs }; + }) as unknown as Parameters[0], + ); + +/** `unknown-prop` messages a one-node document draws for `props`. */ +const unknownProps = (type: string, props: Record): string[] => + validateTree({ type, objectName: 'task', ...props } as never, liveManifest()) + .diagnostics.filter((d) => d.code === 'unknown-prop') + .map((d) => d.message); + +const kanbanSpec = () => (ComponentPropsMap as Record)['object-kanban']; + +/** The key names a strict parse refuses BY NAME on this block. */ +const refusedByName = (props: Record): string[] => { + const parsed = kanbanSpec().safeParse(props); + return parsed.success ? [] : parsed.error.issues.flatMap((issue: any) => issue.keys ?? []); +}; + +describe('objectui#8201 — object-kanban publishes the scalar keys it reads', () => { + it.each(TAG_KEY_ROWS)('$label — the html tier accepts an authored `$key`', ({ type, key, value }) => { + expect( + unknownProps(type, { [key]: value }), + `<${type}> reports the spec-declared \`${key}\` as unknown while ObjectKanban honours it`, + ).toEqual([]); + }); + + it.each(KANBAN_TAGS)('$label — control: a genuinely unknown prop is still reported', ({ type }) => { + expect(unknownProps(type, { bogusProp: 'x' })).toEqual([`<${type}> has no prop "bogusProp"`]); + }); + + it.each(TAG_KEY_ROWS)('$label — the registration declares `$key`', ({ type, namespace, key }) => { + const declared = declaredInputNames(type, namespace); + // Non-vacuity: an empty read (wrong type/namespace) fails this line too, + // rather than silently satisfying the assertion below it. + expect(declared, `${type} inputs`).toContain('objectName'); + expect(declared, `${type} inputs`).toContain(key); + }); + + it('the spec accepts all five keys together, so this declares rather than widens', () => { + const authored = Object.fromEntries(DECLARED_SCALAR_KEYS.map(({ key, value }) => [key, value])); + const refused = refusedByName({ objectName: 'task', ...authored }); + for (const { key } of DECLARED_SCALAR_KEYS) { + expect(refused, `the spec refuses \`${key}\` by name`).not.toContain(key); + } + // The control for that zero, on the same strict schema: a key it never + // declared IS refused by name. Without this the assertions above would also + // pass against a schema that refused nothing (objectui#8172's `limit`). + expect(refusedByName({ objectName: 'task', bogusProp: 'x' })).toContain('bogusProp'); + }); + + it('both tags publish ONE shared list, so a hand-copy cannot drift', () => { + const [a, b] = KANBAN_TAGS.map( + ({ type, namespace }) => (ComponentRegistry.getConfig(type, namespace) as any)?.inputs, + ); + expect(a, 'object-kanban declares no inputs at all').toBeTruthy(); + expect(a.map((i: any) => i.name)).toEqual(b.map((i: any) => i.name)); + expect(a.map((i: any) => i.name)).toEqual( + expect.arrayContaining(DECLARED_SCALAR_KEYS.map(({ key }) => key)), + ); + }); + + it('honoured — `groupBy` buckets records into the lane its value names', () => { + const columns = [ + { id: 'todo', title: 'To Do' }, + { id: 'in_progress', title: 'In Progress' }, + ]; + const data = [ + { id: '1', stage: 'todo' }, + { id: '2', stage: 'in_progress' }, + ]; + const bucketed = bucketCardsIntoColumns(columns, data, 'stage', undefined, 'Uncategorized'); + expect(bucketed.find((c) => c.id === 'todo')!.cards.map((c: any) => c.id)).toEqual(['1']); + expect(bucketed.find((c) => c.id === 'in_progress')!.cards.map((c: any) => c.id)).toEqual(['2']); + // The control for that read: the SAME rows with no `groupBy` reach no lane, + // so the split above is the key doing work rather than the fixture. + const ungrouped = bucketCardsIntoColumns(columns, data, undefined, undefined, 'Uncategorized'); + expect(ungrouped.every((c) => c.cards.length === 0)).toBe(true); + }); + + it('honoured — `coverImageField` becomes the card cover `KanbanImpl` renders', () => { + const columns = [{ id: 'todo', title: 'To Do' }]; + const data = [ + { id: '1', stage: 'todo', thumbnail: 'https://cdn.example/a.png' }, + // The file-object spelling the mapper also accepts. + { id: '2', stage: 'todo', thumbnail: { url: 'https://cdn.example/b.png' } }, + // Neither a URL string nor a `{ url }` — must stay uncovered. + { id: '3', stage: 'todo', thumbnail: 42 }, + ]; + const cards = bucketCardsIntoColumns(columns, data, 'stage', 'thumbnail', 'Uncategorized')[0] + .cards as any[]; + expect(cards.map((c) => c.coverImage)).toEqual([ + 'https://cdn.example/a.png', + 'https://cdn.example/b.png', + undefined, + ]); + // The control: without the key, the same rows carry no cover at all. + const uncovered = bucketCardsIntoColumns(columns, data, 'stage', undefined, 'Uncategorized')[0] + .cards as any[]; + expect(uncovered.map((c) => c.coverImage)).toEqual([undefined, undefined, undefined]); + }); +}); diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index e2006771a8..cde9f2fe77 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -15,7 +15,7 @@ import { } from '@object-ui/react'; import { Skeleton } from '@object-ui/components'; import { createSafeTranslation } from '@object-ui/i18n'; -import type { KanbanConditionalFormattingRule } from '@object-ui/types'; +import type { ComponentInput, KanbanConditionalFormattingRule } from '@object-ui/types'; import { ObjectKanban } from './ObjectKanban'; /** @@ -411,6 +411,89 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> ); }); +/** + * The authoring surface both `ObjectKanbanRenderer` tags publish, spelled ONCE + * and spread into both registrations (objectui#8201). + * + * ## Why it is shared rather than hand-copied + * + * `object-kanban` and `view:kanban` are the SAME renderer, so the only way the + * two lists could ever disagree is a hand-copy that missed one — which is + * precisely what this card found: `filter` reached both because objectui#8186 + * edited both, but nothing structural said it had to. + * + * ## Why these five keys were added + * + * `@objectstack/spec`'s `ComponentPropsMap['object-kanban']` declares thirteen + * top-level keys; this list published three until objectui#8186 added `filter`. + * The gap was STRUCTURAL rather than considered — the console registers this + * block with `ComponentRegistry.registerLazy` and `getConfig` is loaded-only by + * design, so the block sat outside the console's reverse-parity population + * entirely until objectui#8176 loaded it. objectui#8201 asked the per-key + * question that census never got to ask. + * + * Each key below was measured against a read site that CHANGES BEHAVIOUR on the + * `ObjectKanban` path — not a mention, and not a read site belonging to the + * sibling `kanban-ui` block, which is a different renderer with a different + * declared surface: + * + * - `groupBy` — `ObjectKanban.tsx` materializes the lanes from this field's + * picklist options, `bucketCardsIntoColumns` buckets records by its value, + * and a drag between lanes writes the new value back to the record. + * - `cardTitle` / `titleField` — one choice with two spellings, `cardTitle` + * first; it selects the record field rendered as the card title. + * - `swimlaneField` — becomes `effectiveSchema.swimlaneField`, which + * `KanbanImpl` splits the board into horizontal swimlanes on (and keys its + * per-lane collapsed-state storage by). + * - `coverImageField` — `bucketCardsIntoColumns` maps it onto each card's + * `coverImage`, which `KanbanImpl` renders as the card's ``. + * + * ## What declaring them widens, and on what grounds (clause ②) + * + * Declaring an input WIDENS the authoring surface, so the grounds are stated + * rather than assumed. They are the same grounds objectui#8186 (`filter`) and + * objectui#8223 (`sort`) cleared on: the SPEC already declares all five and the + * RENDERER already honours all five, so this restores `declared = enforced` + * instead of publishing anything new. Measured with a control on the same + * `safeParse` call — because "the spec declares it" is exactly the assumption + * objectui#8172 falsified for `limit`, which four faces teach and the strict + * `ComponentPropsMap` refuses BY NAME. An unrecognised probe key draws + * `unrecognized_keys` on these calls while none of these five does. + * + * ## What is deliberately NOT here yet + * + * Five of the thirteen keys stay undeclared, each keeping its live entry in + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: + * + * - `data`, `cardFields`, `grouping` and `conditionalFormatting` are + * array/object-armed. objectui#8212 made such a declaration a THREE-part + * obligation — the entry, the exemption deletion, and a `MEMBER_PINS` entry + * whose shape must be MEASURED at the sink rather than assumed (the error + * objectui#8223 had to correct for `sort`). That is a different and larger + * piece of work, and slicing it out is what keeps this change reviewable. + * - `quickAdd` is ESCALATED, not deferred: this renderer does not honour it + * at all. `KanbanImpl` gates the control on `quickAdd && onQuickAdd`, and + * `onQuickAdd` is an objectui#6124 RUNTIME SLOT the zod twin refuses by + * name; nothing on the `ObjectKanban` path supplies one. Whether that is a + * permanent carve-out or a feature gap is a product ruling, not a + * measurement, so objectui#8201 hands it to the maintainer rather than + * writing a carve-out reason it has no standing to write. + * + * The declarations are pinned by + * `__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts`, per tag and per + * key, so removing one from this list reddens a NAMED row rather than a file. + */ +const OBJECT_KANBAN_INPUTS: ComponentInput[] = [ + { name: 'objectName', type: 'string', required: true }, + { name: 'columns', type: 'array' }, + { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the board fetches. Lowered to `$filter` on the query.' }, + { name: 'groupBy', type: 'string', description: 'Record field whose value buckets cards into lanes. Its picklist options become the lanes when `columns` is absent, and a drag between lanes writes the target lane’s value back to the record. A value matching no lane lands in the trailing “Uncategorized” lane rather than disappearing.' }, + { name: 'cardTitle', type: 'string', description: 'Record field rendered as the card title. Read AHEAD of `titleField`, which is the legacy spelling of the same choice; when neither yields a value the shared record-display resolver names the card.' }, + { name: 'titleField', type: 'string', description: 'Legacy spelling of `cardTitle` — the record field rendered as the card title. `cardTitle` wins when both are authored.' }, + { name: 'swimlaneField', type: 'string', description: 'Record field that splits the board into horizontal swimlanes. When absent the board falls back to `grouping.fields[0].field`.' }, + { name: 'coverImageField', type: 'string', description: 'Record field holding a card cover image — a URL string, or a file object carrying a `url`. Any other value leaves the card without a cover.' }, +]; + ComponentRegistry.register( 'object-kanban', ObjectKanbanRenderer, @@ -418,11 +501,7 @@ ComponentRegistry.register( namespace: 'plugin-kanban', label: 'Object Kanban', category: 'view', - inputs: [ - { name: 'objectName', type: 'string', required: true }, - { name: 'columns', type: 'array' }, - { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the board fetches. Lowered to `$filter` on the query.' } - ] + inputs: [...OBJECT_KANBAN_INPUTS], } ); ComponentRegistry.register( @@ -432,11 +511,8 @@ ComponentRegistry.register( namespace: 'view', label: 'Kanban Board', category: 'view', - // Same renderer as `object-kanban`, therefore the same declared surface. - inputs: [ - { name: 'objectName', type: 'string', required: true }, - { name: 'columns', type: 'array' }, - { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the board fetches. Lowered to `$filter` on the query.' } - ] + // Same renderer as `object-kanban`, therefore the same declared surface — + // now SHARED rather than hand-copied (objectui#8201). + inputs: [...OBJECT_KANBAN_INPUTS], } ); \ No newline at end of file