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
28 changes: 28 additions & 0 deletions .changeset/10859-object-form-properties-bag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@object-ui/types': minor
---

feat(types): an authored `object-form` takes its props in the spec's `properties` bag; the flat spelling is refused by name (objectui#10859, batch 4)

**BREAKING (authoring):** an `object-form` node that writes its props on the node itself is now refused by `safeValidateSchema`, `validateSchema` and the strict authoring face, and so by `objectui validate`. Move each prop into the node's `properties` bag:

```json
{ "type": "object-form", "properties": { "objectName": "account", "mode": "create", "fields": ["name", "rating"] } }
```

Nothing changes at render time: `SchemaRenderer` hoists every `properties` key onto the node before `ObjectForm` runs, so the bag node draws what the flat one drew, and a node built in code keeps working with its flat keys.

**Clause-②: yes** — the accept set narrows (a flat `object-form` node that parsed is refused) and widens (a spec-shaped bag node that was refused parses). Released as `minor` under the objectui narrowing rule, with this banner.

**Why.** `@objectstack/spec`'s `ComponentPropsMap['object-form']` row is the published declaration of an authored `object-form` node's props, and the spec's own strict `PageComponentSchema` refuses a prop written on the node as mis-layered (ADR-0089 D3a). The arm here was the flat mirror of the TypeScript `ObjectFormSchema`, so the two validators disagreed both ways: the objectstack showcase's wizard page, written in the bag, was refused (`objectName` `invalid_type`, `mode` `invalid_value`), and the flat node `os validate` refuses was accepted.

**What changed, in observable terms.**

- `@object-ui/types/zod` exports `ObjectFormBlockSchema`, built the way `ObjectMetricBlockSchema` is: `BaseSchema`, the `object-form` literal, and `properties`, which is the spec row by reference, so the row's members, value types and strictness judge the bag. It declares the node's `dataSource` binding (the spec's `ElementDataSourceSchema`), refuses the five handler keys `ObjectForm` reads off the node as runtime slots (objectui#6124), and refuses `body` / `children` (objectui#9256).
- Each member of the row written flat on the node is refused on both faces, with a message that names its bag member (`Did you mean \`mode\` → \`properties.mode\`?`). The refusal set is read off the row, so it follows the spec.
- `ObjectFormBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`. `ObjectFormSchema` leaves `ObjectQLComponentSchema`.
- Inside the bag the spec row decides, and it differs from the flat mirror in a few places. `objectName` and `mode` are optional (a `dataSource` binding can name the object). `layout` takes `vertical` or `horizontal` only (the spec retired `inline` and `grid`; set `columns` for several columns). `recordId` takes a string or a number. `buttons`, `defaults`, `subforms` and `groups` are form-VIEW keys, not members of the row, so the bag refuses them; `buttons`, `defaults` and `subforms` stay authorable in an `object-view`'s `form` slot. The row types `sections` entries as `unknown`, so a section's own keys are not judged inside the bag.

**The same release's `.changeset/6152-object-form-unmirrored-members.md`** declared members on the flat mirror so that the strict face stopped refusing them on an authored node. On an authored node those members are now written in the bag, where the spec row judges them: every one of them except `buttons`, `defaults` and `subforms`, which the row does not declare. Those three stay authorable in an `object-view`'s `form` slot, which is built from the mirror, and a section there is still judged member by member.

**What did not move.** The TypeScript `ObjectFormSchema` and its zod mirror `ObjectFormSchema` stay published and unchanged in shape. They are the node as `ObjectForm` reads it after the hoist, and as code composes it (`ObjectView`, `RecordFormPage`, `ScreenView`, a host mounting `<ObjectForm schema={…}>`). The object-view `form` slot is still built from the mirror.
7 changes: 7 additions & 0 deletions .changeset/10859-plugin-form-readme-object-form-bag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@object-ui/plugin-form': patch
---

docs(plugin-form): the README's authored `object-form` examples write their props in the `properties` bag (objectui#10859, batch 4)

The multi-step and metadata-route examples now author `{ "type": "object-form", "properties": { … } }`, the spelling `@objectstack/spec`'s `ComponentPropsMap['object-form']` row declares and `objectui validate` now requires. The example that mounts `WizardForm` directly keeps the flat `schema` prop: a component mounted without `SchemaRenderer` receives the node as the renderer reads it after the hoist. No runtime change.
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,19 +326,22 @@ declare class MyCustomDataSource<T = unknown> implements DataSource<T> {
**Stop writing repetitive UI code.** A form is a schema, not a component:

```tsx
import type { ObjectFormSchema } from '@object-ui/types';
import type { BaseSchema } from '@object-ui/types';

// Traditional React: useState, validation, handlers, JSX — per form
function UserForm() {
// ...
}

// Object UI: declare it
const schema: ObjectFormSchema = {
// Object UI: declare it — the block's props go in its `properties` bag,
// as `@objectstack/spec` declares them
const schema: BaseSchema = {
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
properties: {
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
}
```

Expand Down
50 changes: 27 additions & 23 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -815,30 +815,34 @@ presence enables paging.

A smart form that auto-generates fields from an ObjectQL object. Supports simple, tabbed, wizard, split, drawer, and modal layouts.

An authored `object-form` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-form']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#10859). `SchemaRenderer` hoists the bag onto the node before `ObjectForm` runs, so `ObjectFormSchema` is the node as the renderer reads it, and the table below lists its members.

```json
{
"type": "object-form",
"objectName": "Contact",
"mode": "create",
"formType": "tabbed",
"title": "New Contact",
"layout": "vertical",
"columns": 2,
"fields": ["firstName", "lastName", "email", "phone", "company"],
"sections": [
{
"label": "Basic Info",
"fields": ["firstName", "lastName", "email"]
},
{
"label": "Details",
"fields": ["phone", "company", "address"]
}
],
"showSubmit": true,
"submitText": "Create Contact",
"showCancel": true,
"cancelText": "Cancel"
"properties": {
"objectName": "Contact",
"mode": "create",
"formType": "tabbed",
"title": "New Contact",
"layout": "vertical",
"columns": 2,
"fields": ["firstName", "lastName", "email", "phone", "company"],
"sections": [
{
"label": "Basic Info",
"fields": ["firstName", "lastName", "email"]
},
{
"label": "Details",
"fields": ["phone", "company", "address"]
}
],
"showSubmit": true,
"submitText": "Create Contact",
"showCancel": true,
"cancelText": "Cancel"
}
}
```

Expand All @@ -851,8 +855,8 @@ A smart form that auto-generates fields from an ObjectQL object. Supports simple
| `fields` | `string[]` | Field API names to include (auto-resolved from object metadata). |
| `customFields` | `FormField[]` | Manually defined fields that override auto-generated ones. |
| `sections` | `ObjectFormSection[]` | Field sections (simple groups, tabs, or wizard steps depending on `formType`). Spec-aligned key. |
| `groups` | `array` | **Deprecated.** Legacy alias of `sections` (spec defines `groups` as an alias); normalized into `sections` when `sections` is absent. Legacy shape: `title`→`label`, `defaultCollapsed`→`collapsed`. |
| `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`, `"inline"`, `"grid"`. |
| `groups` | `array` | **Deprecated.** Legacy alias of `sections` (spec defines `groups` as an alias); normalized into `sections` when `sections` is absent. Legacy shape: `title`→`label`, `defaultCollapsed`→`collapsed`. Not a member of the spec's `object-form` row, so an authored bag refuses it; write `sections`. |
| `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`. The spec row retired `"inline"` and `"grid"` (both rendered as `"vertical"`), so an authored bag refuses them; for several columns set `columns`. |
| `columns` | `number` | Number of form columns. |
| `submitText` / `cancelText` | `string \| I18nLabel` | Button labels. |
| `title` / `description` | `string \| I18nLabel` | Heading and subtitle of the drawer and modal presentations. |
Expand Down
8 changes: 5 additions & 3 deletions content/docs/guide/building-crud-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,11 @@ carries a data source of its own:
<SchemaRenderer
schema={{
type: 'object-form',
objectName: 'task',
mode: editId ? 'edit' : 'create',
recordId: editId,
properties: {
objectName: 'task',
mode: editId ? 'edit' : 'create',
recordId: editId,
},
}}
onSubmit={() => setShowForm(false)}
onCancel={() => setShowForm(false)}
Expand Down
19 changes: 10 additions & 9 deletions content/docs/guide/objectos-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -539,19 +539,20 @@ unsubscribe();

### From Appsmith

{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; `dataSource` continues the "Using ObjectQL for Queries" block above, and a bare object literal at statement position parses as a block with labels (measured: TS1005 x4) */}
{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; a bare object literal at statement position parses as a block with labels */}

```typescript
// Appsmith form → ObjectUI form
// Appsmith form → ObjectUI form. The props go in the node's `properties`
// bag (the spec's `ComponentPropsMap['object-form']` row). The form saves
// through the adapter `SchemaRendererProvider` supplies, so no submit
// handler is written here.
{
type: 'object-form',
objectName: 'contact',
dataSource,
mode: 'create',
fieldNames: ['name', 'email', 'phone', 'company'],
onSubmit: async (data) => {
await dataSource.create('contact', data);
}
properties: {
objectName: 'contact',
mode: 'create',
fields: ['name', 'email', 'phone', 'company'],
},
}
```

Expand Down
37 changes: 21 additions & 16 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -531,26 +531,31 @@ no `multi-step-form` type. The registered type is `object-form`, and `formType:
turns its `sections` into steps; `WizardFormSchema` (exported by
`@object-ui/plugin-form`) declares the shape. Because the wizard resolves its fields from
the object's own metadata, a section lists field **names** rather than field definitions.
Like every `object-form` prop, they go in the node's `properties` bag — the members of
`@objectstack/spec`'s `ComponentPropsMap['object-form']` row. `objectui validate` refuses a
prop written flat on the node by name, as the spec's own page component does.

```json
{
"type": "object-form",
"objectName": "contact",
"mode": "create",
"formType": "wizard",
"showStepIndicator": true,
"sections": [
{
"name": "personal",
"label": "Personal Info",
"fields": ["first_name", "last_name"]
},
{
"name": "contact_details",
"label": "Contact Info",
"fields": ["email", "phone"]
}
]
"properties": {
"objectName": "contact",
"mode": "create",
"formType": "wizard",
"showStepIndicator": true,
"sections": [
{
"name": "personal",
"label": "Personal Info",
"fields": ["first_name", "last_name"]
},
{
"name": "contact_details",
"label": "Contact Info",
"fields": ["email", "phone"]
}
]
}
}
```

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{
"type": "object-form",
"objectName": "users",
"mode": "edit",
"recordId": "1",
"columns": 2,
"fields": ["name", "email", "role", "department", "status"],
"submitText": "Save changes"
"properties": {
"objectName": "users",
"mode": "edit",
"recordId": "1",
"columns": 2,
"fields": ["name", "email", "role", "department", "status"],
"submitText": "Save changes"
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
{
"type": "object-form",
"objectName": "users",
"mode": "edit",
"recordId": "1",
"formType": "tabbed",
"defaultTab": "identity",
"sections": [
{ "name": "identity", "label": "Identity", "columns": 2, "fields": ["name", "email"] },
{ "name": "organisation", "label": "Organisation", "fields": ["department", "created_at"] },
{ "name": "access", "label": "Access", "fields": ["role", "status"] }
],
"submitText": "Save changes"
"properties": {
"objectName": "users",
"mode": "edit",
"recordId": "1",
"formType": "tabbed",
"defaultTab": "identity",
"sections": [
{ "name": "identity", "label": "Identity", "columns": 2, "fields": ["name", "email"] },
{ "name": "organisation", "label": "Organisation", "fields": ["department", "created_at"] },
{ "name": "access", "label": "Access", "fields": ["role", "status"] }
],
"submitText": "Save changes"
}
}
11 changes: 7 additions & 4 deletions examples/schema-catalog/test/catalog-gallery-render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1135,12 +1135,15 @@ describe('objectui#6024 — the derivation this pin is built on', () => {
* its first paint until the fetch lands, which is why this shape settles.)
*/
it('the provenance instrument still fails on a surface with no record behind it', async () => {
// The entries' spelling: the props in the `properties` bag (objectui#10859 batch 4).
const r = await renderEntry({
type: 'object-form',
objectName: 'users',
mode: 'edit',
recordId: 'no-such-record',
fields: ['name', 'email', 'department'],
properties: {
objectName: 'users',
mode: 'edit',
recordId: 'no-such-record',
fields: ['name', 'email', 'department'],
},
});
try {
expect(
Expand Down
66 changes: 38 additions & 28 deletions packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,27 +719,31 @@ steps are its `sections` — one step per section. There is no
unknown-component placeholder and the fields inside `steps` are never read.

```typescript
import type { ObjectFormSchema } from '@object-ui/types';
import type { BaseSchema } from '@object-ui/types';

const schema: ObjectFormSchema = {
// The props go in the node's `properties` bag — the members of
// `@objectstack/spec`'s `ComponentPropsMap['object-form']` row.
const schema: BaseSchema = {
type: 'object-form',
objectName: 'contacts', // required
mode: 'create', // required
formType: 'wizard', // routes to WizardForm — needs at least one section
sections: [
{
name: 'personal',
label: 'Personal Info',
fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema
},
{
name: 'contact',
label: 'Contact Info',
fields: ['email', 'phone']
}
],
allowSkip: false, // see "Wizard steps and allowSkip" above
showStepIndicator: true
properties: {
objectName: 'contacts', // the object whose fields the steps list
mode: 'create',
formType: 'wizard', // routes to WizardForm — needs at least one section
sections: [
{
name: 'personal',
label: 'Personal Info',
fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema
},
{
name: 'contact',
label: 'Contact Info',
fields: ['email', 'phone']
}
],
allowSkip: false, // see "Wizard steps and allowSkip" above
showStepIndicator: true
}
};
```

Expand Down Expand Up @@ -951,29 +955,35 @@ registrations.

### The metadata route — `object-form`

This is the route that actually reads and writes a record. `ObjectFormSchema`
names its object with **`objectName`** and its intent with **`mode`**, and both
are **required** (`packages/types/src/objectql.ts`); the adapter arrives on the
This is the route that actually reads and writes a record. An `object-form`
node names its object with **`objectName`** and its intent with **`mode`**, both
in its `properties` bag, whose members are `@objectstack/spec`'s
`ComponentPropsMap['object-form']` row; `objectui validate` refuses a prop
written flat on the node by name. `SchemaRenderer` hoists the bag onto the node,
and `ObjectFormSchema` (`packages/types/src/objectql.ts`) is the node as
`ObjectForm` then reads it, with both keys required. The adapter arrives on the
context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing
`ObjectForm` its `dataSource` prop.

```tsx
import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react';
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
import '@object-ui/plugin-form';
import type { ObjectFormSchema } from '@object-ui/types';
import type { BaseSchema } from '@object-ui/types';

const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-auth-token',
});

const schema: ObjectFormSchema = {
const schema: BaseSchema = {
type: 'object-form',
objectName: 'users',
mode: 'create',
fields: ['name', 'email'],
submitText: 'Create user',
properties: {
objectName: 'users',
mode: 'create',
fields: ['name', 'email'],
submitText: 'Create user',
},
};

export const App = () => (
Expand Down
Loading
Loading