diff --git a/.changeset/tabs-screenfield-visible-when-aliases.md b/.changeset/tabs-screenfield-visible-when-aliases.md new file mode 100644 index 0000000000..fbc66e6235 --- /dev/null +++ b/.changeset/tabs-screenfield-visible-when-aliases.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): alias guidance for `visible` / `showWhen` on the `page:tabs` item and screen-field `visibleWhen` shapes (#8382) + +#7832 curated the `visible` / `showWhen` action-side spellings onto `visibleWhen` +across six shapes, pinning the inventory in `visible-when-alias-guidance.test.ts`. +Two more `visibleWhen` shapes in `packages/spec` were never in that inventory: +`page:tabs` items (`ui/component.zod.ts`) and the automation `screen` node's +`ScreenFieldConfigSchema` (`automation/builtin-node-config.zod.ts`). On both, an +author who wrote `visible` or `showWhen` got a rejection naming the surface but +never the key to write instead. + +**Nothing changes about what parses.** Every key named here was rejected before +and is rejected after; only the message differs — an alias row is a message +channel, not a parse-time rename. + +What each surface says now: + +- **`page:tabs` items** rename `visible` and `showWhen` onto `visibleWhen`, the + simple case (one landing key, no boolean sibling). The item's docblock also + states that the deprecated ADR-0089 `visibility` / `visibleOn` spellings are + not accepted here (unlike the view/page shapes that fold them in via + `normalizeVisibleWhen`) — that statement is about **acceptance**, which an + alias row does not disturb, so both now get the same pointer at `visibleWhen` + while staying rejected exactly as before. +- **`ScreenFieldConfigSchema`** (the `screen` automation node's per-field + config) renames `visible` and `showWhen` onto `visibleWhen` the same way. The + shape's pre-existing `visibleIf` prescription (an exact `guidance` entry) is + unaffected — an exact entry wins over the alias table, so that bespoke prose + keeps firing for the four-edit-away typo it was written for. + +Both shapes are hand-rolled `strictObject` calls with their own options and +neither spreads `VISIBILITY_STRICT_OPTIONS`, so no shared guidance set answers +these keys ahead of the new alias rows — verified against +`alias-integrity.test.ts`, which fails on a row a guidance set would shadow. diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 93b9fff467..d7af0c90af 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -379,6 +379,19 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ + 'typo the whole undeclared-key ladder descends from — three diagnostic passes for a field that silently ' + 'never hid.', }, + aliases: { + /** + * Action-side spellings (#8382) — the same `visible` / `showWhen` gap + * #7832 closed on six other `visibleWhen` shapes. One landing key here, + * no boolean sibling, so this is the simple rename case per this + * package's alias/guidance rule (`visible-when-alias-guidance.test.ts` + * header). `visibleIf` stays on `guidance` above (an exact match wins + * over `aliases` and keeps its bespoke prose); these two are plain + * renames onto the same target. + */ + visible: 'visibleWhen', + showWhen: 'visibleWhen', + }, }, { /** Field name — an item with an empty name is dropped. */ name: z.string().describe('Field name (the flow variable the value binds to)'), diff --git a/packages/spec/src/shared/visible-when-alias-guidance.test.ts b/packages/spec/src/shared/visible-when-alias-guidance.test.ts index 1e2c33cffd..8076d198ba 100644 --- a/packages/spec/src/shared/visible-when-alias-guidance.test.ts +++ b/packages/spec/src/shared/visible-when-alias-guidance.test.ts @@ -44,6 +44,8 @@ import { RowCrudActionOverrideSchema } from '../data/object.zod'; import { FieldSchema, SelectOptionSchema } from '../data/field.zod'; import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod'; import { PageComponentSchema } from '../ui/page.zod'; +import { PageTabsProps } from '../ui/component.zod'; +import { ScreenFieldConfigSchema } from '../automation/builtin-node-config.zod'; /** * The `unrecognized_keys` message for `value`, or a loud failure. @@ -239,3 +241,104 @@ describe('#7832 — no acceptance change', () => { expect(FormFieldSchema.safeParse({ ...FORM_FIELD, disabled: true }).success).toBe(false); }); }); + +// =========================================================================== +// 5. #8382 — the two `visibleWhen` shapes #7832's inventory never enumerated +// =========================================================================== +// +// `page:tabs` items (`component.zod.ts`) and `ScreenFieldConfigSchema` +// (`builtin-node-config.zod.ts`) both declare `visibleWhen` and were outside +// the six shapes #7832 curated: `visible` / `showWhen` were rejected without +// naming the key to write instead. Both have exactly ONE landing key for the +// visibility intent and no boolean sibling, so per the header's rule this is +// the simple alias case on both: `visible → visibleWhen`, `showWhen → +// visibleWhen`. Neither shape spreads `VISIBILITY_STRICT_OPTIONS` — both are +// hand-rolled `strictObject` calls with their own options (the tab item +// already carried one alias row, `key → value`; the screen field carried +// `guidance` only, keyed to the `visibleIf` typo) — so no guidance set +// consumes these keys before the alias channel does; the assertions below +// confirm the rename fires (a shadowed row would emit the guidance-set +// prescription instead, per section 2's `.not.toContain('Did you mean')` +// pattern), which is exactly what keeps `alias-integrity.test.ts` green. + +/** Minimal bodies that reach each new surface's unknown-key path. */ +const TAB_ITEM = { label: 'Tab', children: [] } as const; +const SCREEN_FIELD = { name: 'f' } as const; + +describe('#8382 — the two shapes #7832 never enumerated', () => { + describe('`page:tabs` item (`PageTabsProps.items`) — `visibleWhen` declared, no alias for the action-side spellings', () => { + it('`visible` renames onto `visibleWhen`', () => { + const m = unknownKeyMessage(PageTabsProps, { items: [{ ...TAB_ITEM, visible: true }] }); + expect(m).toContain('Did you mean `visible` → `visibleWhen`?'); + }); + + it('`showWhen` renames onto `visibleWhen`', () => { + const m = unknownKeyMessage(PageTabsProps, { items: [{ ...TAB_ITEM, showWhen: 'record.x' }] }); + expect(m).toContain('Did you mean `showWhen` → `visibleWhen`?'); + }); + + it('the canonical `visibleWhen` still parses, unchanged', () => { + // `ExpressionInputSchema` normalizes a bare string into `{ dialect: 'cel', + // source }` — that normalization is pre-existing and untouched by this + // card; what this pins is that the alias rows did not disturb it. + const r = PageTabsProps.safeParse({ items: [{ ...TAB_ITEM, visibleWhen: 'record.x' }] }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.items[0]?.visibleWhen).toEqual({ dialect: 'cel', source: 'record.x' }); + }); + + it('`visible` and `showWhen` stay REJECTED — a pointer is not an acceptance', () => { + expect(PageTabsProps.safeParse({ items: [{ ...TAB_ITEM, visible: true }] }).success).toBe(false); + expect(PageTabsProps.safeParse({ items: [{ ...TAB_ITEM, showWhen: 'record.x' }] }).success).toBe(false); + }); + + // The judgment call: `visibility` / `visibleOn` are the ADR-0089 spellings + // this surface's own docblock says are deliberately NOT folded in (the + // key is new; there is no legacy convention to carry forward here, unlike + // the view/page shapes that fold them via `normalizeVisibleWhen`). That + // sentence is about ACCEPTANCE and an alias row does not disturb it — both + // stay rejected below. But an author who used the ADR-0089 spelling + // correctly on a page component or view form and reaches for the same + // word on a tab item is signalling the identical intent, so #8382 points + // the rejection at `visibleWhen` for these two as well, on the same + // one-landing-key rule as `visible` / `showWhen`. Pinned here so a future + // edit cannot silently drop the pointer OR silently start accepting them. + it('`visibility` / `visibleOn` are POINTED at `visibleWhen` but stay rejected (the #8382 judgment call)', () => { + const mVisibility = unknownKeyMessage(PageTabsProps, { items: [{ ...TAB_ITEM, visibility: true }] }); + expect(mVisibility).toContain('Did you mean `visibility` → `visibleWhen`?'); + expect(PageTabsProps.safeParse({ items: [{ ...TAB_ITEM, visibility: true }] }).success).toBe(false); + + const mVisibleOn = unknownKeyMessage(PageTabsProps, { items: [{ ...TAB_ITEM, visibleOn: 'record.x' }] }); + expect(mVisibleOn).toContain('Did you mean `visibleOn` → `visibleWhen`?'); + expect(PageTabsProps.safeParse({ items: [{ ...TAB_ITEM, visibleOn: 'record.x' }] }).success).toBe(false); + }); + }); + + describe('`ScreenFieldConfigSchema` — `visibleWhen` declared, no alias for the action-side spellings', () => { + it('`visible` renames onto `visibleWhen`', () => { + const m = unknownKeyMessage(ScreenFieldConfigSchema, { ...SCREEN_FIELD, visible: true }); + expect(m).toContain('Did you mean `visible` → `visibleWhen`?'); + }); + + it('`showWhen` renames onto `visibleWhen`', () => { + const m = unknownKeyMessage(ScreenFieldConfigSchema, { ...SCREEN_FIELD, showWhen: 'record.x' }); + expect(m).toContain('Did you mean `showWhen` → `visibleWhen`?'); + }); + + it('the canonical `visibleWhen` still parses, unchanged', () => { + const r = ScreenFieldConfigSchema.safeParse({ ...SCREEN_FIELD, visibleWhen: 'record.x' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.visibleWhen).toBe('record.x'); + }); + + it('`visible` and `showWhen` stay REJECTED — a pointer is not an acceptance', () => { + expect(ScreenFieldConfigSchema.safeParse({ ...SCREEN_FIELD, visible: true }).success).toBe(false); + expect(ScreenFieldConfigSchema.safeParse({ ...SCREEN_FIELD, showWhen: 'record.x' }).success).toBe(false); + }); + + it('the pre-existing `visibleIf` guidance is untouched by the new aliases (`guidance` wins over `aliases`)', () => { + const m = unknownKeyMessage(ScreenFieldConfigSchema, { ...SCREEN_FIELD, visibleIf: 'record.x' }); + expect(m).toContain('The visibility predicate is `visibleWhen`'); + expect(m).not.toContain('Did you mean'); + }); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e6e3be7a49..47e0bb01d3 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -584,6 +584,25 @@ export const PageTabsProps = strictObject({ * tokens that move the moment the item list changes. */ key: 'value', + /** + * Action-side spellings (#8382) — an author who learned `visible` / + * `showWhen` from `ui/action.zod.ts` and reaches for the same words + * here. One landing key, no boolean sibling, so per this package's + * alias/guidance rule (`visible-when-alias-guidance.test.ts` header) + * this is the simple rename case, not guidance prose. + */ + visible: 'visibleWhen', + showWhen: 'visibleWhen', + /** + * `visibility` / `visibleOn` (#8382) — the ADR-0089 spellings this + * surface deliberately does NOT fold in (see the docblock below): they + * stay rejected, but an author who used them correctly on a page + * component or view form is reaching for the identical intent here, so + * the rejection still points at the one key that lands it. A pointer is + * a message, not acceptance — nothing below changes what parses. + */ + visibility: 'visibleWhen', + visibleOn: 'visibleWhen', }, }, { label: I18nLabelSchema, @@ -596,10 +615,15 @@ export const PageTabsProps = strictObject({ * Binds the same environment as page-component `visibleWhen`: `record` + * `current_user`, plus page state as `page.` (re-evaluated live). * Canonical `*When` name per ADR-0089 — this key is new, so the deprecated - * `visibility` / `visibleOn` aliases are NOT accepted on tab items. + * `visibility` / `visibleOn` aliases are NOT ACCEPTED on tab items: unlike + * the view/page surfaces that fold them into `visibleWhen` via + * `normalizeVisibleWhen`, none of `visible` / `showWhen` / `visibility` / + * `visibleOn` parses here — all four are rejected. #8382 gave the + * rejection a pointer at this key for all four spellings (message only: + * being pointed AT `visibleWhen` is not the same as being accepted). */ visibleWhen: ExpressionInputSchema.optional().describe( - 'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Binds `record`, `current_user`, `page.`. ADR-0089 canonical name (`visibility`/`visibleOn` aliases are not accepted here).', + 'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Binds `record`, `current_user`, `page.`. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key.', ), /** * Stable URL token for this tab — the value `?tab=` carries and the