From 4950493abc02e253f5a2a0b612501166a9d1f3e0 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 17 Jun 2026 07:16:48 +0800 Subject: [PATCH] feat(crm,action): conditional action visibility + undoable reassign demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec: UI Action gains `undoable` (single-record update actions offer an Undo affordance on success). `visible`/`disabled` CEL predicates already existed. - app-crm: Convert Lead action gains `visible: record.status != "converted"` (the button hides on converted leads instead of relying on the flow's guard screen). New "Reassign Lead" list action (`undoable`) demonstrates the Undo affordance — reassigns `assigned_to`, restorable from the toast. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/action-undoable.md | 12 +++++++ .../src/actions/convert-lead.action.ts | 5 +++ examples/app-crm/src/actions/index.ts | 1 + .../app-crm/src/actions/park-lead.action.ts | 31 +++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 4 +++ 5 files changed, 53 insertions(+) create mode 100644 .changeset/action-undoable.md create mode 100644 examples/app-crm/src/actions/park-lead.action.ts diff --git a/.changeset/action-undoable.md b/.changeset/action-undoable.md new file mode 100644 index 0000000000..4561fbf88c --- /dev/null +++ b/.changeset/action-undoable.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +--- + +feat(action): `undoable` flag on the UI Action schema + +Single-record update actions can declare `undoable: true`. The runtime captures +the record's prior field values and offers an "Undo" affordance on the success +toast (backed by the client UndoManager). Pairs with the objectui runtime that +honours it. Also documents that conditional `visible` / `disabled` CEL +predicates are evaluated by the action renderers (used here to hide an action +when it no longer applies, e.g. Convert Lead on an already-converted lead). diff --git a/examples/app-crm/src/actions/convert-lead.action.ts b/examples/app-crm/src/actions/convert-lead.action.ts index e31d1376be..91045a4a81 100644 --- a/examples/app-crm/src/actions/convert-lead.action.ts +++ b/examples/app-crm/src/actions/convert-lead.action.ts @@ -16,4 +16,9 @@ export const ConvertLeadAction: UI.Action = { target: 'crm_convert_lead_wizard', locations: ['list_item', 'record_header', 'record_more'], recordIdParam: 'recordId', + // Conditional visibility (CEL): hide the action once the lead is already + // converted — so the button disappears rather than the user clicking it and + // hitting the flow's "already converted" guard screen. The flow keeps that + // guard as a server-side backstop. + visible: 'record.status != "converted"', }; diff --git a/examples/app-crm/src/actions/index.ts b/examples/app-crm/src/actions/index.ts index 0957b456b8..9490e28e5f 100644 --- a/examples/app-crm/src/actions/index.ts +++ b/examples/app-crm/src/actions/index.ts @@ -1,3 +1,4 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { ConvertLeadAction } from './convert-lead.action.js'; +export { ParkLeadAction } from './park-lead.action.js'; diff --git a/examples/app-crm/src/actions/park-lead.action.ts b/examples/app-crm/src/actions/park-lead.action.ts new file mode 100644 index 0000000000..3960c831fd --- /dev/null +++ b/examples/app-crm/src/actions/park-lead.action.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { UI } from '@objectstack/spec'; + +/** + * Row-level action on crm_lead — reassign the lead's owner. + * + * Demonstrates the `undoable` action affordance: it's a single-record field + * update (`assigned_to`), so the runtime captures the prior owner and the + * success toast offers an "Undo" that restores it (backed by the client + * UndoManager — Ctrl+Z works too). Prompts for the new owner via one param + * (pre-filled with "Triage Queue"). + */ +export const ParkLeadAction: UI.Action = { + name: 'crm_park_lead', + label: 'Reassign Lead', + description: 'Reassign this lead to a new owner (undoable).', + icon: 'UserPlus', + objectName: 'crm_lead', + // `type: 'api'` with a non-URL target routes to the console runtime's generic + // `dataSource.update` path (the row id comes from the list_item row record). + type: 'api', + target: 'crm_lead', + // List-row only: the apiHandler needs the row record to capture prior values. + locations: ['list_item'], + params: [ + { field: 'assigned_to', label: 'Reassign to', defaultValue: 'Triage Queue', required: true }, + ], + undoable: true, + successMessage: 'Lead reassigned.', +}; diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index db0cf8345e..d26cd40301 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -316,6 +316,10 @@ export const ActionSchema = lazySchema(() => z.object({ // set a friendly failure toast instead of surfacing the raw error string. errorMessage: I18nLabelSchema.optional().describe('Error message to show when the action fails (overrides the raw error).'), refreshAfter: z.boolean().default(false).describe('Refresh view after execution'), + // Single-record update actions only. When true, the runtime captures the + // record's prior field values and offers an "Undo" affordance on the success + // toast (backed by the client UndoManager) to restore them. + undoable: z.boolean().optional().describe('Offer an Undo affordance after this single-record update action succeeds.'), /** * Result Dialog — describe how to render the API response on success.