Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/tabs-screenfield-visible-when-aliases.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions packages/spec/src/automation/builtin-node-config.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
Expand Down
103 changes: 103 additions & 0 deletions packages/spec/src/shared/visible-when-alias-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
});
});
});
28 changes: 26 additions & 2 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -596,10 +615,15 @@ export const PageTabsProps = strictObject({
* Binds the same environment as page-component `visibleWhen`: `record` +
* `current_user`, plus page state as `page.<var>` (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.<var>`. 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.<var>`. 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
Expand Down
Loading