diff --git a/.changeset/action-order-primary-button.md b/.changeset/action-order-primary-button.md new file mode 100644 index 0000000000..1bd5e4d26a --- /dev/null +++ b/.changeset/action-order-primary-button.md @@ -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`. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index fa289922eb..58be340926 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -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' @@ -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. + + +`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*. + + ### Visibility & Disabled Rules `visible` and `disabled` are **CEL** predicates (not declarative `{ field, value }` objects). `disabled` may also be a plain boolean. diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index fc646a91d3..37000ac3c8 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -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." diff --git a/packages/spec/src/compose-stacks.test.ts b/packages/spec/src/compose-stacks.test.ts index f9fdf19030..06a8025982 100644 --- a/packages/spec/src/compose-stacks.test.ts +++ b/packages/spec/src/compose-stacks.test.ts @@ -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']); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 5f5fa6493c..56b1bb33aa 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -769,25 +769,60 @@ 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(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>(); - for (const action of config.actions) { + for (const action of config.actions ?? []) { if (action.objectName) { const list = actionsByObject.get(action.objectName) ?? []; list.push(action); @@ -795,20 +830,27 @@ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefi } } - 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 } : {}), + }; } /** diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index e6596622f7..9bf4b9b651 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -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 // ============================================================================ diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index d840258e69..feed011722 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -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'),