diff --git a/examples/app-crm/src/actions/contact.actions.ts b/examples/app-crm/src/actions/contact.actions.ts index a2511b01f5..e29c946632 100644 --- a/examples/app-crm/src/actions/contact.actions.ts +++ b/examples/app-crm/src/actions/contact.actions.ts @@ -8,7 +8,7 @@ export const MarkPrimaryContactAction: Action = { label: 'Mark as Primary Contact', icon: 'star', type: 'script', - execute: 'markAsPrimaryContact', + target: 'markAsPrimaryContact', locations: ['record_header', 'list_item'], visible: 'is_primary = false', confirmText: 'Mark this contact as the primary contact for the account?', diff --git a/examples/app-crm/src/actions/global.actions.ts b/examples/app-crm/src/actions/global.actions.ts index c4e1be758d..b9f6e5b001 100644 --- a/examples/app-crm/src/actions/global.actions.ts +++ b/examples/app-crm/src/actions/global.actions.ts @@ -40,7 +40,7 @@ export const ExportToCsvAction: Action = { label: 'Export to CSV', icon: 'download', type: 'script', - execute: 'exportToCSV', + target: 'exportToCSV', locations: ['list_toolbar'], successMessage: 'Export completed!', refreshAfter: false, diff --git a/examples/app-crm/src/actions/opportunity.actions.ts b/examples/app-crm/src/actions/opportunity.actions.ts index cb6b297049..f0917b22c3 100644 --- a/examples/app-crm/src/actions/opportunity.actions.ts +++ b/examples/app-crm/src/actions/opportunity.actions.ts @@ -8,7 +8,7 @@ export const CloneOpportunityAction: Action = { label: 'Clone Opportunity', icon: 'copy', type: 'script', - execute: 'cloneRecord', + target: 'cloneRecord', locations: ['record_header', 'record_more'], successMessage: 'Opportunity cloned successfully!', refreshAfter: true, diff --git a/examples/app-todo/src/actions/task.actions.ts b/examples/app-todo/src/actions/task.actions.ts index a61c0b8bdd..f1dfa32c61 100644 --- a/examples/app-todo/src/actions/task.actions.ts +++ b/examples/app-todo/src/actions/task.actions.ts @@ -8,7 +8,7 @@ export const CompleteTaskAction: Action = { label: 'Mark Complete', icon: 'check-circle', type: 'script', - execute: 'completeTask', + target: 'completeTask', locations: ['record_header', 'list_item'], successMessage: 'Task marked as complete!', refreshAfter: true, @@ -20,7 +20,7 @@ export const StartTaskAction: Action = { label: 'Start Task', icon: 'play-circle', type: 'script', - execute: 'startTask', + target: 'startTask', locations: ['record_header', 'list_item'], successMessage: 'Task started!', refreshAfter: true, @@ -78,7 +78,7 @@ export const CloneTaskAction: Action = { label: 'Clone Task', icon: 'copy', type: 'script', - execute: 'cloneTask', + target: 'cloneTask', locations: ['record_header'], successMessage: 'Task cloned successfully!', refreshAfter: true, @@ -90,7 +90,7 @@ export const MassCompleteTasksAction: Action = { label: 'Complete Selected', icon: 'check-square', type: 'script', - execute: 'massCompleteTasks', + target: 'massCompleteTasks', locations: ['list_toolbar'], successMessage: 'Selected tasks marked as complete!', refreshAfter: true, @@ -102,7 +102,7 @@ export const DeleteCompletedAction: Action = { label: 'Delete Completed', icon: 'trash-2', type: 'script', - execute: 'deleteCompletedTasks', + target: 'deleteCompletedTasks', locations: ['list_toolbar'], successMessage: 'Completed tasks deleted!', refreshAfter: true, @@ -114,7 +114,7 @@ export const ExportToCsvAction: Action = { label: 'Export to CSV', icon: 'download', type: 'script', - execute: 'exportTasksToCSV', + target: 'exportTasksToCSV', locations: ['list_toolbar'], successMessage: 'Export completed!', refreshAfter: false, diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 8a49c310f4..0c59772781 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -961,6 +961,82 @@ describe('defineStack - Navigation Cross-Reference Validation', () => { }); }); +// ============================================================================ +// Action Cross-Reference Validation — ensures action targets resolve +// ============================================================================ + +describe('defineStack - Action Cross-Reference Validation', () => { + const baseManifest = { + id: 'com.example.test', + name: 'test-project', + version: '1.0.0', + type: 'app' as const, + }; + + it('should detect action referencing undefined flow', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + flows: [ + { name: 'existing_flow', label: 'Existing Flow', type: 'autolaunched' as const, nodes: [], edges: [] }, + ], + actions: [ + { name: 'run_flow', label: 'Run Flow', type: 'flow' as const, target: 'nonexistent_flow' }, + ], + }; + + expect(() => defineStack(config)).toThrow('cross-reference validation failed'); + expect(() => defineStack(config)).toThrow('nonexistent_flow'); + }); + + it('should pass when action references a defined flow', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + flows: [ + { name: 'approval_flow', label: 'Approval Flow', type: 'autolaunched' as const, nodes: [], edges: [] }, + ], + actions: [ + { name: 'run_approval', label: 'Run Approval', type: 'flow' as const, target: 'approval_flow' }, + ], + }; + + expect(() => defineStack(config)).not.toThrow(); + }); + + it('should skip action flow validation when no flows are defined', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + actions: [ + { name: 'run_flow', label: 'Run Flow', type: 'flow' as const, target: 'some_flow' }, + ], + }; + + expect(() => defineStack(config)).not.toThrow(); + }); + + it('should accept script actions without cross-reference validation', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + actions: [ + { name: 'approve_task', label: 'Approve', type: 'script' as const, target: 'approveTask' }, + ], + }; + + expect(() => defineStack(config)).not.toThrow(); + }); +}); + // ============================================================================ // Example-Level Strict Validation — mirrors examples/app-todo & examples/app-crm // ============================================================================ diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index c008664342..ccbaa5febd 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -400,6 +400,28 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } + // Validate action → flow/target cross-references + // Note: When no flows are defined (flowNames.size === 0), flow-type action targets + // are not validated because the referenced flow may be provided by a plugin. + // This is consistent with dashboard/page/report validation in navigation. + if (config.actions) { + const flowNames = new Set(); + if (config.flows) { + for (const flow of config.flows) { + flowNames.add(flow.name); + } + } + + for (const action of config.actions) { + // Validate flow-type actions reference a defined flow + if (action.type === 'flow' && action.target && flowNames.size > 0 && !flowNames.has(action.target)) { + errors.push( + `Action '${action.name}' references flow '${action.target}' which is not defined in flows.`, + ); + } + } + } + return errors; } diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index fa3444d590..937e66ff82 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -77,7 +77,7 @@ describe('ActionSchema', () => { }); describe('Action Types', () => { - it('should accept all action types', () => { + it('should accept all action types with target', () => { const types = ['script', 'url', 'modal', 'flow', 'api'] as const; types.forEach(type => { @@ -85,11 +85,31 @@ describe('ActionSchema', () => { name: 'test_action', label: 'Test', type, + target: 'test_handler', }; expect(() => ActionSchema.parse(action)).not.toThrow(); }); }); + it('should accept script type without target', () => { + expect(() => ActionSchema.parse({ + name: 'test_action', + label: 'Test', + type: 'script', + })).not.toThrow(); + }); + + it('should reject url/flow/modal/api types without target', () => { + const targetRequiredTypes = ['url', 'flow', 'modal', 'api'] as const; + targetRequiredTypes.forEach(type => { + expect(() => ActionSchema.parse({ + name: 'test_action', + label: 'Test', + type, + })).toThrow(/target/); + }); + }); + it('should default to script type', () => { const action = { name: 'custom_action', @@ -294,6 +314,7 @@ describe('ActionSchema', () => { label: 'Transfer Case', icon: 'arrow-right', type: 'modal', + target: 'transfer_case_modal', locations: ['record_more'], params: [ { @@ -561,3 +582,89 @@ describe('ActionSchema - variant', () => { expect(result.confirmText).toBe('Are you sure?'); }); }); + +// ============================================================================ +// Protocol Improvement Tests: execute → target migration & target validation +// ============================================================================ + +describe('ActionSchema - execute → target migration', () => { + it('should auto-migrate execute to target when target is not set', () => { + const result = ActionSchema.parse({ + name: 'legacy_action', + label: 'Legacy', + type: 'script', + execute: 'legacyHandler', + }); + expect(result.target).toBe('legacyHandler'); + }); + + it('should preserve target over execute when both are set', () => { + const result = ActionSchema.parse({ + name: 'both_fields', + label: 'Both', + type: 'script', + target: 'preferredHandler', + execute: 'legacyHandler', + }); + expect(result.target).toBe('preferredHandler'); + }); + + it('should allow script type without target or execute', () => { + expect(() => ActionSchema.parse({ + name: 'inline_script', + label: 'Inline', + type: 'script', + })).not.toThrow(); + }); +}); + +describe('ActionSchema - target required for non-script types', () => { + it('should require target for url type', () => { + expect(() => ActionSchema.parse({ + name: 'url_action', + label: 'Open URL', + type: 'url', + })).toThrow(/target/); + }); + + it('should require target for flow type', () => { + expect(() => ActionSchema.parse({ + name: 'flow_action', + label: 'Run Flow', + type: 'flow', + })).toThrow(/target/); + }); + + it('should require target for modal type', () => { + expect(() => ActionSchema.parse({ + name: 'modal_action', + label: 'Open Modal', + type: 'modal', + })).toThrow(/target/); + }); + + it('should require target for api type', () => { + expect(() => ActionSchema.parse({ + name: 'api_action', + label: 'Call API', + type: 'api', + })).toThrow(/target/); + }); + + it('should accept non-script types when target is provided', () => { + expect(() => ActionSchema.parse({ name: 'url_ok', label: 'URL', type: 'url', target: 'https://example.com' })).not.toThrow(); + expect(() => ActionSchema.parse({ name: 'flow_ok', label: 'Flow', type: 'flow', target: 'my_flow' })).not.toThrow(); + expect(() => ActionSchema.parse({ name: 'modal_ok', label: 'Modal', type: 'modal', target: 'my_modal' })).not.toThrow(); + expect(() => ActionSchema.parse({ name: 'api_ok', label: 'API', type: 'api', target: '/api/endpoint' })).not.toThrow(); + }); + + it('should accept non-script types when execute is provided (auto-migrated)', () => { + const result = ActionSchema.parse({ + name: 'flow_legacy', + label: 'Flow Legacy', + type: 'flow', + execute: 'my_flow', + }); + expect(result.target).toBe('my_flow'); + }); +}); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 7f394080bb..3e21b11d1c 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -17,12 +17,38 @@ export const ActionParamSchema = z.object({ options: z.array(z.object({ label: I18nLabelSchema, value: z.string() })).optional(), }); +/** + * Action type enum values. + */ +export const ActionType = z.enum(['script', 'url', 'modal', 'flow', 'api']); + +/** + * Action types that require a `target` field. + * Derived from ActionType, excluding 'script' which allows inline handlers. + * These types reference an external resource (URL, flow, modal, or API endpoint) + * and cannot function without a target binding. + */ +const TARGET_REQUIRED_TYPES: ReadonlySet = new Set( + ActionType.options.filter((t) => t !== 'script'), +); + /** * Action Schema * * **NAMING CONVENTION:** * Action names are machine identifiers used in code and must be lowercase snake_case. * + * **TARGET BINDING:** + * The `target` field is the canonical way to bind an action to its handler. + * - `type: 'script'` — `target` is recommended (references a script/function name). + * - `type: 'url'` — `target` is **required** (the URL to navigate to). + * - `type: 'flow'` — `target` is **required** (the flow name to invoke). + * - `type: 'modal'` — `target` is **required** (the modal/page name to open). + * - `type: 'api'` — `target` is **required** (the API endpoint to call). + * + * The `execute` field is **deprecated** and will be removed in a future version. + * If `execute` is provided without `target`, it is automatically migrated to `target`. + * * @example Good action names * - 'on_close_deal' * - 'send_welcome_email' @@ -67,11 +93,19 @@ export const ActionSchema = z.object({ ]).optional().describe('Visual component override'), /** What type of interaction? */ - type: z.enum(['script', 'url', 'modal', 'flow', 'api']).default('script').describe('Action functionality type'), + type: ActionType.default('script').describe('Action functionality type'), - /** Payload / Target */ - target: z.string().optional().describe('URL, Script Name, Flow ID, or API Endpoint'), // For URL/Flow types - execute: z.string().optional().describe('Legacy execution logic'), + /** + * Payload / Target — the canonical binding for the action handler. + * Required for url, flow, modal, and api types. + * Recommended for script type. + */ + target: z.string().optional().describe('URL, Script Name, Flow ID, or API Endpoint'), + + /** + * @deprecated Use `target` instead. This field is auto-migrated to `target` during parsing. + */ + execute: z.string().optional().describe('@deprecated — Use target instead. Auto-migrated to target during parsing.'), /** User Input Requirements */ params: z.array(ActionParamSchema).optional().describe('Input parameters required from user'), @@ -99,6 +133,21 @@ export const ActionSchema = z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}).transform((data) => { + // Auto-migrate deprecated `execute` → `target` for backward compatibility + if (data.execute && !data.target) { + return { ...data, target: data.execute }; + } + return data; +}).refine((data) => { + // Require `target` for types that reference an external resource + if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) { + return false; + } + return true; +}, { + message: "Action 'target' is required when type is 'url', 'flow', 'modal', or 'api'.", + path: ['target'], }); export type Action = z.infer;