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
16 changes: 16 additions & 0 deletions .changeset/element-form-lint-residue.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/lint": patch
---

fix(lint): drop the `element:form` entry from `COMPONENT_FIELD_SPECS` (#9249)

The whole `element:form` element retired at element grain (ADR-0049 — no
renderer ever shipped for it; the #9220 shape one element over), so every
`ElementFormProps` key is a `retiredKey()` tombstone and no spec-conformant
page carries `fields` on it. The field-binding rule's job (resolve a field
NAME against the object) is not the question a retired key raises: an authored
key is already reported by name with the element-retirement prescription —
which names the live replacement, the object-bound `object-form` block —
through the #5068 props gate, and the binding entry would only add a second
finding about a key that no longer exists — the #5775/#6629 residue class the
package's own `component-field-specs-liveness` gate refuses.
88 changes: 88 additions & 0 deletions .changeset/element-form-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
---
"@objectstack/spec": minor
---

feat(spec): retire the `element:form` element at element grain (#9249, ADR-0049)

<!-- adr-0087: registered element-form-removed -->

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`; the migration prescription is
registered under protocol major 18, where `os migrate meta` users will look).

`element:form` never had a renderer or reader anywhere — the #9220
(`element:filter`) shape one element over, recorded by that card's own verdict
sweep. Measured at retirement (objectstack `dd84ddd796`, objectui `3ece13e33`;
cloud per the origin card's two recorded readings at `5f1bf23f` and `a11458b`,
positive controls passing): objectui registers no renderer for it — its
`renderers/basic/elements.tsx` header deferred the element to "owning plugins"
that never materialized — and Studio's designer palette carries it as a
no-renderer `PALETTE_EXCLUSIONS` entry that names the live replacement ("no
renderer — use the object-bound `object-form` block"). The 2026-06
page-liveness audit already recorded it rendering "Unknown component type". So
the full form contract `ElementFormPropsSchema` declared — `object`, `fields`,
`mode`, `submitLabel`, `onSubmit` (CEL), `aria` — was a capability claim
nothing kept.

**What is refused:** every authored `element:form` key. All six are
`retiredKey()` tombstones, so authoring one is a `tsc` error and a parse error
carrying the prescription (fully-qualified key, why the element is dead, the
replacement, the `os migrate meta` pointer), dispatched through the KEPT
`ComponentPropsMap` row — deleting the row would demote the type to an
unregistered custom string the #5068 props gate deliberately skips.

**What stays accepted:** a bare `element:form` node with empty `properties`
(the migrated shape — the open `type` union accepts any string, and deleting
authored page nodes is a layout decision a mechanical conversion must not
make). It renders nothing, exactly as it always did.

The retirement kit:

- `retiredKey()` tombstones for all six keys at the schema
(`packages/spec/src/ui/component.zod.ts`); the `PageComponentType` enum
drops the value (de-advertisement — the open string arm still accepts it)
- ADR-0087 registration: six retired-key entries
`ui/ElementFormProps:{aria,fields,mode,object,onSubmit,submitLabel}` and the
D2 conversion `element-form-removed` (protocol 18), wired into the step-18
chain — `os migrate meta --from 17` strips all six keys from authored
`element:form` blocks (pure lossless deletes; none ever had an effect to
lose) and leaves the bare node
- pin tests (`component.test.ts` — refusal pins assert the prescription per
key; a positive pin parses the bare migrated node clean and asserts nothing
materializes; the kept-map-row pin flips from parse to refusal)
- `packages/lint`'s `COMPONENT_FIELD_SPECS` entry drops (its own changeset);
the component-translation `submitLabel` describes stop naming `element:form`
as carrier (the orphaned-key decision is #10926, out of this card's scope)
- generated baselines/docs follow the schema (authorable surface/defaults,
spec-changes, upgrade guide, reference docs)
- objectui's `elements.tsx` comment and Studio palette-exclusion lines are
cross-repo and already queued under objectui#4935

## FROM → TO

```ts
// before — parsed green; nothing anywhere rendered it, so the page showed
// "Unknown component type" where the author expected a form
{
type: 'element:form',
properties: {
object: 'lead',
fields: ['name', 'email'],
mode: 'create',
submitLabel: 'Create Lead',
},
}

// after — use the object-bound `object-form` block (#7751): rendered,
// designer-publishable, same intent (`objectName`, `fields`, `mode`,
// `submitText`)
{
type: 'object-form',
properties: {
objectName: 'lead',
fields: ['name', 'email'],
mode: 'create',
submitText: 'Create Lead',
},
}
```
12 changes: 6 additions & 6 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,12 +77,12 @@ const result = AIChatWindowProps.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | | Object for the form |
| **fields** | `string[]` | optional | Fields to display (defaults to all editable fields) |
| **mode** | `Enum<'create' \| 'edit'>` | optional (default: `"create"`) | Form mode |
| **submitLabel** | `string \| Record<string, string>` | optional | Submit button label |
| **onSubmit** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Action expression on form submit (CEL) |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
| **object** | `never` | optional | [REMOVED] `element:form` property `object` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **fields** | `never` | optional | [REMOVED] `element:form` property `fields` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **mode** | `never` | optional | [REMOVED] `element:form` property `mode` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **submitLabel** | `never` | optional | [REMOVED] `element:form` property `submitLabel` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **onSubmit** | `never` | optional | [REMOVED] `element:form` property `onSubmit` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **aria** | `never` | optional | [REMOVED] `element:form` property `aria` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |


---
Expand Down
3 changes: 1 addition & 2 deletions content/docs/references/ui/page.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,7 @@ Interface-level page configuration (Airtable parity)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type (Standard enum or custom string) |
| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type (Standard enum or custom string) |
| **id** | `string` | optional | Unique instance ID |
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand DownExpand Up@@ -157,7 +157,6 @@ Interface-level page configuration (Airtable parity)
* `element:image`
* `element:divider`
* `element:button`
* `element:form`
* `element:record_picker`
* `element:text_input`

Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -186,7 +186,7 @@ The `type` field is a union of the standard `PageComponentType` enum and any cus
- **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb`
- **Utility:** `global:search`, `global:notifications`, `user:profile`
- **AI:** `ai:chat_window`, `ai:suggestion`
- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:form`, `element:record_picker`, `element:text_input` (`element:filter` was retired in v17.x — no renderer ever shipped for it; list surfaces own their filtering via a view's `userFilters` quick-filter bar or the list toolbar's filter builder)
- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:record_picker`, `element:text_input` (`element:filter` and `element:form` were retired in v17.x — no renderer ever shipped for either. List surfaces own their filtering via a view's `userFilters` quick-filter bar or the list toolbar's filter builder; for forms use the object-bound `object-form` block, which is rendered and designer-publishable)

Components may also carry `dataSource` (per-element object binding for multi-object pages), `responsive`, and `aria` configuration. Custom string types are also accepted for project-specific widgets.

Expand Down
5 changes: 4 additions & 1 deletion packages/lint/src/validate-page-field-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,7 +197,10 @@ export const COMPONENT_FIELD_SPECS: Readonly<Record<string, ComponentFieldSpec>>
// spec-conformant page carries `fields` on it, and the #5068 props gate
// reports an authored one by name with the element-retirement prescription —
// the same #5775/#6629 residue class as the record-picker entries below.
'element:form': { props: ['fields'] },
// `element:form` had the same `{ props: ['fields'] }` entry until #9249
// retired that element the same way (ADR-0049, the #9220 shape one element
// over); its tombstones' prescription names the live replacement, the
// object-bound `object-form` block.
// `labelField` is the one field-bearing prop this element declares. Its former
// companions `displayField` (renamed to `labelField`, ADR-0087 D2) and
// `searchFields` (deleted, ADR-0049) were retired in #5775 and are
Expand Down
12 changes: 8 additions & 4 deletions packages/lint/src/validate-react-page-props.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -721,14 +721,16 @@ describe('validateReactPageProps — record:* blocks need a context this surface

describe('validateReactPageProps — <Block> escape hatch (#4340)', () => {
it('checks the props bag by the registered type the author names', () => {
// `element:number` carries the pin since #9249 retired `element:form`
// (whose `fields` entry left COMPONENT_FIELD_SPECS with the element).
const f = unknownFields(
validateReactPageProps(
propsPage(jsx('Block', `type="element:form" objectName="crm_account" fields={['nope']}`)),
propsPage(jsx('Block', `type="element:number" objectName="crm_account" field="nope"`)),
),
);
expect(f).toHaveLength(1);
expect(f[0].where).toBe('page "p" › <Block>');
expect(f[0].path).toBe('pages[0].source › fields[0]');
expect(f[0].path).toBe('pages[0].source › field');
});

it('skips a type with no descriptor, and a non-static type', () => {
Expand DownExpand Up@@ -1332,15 +1334,17 @@ describe('validateReactPageProps — unprovisioned injected anchors (#8340)', ()
// The shared table `validate-page-field-bindings` walks; on this surface
// it is reachable only by the type the author spells out, which is what
// makes the escape hatch checked rather than a hole.
// `element:number` carries the pin since #9249 retired `element:form`
// (whose `fields` entry left COMPONENT_FIELD_SPECS with the element).
const f = validateReactPageProps(
extPage(`function Page(){ return <Block type="element:form" objectName="ext_customer" fields={['owner_id']} />; }`),
extPage(`function Page(){ return <Block type="element:number" objectName="ext_customer" field="owner_id" />; }`),
);
expect(f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN)).toHaveLength(0);
const warned = f.filter((x) => x.rule === PAGE_FIELD_UNPROVISIONED);
expect(warned).toHaveLength(1);
expect(warned[0].severity).toBe('warning');
expect(warned[0].where).toBe('page "p" › <Block>');
expect(warned[0].path).toBe('pages[0].source › fields[0]');
expect(warned[0].path).toBe('pages[0].source › field');
expect(warned[0].message).toContain('external object (ADR-0015)');
});
});
Expand Down
8 changes: 4 additions & 4 deletions packages/lint/src/validate-react-page-props.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -608,7 +608,7 @@ function checkObjectChart(
//
// `REACT_FIELD_SPECS` below describes the blocks whose metadata twin lives
// under different prop names — `<ListView>` (twin: a list page's
// `interfaceConfig`) and `<ObjectForm>` (twin: `element:form` + the
// `interfaceConfig`) and `<ObjectForm>` (twin: the `object-form` block + the
// form-layout rule) — plus `<ObjectChart>`'s `filter`.
//
// `COMPONENT_FIELD_SPECS`, the table `validate-page-field-bindings` walks on
Expand DownExpand Up@@ -883,9 +883,9 @@ function checkBlockFieldProps(
);
}

// `<Block type="element:form">` renders the registered component the author
// names; every other block's type is fixed by its tag. A `record:*` type
// never arrives here — `recordContextFinding` rejected it upstream.
// `<Block type="element:number">` renders the registered component the
// author names; every other block's type is fixed by its tag. A `record:*`
// type never arrives here — `recordContextFinding` rejected it upstream.
const schemaType = tag === 'Block' ? strOf(values.get('type')) : SCHEMA_TYPE_BY_TAG.get(tag);
if (schemaType && COMPONENT_FIELD_SPECS[schemaType]) {
out.push(
Expand Down
11 changes: 9 additions & 2 deletions packages/qa/dogfood/test/expression-conformance.ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,9 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
},
{
id: 'cel-ui',
summary: 'UI visibility / routing / submit predicates',
// "+ submit predicates" left this summary with #9249: `element:form.onSubmit`
// was the one submit predicate here, and the whole element retired.
summary: 'UI visibility / routing predicates',
dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log',
enforcement: 'console (objectui) SchemaRenderer + server celEngine (interpret)',
covers: [
Expand All@@ -148,7 +150,12 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
'ui/view.zod.ts:condition',
'ui/view.zod.ts:visibleWhen',
'ui/view.zod.ts:visibleOn',
'ui/component.zod.ts:onSubmit',
// `ui/component.zod.ts:onSubmit` (element:form's submit CEL) sat here
// until #9249 retired the whole `element:form` element under ADR-0049
// (no renderer ever shipped, so nothing ever evaluated the predicate).
// The key is a retiredKey() tombstone now — no ExpressionInputSchema
// member left in source — so the cover is deleted rather than
// re-pointed, the mapping.zod.ts:expression (#5552) way.
// Conditional tabs (framework#2606): `page:tabs` item-level visibility.
// Canonical ADR-0089 `visibleWhen` from day one (no deprecated alias on
// this new surface). Interpreted by the objectui page:tabs renderer to
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ui.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,6 @@
"ui/ElementButtonProps:iconPosition = \"left\"",
"ui/ElementButtonProps:size = \"medium\"",
"ui/ElementButtonProps:variant = \"primary\"",
"ui/ElementFormProps:mode = \"create\"",
"ui/ElementImageProps:fit = \"cover\"",
"ui/ElementMetadataViewerProps:detail = \"business\"",
"ui/ElementTextInputProps:disabled = false",
Expand Down
Loading
Loading