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
9 changes: 9 additions & 0 deletions .changeset/action-order-primary-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/spec": minor
---

`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).

`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".

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`.
25 changes: 25 additions & 0 deletions content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ interface Action {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link';
component?: 'action:button' | 'action:icon' | 'action:menu' | 'action:group';
locations?: ActionLocation[]; // Where the action surfaces
order?: number; // Sort order within a location group (lower = higher)

// Behavior
type?: 'script' | 'url' | 'modal' | 'flow' | 'api' | 'form'; // default 'script'
Expand Down Expand Up @@ -229,6 +230,30 @@ target: /api/customers/export
locations: [list_toolbar]
```

### Ordering & the Primary Button

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.

```yaml
# Approve holds the primary button even though the app action registered first.
name: approve_request
label: Approve
locations: [record_header]
variant: primary
order: -10 # negative → floats ahead of app actions (order defaults to 0)
```

Semantics:

- **`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.
- **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.
- **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.
- When two actions tie on `order`, the record-header renderer MAY additionally prefer the one with `variant: 'primary'` when choosing the primary button.

<Callout type="info">
`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*.
</Callout>

### Visibility & Disabled Rules

`visible` and `disabled` are **CEL** predicates (not declarative `{ field, value }` objects). `disabled` may also be a plain boolean.
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/liveness/action.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@
"status": "live",
"note": "objectui button styling."
},
"order": {
"status": "live",
"evidence": "packages/spec/src/stack.zod.ts (mergeActionsIntoObjects stable-sorts each action group by order)",
"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."
},
"confirmText": {
"status": "live",
"note": "objectui confirm dialog."
Expand Down
114 changes: 114 additions & 0 deletions packages/spec/src/compose-stacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,117 @@ describe('composeStacks + defineStack integration', () => {
expect(combined.actions).toHaveLength(1);
});
});

// ─── Action `order` sorting (mergeActionsIntoObjects) ───────────────
// Regression coverage for #2670: the record-header primary button is the first
// visible `record_header` action, so `order` must control which action lands
// first — instead of fragile cross-file registration order.

describe('Action order sorting', () => {
it("sorts an object's inline actions by `order` (lower = earlier / primary slot)", () => {
// Registration order puts the app action first; a negative `order` must
// float Approve up into the primary-button slot.
const stack = makeStack({
objects: [
{
name: 'deal',
fields: { title: { type: 'text' } },
actions: [
{ name: 'close_deal', label: 'Close', locations: ['record_header'] },
{ name: 'print', label: 'Print', locations: ['record_header'], order: 5 },
{ name: 'approve', label: 'Approve', locations: ['record_header'], order: -10 },
],
},
],
});

const names = stack.objects![0].actions!.map((a) => a.name);
// approve (-10) → close_deal (unset = 0) → print (5)
expect(names).toEqual(['approve', 'close_deal', 'print']);
// What objectui renders as the primary button is now the approval decision.
expect(names[0]).toBe('approve');
});

it('is STABLE — actions that tie on `order` (incl. all-unset) keep registration order', () => {
const stack = makeStack({
objects: [
{
name: 'deal',
fields: { title: { type: 'text' } },
actions: [
{ name: 'a', label: 'A' },
{ name: 'b', label: 'B' },
{ name: 'c', label: 'C' },
],
},
],
});
expect(stack.objects![0].actions!.map((a) => a.name)).toEqual(['a', 'b', 'c']);
});

it('interleaves merged top-level actions with inline actions by `order`', () => {
const stack = makeStack({
objects: [
{
name: 'deal',
fields: { title: { type: 'text' } },
actions: [{ name: 'inline_mid', label: 'Mid', order: 0 }],
},
],
actions: [
{ name: 'top_first', label: 'First', objectName: 'deal', order: -5 },
{ name: 'top_last', label: 'Last', objectName: 'deal', order: 5 },
],
});
expect(stack.objects![0].actions!.map((a) => a.name)).toEqual([
'top_first',
'inline_mid',
'top_last',
]);
});

it('is backward compatible — no `order` anywhere leaves the merged order untouched', () => {
const stack = makeStack({
objects: [
{
name: 'deal',
fields: { title: { type: 'text' } },
actions: [{ name: 'inline', label: 'Inline' }],
},
],
actions: [{ name: 'merged', label: 'Merged', objectName: 'deal' }],
});
// Inline first (pre-existing behaviour), then the merged top-level action.
expect(stack.objects![0].actions!.map((a) => a.name)).toEqual(['inline', 'merged']);
});

it('sorts the top-level (global) actions array by `order` too', () => {
const stack = makeStack({
actions: [
{ name: 'g_b', label: 'B', order: 2 },
{ name: 'g_a', label: 'A', order: 1 },
],
});
expect(stack.actions!.map((a) => a.name)).toEqual(['g_a', 'g_b']);
});

it('respects `order` when composing stacks (funnels through mergeActionsIntoObjects)', () => {
const base = makeStack({
objects: [
{
name: 'deal',
fields: { title: { type: 'text' } },
actions: [{ name: 'app_action', label: 'App', locations: ['record_header'] }],
},
],
});
// A plugin (e.g. plugin-approvals) contributes a high-priority decision.
const approvals = makeStack({
actions: [
{ name: 'approve', label: 'Approve', objectName: 'deal', locations: ['record_header'], order: -100 },
],
});
const combined = composeStacks([base, approvals]);
expect(combined.objects![0].actions!.map((a) => a.name)).toEqual(['approve', 'app_action']);
});
});
88 changes: 65 additions & 23 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,46 +769,88 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
}

/**
* Merge top-level actions into their target objects based on `objectName`.
*
* Actions with `objectName` are appended to the corresponding object's `actions` array.
* Actions without `objectName` (global actions) are left untouched.
* The top-level `actions` array is preserved for global access (e.g., platform overview, search).
*
* This aligns with Salesforce/ServiceNow patterns where object metadata includes its actions,
* so API responses like `/api/v1/meta/objects/:name` include actions without downstream merge.
*
* Stable-sort an actions array by explicit `order` (lower = higher / earlier).
*
* - Actions that leave `order` unset are treated as `0`.
* - The sort is STABLE (`Array.prototype.sort` is stable since ES2019), so
* actions that tie on `order` — including the overwhelmingly common case where
* NOBODY sets `order` — keep their original registration order. This is what
* lets `order` promote a `record_header` action into the primary-button slot
* without disturbing everything else.
* - Returns the SAME array reference untouched when no action opts in, so callers
* pay zero allocation on the common path and can cheaply detect "unchanged".
*
* @internal
*/
function sortActionsByOrder<T extends { order?: number }>(actions: T[]): T[] {
if (!actions.some((a) => a.order !== undefined)) return actions;
// Copy first so the stable sort never mutates the caller's array.
return actions.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}

/**
* Merge top-level actions into their target objects based on `objectName`, then
* honour each action's explicit `order`.
*
* Actions with `objectName` are appended to the corresponding object's `actions`
* array. Actions without `objectName` (global actions) are left in place. The
* top-level `actions` array is preserved for global access (e.g., platform
* overview, search).
*
* After merging, every action group (each object's `actions` and the top-level
* `actions`) is stable-sorted by `order` via {@link sortActionsByOrder}. Because
* that sort is a no-op unless an author sets `order`, this is fully backward
* compatible — arrays with no `order` keep their exact registration order and
* reference. Renderers that pick a single primary action from `record_header`
* (objectui) therefore see approve/reject-style actions in their declared
* priority rather than in fragile cross-file registration order.
*
* This aligns with Salesforce/ServiceNow patterns where object metadata includes
* its actions, so API responses like `/api/v1/meta/objects/:name` include actions
* (already ordered) without downstream merge.
*
* @internal
*/
function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefinition {
if (!config.actions || !config.objects || config.objects.length === 0) {
return config;
// Honour `order` on the preserved top-level actions regardless of objects.
const sortedTop = config.actions ? sortActionsByOrder(config.actions) : config.actions;
const topChanged = sortedTop !== config.actions;

if (!config.objects || config.objects.length === 0) {
return topChanged ? { ...config, actions: sortedTop } : config;
}

// Build map: objectName → actions[]
// Build map: objectName → actions[] (top-level actions targeting an object)
const actionsByObject = new Map<string, NonNullable<ObjectStackDefinition['actions']>>();
for (const action of config.actions) {
for (const action of config.actions ?? []) {
if (action.objectName) {
const list = actionsByObject.get(action.objectName) ?? [];
list.push(action);
actionsByObject.set(action.objectName, list);
}
}

if (actionsByObject.size === 0) return config;

// Merge into objects (shallow copy — only the `actions` field is modified;
// other fields are shared references, consistent with mergeObjects() and Zod output)
// Merge into objects and sort each object's final actions by `order` (shallow
// copy — only the `actions` field is modified; other fields stay shared
// references, consistent with mergeObjects() and Zod output).
let objectsChanged = false;
const newObjects = config.objects.map((obj) => {
const objActions = actionsByObject.get(obj.name);
if (!objActions) return obj;
return {
...obj,
actions: [...(obj.actions ?? []), ...objActions],
};
const base = obj.actions ?? [];
const merged = objActions ? [...base, ...objActions] : base;
const sorted = sortActionsByOrder(merged);
// Untouched: no top-level actions merged in AND the sort was a no-op.
if (!objActions && sorted === base) return obj;
objectsChanged = true;
return { ...obj, actions: sorted };
});

return { ...config, objects: newObjects };
if (!objectsChanged && !topChanged) return config;
return {
...config,
...(objectsChanged ? { objects: newObjects } : {}),
...(topChanged ? { actions: sortedTop } : {}),
};
}

/**
Expand Down
46 changes: 46 additions & 0 deletions packages/spec/src/ui/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,52 @@ describe('ActionSchema - variant', () => {
});
});

describe('ActionSchema - order', () => {
it('should accept a numeric order (incl. negative and zero)', () => {
for (const order of [-100, -1, 0, 5, 999]) {
const result = ActionSchema.parse({
name: 'ordered_action',
label: 'Ordered',
target: 'noop',
order,
});
expect(result.order).toBe(order);
}
});

it('should accept action without order (optional)', () => {
const result = ActionSchema.parse({
name: 'no_order',
label: 'Action',
target: 'noop',
});
expect(result.order).toBeUndefined();
});

it('should reject a non-numeric order', () => {
expect(() => ActionSchema.parse({
name: 'bad_order',
label: 'Action',
target: 'noop',
order: 'first',
})).toThrow();
});

it('should combine order with variant and locations (record_header primary hint)', () => {
const result = ActionSchema.parse({
name: 'approve',
label: 'Approve',
target: 'approve_handler',
locations: ['record_header', 'record_more'],
variant: 'primary',
order: -10,
});
expect(result.order).toBe(-10);
expect(result.variant).toBe('primary');
expect(result.locations).toContain('record_header');
});
});

// ============================================================================
// Protocol Improvement Tests: execute → target migration & target validation
// ============================================================================
Expand Down
21 changes: 21 additions & 0 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,27 @@ export const ActionSchema = lazySchema(() => z.object({
/** Visual Style */
variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'link']).optional().describe('Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent)'),

/**
* Explicit sort order WITHIN a UI location group (lower = higher / more
* prominent). Controls where the action lands in each `locations` group
* instead of relying on cross-file `defineStack({ actions })` registration
* order — which is fragile and couples unrelated features.
*
* In `record_header` the first visible action becomes the primary button, so
* a low (or negative) `order` promotes an action into the primary slot and a
* high `order` demotes it toward the `⋯` overflow menu. This is the
* declarative lever a plugin (e.g. plugin-approvals) or app author uses to
* make a decision like Approve/Reject stably outrank app actions, rather than
* hiding the other actions to "make room".
*
* Honoured by a STABLE sort in `mergeActionsIntoObjects()` (see stack.zod):
* actions that leave `order` unset are treated as `0` and keep their original
* registration order, so setting `order` on nobody is a no-op — fully
* backward compatible. Renderers MAY additionally prefer a `variant:'primary'`
* action when two actions tie on `order` (see objectui record-header renderer).
*/
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.'),

/** UX Behavior */
confirmText: I18nLabelSchema.optional().describe('Confirmation message before execution'),
successMessage: I18nLabelSchema.optional().describe('Success message to show after execution'),
Expand Down