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/11168-button-undoable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/components': minor
'@object-ui/types': patch
---

`action:button` delivers `undoable` where a record is in scope, and publishes it (objectui#11168, ruling B on objectui#11754).

An `action:button` that declares `undoable: true` on an `operation: update` now offers Undo in its success toast. Undo writes back the prior values of the fields the update wrote, read off the record in scope: the record page's record, or the row the host binds to the node through `data` (a table's row, `DetailView`'s header, an `action:bar` member). Before, the block forwarded `undoable` but handed the runner no record, so the update ran and no Undo was offered anywhere the block was used.

- **What the block now sends.** For an `undoable` `operation: update`, the button hands the runner the record in scope under `params._rowRecord`, the spelling the record page's header, the declared-actions bar, the related-record bridge and the grid's rows already use. The route dispatch strips it before it POSTs. It is attached only when the update writes that record, so where no explicit `recordId` is given, the shared route dispatch (`createServerActionHandler`) now takes the record id from it, as it does for those hosts; the record page's own dispatch already wrote to its record.
- **The one limit.** A button with no record in scope offers no Undo, because there is no row to restore. The same holds for a button whose `recordId` names a record other than the one in scope: its Undo would restore another record's values. A record that does not carry every written field offers no Undo, as before.
- **Unchanged.** A button that is not `undoable`, and an `undoable` action that is not an `operation: update`, dispatch exactly as before, with no record attached.
- **Published.** `undoable` is a published input of `action:button` (a boolean, with a description that states the limit), so the page validator stops reporting it as an unknown prop. Nothing is refused that was accepted before.

`@object-ui/types`: the `UIActionSchema.undoable` doc comment no longer says `action:button` never hands the runner a record. No type changes.
43 changes: 16 additions & 27 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,24 +846,13 @@ const FIELD_SECURITY_TRIPLE = ['enforceFieldSecurity', 'redactFields', 'required
/** Every entry the ruling books starts with this, so the caps can count them. */
const OWED_PREFIX = 'OWED TO ';

/** One ruled entry's reason: owner first, then what is owed, then the ruling and the expiry. */
const OWED_TO = (owner: Objectui11111Owner, what: string): string => {
const { bump, bookedBy, expires } = OBJECTUI_11111_BOOKINGS[owner];
return (
`${OWED_PREFIX}${owner}. ${what} Booked by ${bookedBy}: ` +
`the ${bump} bump re-pins and declares nothing; ${owner} decides it by its own measurement. ` +
`Expires ${expires}, or when ${owner} lands, whichever is first.`
);
};

/** `BLOCK.KEY` entries for every listed key of one block, all with the same owner and reason. */
const owedEntries = (
type: string,
keys: readonly string[],
owner: Objectui11111Owner,
what: string,
): Record<string, string> =>
Object.fromEntries(keys.map((key) => [`${type}.${key}`, OWED_TO(owner, what)]));
// The two helpers that wrote a booked entry — `OWED_TO` (the reason: owner,
// what is owed, the booking record and the expiry) and `owedEntries` (one
// entry per key of a block) — left with the last entry they wrote:
// `action:button.undoable`, struck by objectui#11168's last slice. Every ledger
// is empty and every cap below is 0. The cap test still reads each ledger for
// the `OWED TO ` prefix, so anything booked again is counted against a cap of 0
// and goes red until a ruling books it with an owner and an expiry.

/** Which owner the ruling routes an entry id to — asserted against every entry's reason. */
function objectui11111OwnerOf(id: string): Objectui11111Owner {
Expand Down Expand Up @@ -892,7 +881,7 @@ const owedIdsOf = (ledger: Record<string, string>): string[] =>
const OBJECTUI_11111_LEDGER_CAPS = {
unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline
offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name
unpublishedKeys: 1, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0, objectui#8649: 0, objectui#11536: 0 and objectui#11068: 0 (each struck by its landing; objectui#11536 declared all ten record:line_items keys, objectui#11068's build published object-grid keyboardNavigation)
unpublishedKeys: 0, // objectui#11168: 0 (its last slice declared action:button undoable, ruling B on objectui#11754; the two `endpoint` entries had left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0, objectui#8649: 0, objectui#11536: 0 and objectui#11068: 0 (each struck by its landing; objectui#11536 declared all ten record:line_items keys, objectui#11068's build published object-grid keyboardNavigation)
refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout
memberPins: 0, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536 pinned record:line_items columns and dataSource
} as const;
Expand Down Expand Up @@ -1529,13 +1518,13 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
// at the 17.6.0 bump (objectui#11438): 17.6.0 refuses `endpoint` on both
// blocks (objectstack `b3917d90`, the rename to `target`), so the entries no
// longer named a key the spec declares and `every unpublished-key exemption
// names a key the spec really declares` went red on them. One is left.
...owedEntries(
'action:button',
['undoable'],
'objectui#11168',
'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `undoable`, but the runner\'s `operation: update` path and the console runtime offer Undo only with a host `_rowRecord` stash this block never writes; only the record page\'s own `api` handler honours it.',
),
// names a key the spec really declares` went red on them. The third,
// `action:button.undoable`, is STRUCK: ruling B on objectui#11754 (record
// 6030342264) had the block hand the runner the record in scope as the Undo
// baseline its `operation: update` path reads, and the key is DECLARED on
// the block's `inputs`
// (`packages/components/src/renderers/action/__tests__/action-button-undoable-11168.test.tsx`).
// It was objectui#11168's last entry, so every owner's count below is 0.
// `action:group`'s `location` / `visible` and `action:menu`'s `size` /
// `visible` stood here until objectui#11168 slice 1 measured each against its
// renderer through the real `SchemaRenderer` and DECLARED all four — the
Expand Down Expand Up @@ -6014,7 +6003,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
]),
),
).toEqual({
'objectui#11168': 1,
'objectui#11168': 0,
'objectui#8652': 0,
'objectui#8649': 0,
'objectui#11536': 0,
Expand Down
6 changes: 6 additions & 0 deletions content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,12 @@ registration's `inputs` lists, with a `description` per key. For an `api` action
the request URL in `target`: `endpoint` is not published, because the console's `api`
handler never reads it.

`undoable: true` on an `operation: update` makes the success toast offer Undo, which
writes back the prior values of the fields the update wrote. `action:button` reads those
values off the record in scope: the record page's record, or the row the host binds
through `data`. A button with no record in scope offers no Undo, because there is no row
to restore; neither does one whose `recordId` names a record other than the one in scope.

## Responsive Behavior

The shell has exactly **one** layout breakpoint, at **768px** — Tailwind's `md`, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@
* and `objectName` are the ones `scripts/check-action-forward-parity.mjs`
* extracts from its runtime.
*
* NOT published, and so not pinned here — each stays booked to objectui#11168
* with its measurement on the card: `endpoint` on both blocks (the runner's
* built-in `api` executor reads it, the console's own `api` handler reads
* `target` and never `endpoint`) and `undoable` on `action:button` (the
* runner's update path offers Undo only with a host row stash this block never
* writes).
* Held back by slice 1, each with its measurement on the card: `endpoint` on
* both blocks (the runner's built-in `api` executor reads it, the console's own
* `api` handler reads `target` and never `endpoint`; 17.6.0 then refused the
* key in favour of `target`) and `undoable` on `action:button` (the runner's
* update path offered Undo only with a host row stash this block did not
* write). Ruling B on objectui#11754 made the block hand the runner the record
* in scope, and `undoable` is published on `action:button` since: its
* behaviour is pinned in `action-button-undoable-11168.test.tsx`, and its row
* in `DECLARED` below.
*
* Slice 2 added the `size` rows at the end: `action:button` publishes the five
* sizes its spec row declares. The renderer hands `default`, `sm`, `lg` and
Expand Down Expand Up @@ -85,7 +88,7 @@ const LEAF_KEYS = [

/** The keys each block now publishes, beyond the ones it published before. */
const DECLARED: Record<Block, string[]> = {
'action:button': [...LEAF_KEYS, 'recordIdField'],
'action:button': [...LEAF_KEYS, 'recordIdField', 'undoable'],
'action:icon': LEAF_KEYS,
};

Expand Down
Loading
Loading