Skip to content

Commit 6cebf22

Browse files
os-zhuangclaude
andauthored
feat(spec): add explicit Action.order for deterministic action ordering (#2670) (#2682)
* feat(spec): add explicit Action.order for deterministic action ordering (#2670) The record-header renders the first visible `record_header` action as the primary button and pushes the rest into the `⋯` overflow menu. Which action wins was decided purely by cross-file `defineStack({ actions })` registration order, so system Approve/Reject decisions were appended after app actions and buried in the overflow menu — the approver's first-glance decision was hidden. Add an optional `Action.order` (number, lower = higher / more prominent, default 0) and stable-sort every action group by it in `mergeActionsIntoObjects()` — the single choke point for both `defineStack()` and `composeStacks()`. The sort is stable and treats unset `order` as 0, so groups where nobody sets `order` keep their exact registration order and array reference: the change is a no-op until an author opts in. A plugin (e.g. plugin-approvals) or app author can now give an action a low `order` to make it stably hold the primary-button slot, instead of hiding other actions to "make room". Includes schema tests, merge-sort regression tests, and Action Protocol docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018H4wb2JGo68NggKiTh5Dag * chore(spec): classify Action.order in the liveness gate The spec property-liveness gate (check:liveness) requires every authorable property on a governed type to be classified. Classify the new `action/order` property as `live` (framework stable-sorts each action group by it in mergeActionsIntoObjects; objectui record_header consumes it for primary-button selection). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018H4wb2JGo68NggKiTh5Dag --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b5a87eb commit 6cebf22

7 files changed

Lines changed: 285 additions & 23 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`Action`: add an explicit `order` field so authors and plugins can decide which action holds the record-header primary-button slot, instead of depending on fragile cross-file `defineStack({ actions })` registration order (#2670).
6+
7+
`order` is an optional number, **lower = higher / more prominent**, defaulting to `0`. `mergeActionsIntoObjects()` now stable-sorts every action group — each object's `actions` and the top-level `actions` — by `order` at both `defineStack()` and `composeStacks()` time. In `record_header` the first visible action becomes the primary button, so a negative `order` promotes an action into the primary slot and a positive `order` demotes it toward the `` overflow menu. This is the declarative lever a plugin such as plugin-approvals uses to make an `Approve`/`Reject` decision stably outrank app actions, rather than hiding the other actions to "make room".
8+
9+
Fully backward compatible: the sort is stable and treats unset `order` as `0`, so action groups where nobody sets `order` keep their exact registration order (and array reference). The record-header renderer (objectui) may additionally prefer a `variant: 'primary'` action when two actions tie on `order`.

content/docs/protocol/objectui/actions.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ interface Action {
169169
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link';
170170
component?: 'action:button' | 'action:icon' | 'action:menu' | 'action:group';
171171
locations?: ActionLocation[]; // Where the action surfaces
172+
order?: number; // Sort order within a location group (lower = higher)
172173
173174
// Behavior
174175
type?: 'script' | 'url' | 'modal' | 'flow' | 'api' | 'form'; // default 'script'
@@ -229,6 +230,30 @@ target: /api/customers/export
229230
locations: [list_toolbar]
230231
```
231232

233+
### Ordering & the Primary Button
234+
235+
Within each location group, actions render in **`order`** sequence — **lower comes first / more prominent**. In `record_header` the first visible action becomes the **primary button** and the rest fall into the `⋯` overflow menu, so `order` is how you decide which action holds the primary slot.
236+
237+
```yaml
238+
# Approve holds the primary button even though the app action registered first.
239+
name: approve_request
240+
label: Approve
241+
locations: [record_header]
242+
variant: primary
243+
order: -10 # negative → floats ahead of app actions (order defaults to 0)
244+
```
245+
246+
Semantics:
247+
248+
- **`order` defaults to `0`.** An action with a **negative** `order` is promoted toward the primary slot; a **positive** `order` is demoted toward the overflow menu.
249+
- **The sort is stable.** Actions that leave `order` unset (or tie on the same value) keep their original registration order, so adding `order` to one action never reshuffles the others. Setting `order` on nobody is a no-op — existing screens are unaffected.
250+
- **No more fragile registration order.** Previously "who becomes the primary button" depended on the cross-file order in which `defineStack({ actions })` merged its arrays. `order` makes it declarative: a plugin such as [plugin-approvals](/docs/plugins/approvals) can inject an `Approve`/`Reject` decision with a low `order` so it stably outranks app actions, instead of the app having to *hide* its other actions to make room.
251+
- When two actions tie on `order`, the record-header renderer MAY additionally prefer the one with `variant: 'primary'` when choosing the primary button.
252+
253+
<Callout type="info">
254+
`order` sorts actions **within the same location**; it does not move an action between locations. Use `locations` to choose *where* an action appears and `order` to choose *its position there*.
255+
</Callout>
256+
232257
### Visibility & Disabled Rules
233258

234259
`visible` and `disabled` are **CEL** predicates (not declarative `{ field, value }` objects). `disabled` may also be a plain boolean.

packages/spec/liveness/action.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
"status": "live",
5757
"note": "objectui button styling."
5858
},
59+
"order": {
60+
"status": "live",
61+
"evidence": "packages/spec/src/stack.zod.ts (mergeActionsIntoObjects stable-sorts each action group by order)",
62+
"note": "Deterministic action ordering (#2670). Framework stable-sorts every action group by `order` at defineStack/composeStacks time; objectui record_header picks the first as the primary button. Lower = higher; unset = 0."
63+
},
5964
"confirmText": {
6065
"status": "live",
6166
"note": "objectui confirm dialog."

packages/spec/src/compose-stacks.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,117 @@ describe('composeStacks + defineStack integration', () => {
502502
expect(combined.actions).toHaveLength(1);
503503
});
504504
});
505+
506+
// ─── Action `order` sorting (mergeActionsIntoObjects) ───────────────
507+
// Regression coverage for #2670: the record-header primary button is the first
508+
// visible `record_header` action, so `order` must control which action lands
509+
// first — instead of fragile cross-file registration order.
510+
511+
describe('Action order sorting', () => {
512+
it("sorts an object's inline actions by `order` (lower = earlier / primary slot)", () => {
513+
// Registration order puts the app action first; a negative `order` must
514+
// float Approve up into the primary-button slot.
515+
const stack = makeStack({
516+
objects: [
517+
{
518+
name: 'deal',
519+
fields: { title: { type: 'text' } },
520+
actions: [
521+
{ name: 'close_deal', label: 'Close', locations: ['record_header'] },
522+
{ name: 'print', label: 'Print', locations: ['record_header'], order: 5 },
523+
{ name: 'approve', label: 'Approve', locations: ['record_header'], order: -10 },
524+
],
525+
},
526+
],
527+
});
528+
529+
const names = stack.objects![0].actions!.map((a) => a.name);
530+
// approve (-10) → close_deal (unset = 0) → print (5)
531+
expect(names).toEqual(['approve', 'close_deal', 'print']);
532+
// What objectui renders as the primary button is now the approval decision.
533+
expect(names[0]).toBe('approve');
534+
});
535+
536+
it('is STABLE — actions that tie on `order` (incl. all-unset) keep registration order', () => {
537+
const stack = makeStack({
538+
objects: [
539+
{
540+
name: 'deal',
541+
fields: { title: { type: 'text' } },
542+
actions: [
543+
{ name: 'a', label: 'A' },
544+
{ name: 'b', label: 'B' },
545+
{ name: 'c', label: 'C' },
546+
],
547+
},
548+
],
549+
});
550+
expect(stack.objects![0].actions!.map((a) => a.name)).toEqual(['a', 'b', 'c']);
551+
});
552+
553+
it('interleaves merged top-level actions with inline actions by `order`', () => {
554+
const stack = makeStack({
555+
objects: [
556+
{
557+
name: 'deal',
558+
fields: { title: { type: 'text' } },
559+
actions: [{ name: 'inline_mid', label: 'Mid', order: 0 }],
560+
},
561+
],
562+
actions: [
563+
{ name: 'top_first', label: 'First', objectName: 'deal', order: -5 },
564+
{ name: 'top_last', label: 'Last', objectName: 'deal', order: 5 },
565+
],
566+
});
567+
expect(stack.objects![0].actions!.map((a) => a.name)).toEqual([
568+
'top_first',
569+
'inline_mid',
570+
'top_last',
571+
]);
572+
});
573+
574+
it('is backward compatible — no `order` anywhere leaves the merged order untouched', () => {
575+
const stack = makeStack({
576+
objects: [
577+
{
578+
name: 'deal',
579+
fields: { title: { type: 'text' } },
580+
actions: [{ name: 'inline', label: 'Inline' }],
581+
},
582+
],
583+
actions: [{ name: 'merged', label: 'Merged', objectName: 'deal' }],
584+
});
585+
// Inline first (pre-existing behaviour), then the merged top-level action.
586+
expect(stack.objects![0].actions!.map((a) => a.name)).toEqual(['inline', 'merged']);
587+
});
588+
589+
it('sorts the top-level (global) actions array by `order` too', () => {
590+
const stack = makeStack({
591+
actions: [
592+
{ name: 'g_b', label: 'B', order: 2 },
593+
{ name: 'g_a', label: 'A', order: 1 },
594+
],
595+
});
596+
expect(stack.actions!.map((a) => a.name)).toEqual(['g_a', 'g_b']);
597+
});
598+
599+
it('respects `order` when composing stacks (funnels through mergeActionsIntoObjects)', () => {
600+
const base = makeStack({
601+
objects: [
602+
{
603+
name: 'deal',
604+
fields: { title: { type: 'text' } },
605+
actions: [{ name: 'app_action', label: 'App', locations: ['record_header'] }],
606+
},
607+
],
608+
});
609+
// A plugin (e.g. plugin-approvals) contributes a high-priority decision.
610+
const approvals = makeStack({
611+
actions: [
612+
{ name: 'approve', label: 'Approve', objectName: 'deal', locations: ['record_header'], order: -100 },
613+
],
614+
});
615+
const combined = composeStacks([base, approvals]);
616+
expect(combined.objects![0].actions!.map((a) => a.name)).toEqual(['approve', 'app_action']);
617+
});
618+
});

packages/spec/src/stack.zod.ts

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -769,46 +769,88 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
769769
}
770770

771771
/**
772-
* Merge top-level actions into their target objects based on `objectName`.
773-
*
774-
* Actions with `objectName` are appended to the corresponding object's `actions` array.
775-
* Actions without `objectName` (global actions) are left untouched.
776-
* The top-level `actions` array is preserved for global access (e.g., platform overview, search).
777-
*
778-
* This aligns with Salesforce/ServiceNow patterns where object metadata includes its actions,
779-
* so API responses like `/api/v1/meta/objects/:name` include actions without downstream merge.
780-
*
772+
* Stable-sort an actions array by explicit `order` (lower = higher / earlier).
773+
*
774+
* - Actions that leave `order` unset are treated as `0`.
775+
* - The sort is STABLE (`Array.prototype.sort` is stable since ES2019), so
776+
* actions that tie on `order` — including the overwhelmingly common case where
777+
* NOBODY sets `order` — keep their original registration order. This is what
778+
* lets `order` promote a `record_header` action into the primary-button slot
779+
* without disturbing everything else.
780+
* - Returns the SAME array reference untouched when no action opts in, so callers
781+
* pay zero allocation on the common path and can cheaply detect "unchanged".
782+
*
783+
* @internal
784+
*/
785+
function sortActionsByOrder<T extends { order?: number }>(actions: T[]): T[] {
786+
if (!actions.some((a) => a.order !== undefined)) return actions;
787+
// Copy first so the stable sort never mutates the caller's array.
788+
return actions.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
789+
}
790+
791+
/**
792+
* Merge top-level actions into their target objects based on `objectName`, then
793+
* honour each action's explicit `order`.
794+
*
795+
* Actions with `objectName` are appended to the corresponding object's `actions`
796+
* array. Actions without `objectName` (global actions) are left in place. The
797+
* top-level `actions` array is preserved for global access (e.g., platform
798+
* overview, search).
799+
*
800+
* After merging, every action group (each object's `actions` and the top-level
801+
* `actions`) is stable-sorted by `order` via {@link sortActionsByOrder}. Because
802+
* that sort is a no-op unless an author sets `order`, this is fully backward
803+
* compatible — arrays with no `order` keep their exact registration order and
804+
* reference. Renderers that pick a single primary action from `record_header`
805+
* (objectui) therefore see approve/reject-style actions in their declared
806+
* priority rather than in fragile cross-file registration order.
807+
*
808+
* This aligns with Salesforce/ServiceNow patterns where object metadata includes
809+
* its actions, so API responses like `/api/v1/meta/objects/:name` include actions
810+
* (already ordered) without downstream merge.
811+
*
781812
* @internal
782813
*/
783814
function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefinition {
784-
if (!config.actions || !config.objects || config.objects.length === 0) {
785-
return config;
815+
// Honour `order` on the preserved top-level actions regardless of objects.
816+
const sortedTop = config.actions ? sortActionsByOrder(config.actions) : config.actions;
817+
const topChanged = sortedTop !== config.actions;
818+
819+
if (!config.objects || config.objects.length === 0) {
820+
return topChanged ? { ...config, actions: sortedTop } : config;
786821
}
787822

788-
// Build map: objectName → actions[]
823+
// Build map: objectName → actions[] (top-level actions targeting an object)
789824
const actionsByObject = new Map<string, NonNullable<ObjectStackDefinition['actions']>>();
790-
for (const action of config.actions) {
825+
for (const action of config.actions ?? []) {
791826
if (action.objectName) {
792827
const list = actionsByObject.get(action.objectName) ?? [];
793828
list.push(action);
794829
actionsByObject.set(action.objectName, list);
795830
}
796831
}
797832

798-
if (actionsByObject.size === 0) return config;
799-
800-
// Merge into objects (shallow copy — only the `actions` field is modified;
801-
// other fields are shared references, consistent with mergeObjects() and Zod output)
833+
// Merge into objects and sort each object's final actions by `order` (shallow
834+
// copy — only the `actions` field is modified; other fields stay shared
835+
// references, consistent with mergeObjects() and Zod output).
836+
let objectsChanged = false;
802837
const newObjects = config.objects.map((obj) => {
803838
const objActions = actionsByObject.get(obj.name);
804-
if (!objActions) return obj;
805-
return {
806-
...obj,
807-
actions: [...(obj.actions ?? []), ...objActions],
808-
};
839+
const base = obj.actions ?? [];
840+
const merged = objActions ? [...base, ...objActions] : base;
841+
const sorted = sortActionsByOrder(merged);
842+
// Untouched: no top-level actions merged in AND the sort was a no-op.
843+
if (!objActions && sorted === base) return obj;
844+
objectsChanged = true;
845+
return { ...obj, actions: sorted };
809846
});
810847

811-
return { ...config, objects: newObjects };
848+
if (!objectsChanged && !topChanged) return config;
849+
return {
850+
...config,
851+
...(objectsChanged ? { objects: newObjects } : {}),
852+
...(topChanged ? { actions: sortedTop } : {}),
853+
};
812854
}
813855

814856
/**

packages/spec/src/ui/action.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,52 @@ describe('ActionSchema - variant', () => {
821821
});
822822
});
823823

824+
describe('ActionSchema - order', () => {
825+
it('should accept a numeric order (incl. negative and zero)', () => {
826+
for (const order of [-100, -1, 0, 5, 999]) {
827+
const result = ActionSchema.parse({
828+
name: 'ordered_action',
829+
label: 'Ordered',
830+
target: 'noop',
831+
order,
832+
});
833+
expect(result.order).toBe(order);
834+
}
835+
});
836+
837+
it('should accept action without order (optional)', () => {
838+
const result = ActionSchema.parse({
839+
name: 'no_order',
840+
label: 'Action',
841+
target: 'noop',
842+
});
843+
expect(result.order).toBeUndefined();
844+
});
845+
846+
it('should reject a non-numeric order', () => {
847+
expect(() => ActionSchema.parse({
848+
name: 'bad_order',
849+
label: 'Action',
850+
target: 'noop',
851+
order: 'first',
852+
})).toThrow();
853+
});
854+
855+
it('should combine order with variant and locations (record_header primary hint)', () => {
856+
const result = ActionSchema.parse({
857+
name: 'approve',
858+
label: 'Approve',
859+
target: 'approve_handler',
860+
locations: ['record_header', 'record_more'],
861+
variant: 'primary',
862+
order: -10,
863+
});
864+
expect(result.order).toBe(-10);
865+
expect(result.variant).toBe('primary');
866+
expect(result.locations).toContain('record_header');
867+
});
868+
});
869+
824870
// ============================================================================
825871
// Protocol Improvement Tests: execute → target migration & target validation
826872
// ============================================================================

packages/spec/src/ui/action.zod.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,27 @@ export const ActionSchema = lazySchema(() => z.object({
327327
/** Visual Style */
328328
variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'link']).optional().describe('Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent)'),
329329

330+
/**
331+
* Explicit sort order WITHIN a UI location group (lower = higher / more
332+
* prominent). Controls where the action lands in each `locations` group
333+
* instead of relying on cross-file `defineStack({ actions })` registration
334+
* order — which is fragile and couples unrelated features.
335+
*
336+
* In `record_header` the first visible action becomes the primary button, so
337+
* a low (or negative) `order` promotes an action into the primary slot and a
338+
* high `order` demotes it toward the `⋯` overflow menu. This is the
339+
* declarative lever a plugin (e.g. plugin-approvals) or app author uses to
340+
* make a decision like Approve/Reject stably outrank app actions, rather than
341+
* hiding the other actions to "make room".
342+
*
343+
* Honoured by a STABLE sort in `mergeActionsIntoObjects()` (see stack.zod):
344+
* actions that leave `order` unset are treated as `0` and keep their original
345+
* registration order, so setting `order` on nobody is a no-op — fully
346+
* backward compatible. Renderers MAY additionally prefer a `variant:'primary'`
347+
* action when two actions tie on `order` (see objectui record-header renderer).
348+
*/
349+
order: z.number().optional().describe('Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order.'),
350+
330351
/** UX Behavior */
331352
confirmText: I18nLabelSchema.optional().describe('Confirmation message before execution'),
332353
successMessage: I18nLabelSchema.optional().describe('Success message to show after execution'),

0 commit comments

Comments
 (0)