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
43 changes: 43 additions & 0 deletions .changeset/10286-mirror-groups-cd-settled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@object-ui/types': minor
---

fix(types): settle four mirror-vs-declaration disagreements from objectui#7759 groups C and D

Each of these keys had a zod mirror that accepted something its TypeScript
declaration refused, or the other way round. Each is now settled by the
objectui#7759 ruling: where the spec declares a key, both faces follow the spec;
where it does not, the renderer's read site decides.

- `FilterField.operators` (inside `FilterBuilderSchema.fields`) now states the
spec's canonical filter vocabulary on both faces: `VIEW_FILTER_OPERATORS` from
`@objectstack/spec/ui`, twenty members. The declaration used to offer
`is_empty` / `is_not_empty` and the mirror `is_null` / `is_not_null`, so each
face refused a spelling the other accepted. Both faces now accept all four,
plus `icontains`, `before`, `after` and `between`.
- `ContainerSchema.maxWidth` no longer parses `true`. The key is not in the spec,
and the `container` renderer draws no max-width class at all for `true` (not the
default `max-w-xl`, and not the `max-w-none` that `false` gives). The
declaration never admitted it.
- `HeaderBarSchema.variant` is retired on both faces (ADR-0049). The key is not in
the spec, and the `header-bar` renderer reads no variant: every spelling
rendered the same header. The declaration offered `floating` and the mirror
`transparent`. The mirror now refuses the key by name, and the declaration types
it `never`.

- `FormSchema.mode` (the plain `form` node) is retired on both faces, under the
objectui#7759 ruling's D1-(ii). The spec declares no `form` node; its
`create` / `edit` / `view` belongs to `object-form`. Nothing read the key on a
`form` node: every spelling rendered the same form. The declaration offered
`edit` / `read` / `disabled`, the mirror `create` / `edit` / `view`, and
neither was honoured. The mirror now refuses the key by name and points at
`object-form`, whose `mode` (`ObjectFormSchema.mode`) is unchanged and live.
For a non-editable basic form, set `disabled`. The renderer is unchanged: a
document that skips validation and still carries `mode` gets the same stray
`mode` attribute on the rendered `form` element as before. A validated
document can no longer carry the key, so it no longer reaches the DOM that way.

Breaking, but only for documents the renderer ignored anyway: a `container` with
`maxWidth: true`, a `header-bar` with any `variant`, or a `form` with any `mode`
now fails validation. Delete the key. For a form, use `object-form` or `disabled`
if the mode was meant to do something.
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ A complete form with fields, validation, layout, and actions.
| `showCancel` | `boolean` | Whether to show a cancel button. |
| `showActions` | `boolean` | Whether to show the action buttons row. |
| `resetOnSubmit` | `boolean` | Reset form after successful submit. |
| `mode` | `"edit" \| "read" \| "disabled"` | Form interaction mode. |
| `disabled` | `boolean` | Disable every input and the submit button. (`mode` is retired on this node and fails validation; for a create / edit / view form use [ObjectFormSchema](#objectformschema).) |
| `actions` | `SchemaNode[]` | Custom action buttons to replace defaults. |

**Related:** [InputSchema](#inputschema), [SelectSchema](#selectschema), [ObjectFormSchema](#objectformschema)
Expand Down
6 changes: 3 additions & 3 deletions packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ unknown-component placeholder.
| `columns` | `number` | grid width (1–4) |
| `validationMode` | `'onSubmit' \| 'onBlur' \| 'onChange' \| 'onTouched' \| 'all'` | when the rules run |
| `resetOnSubmit` / `disabled` | `boolean` | |
| `mode` | `'edit' \| 'read' \| 'disabled'` | whole-form mode |
| `mode` | — | **retired** (objectui#10286): nothing read it, and validation now refuses it. Use `disabled` for a read-only basic form, or `object-form` for create / edit / view |
| `objectName` | `string` | enables metadata field locators `data-testid="field:{objectName}.{field}"` (ADR-0054 C4) |
| `previousValues` | `Record<string, any>` | edit-mode hosts only — the persisted record, as evaluation context for `previous` / `readonlyWhen`. Never sent anywhere |
| `fieldContainerClass` | `string` | class for the field grid inside the `<form>` |
Expand Down Expand Up @@ -914,8 +914,8 @@ export const App = () => (

The object comes from `objectName`; there is no `resource` key. For `mode: 'edit'`
or `'view'`, add the `recordId` of the record being opened. Note that this
`mode` vocabulary is `'create' | 'edit' | 'view'` — the basic form's `mode` is a
different key with a different vocabulary (`'edit' | 'read' | 'disabled'`, see
`mode` vocabulary is `'create' | 'edit' | 'view'` and it lives on `object-form`
only — the basic `form` node has no `mode` (retired by objectui#10286, see
[Schema API](#schema-api)).

### The TypeScript route — basic `form`
Expand Down
187 changes: 187 additions & 0 deletions packages/types/src/__tests__/mirror-groups-cd-10286.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* 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#10286 — the decidable subset of objectui#7759's groups C and D: three
* pairs where the zod mirror and the TypeScript declaration disagreed about a key,
* each settled by the objectui#7759 ruling (where the spec declares a key both
* faces follow the spec; for an objectui-own key the read site is the truth).
*
* - `FilterField.operators` — both faces now state the spec's canonical filter
* vocabulary (`VIEW_FILTER_OPERATORS`). The TS face takes the spec's type by
* reference; the mirror spells the list out, so the runtime comparison below is
* what reddens if the two lists ever part.
* - `ContainerSchema.maxWidth` — not in the spec. The `container` renderer maps
* `false` to `max-w-none` and `true` to no class at all, so the mirror narrowed
* its `z.boolean()` arm to the `false` the declaration already stated.
* - `HeaderBarSchema.variant` — not in the spec, and the `header-bar` renderer
* reads no `variant`, so both faces retire it.
* - `FormSchema.mode` — the spec declares no `form` node (its create / edit /
* view is the `object-form` block's), and nothing reads the key on a `form`
* node, so both faces retire it (objectui#7759 ruling D1-(ii)). The live mode
* is `ObjectFormSchema.mode`, which this file shows is untouched.
*
* `BaseSchema` is `.passthrough()`: an UNDECLARED key parses green unexamined. So
* every refusal here has a lit control on the same schema and document — an
* unknown key that must stay green — which is what shows the refusal is the
* declared key's own verdict and not a strict object refusing everything.
*/

import { describe, it, expect } from 'vitest';
import { VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui';
import { FilterBuilderSchema, FilterFieldSchema } from '../zod/complex.zod.js';
import { ContainerSchema } from '../zod/layout.zod.js';
import { HeaderBarSchema } from '../zod/navigation.zod.js';
import { FormSchema } from '../zod/form.zod.js';
import { ObjectFormSchema } from '../zod/objectql.zod.js';
import type { FilterField } from '../complex.js';
import type { ContainerSchema as ContainerSchemaType } from '../layout.js';
import type { HeaderBarSchema as HeaderBarSchemaType } from '../navigation.js';
import type { FormSchema as FormSchemaType } from '../form.js';
import type { ObjectFormSchema as ObjectFormSchemaType } from '../objectql.js';

/** A control key no surface in this package declares. */
const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares10286';

/** The issue paths of a failed parse, joined, so a refusal can be told apart from a stray one. */
function refusedPaths(result: { success: boolean; error?: { issues: { path: PropertyKey[] }[] } }): string[] {
return result.success ? [] : (result.error?.issues ?? []).map((i) => i.path.map(String).join('.'));
}

describe('FilterField.operators states the spec vocabulary on both faces (objectui#10286)', () => {
const field = (operators: unknown) => ({ value: 'status', label: 'Status', operators });

it('the mirror accepts exactly the spec list — no member missing, none extra', () => {
for (const op of VIEW_FILTER_OPERATORS) {
expect(FilterFieldSchema.safeParse(field([op])).success, `spec operator \`${op}\` refused`).toBe(true);
}
// The element enum's own options, compared as a SET with the spec's array: a
// member the spec gains, or one this mirror keeps after the spec drops it,
// reddens here.
const element = FilterFieldSchema.shape.operators.unwrap().element;
expect([...element.options].sort()).toEqual([...VIEW_FILTER_OPERATORS].sort());
});

it('the two spellings each face used to refuse are both accepted now', () => {
// The declaration carried `is_empty` / `is_not_empty`; the mirror refused them.
expect(FilterFieldSchema.safeParse(field(['is_empty', 'is_not_empty'])).success).toBe(true);
// The mirror carried `is_null` / `is_not_null`; the declaration refused them.
const typed: FilterField = { value: 'status', label: 'Status', operators: ['is_null', 'is_not_null'] };
expect(FilterFieldSchema.safeParse(typed).success).toBe(true);
});

it('a spelling outside the spec vocabulary is still refused, at the element', () => {
// `isEmpty` is the builder dropdown's camelCase id: an ALIAS in the spec's
// fold table, not a member of the canonical list this key declares.
const result = FilterFieldSchema.safeParse(field(['isEmpty']));
expect(refusedPaths(result)).toEqual(['operators.0']);
});

it('the vocabulary reaches `FilterBuilderSchema.fields` through the element', () => {
const node = { type: 'filter-builder', fields: [field(['icontains', 'between'])] };
expect(FilterBuilderSchema.safeParse(node).success).toBe(true);
expect(refusedPaths(FilterBuilderSchema.safeParse({ ...node, fields: [field(['isEmpty'])] })))
.toEqual(['fields.0.operators.0']);
});

it('the TS face takes the same set (compile-time: refused by `tsc` when it does not)', () => {
const ok: FilterField = { value: 'a', label: 'A', operators: ['icontains', 'before', 'after', 'between'] };
// @ts-expect-error — the builder's camelCase id is not a canonical spec operator.
const bad: FilterField = { value: 'a', label: 'A', operators: ['isEmpty'] };
expect([ok, bad].length).toBe(2);
});
});

describe('ContainerSchema.maxWidth admits `false` and not `true` (objectui#10286)', () => {
const node = (maxWidth: unknown) => ({ type: 'container', maxWidth });

it('`true` is refused by the declared key, `false` and a size word parse', () => {
expect(refusedPaths(ContainerSchema.safeParse(node(true)))).toEqual(['maxWidth']);
expect(ContainerSchema.safeParse(node(false)).success).toBe(true);
expect(ContainerSchema.safeParse(node('xl')).success).toBe(true);
});

it('lit control: an undeclared key on the same document stays green', () => {
expect(ContainerSchema.safeParse({ ...node(false), [UNKNOWN_KEY]: true }).success).toBe(true);
});

it('the declaration refuses `true` too (compile-time)', () => {
// @ts-expect-error — `maxWidth` declares the false literal alone.
const bad: ContainerSchemaType = { type: 'container', maxWidth: true };
const ok: ContainerSchemaType = { type: 'container', maxWidth: false };
expect([ok, bad].length).toBe(2);
});
});

describe('HeaderBarSchema.variant is retired on both faces (objectui#10286)', () => {
const NODE = { type: 'header-bar' as const, crumbs: [{ label: 'Home' }] };

it('every spelling either face used to offer is refused BY NAME', () => {
for (const variant of ['default', 'bordered', 'floating', 'transparent']) {
const result = HeaderBarSchema.safeParse({ ...NODE, variant });
expect(refusedPaths(result), `variant \`${variant}\``).toEqual(['variant']);
}
});

it('the refusal says why, and names what the renderer reads instead', () => {
const result = HeaderBarSchema.safeParse({ ...NODE, variant: 'floating' });
expect(result.success).toBe(false);
const message = result.success ? '' : result.error.issues[0].message;
expect(message.startsWith('REFUSED (objectui#10286, ADR-0049)')).toBe(true);
for (const key of ['actions', 'crumbs', 'rightContent', 'search']) expect(message).toContain(`\`${key}\``);
});

it('lit control: the same document without `variant`, and with an undeclared key, parses', () => {
expect(HeaderBarSchema.safeParse(NODE).success).toBe(true);
expect(HeaderBarSchema.safeParse({ ...NODE, [UNKNOWN_KEY]: 'x' }).success).toBe(true);
});

it('the declaration refuses it too (compile-time)', () => {
// @ts-expect-error — `variant` is `never` on the TS face.
const bad: HeaderBarSchemaType = { type: 'header-bar', variant: 'floating' };
expect(bad.type).toBe('header-bar');
});
});

describe('FormSchema.mode is retired on both faces (objectui#10286, objectui#7759 D1-(ii))', () => {
const NODE = { type: 'form' as const, fields: [{ name: 'title', label: 'Title', type: 'text' }] };

it('every spelling either face used to offer is refused BY NAME', () => {
for (const mode of ['edit', 'read', 'disabled', 'create', 'view']) {
const result = FormSchema.safeParse({ ...NODE, mode });
expect(refusedPaths(result), `mode \`${mode}\``).toEqual(['mode']);
}
});

it('the refusal points the author at `object-form`, where the mode is live', () => {
const result = FormSchema.safeParse({ ...NODE, mode: 'edit' });
const message = result.success ? '' : result.error.issues[0].message;
expect(message.startsWith('REFUSED (objectui#10286, ADR-0049')).toBe(true);
expect(message).toContain('`object-form`');
expect(message).toContain('`disabled`');
});

it('lit control: the same document without `mode`, and with an undeclared key, parses', () => {
expect(FormSchema.safeParse(NODE).success).toBe(true);
expect(FormSchema.safeParse({ ...NODE, [UNKNOWN_KEY]: 'x' }).success).toBe(true);
});

it('the object-form mode the refusal names is untouched and still parses', () => {
for (const mode of ['create', 'edit', 'view']) {
const doc = { type: 'object-form', objectName: 'probe', mode };
expect(ObjectFormSchema.safeParse(doc).success, `object-form mode \`${mode}\``).toBe(true);
}
});

it('the declaration refuses it too, and object-form keeps it (compile-time)', () => {
// @ts-expect-error — `mode` is `never` on the `form` node's TS face.
const bad: FormSchemaType = { type: 'form', mode: 'read' };
const live: Pick<ObjectFormSchemaType, 'mode'> = { mode: 'edit' };
expect([bad.type, live.mode]).toEqual(['form', 'edit']);
});
});
Loading
Loading