diff --git a/.changeset/7780-object-kanban-record-source.md b/.changeset/7780-object-kanban-record-source.md new file mode 100644 index 0000000000..661707c8a0 --- /dev/null +++ b/.changeset/7780-object-kanban-record-source.md @@ -0,0 +1,68 @@ +--- +'@object-ui/types': minor +--- + +`ObjectKanbanSchema.objectName` becomes a PRESENCE RULE on both faces — at least one of +`bind`, `data`, `objectName` (objectui#7780). + +`packages/plugin-kanban/src/ObjectKanban.tsx` resolves a board's rows in four steps: the +pre-fetched `data` PROP a parent passes (`hasExternalData`), then `bind` through +`useDataScope(schema.bind)`, then the inline ROW ARRAY on `schema.data`, and only then a +fetch keyed by `schema.objectName` — `rawData = (hasExternalData ? externalData : +undefined) || boundData || schema.data || fetchedData`, with the fetch itself gated on +`schema.objectName && !boundData && !schema.data`. Every `objectName` read is guarded. +Both published faces nevertheless REQUIRED `objectName`, so a `bind`-only or `data`-only +board — one that renders correctly today — was refused by the shipped validator and could +not be annotated with its own type. + +**The accept set moves in one direction only.** Measured from source on `origin/main` +`fff250ff` and on this branch, both entry paths (`ObjectKanbanSchema.safeParse` and the +published `safeValidateSchema`), against a rebuilt `dist`: + +| document (all carry `groupBy`) | before | after | +| --- | --- | --- | +| `bind` only | refused at `objectName` (`invalid_type`) | **accepted** | +| `data` only (raw rows) | refused at `objectName` (`invalid_type`) | **accepted** | +| `objectName` only | accepted | accepted | +| `objectName: ''` | accepted | accepted | +| none of the three | refused at `objectName` | refused **on the refinement** (`RECORD_SOURCE_REQUIRED`, root path) | + +Nothing went from accepted to refused. Presence is `!== undefined`, matching the sibling +predicate's wording rather than the renderer's truthiness, so an empty `objectName: ''` +keeps validating. + +**⚠️ Not the map / gantt / calendar ladder, and deliberately not built on it.** Those +three (objectui#6939, objectui#7313) resolve `data` as a `ViewData` PROVIDER BLOCK → +`staticData` → `objectName` through the shared `resolveRecordSourceConfig`, refined by +`requireRecordSource`. This board has **no** `staticData` rung, reads `data` as a **raw row +array** directly, **has** a `bind` rung the other three never walk, and calls +`getDataConfig` / `resolveRecordSourceConfig` **zero** times. The two key sets are neither +equal nor nested, so the predicate is a new one — `requireKanbanRecordSource` — and the +shared one is untouched. + +**objectui#7651 is a prior ruling and it holds.** It was ruled B and closed `not_planned` +on 2026-09-05, refusing a sixth `getDataConfig` producer, a `ViewData` retype of the +board's `data`, and a `staticData` rung; its epitaph is in the tree on +`KanbanSchema.data`. Nothing here adds a rung. `data` and `bind` stay INHERITED from +`BaseSchema` rather than re-declared on this member, and the new pin asserts that by +IDENTITY (`shape.data === BaseSchema.shape.data`) rather than by membership, because +membership cannot tell inherited from re-declared. + +**`groupBy` is untouched and stays REQUIRED** (objectui#7322, PR #7774). A record source +and a lane key are different questions: every lane-less document above is still refused at +`groupBy`, and the two readings PR #7774 excluded from counting as a lane-less mode — the +`dataSource` json fragment in `content/docs/utilities/data-objectstack.mdx` and +`ListView.tsx`'s runtime-generated node — are asserted still-refused in the new pin. The +retired `groupField` tombstone is likewise still refused by name. + +Landed on both faces per the zod-mirror-parity pairing: `objectName?: string` on the +`ObjectKanbanSchema` interface in `@object-ui/types`, `z.string().optional()` plus +`.superRefine(requireKanbanRecordSource)` on its mirror in `@object-ui/types/zod`. The key +sets are unchanged on both sides, so no parity ledger row and none of the objectui#7279 +header figures move. `object-calendar-record-source-7313.test.ts` loses the +`@ts-expect-error` it carried to record where this defect class ended — leaving it would +have reddened `TS2578` under `tsconfig.test.json` — and the literal it guarded now +compiles, which is the assertion. + +Marked `minor` per this repo's version-alignment rule: a published accept set widens, and +no declared shape narrows. diff --git a/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts index 1eade8eed9..6af84d2f71 100644 --- a/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts +++ b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts @@ -123,21 +123,32 @@ export const DATA_DOCUMENT: TsObjectCalendarSchema = { }; /** - * CLASS BOUNDARY, recorded without touching it (triage boundary 3 on - * objectui#7313): `ObjectKanbanSchema` still REQUIRES `objectName`. Its - * renderer reads `schema.data` ahead of the fetch and guards every - * `objectName` read, so it carries the same defect class — but that block is - * objectui#7322's, and this control only says where the class currently ends. - * When a card moves the kanban's `objectName` to optional, this directive is - * the line that card deletes. + * CLASS BOUNDARY — and it MOVED. This literal used to carry a + * `@ts-expect-error` reading "objectName is still required on + * ObjectKanbanSchema", recorded here by objectui#7313 as the one deliberate + * error in this file, with the note "when a card moves the kanban's + * `objectName` to optional, this directive is the line that card deletes". + * objectui#7780 is that card, and this is that deletion: the directive is gone + * and the literal now compiles, which is the assertion — an unused + * `@ts-expect-error` is `TS2578`, so leaving it would have reddened + * `tsconfig.test.json` rather than quietly meaning nothing. * - * `groupBy` is supplied because objectui#7322 made it the required lane key - * (and retired `groupField`), so `objectName` is the ONE member this literal - * is missing — the directive would otherwise be satisfied by an unrelated - * omission and stop saying anything about `objectName`. + * ⚠️ It is NOT the same fix as this file's own subject. `object-calendar` + * joined the `object-map` / `object-gantt` ladder — `data` (a `ViewData` + * provider block) → `staticData` → `objectName`, `requireRecordSource`. The + * kanban board walks its own: pre-fetched `data` prop → `bind` → an inline ROW + * ARRAY on `data` → `objectName`, with no `staticData` rung, and objectui#7651 + * (ruled B, closed `not_planned`) refuses giving it the shared one. Its + * refinement is `requireKanbanRecordSource`, written for that ladder, and the + * verdict table lives in `object-kanban-record-source-7780.test.ts`. + * + * `groupBy` is still supplied, and now for a second reason as well as the + * first: objectui#7322 made it the required lane key, so it was the only way + * `objectName` could be the ONE member this literal was missing — and + * objectui#7780 deliberately did NOT touch it, so this literal also witnesses + * that a record source and a lane key stayed different questions. */ -// @ts-expect-error — objectName is still required on ObjectKanbanSchema (the one deliberate error here) -export const KANBAN_STILL_REQUIRES_OBJECT_NAME: TsObjectKanbanSchema = { +export const KANBAN_NO_LONGER_REQUIRES_OBJECT_NAME: TsObjectKanbanSchema = { type: 'object-kanban', groupBy: 'status', }; diff --git a/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts b/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts new file mode 100644 index 0000000000..38b70c2c92 --- /dev/null +++ b/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts @@ -0,0 +1,311 @@ +/** + * 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#7780 — `ObjectKanbanSchema.objectName` becomes a PRESENCE RULE on + * both faces: at least one of `bind`, `data`, `objectName`. + * + * ## The defect + * + * `packages/plugin-kanban/src/ObjectKanban.tsx` — the component the + * `object-kanban` registration renders — resolves its rows in four steps: + * + * const boundData = useDataScope(schema.bind); + * if (schema.objectName && !boundData && !schema.data) { …fetch… } + * const rawData = (hasExternalData ? externalData : undefined) + * || boundData || schema.data || fetchedData; + * + * — the pre-fetched `data` PROP, then `bind`, then the inline ROW ARRAY on + * `schema.data`, and only then a fetch keyed by `schema.objectName`. Every + * `objectName` read is guarded. Yet both published faces REQUIRED + * `objectName`, so a `bind`-only or `data`-only board — which renders today — + * was refused by `ObjectKanbanSchema.safeParse` and by `safeValidateSchema`, + * and could not be annotated with its own type. Measured on `origin/main` + * `fff250ff`, both entry paths, pristine dist: + * + * bind only -> refused at `objectName` (invalid_type) + * data only -> refused at `objectName` (invalid_type) + * objectName only -> accepted + * none of the three-> refused at `objectName` (invalid_type) + * + * This file pins the post-fix vector — accepted / accepted / accepted / + * refused ON THE REFINEMENT — and, more to the point, WHY each verdict is what + * it is. + * + * ## ⚠️ NOT the map / gantt / calendar ladder, and that is a boundary + * + * objectui#6939 (map, gantt) and objectui#7313 (calendar) fixed the SAME defect + * CLASS on a DIFFERENT ladder: `data` as a `ViewData` PROVIDER BLOCK → + * `staticData` → `objectName`, resolved by the shared + * `resolveRecordSourceConfig` in `@object-ui/core`, refined by + * `requireRecordSource`. This board: + * + * - has NO `staticData` rung; + * - reads `data` as a RAW ROW ARRAY, directly, not through any resolver; + * - HAS a `bind` rung the other three never walk; + * - calls `resolveRecordSourceConfig` / `getDataConfig` ZERO times. + * + * So the predicate is `requireKanbanRecordSource`, written for this ladder, and + * the last two `it`s below are the CONTROL that keeps it that way: objectui#7651 + * ("Should `ObjectKanban` get a real record-source ladder?") was ruled B and + * closed `not_planned` on 2026-09-05, refusing a sixth `getDataConfig` producer, + * a `ViewData` retype of `data`, and a `staticData` rung. This card moved ONE + * thing — the requiredness of `objectName` — and this file fails if a later + * change quietly adds a rung under the same heading. + * + * ## ⛔ `groupBy` is untouched, and that is pinned too + * + * objectui#7322 / PR #7774 made `groupBy` the REQUIRED lane key. Its + * requiredness measurement deliberately EXCLUDED two readings from counting as + * a lane-less mode: the `dataSource` json fragment in + * `content/docs/utilities/data-objectstack.mdx`, and `ListView.tsx`'s + * runtime-generated node. A presence rule over `bind` / `data` / `objectName` + * must not incidentally overturn that, so the `groupBy` half of the vector is + * asserted here alongside the record-source half. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { ObjectKanbanSchema, ObjectCalendarSchema } from '../zod/objectql.zod'; +import { safeValidateSchema } from '../zod/index.zod'; +import { BaseSchema } from '../zod/base.zod'; +import type { ObjectKanbanSchema as TsObjectKanbanSchema } from '../objectql'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const RENDERER = 'packages/plugin-kanban/src/ObjectKanban.tsx'; +const LADDER_CONTROL = 'packages/plugin-calendar/src/ObjectCalendar.tsx'; + +/** + * The four documents the card's verdict table is written over. `groupBy` is on + * every one of them, so `objectName` is the ONE member under test — otherwise + * a row could go green or red for the lane key's reasons instead. + */ +const DOCUMENTS = { + bindOnly: { groupBy: 'status', bind: 'app.settings.users' }, + dataOnly: { groupBy: 'status', data: [{ id: 1, status: 'todo' }] }, + objectOnly: { groupBy: 'status', objectName: 'task' }, + none: { groupBy: 'status' }, +} as const; +type DocumentName = keyof typeof DOCUMENTS; +const DOCUMENT_NAMES = Object.keys(DOCUMENTS) as DocumentName[]; + +/** The refinement's message, spelled exactly as the member emits it. */ +const REFUSAL_MESSAGE = '`object-kanban` has no record source: declare one of `bind`, `data` or `objectName`'; + +function doc(name: DocumentName): Record { + return { type: 'object-kanban', ...DOCUMENTS[name] }; +} + +/** Report the issues rather than `false`, so a red run says what broke. */ +function reasons(schema: unknown): string[] { + const r = safeValidateSchema(schema); + return r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`); +} + +/* ── Type-level: the declaration half ───────────────────────────────────────── */ + +/** + * A `bind`-only board annotates. Before this card `TS2741: Property + * 'objectName' is missing` — which is why the two static fixtures under + * `packages/plugin-kanban/` still carry an `as never` escape naming this issue. + */ +export const BIND_ONLY_BOARD: TsObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + bind: 'app.settings.users', +}; + +/** A `data`-only board annotates — the inline ROW ARRAY, read ahead of the fetch. */ +export const DATA_ONLY_BOARD: TsObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [{ id: 1, status: 'todo' }], +}; + +/** The object-driven board the plugin page documents keeps annotating. */ +export const OBJECT_BOARD: TsObjectKanbanSchema = { + type: 'object-kanban', + objectName: 'opportunity', + groupBy: 'stage', + limit: 250, +}; + +/** + * `groupBy` stayed REQUIRED (objectui#7322). This is the ONE deliberate error + * in this file, and it is the control that keeps the widening from having + * reached the lane key: delete `groupBy`'s requiredness and `TS2578` (unused + * `@ts-expect-error`) reddens `tsconfig.test.json`. + */ +// @ts-expect-error — groupBy is still required on ObjectKanbanSchema (the one deliberate error here) +export const LANELESS_BOARD_STILL_REFUSED: TsObjectKanbanSchema = { + type: 'object-kanban', + objectName: 'task', +}; + +/* ── Runtime pins ───────────────────────────────────────────────────────────── */ + +describe('objectui#7780 — the four documents, through the member and the published entry point', () => { + it('`bind` alone validates — the rung `useDataScope(schema.bind)` reads', () => { + const d = doc('bindOnly'); + expect('objectName' in d).toBe(false); + expect(ObjectKanbanSchema.safeParse(d).success).toBe(true); + expect(reasons(d)).toEqual([]); + }); + + it('`data` alone validates — the inline ROW ARRAY, not a provider block', () => { + const d = doc('dataOnly'); + expect('objectName' in d).toBe(false); + expect(ObjectKanbanSchema.safeParse(d).success).toBe(true); + expect(reasons(d)).toEqual([]); + }); + + it('`objectName` alone still validates — the accept set only WIDENED, an empty name included', () => { + expect(ObjectKanbanSchema.safeParse(doc('objectOnly')).success).toBe(true); + expect(reasons(doc('objectOnly'))).toEqual([]); + // Presence is `!== undefined`, not the renderer's truthiness: `objectName: + // ''` validated before this card (a required `z.string()` accepts '') and + // must still validate, or the change would narrow something. + expect(ObjectKanbanSchema.safeParse({ type: 'object-kanban', groupBy: 'status', objectName: '' }).success).toBe(true); + }); + + it('NONE of the three is refused ON THE REFINEMENT — by name, not at `objectName`', () => { + const result = ObjectKanbanSchema.safeParse(doc('none')); + expect(result.success).toBe(false); + if (result.success) return; + // Exactly one issue, and it is the refinement's — not the `objectName` + // key-level failure this document used to get, which would make this case + // green for the wrong reason. + expect(result.error.issues).toHaveLength(1); + const issue = result.error.issues[0]; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual([]); + expect((issue as { params?: { code?: string } }).params?.code).toBe('RECORD_SOURCE_REQUIRED'); + expect(issue.message).toBe(REFUSAL_MESSAGE); + }); + + it('…and the published entry point agrees on all four, with the same message on the refusal', () => { + const vector = DOCUMENT_NAMES.map((n) => safeValidateSchema(doc(n)).success); + expect(vector).toEqual([true, true, true, false]); + // Non-vacuity: a member that accepted everything would "agree" too. + expect(new Set(vector).size).toBe(2); + expect(reasons(doc('none'))).toContain(`: ${REFUSAL_MESSAGE}`); + }); + + it('control: `BaseSchema` accepts all four — the refusal above is the refinement\'s, not the base\'s', () => { + for (const name of DOCUMENT_NAMES) { + expect(BaseSchema.safeParse(doc(name)).success).toBe(true); + } + }); +}); + +describe('objectui#7780 — `groupBy` requiredness is NOT overturned (objectui#7322 / PR #7774)', () => { + it.each(DOCUMENT_NAMES)('%s without `groupBy` is still refused AT `groupBy`', (name) => { + const { groupBy: _dropped, ...rest } = DOCUMENTS[name]; + void _dropped; + const laneless = { type: 'object-kanban', ...rest }; + const r = ObjectKanbanSchema.safeParse(laneless); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path[0])).toContain('groupBy'); + expect(safeValidateSchema(laneless).success).toBe(false); + }); + + it("PR #7774's two EXCLUDED readings stay refused — a record source is not a lane key", () => { + // The `dataSource` json fragment taught in + // `content/docs/utilities/data-objectstack.mdx`. `dataSource` is not a rung + // of this ladder (`ElementDataSourceGate` maps its `object` ONTO + // `objectName` upstream of the node), so this document has no record source + // AND no lane key, and is refused for both. + const fragment = { type: 'object-kanban', dataSource: { object: 'task', filter: { project: 'acme' } } }; + const f = ObjectKanbanSchema.safeParse(fragment); + expect(f.success).toBe(false); + if (!f.success) { + expect(f.error.issues.map((i) => i.path[0])).toContain('groupBy'); + } + // `ListView.tsx`'s runtime-generated node, in the shape it emits when the + // view declared no lane field: `groupBy: laneField` with `laneField` + // undefined. Still refused, at `groupBy`. + const generated = { type: 'object-kanban', objectName: 'task', groupBy: undefined, cardFields: [] }; + const g = ObjectKanbanSchema.safeParse(generated); + expect(g.success).toBe(false); + if (!g.success) expect(g.error.issues.map((i) => i.path[0])).toContain('groupBy'); + }); + + it('the retired `groupField` is still refused BY NAME — the tombstone is untouched', () => { + const r = ObjectKanbanSchema.safeParse({ type: 'object-kanban', objectName: 'task', groupBy: 'status', groupField: 'status' }); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues.map((i) => i.path[0])).toContain('groupField'); + }); +}); + +describe('objectui#7780 — the member is still an object, and NO rung was added (objectui#7651)', () => { + const shape = () => + (ObjectKanbanSchema as unknown as { shape: Record { success: boolean } }> }).shape; + + it('`.shape` is exposed, with `objectName` optional and `groupBy` not', () => { + // zod 4 attaches a refinement in place; had it wrapped the object, `.shape` + // would be gone — `object-kanban-group-by-limit-7322.test.ts` reads it, and + // the parity census in `zod-mirror-parity.test.ts` would read the pair as + // vacuous. + expect(Object.keys(shape())).toEqual(expect.arrayContaining(['objectName', 'groupBy', 'groupField', 'limit'])); + expect(shape().objectName.safeParse(undefined).success).toBe(true); + expect(shape().objectName.safeParse(5).success).toBe(false); + expect(shape().groupBy.safeParse(undefined).success).toBe(false); + }); + + it('⛔ NO `staticData` rung, and `data` / `bind` are INHERITED, not re-declared — objectui#7651 refused both', () => { + // `data` and `bind` reach this member through `BaseSchema.extend`, so they + // appear in `.shape` by inheritance. Membership alone therefore cannot tell + // "inherited" from "re-declared here"; IDENTITY can. Re-typing `data` to a + // `ViewData` provider block — or adding a `staticData` rung — is exactly + // what objectui#7651 ruled out, and either one replaces the entry. + const base = (BaseSchema as unknown as { shape: Record }).shape; + expect(shape().data).toBe(base.data); + expect(shape().bind).toBe(base.bind); + expect(Object.keys(shape())).not.toContain('staticData'); + // …so the member still admits a RAW ROW ARRAY on `data`, and anything else + // the base admits — a `ViewData` retype would refuse the string below. + expect(ObjectKanbanSchema.safeParse({ type: 'object-kanban', groupBy: 'status', data: [{ id: 1 }] }).success).toBe(true); + expect(shape().data.safeParse('raw').success).toBe(true); + }); + + it('the sibling ladder is NOT this one: `object-calendar` still requires its own three, and refuses a bind-only board', () => { + // Same document shape, opposite verdict — the two predicates are distinct. + // A `bind`-only calendar has none of `data` / `staticData` / `objectName`, + // so `requireRecordSource` refuses it while `requireKanbanRecordSource` + // accepts the kanban twin. + const bindOnlyCalendar = { type: 'object-calendar', bind: 'app.settings.users' }; + expect(ObjectCalendarSchema.safeParse(bindOnlyCalendar).success).toBe(false); + expect(ObjectKanbanSchema.safeParse(doc('bindOnly')).success).toBe(true); + }); +}); + +describe('objectui#7780 — the declaration names a live read, in the declared order', () => { + it('the renderer reads bind → inline data → objectName, in that order, on disk', () => { + const src = readFileSync(join(REPO_ROOT, RENDERER), 'utf8'); + expect(src, `${RENDERER} no longer binds through useDataScope(schema.bind)`) + .toContain('const boundData = useDataScope(schema.bind);'); + expect(src, `${RENDERER} no longer gates the fetch behind bind and inline data`) + .toContain('if (schema.objectName && !boundData && !schema.data) {'); + expect(src, `${RENDERER} no longer resolves rawData through the declared ladder`) + .toContain('const rawData = (hasExternalData ? externalData : undefined) || boundData || schema.data || fetchedData;'); + // Order, not just presence: the gate must precede the resolution it guards. + expect(src.indexOf('const boundData = useDataScope(schema.bind);')) + .toBeLessThan(src.indexOf('const rawData = (hasExternalData ? externalData : undefined)')); + }); + + it('⛔ CONTROL: the board still walks NO shared ladder — zero `getDataConfig` / `resolveRecordSourceConfig`', () => { + const src = readFileSync(join(REPO_ROOT, RENDERER), 'utf8'); + expect(src).not.toContain('getDataConfig'); + expect(src).not.toContain('resolveRecordSourceConfig'); + // The instrument fires: the sibling that DOES walk the shared ladder. + const control = readFileSync(join(REPO_ROOT, LADDER_CONTROL), 'utf8'); + expect(control).toContain('resolveRecordSourceConfig'); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 9d3a05b45c..5d76024d21 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2743,8 +2743,40 @@ export interface ObjectCalendarSchema extends BaseSchema { */ export interface ObjectKanbanSchema extends BaseSchema { type: 'object-kanban'; - /** ObjectQL object name */ - objectName: string; + /** + * ObjectQL object name — the LAST rung of this board's record-source ladder, + * and the only one that names an object. + * + * `packages/plugin-kanban/src/ObjectKanban.tsx` resolves its rows in four + * steps: the `data` PROP a parent pre-fetched (`hasExternalData`), then + * {@link BaseSchema.bind} through `useDataScope(schema.bind)`, then the + * inline rows on {@link BaseSchema.data}, and only then a fetch keyed by this + * member — `rawData = external || boundData || schema.data || fetchedData`, + * with the fetch itself gated on `schema.objectName && !boundData && + * !schema.data`. So a board authored on `bind` or on inline rows never reads + * this key, and every read of it is guarded (`schema.objectName ?? ''`, + * `if (!schema.objectName) return`, `schema.objectName || ''`). + * + * Optional since objectui#7780. It was REQUIRED, so a `bind`-only or + * `data`-only board — which renders correctly today — was refused by both + * published faces and could not be annotated with its own type. The + * requirement the renderer really has, at least one of `bind`, `data`, + * `objectName` present, lives on the mirror as a refinement + * (`requireKanbanRecordSource` in `zod/objectql.zod.ts`), so the published + * declaration and the published validator say the same thing. + * + * ⚠️ This is NOT the `object-map` / `object-gantt` / `object-calendar` ladder + * and shares no code with it. Those three resolve through + * `resolveRecordSourceConfig` over `data` (a {@link ViewData} PROVIDER BLOCK) + * → `staticData` → `objectName`, and their mirror members end in + * `requireRecordSource`. This board has NO `staticData` rung, its `data` is a + * RAW ROW ARRAY read directly, and it has a `bind` rung the other three do + * not walk. objectui#7651 (ruled B, closed `not_planned`) refuses building + * the shared ladder here — see `KanbanSchema.data` in `./complex.ts`, whose + * epitaph records it. Nothing below adds a rung; this member's requiredness + * is the only thing objectui#7780 moved. + */ + objectName?: string; /** * Field whose value places a record in a lane (e.g. `status`). * diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 3b83805b0f..4ddc342c65 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -987,6 +987,78 @@ export const KanbanConditionalFormattingRuleSchema = z.union([ }), ]); +/** + * The `object-kanban` board has a record source — at least one of `bind`, + * `data`, `objectName` is present (objectui#7780). + * + * ⚠️ NOT `requireRecordSource` above, and deliberately not built on it. That + * one serves the `object-map` / `object-gantt` / `object-calendar` ladder, + * whose rungs are `data` (a `ViewData` PROVIDER BLOCK) → `staticData` → + * `objectName`, resolved by the shared `resolveRecordSourceConfig` in + * `@object-ui/core`. This board walks a DIFFERENT ladder in + * `plugin-kanban/src/ObjectKanban.tsx`: the pre-fetched `data` PROP → + * `useDataScope(schema.bind)` → the inline ROW ARRAY on `schema.data` → a + * fetch keyed by `schema.objectName` + * (`rawData = external || boundData || schema.data || fetchedData`, the fetch + * gated on `schema.objectName && !boundData && !schema.data`). It has NO + * `staticData` rung and it HAS a `bind` rung, so the two key sets are neither + * equal nor nested and one predicate cannot serve both. objectui#7651 (ruled + * B, closed `not_planned`) refuses giving this board the shared ladder; this + * refinement describes the ladder that is already there rather than adding + * one. + * + * The pre-fetched `data` PROP is NOT a key here: it is a React prop + * (`ObjectKanbanComponentProps.data`, passed by a parent such as `ListView`), + * not something an author writes on the node, so it can neither be declared + * nor required. + * + * `bind` and `data` are `BaseSchema` members on BOTH faces — declared once, on + * the base, as optional members (`base.zod.ts` here, `../base.ts` there) and + * INHERITED by this member rather than restated on it. So this refinement names + * no key its own mirror has never heard of, the property objectui#7313 had to + * buy by declaring `data` / `staticData` first, and it names no key this + * member re-declares — `base-bind-declared.test.ts` (objectui#6357) keeps the + * `bind` declaration single, and the identity assertion in + * `__tests__/object-kanban-record-source-7780.test.ts` keeps both inherited. + * + * Presence is `!== undefined`, matching the sibling predicate's wording rather + * than the renderer's truthiness: `objectName: ''` validated before this card + * and still does, so the accept set only WIDENS. The one shape refused here + * (none of the three present) was refused before too, when `objectName` was + * required — see the before/after table in + * `__tests__/object-kanban-record-source-7780.test.ts`. + * + * ⛔ `groupBy` is NOT a rung and is untouched: it stays REQUIRED (objectui#7322, + * PR #7774). A record source and a lane key are different questions, and the + * two readings PR #7774 excluded from counting as a lane-less mode — the + * `dataSource` json fragment in `content/docs/utilities/data-objectstack.mdx` + * and `ListView.tsx`'s runtime-generated node — are still refused here, on + * `groupBy`, exactly as they were. + * + * Carries `params.code` so a consumer keys off the finding rather than + * string-matching the message, and reports at the ROOT path (`[]`): no single + * key is at fault when all three are absent, and blaming `objectName` would + * re-teach the requiredness this card removes. + * + * Deliberately a `function`, not an `export const`, for the same reason + * `requireRecordSource` is: the parity census in + * `__tests__/zod-mirror-parity.test.ts` reads `^export const` out of this + * directory and would demand a registered TS counterpart for it. + */ +const KANBAN_RECORD_SOURCE_KEYS = ['bind', 'data', 'objectName'] as const; +function requireKanbanRecordSource( + schema: Partial>, + ctx: z.core.$RefinementCtx, +): void { + if (KANBAN_RECORD_SOURCE_KEYS.some((key) => schema[key] !== undefined)) return; + ctx.addIssue({ + code: 'custom', + path: [], + params: { code: 'RECORD_SOURCE_REQUIRED' }, + message: '`object-kanban` has no record source: declare one of `bind`, `data` or `objectName`', + }); +} + // objectui#7322 — `groupBy` and `limit` are the keys `ObjectKanban.tsx` reads // (thirteen `schema.groupBy` sites; `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` // at `:264`); until this card neither was declared and both rode `BaseSchema`'s @@ -997,7 +1069,7 @@ export const KanbanConditionalFormattingRuleSchema = z.union([ // `KanbanConfig.groupField` above is live and untouched. export const ObjectKanbanSchema = BaseSchema.extend({ type: z.literal('object-kanban'), - objectName: z.string().describe('ObjectQL object name'), + objectName: z.string().optional().describe('ObjectQL object name — the LAST rung of the board ladder, after the pre-fetched data prop, bind and the inline row array on data; one of bind, data, objectName must be present (objectui#7780)'), groupBy: z.string().describe('Field whose value places a record in a lane — the lane key the object-kanban renderer reads (ObjectKanban.tsx, thirteen sites); required, as the retired groupField was'), groupField: retirementTombstone('RETIRED (objectui#7322) — `groupField` is not read by the object-kanban renderer; author `groupBy`. (The view-level `kanban.groupField` alias is unaffected.)'), limit: z.number().int().positive().optional().describe('Row cap — the most records the board fetches, sent as a real $top on the query (ObjectKanban.tsx:264); default 100 (DEFAULT_KANBAN_LIMIT)'), @@ -1007,7 +1079,7 @@ export const ObjectKanbanSchema = BaseSchema.extend({ coverImageField: z.string().optional().describe('Field name for cover image on cards'), allowCollapse: z.boolean().optional().describe('Allow columns to collapse/expand'), conditionalFormatting: z.array(KanbanConditionalFormattingRuleSchema).optional().describe('Card conditional formatting rules'), -}); +}).superRefine(requireKanbanRecordSource); /** * ObjectChart Schema