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
15 changes: 15 additions & 0 deletions .changeset/11183-action-node-properties-bag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
---

Docs and pins only, no package released: every taught `action:*` node under `AGENTS.md`,
`skills/objectui/**` and `content/docs/guide/**` writes the block's props in the spec's
`properties` bag, as the `@objectstack/spec` `ComponentPropsMap` row declares them, instead of
flat on the node. The spec's `PageComponentSchema` is `.strict()` (ADR-0089 D3a) and refuses a
node-level `actionType` / `target` as mis-layered; objectui's strict authoring face refuses the
same two keys, and PR objectui#11069 moves `objectui validate` onto that face, so the taught node
had to move first. Same move for the `record:activity` example in the plugin-detail guide
(`limit` / `showCompleted`) and the page-builder skill's `object-form` example, which `objectui
validate` already refuses flat by name. The runtime reads both spellings through the
`SchemaRenderer` `properties` hoist, so no behaviour changes. The two ledgered pins that read the
quick-start's taught node move with it and keep a derived control that the pre-move flat spelling
is still refused by exactly `actionType` and `target` (objectui#11183).
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ interface BaseSchema {
- **#1 — Protocol-agnostic.** Never hardcode `objectql.find()`. Use the DataSource interface; inject `dataSource` via `<SchemaRendererProvider dataSource={...} />`.
- **#2 — Docs-driven.** For every feature/refactor, update package `README.md` **and** `content/docs/guide/*.md`. Not done until docs reflect the code.
- **#3 — "Shadcn-native" aesthetics.** We are "serializable Shadcn". Follow Shadcn's DOM structure (`CardHeader`/`CardTitle`/`CardContent`). Always expose `className` in schema props so users can override via JSON.
- **#4 — Action system (objectui#7898, objectui#6497).** Actions are **data, not functions**, and a control that RUNS something is its own NODE TYPE — `action:button` — never a handler key or an event bag on an ordinary node. `actionType` names the executor the action runner dispatches to; the node's own keys carry that executor's arguments:
- **#4 — Action system (objectui#7898, objectui#6497, objectui#11183).** Actions are **data, not functions**, and a control that RUNS something is its own NODE TYPE — `action:button` — never a handler key or an event bag on an ordinary node. The block's props live in the node's `properties` bag, which is the spec's `ComponentPropsMap` row for it: `actionType` names the executor the action runner dispatches to, and the row's other keys carry that executor's arguments. ⛔ Never write them flat on the node: the spec's `PageComponentSchema` is `.strict()` (ADR-0089 D3a) and refuses a node-level `actionType` / `target` as mis-layered, and objectui's strict authoring face refuses the same two keys. The runtime reads both spellings through the `SchemaRenderer` `properties` hoist, so the bag costs nothing at render:
```json
{ "type": "action:button", "label": "Open details", "actionType": "url", "target": "/users/ada" }
{ "type": "action:button", "properties": { "label": "Open details", "actionType": "url", "target": "/users/ada" } }
```
⛔ Never author an `events` bag: `BaseSchema` declares no `events` member and no renderer reads `schema.events` — the node is `.passthrough()`, so one authored there is kept, judged by nothing and run by nothing. `ButtonSchema.onClick` is a runtime slot for a host-supplied function and is refused by name for the same reason.
- **#5 — Layout as components.** Treat `Grid`/`Stack`/`Container` as first-class. Layout schemas declare responsive columns on the node as `columns` — a number, or a breakpoint object (`columns: { xs: 1, md: 2, lg: 4 }`); never `cols`, which nothing reads (objectui#4001).
Expand Down
29 changes: 18 additions & 11 deletions content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,19 +546,23 @@ Same rule as above, and it governs the trail too: the breadcrumb and the buttons
"children": [
{
"type": "action:button",
"name": "edit_record",
"label": "Edit",
"variant": "default",
"icon": "pencil",
"actionType": "editRecord"
"properties": {
"name": "edit_record",
"label": "Edit",
"variant": "default",
"icon": "pencil",
"actionType": "editRecord"
}
},
{
"type": "action:button",
"name": "delete_record",
"label": "Delete",
"variant": "destructive",
"icon": "trash",
"actionType": "deleteRecord"
"properties": {
"name": "delete_record",
"label": "Delete",
"variant": "destructive",
"icon": "trash",
"actionType": "deleteRecord"
}
}
]
},
Expand All @@ -573,7 +577,10 @@ Same rule as above, and it governs the trail too: the breadcrumb and the buttons
```

A button that RUNS something is an `action:button` node, not a `button` carrying an
`onClick`. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied
`onClick`. Its props — `actionType` and the rest of the block's row — go in the node's
`properties` bag, as `@objectstack/spec` declares them; the spec's strict page component
refuses them written flat on the node, and `SchemaRenderer` hoists the bag onto the node
before the renderer reads it. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied
function and the zod mirror refuses it BY NAME — JSON has no function value, and no
handler key consumes a declarative action object. The refusal is not the whole cost:
`onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`, so an authored string or object is
Expand Down
16 changes: 10 additions & 6 deletions content/docs/guide/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,16 +143,20 @@ Open [http://localhost:5173](http://localhost:5173). You should see a card and d
### Add Actions

Actions are data, not inline functions. Declare one as an `action:button`
node: `actionType` names the built-in executor the action runner dispatches to,
and the action's own keys carry that executor's arguments — `target` is the
location a `url` action navigates to:
node whose `properties` bag carries the block's props, as `@objectstack/spec`
declares them: `actionType` names the built-in executor the action runner
dispatches to, and the bag's other keys carry that executor's arguments —
`target` is the location a `url` action navigates to. Written flat on the node,
`actionType` and `target` are refused by the spec's strict page component:

```json
{
"type": "action:button",
"label": "Open details",
"actionType": "url",
"target": "/users/ada"
"properties": {
"label": "Open details",
"actionType": "url",
"target": "/users/ada"
}
}
```

Expand Down
29 changes: 16 additions & 13 deletions content/docs/guide/record-edit-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,20 @@ refresh the page mid-edit (the form rehydrates from the URL `:recordId`).

In addition to the implicit "click create/edit on a list" entry point,
two declarative actions let you open the page-mode routes from an
`action:button` in metadata. The handler name goes in `actionType`: that
is the key the button renderer forwards to the action runner as the
action's type, and the runner dispatches to the handler registered under
it. Arguments are static values, and they go in the node's `properties`
bag as `properties.params`:
`action:button` in metadata. Every prop of the block lives in the node's
`properties` bag, as `@objectstack/spec` declares it. The handler name goes
in `properties.actionType`: that is the key the button renderer forwards to
the action runner as the action's type, and the runner dispatches to the
handler registered under it. Arguments are static values, and they go in
the same bag as `properties.params`:

```jsonc
{
"type": "action:button",
"label": "New Account",
"icon": "plus",
"actionType": "navigate_create",
"properties": {
"label": "New Account",
"icon": "plus",
"actionType": "navigate_create",
"params": { "objectName": "account" }
}
}
Expand All @@ -89,10 +90,10 @@ names the record it sits on with `${record.id}`:
```jsonc
{
"type": "action:button",
"label": "Edit",
"icon": "pencil",
"actionType": "navigate_edit",
"properties": {
"label": "Edit",
"icon": "pencil",
"actionType": "navigate_edit",
"params": {
"objectName": "account",
"recordId": "${record.id}"
Expand Down Expand Up @@ -126,8 +127,10 @@ entirely:
```jsonc
{
"type": "action:button",
"label": "New",
"actionType": "navigate_create"
"properties": {
"label": "New",
"actionType": "navigate_create"
}
}
```

Expand Down
16 changes: 10 additions & 6 deletions content/docs/guide/schema-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,16 +307,20 @@ Components can emit events that you handle in React:
/>
```

Reference actions in schemas:
Reference actions in schemas — the block's props go in its `properties` bag, as
`@objectstack/spec` declares them; the spec's strict page component refuses them
written flat on the node:

```json
{
"type": "action:button",
"name": "call_api",
"label": "Click Me",
"actionType": "api",
"endpoint": "/api/action",
"method": "POST"
"properties": {
"name": "call_api",
"label": "Click Me",
"actionType": "api",
"endpoint": "/api/action",
"method": "POST"
}
}
```

Expand Down
6 changes: 4 additions & 2 deletions content/docs/plugins/plugin-detail.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ declare const handleAddComment: (text: string) => void;

`RecordActivityTimeline` is a presenter: you hand it `items`. The SDUI block
`record:activity` is the schema-authored form of the same panel, and it finds
its own feed. Drop it anywhere inside a record page:
its own feed. Drop it anywhere inside a record page; its inputs go in the node's
`properties` bag, as `@objectstack/spec` declares them (the strict page component
refuses them written flat on the node):

```json
{
Expand All @@ -195,7 +197,7 @@ its own feed. Drop it anywhere inside a record page:
"label": "Activity",
"value": "activity",
"children": [
{ "type": "record:activity", "limit": 20, "showCompleted": false }
{ "type": "record:activity", "properties": { "limit": 20, "showCompleted": false } }
]
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,17 @@ describe('objectui validate — a page built from ADR-0080 public blocks (object
it('validates a page with the taught `action:button` node (objectui#10872 batch 4)', async () => {
// The node the quick-start's "Add Actions" section teaches — the one
// AGENTS.md #4 and the handler-key refusals' own remedy point at — read
// from the page that teaches it, beside a `page:header`.
// from the page that teaches it, beside a `page:header`. Since
// objectui#11183 the page writes it in the spec's spelling, the block's
// props in `properties`, so this row passes on the strict face as well.
const quickStart = readFileSync(QUICK_START, 'utf8');
const fence = /```json\n([\s\S]*?)\n```/.exec(quickStart.slice(quickStart.indexOf('### Add Actions')));
expect(fence, 'no ```json fence under "### Add Actions" in the quick-start').not.toBeNull();
const taught = JSON.parse((fence as RegExpExecArray)[1]);
// Lit control on the extraction: it is the taught node.
// Lit control on the extraction: it is the taught node, in the bag spelling.
expect(taught.type).toBe('action:button');
expect(taught.properties?.actionType).toBe('url');
expect(taught).not.toHaveProperty('actionType');
const page = {
type: 'page',
title: 'Users',
Expand Down
56 changes: 41 additions & 15 deletions packages/types/src/__tests__/held-public-block-arms-10872.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ const bagOf = (arm: z.ZodObject): z.ZodType => (arm.shape.properties as z.ZodOpt
const keysOf = (schema: z.ZodType): string[] =>
Object.keys((schema as unknown as { shape?: Record<string, unknown> }).shape ?? {}).sort();

/** The taught node with its bag hoisted flat — the spelling the page taught before objectui#11183. */
const flattened = (node: Record<string, unknown>): Record<string, unknown> => {
const { properties, ...rest } = node;
return { ...rest, ...(properties as Record<string, unknown>) };
};

/** Every issue, union branches unfolded and paths made absolute. */
const allIssues = (issues: Issue[] | undefined, prefix: PropertyKey[] = []): Issue[] =>
(issues ?? []).flatMap((issue) => {
Expand Down Expand Up @@ -223,7 +229,11 @@ describe('objectui#10872 batch 4 — the two handler keys the action renderers r
const HERE = dirname(fileURLToPath(import.meta.url));
const QUICK_START = join(HERE, '..', '..', '..', '..', 'content', 'docs', 'guide', 'quick-start.md');

/** The quick-start's "Add Actions" node — the one AGENTS.md #4 teaches, read from the page that teaches it. */
/**
* The quick-start's "Add Actions" node — the one AGENTS.md #4 teaches, read from
* the page that teaches it. Since objectui#11183 the page writes it in the spec's
* spelling: the block's props in `properties`, nothing flat on the node.
*/
function taughtActionButton(): Record<string, unknown> {
const doc = readFileSync(QUICK_START, 'utf8');
const section = doc.slice(doc.indexOf('### Add Actions'));
Expand All @@ -233,10 +243,15 @@ function taughtActionButton(): Record<string, unknown> {
}

describe('objectui#10872 batch 4 — a page with an `action:button` validates', () => {
it('reads the taught node (non-vacuity)', () => {
it('reads the taught node (non-vacuity) — and it is in the bag spelling', () => {
const node = taughtActionButton();
expect(node.type).toBe('action:button');
expect(node.actionType).toBe('url');
const bag = node.properties as Record<string, unknown>;
expect(bag.actionType).toBe('url');
expect(bag.target).toBe('/users/ada');
// Nothing executor-shaped is left flat on the node (objectui#11183).
expect(node).not.toHaveProperty('actionType');
expect(node).not.toHaveProperty('target');
});

it('the taught node, in a page, passes the tolerant face `objectui validate` runs', () => {
Expand All @@ -245,23 +260,34 @@ describe('objectui#10872 batch 4 — a page with an `action:button` validates',
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
});

it.each(FACES)('%s face: the same node in the spec\'s spelling — its props in `properties` — validates', (_face, judge) => {
const { type, ...props } = taughtActionButton();
const page = { type: 'page', children: [{ type: 'page:header', properties: { title: 'Users' } }, { type, properties: props }] };
it.each(FACES)('%s face: the taught node AS WRITTEN — its props in `properties` — validates, in a page and alone', (_face, judge) => {
const page = { type: 'page', children: [{ type: 'page:header', properties: { title: 'Users' } }, taughtActionButton()] };
const result = judge(page);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
expect(judge(taughtActionButton()).success).toBe(true);
});

it('the spec\'s own `PageComponentSchema` accepts the taught node as written', () => {
expect(PageComponentSchema.safeParse(taughtActionButton()).success).toBe(true);
});

it('⚠️ the strict face refuses the taught node\'s FLAT props — the flat-props question objectui#10872 holds', () => {
// A prop written flat on the node is not judged against the row: `label` is
// a `BaseSchema` key, and `actionType` / `target` are undeclared there, so
// the strict face refuses exactly those two, as the spec's own
// `PageComponentSchema` does. Whether the flat spelling becomes an
// authoring channel for `action:*` is that card's call; this reading moves
// with it.
const result = StrictAnyComponentSchema.safeParse(taughtActionButton());
it('CONTROL — the pre-objectui#11183 FLAT spelling of the same node is refused, by exactly its two executor keys', () => {
// objectui#10872's fork, ruled A on objectui#11183: the `properties` bag is
// the contract. A prop written flat on the node is not judged against the
// row — `label` is a `BaseSchema` key, `actionType` / `target` are
// undeclared there — so the strict face refuses exactly those two, as the
// spec's `PageComponentSchema` does. Derived from the taught node rather
// than transcribed, so the control moves with the page.
const flat = flattened(taughtActionButton());
expect(flat.actionType).toBe('url');
const result = StrictAnyComponentSchema.safeParse(flat);
expect(result.success).toBe(false);
expect(refusedKeys(result).sort()).toEqual(['actionType', 'target']);
expect(PageComponentSchema.safeParse(taughtActionButton()).success).toBe(false);
const spec = PageComponentSchema.safeParse(flat);
expect(spec.success).toBe(false);
expect(refusedKeys(spec as Result).sort()).toEqual(['actionType', 'target']);
// The tolerant face still keeps the flat node, which is why the page had to move
// before `objectui validate` reaches the strict face (objectui#5250).
expect(safeValidateSchema(flat).success).toBe(true);
});
});
15 changes: 10 additions & 5 deletions skills/objectui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,21 @@ styles can be overridden from JSON.
### 4. The action system (interactivity)

Actions are data, not functions — and a control that RUNS something is its own
node type, `action:button`. `actionType` names the executor the action runner
dispatches to; the node's own keys carry that executor's arguments:
node type, `action:button`. Its props go in the node's `properties` bag, the spec's
row for the block: `actionType` names the executor the action runner dispatches
to, and the row's other keys carry that executor's arguments. ⛔ Never flat on the
node — the spec's strict page component refuses a node-level `actionType` /
`target` as mis-layered (ADR-0089 D3a), and so does objectui's strict authoring face:

<!-- os:check -->
```json
{
"type": "action:button",
"label": "Open details",
"actionType": "url",
"target": "/users/ada"
"properties": {
"label": "Open details",
"actionType": "url",
"target": "/users/ada"
}
}
```

Expand Down
29 changes: 18 additions & 11 deletions skills/objectui/guides/page-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,19 @@ Keep custom component registrations namespaced to avoid collisions.
### 5. Use action data, not inline callback spaghetti

Represent interactions as data: a control that runs something is an `action:button`
node, and `actionType` names the executor the action runner dispatches to.
node. Its props go in the `properties` bag — `actionType` names the executor the
action runner dispatches to — never flat on the node, which the spec's strict page
component refuses (ADR-0089 D3a).

<!-- os:check -->
```json
{
"type": "action:button",
"label": "Save customer",
"actionType": "url",
"target": "/customers"
"properties": {
"label": "Save customer",
"actionType": "url",
"target": "/customers"
}
}
```

Expand Down Expand Up @@ -251,17 +255,20 @@ renderers do (`schema.objectName`, `schema.columns`, `schema.fields`,
> names the field a form input writes. A grid column written as `{ "name": ... }`
> names no field, so `ObjectGrid` drops it.

**Form plugin example:**
**Form plugin example** — an `object-form` takes its props in the spec's `properties`
bag, and `objectui validate` refuses the flat spelling by name:
<!-- os:check -->
```json
{
"type": "object-form",
"objectName": "customer",
"mode": "edit",
"fields": [
{ "name": "name", "label": "Name", "type": "text", "required": true },
{ "name": "email", "label": "Email", "type": "text" }
]
"properties": {
"objectName": "customer",
"mode": "edit",
"fields": [
{ "name": "name", "label": "Name", "type": "text", "required": true },
{ "name": "email", "label": "Email", "type": "text" }
]
}
}
```

Expand Down
Loading
Loading