diff --git a/.changeset/7313-object-calendar-record-source.md b/.changeset/7313-object-calendar-record-source.md new file mode 100644 index 000000000..50d5f4eae --- /dev/null +++ b/.changeset/7313-object-calendar-record-source.md @@ -0,0 +1,36 @@ +--- +'@object-ui/types': minor +--- + +`ObjectCalendarSchema` declares the record-source ladder its renderer already +reads — `data`, `staticData`, `objectName` — on both faces, in the shape +objectui#6939 landed on `object-map` and `object-gantt` (objectui#7313). + +`ObjectCalendar` resolves its records through the shared ladder +(`resolveRecordSourceConfig` in `@object-ui/core`, called from +`plugin-calendar/src/ObjectCalendar.tsx`): `data` first, then `staticData`, +then `objectName`. The published TypeScript interface REQUIRED `objectName` and +declared neither `data` nor `staticData`; the published Zod mirror did the same. +So an `object-calendar` node authored on `staticData` — the route the plugin +page documents twice — rendered correctly and was refused by +`safeValidateSchema`, and could not be annotated with its own type +(`TS2741: Property 'objectName' is missing`). + +- `objectName` becomes optional on the TypeScript interface and on the mirror + in the same stroke; it stays the object-provider key. +- `data` (`ViewData` / `ViewDataSchema`) and `staticData` (`any[]`) are + declared on both faces, spelled exactly as `ObjectGanttSchema` spells them. +- The member ends in `requireRecordSource('object-calendar')`: a node + authoring NONE of the three is refused by name — one root-level issue, + `params.code = 'RECORD_SOURCE_REQUIRED'`, the map/gantt message naming + `data`, `staticData` and `objectName` — instead of by a missing `objectName`. + +**A widening.** A node authoring `staticData` or `data` without `objectName` +now validates (it always rendered — the read is +`resolveRecordSourceConfig(schema)`, keyed on the three). Every document that +validated before still validates: `objectName` alone still parses, an empty +one included, because presence is `!== undefined`. The one shape the +refinement refuses (none of the three) was refused before too, at +`objectName`. The two static-data examples in +`content/docs/plugins/plugin-calendar.mdx` are now annotated +`ObjectCalendarSchema` and compile under the doc-snippet gate. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index afe4ef410..96704cd19 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -207,7 +207,9 @@ const schema: ObjectCalendarSchema = { ### With Static Data ```tsx -const schema = { +import type { ObjectCalendarSchema } from '@object-ui/types' + +const schema: ObjectCalendarSchema = { type: 'object-calendar', staticData: [ { @@ -307,7 +309,9 @@ const objectProviderCalendar: ObjectCalendarSchema = { #### Value Provider (Static) ```tsx -const valueProviderCalendar = { +import type { ObjectCalendarSchema } from '@object-ui/types'; + +const valueProviderCalendar: ObjectCalendarSchema = { type: 'object-calendar', staticData: [ { id: 1, title: 'Event 1', date: '2024-01-15' }, 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 new file mode 100644 index 000000000..1eade8eed --- /dev/null +++ b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts @@ -0,0 +1,310 @@ +/** + * 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#7313 — `ObjectCalendarSchema` declares the record-source ladder its + * renderer already reads, on both faces, in the shape objectui#6939 landed on + * `object-map` / `object-gantt` (PR #7471). + * + * ## The defect + * + * `ObjectCalendar` resolves its records through the shared ladder — + * `resolveRecordSourceConfig(schema)` in `@object-ui/core` + * (`packages/core/src/utils/record-source.ts`): `data`, then `staticData`, then + * `objectName`, `null` when none is present. The published interface REQUIRED + * `objectName` and declared neither `data` nor `staticData`; the mirror did the + * same (no `requireRecordSource`). So the two static-data examples + * `content/docs/plugins/plugin-calendar.mdx` teaches rendered correctly, were + * refused by `safeValidateSchema`, and could not be annotated with their own + * type (`TS2741: Property 'objectName' is missing`). + * + * Measured on `origin/main` at `91f92768` (identical to the dispatch base + * `4dfdcc3c` on both faces), pristine dist, four documents through + * `ObjectCalendarSchema.safeParse`: + * + * staticData only -> refused at `objectName` (invalid_type) + * data only -> refused at `objectName` (invalid_type) + * none of the three-> refused at `objectName` (invalid_type) + * objectName only -> accepted + * + * `ObjectGanttSchema` on the same four: accepted / accepted / refused ON THE + * REFINEMENT / accepted. This file pins that the calendar now agrees with the + * gantt verdict-for-verdict, and WHY each verdict is what it is. + * + * ## What this file pins — the VALIDATOR and DECLARATION halves + * + * The render half needs no new pin: the renderer never changed. The read it + * performs is pinned off disk below, so a later rewrite of the ladder cannot + * leave this declaration describing a read that no longer exists. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { ObjectCalendarSchema, ObjectGanttSchema, safeValidateSchema } from '../zod/index.zod'; +import { BaseSchema } from '../zod/base.zod'; +import type { + ObjectCalendarSchema as TsObjectCalendarSchema, + ObjectGanttSchema as TsObjectGanttSchema, + ObjectKanbanSchema as TsObjectKanbanSchema, + ViewData, +} from '../objectql'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const RENDERER = 'packages/plugin-calendar/src/ObjectCalendar.tsx'; +const LADDER = 'packages/core/src/utils/record-source.ts'; +const DOC_PAGE = 'content/docs/plugins/plugin-calendar.mdx'; + +/* ── Type-level pins (invariant equality, house form) ─────────────────────── */ + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; +/** `Partial< Pick< T, K > >` is assignable to `Pick< T, K >` exactly when `K` is optional on `T`. */ +type IsOptionalKey< T, K extends keyof T > = Partial< Pick< T, K > > extends Pick< T, K > ? true : false; + +/** + * `objectName` is OPTIONAL and still `string`. Both directions bite: required + * again -> `string` is not `string | undefined` -> red; member DELETED -> the + * key resolves through `BaseSchema`'s index signature to `any`, and + * `Equal< any, … >` is false -> red. + */ +export type _CalendarObjectNameIsOptionalString = + Expect< Equal< TsObjectCalendarSchema['objectName'], string | undefined > >; +export type _CalendarObjectNameIsOptionalKey = + Expect< IsOptionalKey< TsObjectCalendarSchema, 'objectName' > >; + +/** + * `data` is DECLARED, optional, and `ViewData` — not `any`. Deleting the member + * does NOT fall through to the index signature: it lands on the INHERITED + * `BaseSchema.data?: any` (a declared member wins over an index signature), and + * `Equal< any, ViewData | undefined >` is false -> red. That is the whole + * reason the member is declared here rather than left to the base. + */ +export type _CalendarDataIsOptionalViewData = + Expect< Equal< TsObjectCalendarSchema['data'], ViewData | undefined > >; +export type _CalendarDataIsOptionalKey = + Expect< IsOptionalKey< TsObjectCalendarSchema, 'data' > >; + +/** `staticData` is DECLARED and optional; deleted, it would resolve to `any`. */ +export type _CalendarStaticDataIsOptionalAnyArray = + Expect< Equal< TsObjectCalendarSchema['staticData'], any[] | undefined > >; +export type _CalendarStaticDataIsOptionalKey = + Expect< IsOptionalKey< TsObjectCalendarSchema, 'staticData' > >; + +/** One concept, one type: the calendar's three keys are the gantt's three keys. */ +export type _CalendarDataMatchesGantt = + Expect< Equal< TsObjectCalendarSchema['data'], TsObjectGanttSchema['data'] > >; +export type _CalendarStaticDataMatchesGantt = + Expect< Equal< TsObjectCalendarSchema['staticData'], TsObjectGanttSchema['staticData'] > >; +export type _CalendarObjectNameMatchesGantt = + Expect< Equal< TsObjectCalendarSchema['objectName'], TsObjectGanttSchema['objectName'] > >; + +/** + * The document the plugin page teaches under "With Static Data". It did not + * compile before this card — `objectName` was a required member, and an index + * signature cannot rescue a MISSING required key — so this annotation is a real + * compile-time pin. + */ +export const STATIC_DATA_DOCUMENT: TsObjectCalendarSchema = { + type: 'object-calendar', + staticData: [{ id: 1, title: 'Team Meeting', startDate: '2024-01-15T10:00:00' }], +}; + +/** …and the `data`-authored one, typed against the declared `ViewData`. */ +export const DATA_DOCUMENT: TsObjectCalendarSchema = { + type: 'object-calendar', + data: { provider: 'value', items: [{ id: 1, title: 'Team Meeting' }] }, +}; + +/** + * 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. + * + * `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`. + */ +// @ts-expect-error — objectName is still required on ObjectKanbanSchema (the one deliberate error here) +export const KANBAN_STILL_REQUIRES_OBJECT_NAME: TsObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', +}; + +/* ── Runtime pins ─────────────────────────────────────────────────────────── */ + +/** + * The four documents the card's verdict table is written over. `data` uses + * the value provider — the config `staticData` is folded into, so the two + * accepted-without-`objectName` rows exercise different keys but one route. + */ +const DOCUMENTS = { + staticOnly: { staticData: [{ id: 1, title: 'Team Meeting', startDate: '2024-01-15T10:00:00' }] }, + dataOnly: { data: { provider: 'value', items: [{ id: 1, title: 'Team Meeting' }] } }, + none: {}, + objectOnly: { objectName: 'events' }, +} as const; +type DocumentName = keyof typeof DOCUMENTS; +const DOCUMENT_NAMES = Object.keys(DOCUMENTS) as DocumentName[]; + +/** The refinement's message, spelled exactly as the map/gantt members emit it. */ +const REFUSAL_MESSAGE = '`object-calendar` has no record source: declare one of `data`, `staticData` or `objectName`'; + +function withType(type: string, name: DocumentName): Record { + return { type, ...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}`); +} + +describe('objectui#7313 — the four documents, through the member and the published entry point', () => { + it('`staticData` alone validates — the route the plugin page documents twice', () => { + const doc = withType('object-calendar', 'staticOnly'); + expect('objectName' in doc).toBe(false); + expect(ObjectCalendarSchema.safeParse(doc).success).toBe(true); + expect(reasons(doc)).toEqual([]); + }); + + it('`data` alone validates', () => { + const doc = withType('object-calendar', 'dataOnly'); + expect(ObjectCalendarSchema.safeParse(doc).success).toBe(true); + expect(reasons(doc)).toEqual([]); + }); + + it('`objectName` alone still validates — the accept set only WIDENED, an empty name included', () => { + expect(ObjectCalendarSchema.safeParse(withType('object-calendar', 'objectOnly')).success).toBe(true); + expect(reasons(withType('object-calendar', '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(ObjectCalendarSchema.safeParse({ type: 'object-calendar', objectName: '' }).success).toBe(true); + }); + + it('NONE of the three is refused ON THE REFINEMENT — by name, not at `objectName`', () => { + const result = ObjectCalendarSchema.safeParse(withType('object-calendar', '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 refuses it too, with the same message', () => { + const r = safeValidateSchema({ type: 'object-calendar' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.message)).toContain(REFUSAL_MESSAGE); + }); +}); + +describe('objectui#7313 — parity with `ObjectGanttSchema`, verdict for verdict', () => { + const verdicts = (member: { safeParse: (v: unknown) => { success: boolean } }, type: string) => + DOCUMENT_NAMES.map((name) => member.safeParse(withType(type, name)).success); + + it('the two members agree on all four documents, and the vector is not vacuous', () => { + const calendar = verdicts(ObjectCalendarSchema, 'object-calendar'); + const gantt = verdicts(ObjectGanttSchema, 'object-gantt'); + expect(calendar).toEqual(gantt); + // Non-vacuity: a pair that accepted everything, or refused everything, + // would "agree" too. The vector must carry BOTH verdicts, and in the + // positions the card's table names. + expect(new Set(calendar).size).toBe(2); + expect(calendar).toEqual([true, true, false, true]); + }); + + it('the refusal is the same issue on both members, differing only in the member name', () => { + const c = ObjectCalendarSchema.safeParse({ type: 'object-calendar' }); + const g = ObjectGanttSchema.safeParse({ type: 'object-gantt' }); + expect(c.success).toBe(false); + expect(g.success).toBe(false); + if (c.success || g.success) return; + const strip = (m: string) => m.replace(/`object-(calendar|gantt)`/, '`object-MEMBER`'); + expect(strip(c.error.issues[0].message)).toBe(strip(g.error.issues[0].message)); + expect((c.error.issues[0] as { params?: { code?: string } }).params?.code) + .toBe((g.error.issues[0] as { params?: { code?: string } }).params?.code); + }); + + it('control: `BaseSchema` accepts all four — the refusal above is the refinement\'s, not the base\'s', () => { + // `BaseSchema` is `.passthrough()` with no record-source refinement, so it + // takes every one of these documents. Only the member refuses `none`. + for (const name of DOCUMENT_NAMES) { + expect(BaseSchema.safeParse(withType('object-calendar', name)).success).toBe(true); + } + }); +}); + +describe('objectui#7313 — `data` and `staticData` are DECLARED, not passthrough holes', () => { + it('a wrong-typed `data` is refused AT the key; `objectName` is supplied so only `data` is under test', () => { + const r = ObjectCalendarSchema.safeParse({ type: 'object-calendar', objectName: 'events', data: 'nope' }); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues.map((i) => i.path[0])).toContain('data'); + expect(ObjectCalendarSchema.safeParse({ type: 'object-calendar', objectName: 'events', data: { provider: 'object', object: 'events' } }).success).toBe(true); + }); + + it('a wrong-typed `staticData` is refused AT the key', () => { + const r = ObjectCalendarSchema.safeParse({ type: 'object-calendar', objectName: 'events', staticData: 'nope' }); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues.map((i) => i.path[0])).toContain('staticData'); + }); + + it('control: `BaseSchema` alone would have admitted both — delete either member and its refusal becomes this', () => { + expect(BaseSchema.safeParse({ type: 'object-calendar', objectName: 'events', data: 'nope' }).success).toBe(true); + expect(BaseSchema.safeParse({ type: 'object-calendar', objectName: 'events', staticData: 'nope' }).success).toBe(true); + }); + + it('the object stayed an object: `.shape` is exposed, with the three keys in it and `objectName` optional', () => { + // zod 4 attaches a refinement in place; had it wrapped the object, `.shape` + // would be gone and the parity census in `zod-mirror-parity.test.ts` would + // read the pair as vacuous. + const shape = (ObjectCalendarSchema as unknown as { shape: Record { success: boolean } }> }).shape; + expect(Object.keys(shape)).toEqual(expect.arrayContaining(['objectName', 'data', 'staticData'])); + expect(shape.objectName.safeParse(undefined).success).toBe(true); + expect(shape.objectName.safeParse(5).success).toBe(false); + }); +}); + +describe('objectui#7313 — the declaration names a live read, in the declared order', () => { + it('the renderer resolves its records through the shared ladder', () => { + const src = readFileSync(join(REPO_ROOT, RENDERER), 'utf8'); + expect(src, `${RENDERER} no longer calls resolveRecordSourceConfig(schema)`).toContain('resolveRecordSourceConfig(schema)'); + }); + + it('the ladder reads `data`, then `staticData`, then `objectName` — the order the refinement rests on', () => { + const src = readFileSync(join(REPO_ROOT, LADDER), 'utf8'); + const body = src.slice(src.indexOf('export function resolveRecordSourceConfig')); + const data = body.indexOf('if (schema.data)'); + const staticData = body.indexOf('if (schema.staticData)'); + const objectName = body.indexOf('if (schema.objectName)'); + expect(data).toBeGreaterThan(-1); + expect(staticData).toBeGreaterThan(data); + expect(objectName).toBeGreaterThan(staticData); + }); + + it('the two static-data examples on the plugin page carry the annotation (the card\'s completion signal)', () => { + const page = readFileSync(join(REPO_ROOT, DOC_PAGE), 'utf8'); + expect(page).toContain("const schema: ObjectCalendarSchema = {\n type: 'object-calendar',\n staticData: ["); + expect(page).toContain("const valueProviderCalendar: ObjectCalendarSchema = {\n type: 'object-calendar',\n staticData: ["); + // No bare `object-calendar` literal is left unannotated on the page. + expect(page.match(/^const \w+ = \{\n\s+type: 'object-calendar'/gm)).toBeNull(); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 1db955cf2..33212f3bd 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2671,8 +2671,33 @@ export interface ObjectGanttSchema extends BaseSchema { */ export interface ObjectCalendarSchema extends BaseSchema { type: 'object-calendar'; - /** ObjectQL object name */ - objectName: string; + /** + * ObjectQL object name — the THIRD record source `getDataConfig` resolves, + * after {@link ObjectCalendarSchema.data} and {@link ObjectCalendarSchema.staticData} + * (`plugin-calendar/src/ObjectCalendar.tsx`). + * + * Optional since objectui#7313 (the objectui#6939 shape): a calendar authored + * on inline rows never reads this key, and requiring it refused the two + * documented static-data examples that draw correctly. The requirement the + * renderer really has — at least one of + * `data`, `staticData`, `objectName` present — lives on the mirror as a + * refinement (`requireRecordSource` in `zod/objectql.zod.ts`), so the + * published declaration and the published validator say the same thing. + */ + objectName?: string; + /** + * Data source configuration. Read FIRST by `getDataConfig` — `if + * (schema.data) return schema.data;` — ahead of `staticData` / `objectName`. + * + * Declared by objectui#7313, in the same stroke as the mirror's `data`: until + * then the read landed on `BaseSchema`'s index signature on this side and + * on `.passthrough()` on the mirror's, so the record source the resolver + * prefers was the one neither face named. Same type as + * {@link ObjectMapSchema.data}. + */ + data?: ViewData; + /** Inline records, wrapped into a `{ provider: 'value' }` config by `getDataConfig`. */ + staticData?: any[]; /** Field for event start */ startDateField?: string; /** Field for event end */ diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 14209c542..d9dbffe7f 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -620,7 +620,7 @@ export const ObjectMapConfigSchema = z.object({ * directory and would demand a registered TS counterpart for it. */ const RECORD_SOURCE_KEYS = ['data', 'staticData', 'objectName'] as const; -function requireRecordSource(type: 'object-map' | 'object-gantt') { +function requireRecordSource(type: 'object-map' | 'object-gantt' | 'object-calendar') { return ( schema: Partial>, ctx: z.core.$RefinementCtx, @@ -857,15 +857,31 @@ export const ObjectGanttSchema = BaseSchema.extend({ /** * ObjectCalendar Schema + * + * `objectName` is OPTIONAL and the member ends in `requireRecordSource` + * (objectui#7313, the objectui#6939 shape): the renderer resolves its records + * through the shared ladder (`resolveRecordSourceConfig` in + * `@object-ui/core`, `plugin-calendar/src/ObjectCalendar.tsx`) — `data`, then + * `staticData`, then `objectName` — so a calendar authored on inline rows never + * reads the object name, and the two static-data examples the plugin page + * documents drew correctly and were refused here. `data` and `staticData` are + * declared for the first time in the same stroke: they are the FIRST and SECOND + * reads of that resolver and were undeclared on both faces (surviving on + * `BaseSchema`'s index signature and on `.passthrough()`), which would have + * left the refinement naming keys this mirror had never heard of. Both are + * spelled exactly as `ObjectGanttSchema` above spells them, so the members' + * record sources cannot fork. */ export const ObjectCalendarSchema = BaseSchema.extend({ type: z.literal('object-calendar'), - objectName: z.string().describe('ObjectQL object name'), + objectName: z.string().optional().describe('ObjectQL object name — the THIRD record source getDataConfig resolves, after data and staticData; one of the three must be present (objectui#7313)'), + data: ViewDataSchema.optional().describe('Data source configuration — read FIRST by getDataConfig; undeclared on either face until objectui#7313'), + staticData: z.array(z.any()).optional().describe('Inline records, wrapped into a { provider: value } data config — read SECOND by getDataConfig'), startDateField: z.string().optional().describe('Start date field'), endDateField: z.string().optional().describe('End date field'), titleField: z.string().optional().describe('Title field'), defaultView: z.enum(['month', 'week', 'day']).optional().describe("Default view — 'month' | 'week' | 'day', the renderer's rendered set ('agenda' was retired: objectui#5784)"), -}); +}).superRefine(requireRecordSource('object-calendar')); /** * ObjectKanban Schema