diff --git a/.changeset/10296-detail-view-field-options-authoring.md b/.changeset/10296-detail-view-field-options-authoring.md new file mode 100644 index 0000000000..f069dea3e6 --- /dev/null +++ b/.changeset/10296-detail-view-field-options-authoring.md @@ -0,0 +1,37 @@ +--- +'@object-ui/types': minor +--- + +feat(types)!: `DetailViewFieldSchema.options` is the spec's authoring `SelectOptionSchema` (objectui#10296) + +The zod mirror of a detail-view field's `options` carried an inline option shape +that admitted a number or boolean `value` — which the TypeScript declaration +(`SelectOptionMetadata[]`) refuses — and silently stripped every other key. It +now reads `SelectOptionSchema` from `@objectstack/spec/data` by reference, per +ruling F1 on objectui#7759: a structural mirror points at the spec's AUTHORING +schema, never at the declaration's runtime read model. + +BREAKING for authored detail-view metadata that the old shape let through: + +- `value` must be the spec's `SystemIdentifierSchema`: a string matching + `^[a-z][a-z0-9_.]*$`, at least two characters long. So `open`, `in_progress` + and `order.created` are accepted, while `Open`, `1st`, `a-b` and `o` are + refused. A number or boolean `value` is refused with `invalid_type`; write it + as an identifier string. +- `icon` and `disabled` on an option are refused by name (`unrecognized_keys`), + as the spec refuses them on object metadata. They were stripped silently + before, so no renderer ever received them from this surface. +- `description`, `default` and `visibleWhen` are now accepted as the spec + declares them, where before they were stripped. +- A bare-string `visibleWhen` is canonicalized in the PARSE OUTPUT to the + spec's envelope `{ dialect: 'cel', source }`. An authored envelope passes + through unchanged. (objectui's own form select option keeps the bare string; + this surface follows the spec.) +- A `visibleWhen` envelope without `dialect` (objectui's wire spelling + `{ source }`) is refused, as is an empty string. On the installed spec + 17.4.0 a whitespace-only string is still accepted; the next spec release + refuses it. + +The TypeScript declaration is unchanged. The one remaining difference between the +two faces, the `visibleWhen` envelope, is recorded in the parity ledger as an +expected divergence against the read model. diff --git a/packages/types/src/__tests__/detail-view-field-options-10296.test.ts b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts new file mode 100644 index 0000000000..43060e3945 --- /dev/null +++ b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `DetailViewFieldSchema.options` is the spec's AUTHORING option schema, by + * reference (objectui#10296, ruling F1 on objectui#7759). + * + * Before this card the mirror carried an inline option shape that admitted a + * number or boolean `value` the declaration (`SelectOptionMetadata[]`) refuses, + * and silently STRIPPED every other key. F1 rules that a structural mirror points + * at the spec's authoring schema, ⛔ never at the declaration: the declaration here + * is the runtime READ model, which admits `disabled` and `icon` — two keys the + * spec refuses by name. The remaining difference between the two faces + * (`visibleWhen`'s envelope) is recorded as an EXPECTED DIVERGENCE in the parity + * ledgers, not repaired. + * + * Every refusal below is asserted on the issue `code` and the `path` it lands on, + * each beside an accepting control, so a schema that refuses everything cannot + * pass. On a revert to the inline shape the numeric/boolean `value` and the + * by-name refusals turn green-to-red. + */ + +import { describe, it, expect } from 'vitest'; +import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; +import { DetailViewFieldSchema } from '../zod/views.zod.js'; + +const field = (option: Record) => + DetailViewFieldSchema.safeParse({ name: 'status', type: 'select', options: [option] }); + +const issuesOf = (r: ReturnType) => + r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); + +describe('DetailViewFieldSchema.options is the spec authoring option schema (objectui#10296)', () => { + it('accepts an authored option carrying every spec key (accepting control)', () => { + const r = field({ + label: 'Open', + value: 'open', + description: 'Not started yet', + color: '#22c55e', + default: true, + visibleWhen: "record.kind == 'task'", + }); + expect(issuesOf(r)).toEqual([]); + }); + + it.each([ + ['a number', 1], + ['a boolean', true], + ])('refuses %s as `value` — the spec value is a machine identifier string', (_label, value) => { + const r = field({ label: 'One', value }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual({ code: 'invalid_type', path: 'options.0.value' }); + }); + + it.each(['icon', 'disabled'])('refuses the read-model-only key `%s` BY NAME', (key) => { + const r = field({ label: 'Open', value: 'open', [key]: key === 'icon' ? 'circle' : true }); + expect(r.success).toBe(false); + const issue = r.success ? undefined : r.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path.join('.')).toBe('options.0'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]); + }); + + it.each(['Open', '1st', 'a-b', 'o'])( + 'refuses `value: %s` — the spec value is `^[a-z][a-z0-9_.]*$`, at least 2 characters', + (value) => { + const r = field({ label: 'Open', value }); + expect(r.success).toBe(false); + expect(issuesOf(r).map((i) => i.path)).toContain('options.0.value'); + }, + ); + + it.each(['ab', 'a.b', 'in_progress'])('accepts `value: %s` (accepting control for the rule above)', (value) => { + expect(issuesOf(field({ label: 'Open', value }))).toEqual([]); + }); + + /** + * OUTPUT SHAPE, declared rather than left implicit. `stripImportedDefaults` + * removes defaults only, so the spec's expression-input PIPE survives on + * `options[].visibleWhen`: the parsed output is the canonical envelope + * whichever spelling was authored. One authored document therefore has two + * input spellings and ONE output shape here. That differs from objectui's + * own form `SelectOptionSchema`, which keeps objectui's wire and passes a + * bare string through unchanged (objectui#7735 / #8317). The contract review + * on objectui#10296 kept the spec's pipe (option A); these pins keep it + * visible. + */ + const optionOut = (visibleWhen: unknown) => { + const r = field({ label: 'Open', value: 'open', visibleWhen }); + return r.success ? r.data.options?.[0] : r; + }; + + it('canonicalizes a bare-string `visibleWhen` to a `{ dialect: "cel", source }` envelope in OUTPUT', () => { + expect(optionOut("record.kind == 'task'")).toEqual({ + label: 'Open', + value: 'open', + visibleWhen: { dialect: 'cel', source: "record.kind == 'task'" }, + }); + }); + + it('passes an authored `{ dialect, source }` envelope through as the same envelope', () => { + expect(optionOut({ dialect: 'cel', source: 'x == 1' })).toEqual({ + label: 'Open', + value: 'open', + visibleWhen: { dialect: 'cel', source: 'x == 1' }, + }); + }); + + it('refuses an envelope without `dialect` — objectui wire spelling, not the spec envelope', () => { + const r = field({ label: 'Open', value: 'open', visibleWhen: { source: 'x == 1' } }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual({ code: 'invalid_union', path: 'options.0.visibleWhen' }); + }); + + it('refuses an empty-string `visibleWhen`', () => { + const r = field({ label: 'Open', value: 'open', visibleWhen: '' }); + expect(r.success).toBe(false); + expect(issuesOf(r).map((i) => i.path)).toContain('options.0.visibleWhen'); + }); + + /** + * ⚠️ Pinned as it stands on the INSTALLED `@objectstack/spec` 17.4.0: a + * whitespace-only string is ACCEPTED and canonicalized. objectstack `main` + * already moved `visibleWhen` to `EvaluatedExpressionInputSchema`, whose + * string arm refuses blank strings, so the next spec bump TIGHTENS this and + * turns this test red on purpose. When it does, flip it to a refusal. + */ + it('accepts a whitespace-only `visibleWhen` on spec 17.4.0 (the next spec bump refuses it)', () => { + expect(optionOut(' ')).toEqual({ + label: 'Open', + value: 'open', + visibleWhen: { dialect: 'cel', source: ' ' }, + }); + }); + + /** + * TRIPWIRE for the spec-version gate in `zod-mirror-parity.test.ts` + * (`SpecEnvelopeAdmitsSourceless`). On the installed spec 17.4.0 the envelope's + * `source` is OPTIONAL in the static input type, and at runtime `ast` alone + * satisfies its refine (`{ dialect }` with neither is refused). So an envelope + * with no `source` parses, which the declaration's wire refuses. That is why + * `options` and its two containers are ledgered WIDER. objectstack `main` requires + * a non-blank `source`. When the pin bump turns this red, delete the gated rows, + * their `WIDER_ARMS` entries and the gate, then flip this to a refusal. + */ + it('accepts a source-less `{ dialect, ast }` envelope on spec 17.4.0 (the gated WIDER rows rest on this)', () => { + expect(optionOut({ dialect: 'cel', ast: { kind: 'ident' } })).toEqual({ + label: 'Open', + value: 'open', + visibleWhen: { dialect: 'cel', ast: { kind: 'ident' } }, + }); + }); + + it('gives the same verdict as the spec schema itself, over a spread of options', () => { + const samples: Record[] = [ + { label: 'Open', value: 'open' }, + { label: 'Open', value: 'Open' }, + { label: 'Open', value: 'o' }, + { label: 'Open', value: 'in_progress', color: 'blue' }, + { value: 'open' }, + { label: 'Open', value: 'open', stray: 1 }, + ]; + for (const option of samples) { + expect(field(option).success, JSON.stringify(option)) + .toBe(SpecSelectOptionSchema.safeParse(option).success); + } + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 45d282d976..618ce95068 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,14 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **48 entries** in `KnownDrift`, **88 keys** across them — 49 / 90 until + * - **50 entries** in `KnownDrift`, **92 keys** across them — 48 / 88 until + * objectui#10296 pointed `views.zod.ts#DetailViewFieldSchema`'s `options` at the + * spec's AUTHORING `SelectOptionSchema` (ruling F1 on objectui#7759): that pair + * and `views.zod.ts#DetailViewSectionSchema` are NEW entries with one key each, + * plus `fields` and `sections` on the EXISTING `views.zod.ts#DetailViewSchema` + * entry — the two containers carry those field elements. One + * EXPECTED DIVERGENCE against the runtime read model the declaration names, not + * a defect — the two entries' docblocks carry the reading. It was 49 / 90 until * objectui#10293 settled `form.zod.ts#CalendarSchema`'s `defaultValue` and `value` * under objectui#7759 D1-(iii): the declaration gained the ISO string the mirror * accepts and gave up the `Date[]` arm the mirror refused, so both keys measure @@ -601,7 +608,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 48 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 50 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -2248,8 +2255,31 @@ interface KnownDrift { * `DetailView`'s `schema.onNavigate` / `schema.onAddComment` by identity — * exactly as it does under `'detail'`, whose twin arm refused both by name * with objectui#7804. The TS face keeps the callable on all three. + * + * `fields` and `sections` JOINED with objectui#10296: they carry + * `DetailViewFieldSchema` elements, so that pair's EXPECTED DIVERGENCE on + * `options` (the entry below) is measured here one level up. Same cause, + * not a separate finding. + */ + 'views.zod.ts#DetailViewSchema': 'onBack' | 'onNavigate' | 'onAddComment' | 'fields' | 'sections'; + /** + * EXPECTED DIVERGENCE, read model (objectui#10296, ruling F1 on objectui#7759). + * The mirror is the spec's AUTHORING `SelectOptionSchema` by reference; the + * declaration is `SelectOptionMetadata[]`, the runtime READ model. The one member + * the two faces spell differently is `visibleWhen`: the spec's input envelope + * requires `dialect`, the read model carries objectui's wire (objectui#2212), + * where `dialect` is optional and `source` required. ⛔ Not repaired by pointing + * the mirror at the declaration (it would admit `disabled` / `icon`, which the + * spec refuses by name). Also in `WiderThanDeclared`, for the other half. */ - 'views.zod.ts#DetailViewSchema': 'onBack' | 'onNavigate' | 'onAddComment'; + 'views.zod.ts#DetailViewFieldSchema': 'options'; + /** + * objectui#10296: `fields` carries `DetailViewFieldSchema` elements, so the + * EXPECTED DIVERGENCE on that pair's `options` (the entry above) is measured + * here one level up — the same reading as `fields` / `sections` on + * `views.zod.ts#DetailViewSchema`. Same cause, not a separate finding. + */ + 'views.zod.ts#DetailViewSectionSchema': 'fields'; } /* ── The measured unmirrored-declared ledger (objectui#6058) ────────────────── */ @@ -3161,7 +3191,14 @@ interface WiderThanDeclared { // The renderer places `schema.content` RAW in a React child position, so the list arm parsed // green and then failed to render. objectui#10280 NARROWED the MIRROR to the declaration, and // the entry is GONE. - /** CONCRETE: an inline option shape against the named `SelectOptionMetadata`. */ + /** + * EXPECTED DIVERGENCE, read model (objectui#10296, ruling F1): the mirror is the + * spec's authoring `SelectOptionSchema`, whose `visibleWhen` envelope admits an + * object with no `source`, which the read model's wire refuses. Spec-version-gated + * by `SpecEnvelopeAdmitsSourceless`. See the `KnownDrift` + * entry for this pair. (The inline option shape that stood here, admitting a + * number or boolean `value`, is gone.) + */ 'views.zod.ts#DetailViewFieldSchema': 'options'; /** SCHEMA-NODE. (`tabs` left under objectui#7760; `fields` and `sections` did not.) */ 'views.zod.ts#DetailViewSchema': 'fields' | 'sections'; @@ -3430,6 +3467,59 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { [K in MirrorKey]: ReconcileAgainstLedger< K, UnmirroredOf< K >, RecordedUnmirrored< K > >; }[MirrorKey]; +/** + * SPEC-VERSION GATE for the `options` row and its two containers (objectui#10296): does the installed spec's + * `visibleWhen` input envelope admit an object with NO `source`? + * + * `views.zod.ts#DetailViewFieldSchema::options` reads the spec's `SelectOptionSchema` + * by reference, so its WIDER verdict follows the spec that is installed. On the + * pinned `@objectstack/spec` 17.4.0 the envelope's `source` is optional in the static + * input type (at runtime `ast` alone satisfies its refine), so an envelope with no + * `source` is admitted and the declaration (whose wire requires `source`) refuses it: + * WIDER, recorded. On objectstack `main` the key is + * `EvaluatedExpressionInputSchema`, whose envelope REQUIRES `source`, so the mirror is + * no longer wider there (the `KnownDrift` half, `dialect` required, holds on both). + * The Spec Main Shape Gate compiles this file against `main`, so the ledger must + * hold on both. + * + * Read off the MIRROR's own input face, not an import of the spec, so it measures + * exactly the schema the row is about. The ledger entry stays a literal (the census + * parses it). Only the reconciliation consults this, and only for the rows in + * `SpecEnvelopeGatedWider` below: on 17.4.0 they must be measured WIDER exactly as + * before, and on `main` they must measure absent, so neither side is waived. ⛔ At + * the pin bump this reads `false` for good. Then delete those rows, their + * `WIDER_ARMS` entries and this gate together. + * The runtime tripwire in `detail-view-field-options-10296.test.ts` goes red at + * that bump to say so. + */ +type DetailViewOptionVisibleWhenInput = NonNullable< + NonNullable< MirrorInputOf< 'views.zod.ts#DetailViewFieldSchema', 'options' > >[number]['visibleWhen'] +>; +export type SpecEnvelopeAdmitsSourceless = + { dialect: 'cel' } extends DetailViewOptionVisibleWhenInput ? true : false; + +/** + * The rows that gate withholds on a spec whose envelope requires `source`. Measured + * against objectstack `main` `e8f163fc3a62`, not assumed: the two container pairs + * carry `DetailViewFieldSchema` elements, and there their WIDER reading is gone too. + * Their only wider member was `options` (on the base commit, the inline shape's + * number/boolean `value`; on 17.4.0, the source-less envelope). They are ledgered + * SCHEMA-NODE, but that class names the arm, not the cause, so they leave with it. + */ +type SpecEnvelopeGatedWider = { + 'views.zod.ts#DetailViewFieldSchema': 'options'; + 'views.zod.ts#DetailViewSchema': 'fields' | 'sections'; + 'views.zod.ts#DetailViewSectionSchema': 'fields'; +}; + +/** What `WiderThanDeclared` records for a pair, with the spec-version gate above applied. */ +export type WiderRecorded< K extends MirrorKey > = + K extends keyof WiderThanDeclared + ? SpecEnvelopeAdmitsSourceless extends true + ? WiderThanDeclared[K] + : Exclude< WiderThanDeclared[K], K extends keyof SpecEnvelopeGatedWider ? SpecEnvelopeGatedWider[K] : never > + : never; + /** * The THIRD direction: every pair's WIDER key set equals what `WiderThanDeclared` * records for it — `never` for the pairs with no entry. @@ -3446,11 +3536,7 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { * @object-ui/types type-check` is the gate that reads it. */ export type WiderLedgerMismatch = { - [K in MirrorKey]: ReconcileAgainstLedger< - K, - WiderOf< K >, - K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never - >; + [K in MirrorKey]: ReconcileAgainstLedger< K, WiderOf< K >, WiderRecorded< K > >; }[MirrorKey]; export const assertionWiderMatchesLedger: never = 0 as unknown as WiderLedgerMismatch; @@ -3468,8 +3554,8 @@ export const assertionWiderMatchesLedger: never = 0 as unknown as WiderLedgerMis */ export type WiderLedgerKeyDrift = { [K in MirrorKey]: - | Exclude< WiderOf< K >, K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never > - | Exclude< K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never, WiderOf< K > >; + | Exclude< WiderOf< K >, WiderRecorded< K > > + | Exclude< WiderRecorded< K >, WiderOf< K > >; }[MirrorKey]; export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderLedgerKeyDrift; @@ -4067,6 +4153,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // Membership here is what re-derives the pair's one remaining // `UnmirroredDeclared` key (`listViews`) into the split's SPEC-DERIVED half. 'objectql.zod.ts#ObjectViewSchema', + // objectui#10296: `options` is the spec's authoring `SelectOptionSchema` by + // reference (ruling F1 on objectui#7759). + 'views.zod.ts#DetailViewFieldSchema', ]; /* ── Runtime: the population is closed ──────────────────────────────────────── */ diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 635b449fa9..5caaadb66a 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -20,13 +20,15 @@ import { z } from 'zod'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; +import { stripImportedDefaults } from './imported-defaults.js'; /** * The spec's own list-view type vocabulary, unwrapped from its `.default('grid')`. * - * ⭐ NOT a crossing of the objectui#8317 import boundary, and this file has no - * other read of `@objectstack/spec`, which is why it imports no - * `stripImportedDefaults`. A vocabulary is a set of VALUES, not a subschema: + * ⭐ NOT a crossing of the objectui#8317 import boundary. (This file's one + * crossing is `DetailViewFieldSchema.options` below, wrapped in + * `stripImportedDefaults` at the site.) A vocabulary is a set of VALUES, not a subschema: * `.removeDefault()` here reaches the spec's enum and nothing that could write a * key into a parsed document ever flows from it. The declared-exception list in * `../__tests__/imported-defaults-8317.test.ts` names this site and its twin. @@ -75,11 +77,16 @@ export const DetailViewFieldSchema = z.object({ readonly: z.boolean().optional().describe('Whether field is read-only'), visible: z.union([z.boolean(), z.string()]).optional().describe('Field visibility condition'), span: z.number().optional().describe('Span across columns (for grid layout)'), - options: z.array(z.object({ - label: z.string(), - value: z.union([z.string(), z.number(), z.boolean()]), - color: z.string().optional(), - })).optional().describe('Options for select/lookup fields'), + /** + * The spec's AUTHORING option schema, by reference (objectui#10296, ruling F1 + * on objectui#7759) — ⛔ never the declaration's element type + * `SelectOptionMetadata`, which is the runtime READ model and admits keys the + * spec refuses by name (`disabled`, `icon`). The two faces are therefore + * EXPECTED to differ on this key: the ledger records that divergence rather + * than repairing it. Re-derived by `__tests__/detail-view-field-options-10296.test.ts`. + */ + options: z.array(stripImportedDefaults(SpecSelectOptionSchema)).optional() + .describe('Options for select/lookup fields'), reference_to: z.string().optional().describe('Referenced object name for lookup/master_detail fields'), reference_field: z.string().optional().describe('Display field on the referenced object'), currency: z.string().optional().describe('Currency code for currency fields (e.g. USD, EUR)'),