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
2 changes: 1 addition & 1 deletion examples/app-crm/src/actions/contact.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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?',
Expand Down
2 changes: 1 addition & 1 deletion examples/app-crm/src/actions/global.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion examples/app-crm/src/actions/opportunity.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
12 changes: 6 additions & 6 deletions examples/app-todo/src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand DownExpand Up@@ -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,
Expand All@@ -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,
Expand All@@ -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,
Expand All@@ -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,
Expand Down
76 changes: 76 additions & 0 deletions packages/spec/src/stack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
// ============================================================================
Expand Down
22 changes: 22 additions & 0 deletions packages/spec/src/stack.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string>();
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;
}

Expand Down
109 changes: 108 additions & 1 deletion packages/spec/src/ui/action.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,19 +77,39 @@ 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 => {
const action: ActionType = {
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',
Expand DownExpand Up@@ -294,6 +314,7 @@ describe('ActionSchema', () => {
label: 'Transfer Case',
icon: 'arrow-right',
type: 'modal',
target: 'transfer_case_modal',
locations: ['record_more'],
params: [
{
Expand DownExpand Up@@ -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');
});
});
Loading