Skip to content

Commit 00b1843

Browse files
authored
Merge pull request #843 from objectstack-ai/copilot/merge-actions-into-object-metadata
2 parents 4927850 + 5901c29 commit 00b1843

12 files changed

Lines changed: 314 additions & 3 deletions

File tree

.changeset/action-object-merge.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat: auto-merge actions into object metadata via objectName
6+
7+
- Added optional `objectName` field to `ActionSchema` for associating actions with specific objects
8+
- Added optional `actions` field to `ObjectSchema` to hold object-scoped actions
9+
- `defineStack()` and `composeStacks()` now auto-merge top-level actions with `objectName` into their target object's `actions` array
10+
- Added cross-reference validation for `action.objectName` referencing undefined objects
11+
- Top-level `actions` array is preserved for global access (platform overview, search)
12+
- Updated example apps (CRM, Todo) to use `objectName` on their action definitions

examples/app-crm/src/actions/case.actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Action } from '@objectstack/spec/ui';
66
export const EscalateCaseAction: Action = {
77
name: 'escalate_case',
88
label: 'Escalate Case',
9+
objectName: 'case',
910
icon: 'alert-triangle',
1011
type: 'modal',
1112
target: 'escalate_case_modal',
@@ -28,6 +29,7 @@ export const EscalateCaseAction: Action = {
2829
export const CloseCaseAction: Action = {
2930
name: 'close_case',
3031
label: 'Close Case',
32+
objectName: 'case',
3133
icon: 'check-circle',
3234
type: 'modal',
3335
target: 'close_case_modal',

examples/app-crm/src/actions/contact.actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Action } from '@objectstack/spec/ui';
66
export const MarkPrimaryContactAction: Action = {
77
name: 'mark_primary',
88
label: 'Mark as Primary Contact',
9+
objectName: 'contact',
910
icon: 'star',
1011
type: 'script',
1112
target: 'markAsPrimaryContact',
@@ -20,6 +21,7 @@ export const MarkPrimaryContactAction: Action = {
2021
export const SendEmailAction: Action = {
2122
name: 'send_email',
2223
label: 'Send Email',
24+
objectName: 'contact',
2325
icon: 'mail',
2426
type: 'modal',
2527
target: 'email_composer',

examples/app-crm/src/actions/lead.actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Action } from '@objectstack/spec/ui';
66
export const ConvertLeadAction: Action = {
77
name: 'convert_lead',
88
label: 'Convert Lead',
9+
objectName: 'lead',
910
icon: 'arrow-right-circle',
1011
type: 'flow',
1112
target: 'lead_conversion',
@@ -20,6 +21,7 @@ export const ConvertLeadAction: Action = {
2021
export const CreateCampaignAction: Action = {
2122
name: 'create_campaign',
2223
label: 'Add to Campaign',
24+
objectName: 'lead',
2325
icon: 'send',
2426
type: 'modal',
2527
target: 'add_to_campaign_modal',

examples/app-crm/src/actions/opportunity.actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Action } from '@objectstack/spec/ui';
66
export const CloneOpportunityAction: Action = {
77
name: 'clone_opportunity',
88
label: 'Clone Opportunity',
9+
objectName: 'opportunity',
910
icon: 'copy',
1011
type: 'script',
1112
target: 'cloneRecord',
@@ -18,6 +19,7 @@ export const CloneOpportunityAction: Action = {
1819
export const MassUpdateStageAction: Action = {
1920
name: 'mass_update_stage',
2021
label: 'Update Stage',
22+
objectName: 'opportunity',
2123
icon: 'layers',
2224
type: 'modal',
2325
target: 'mass_update_stage_modal',

examples/app-todo/src/actions/task.actions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Action } from '@objectstack/spec/ui';
66
export const CompleteTaskAction: Action = {
77
name: 'complete_task',
88
label: 'Mark Complete',
9+
objectName: 'task',
910
icon: 'check-circle',
1011
type: 'script',
1112
target: 'completeTask',
@@ -18,6 +19,7 @@ export const CompleteTaskAction: Action = {
1819
export const StartTaskAction: Action = {
1920
name: 'start_task',
2021
label: 'Start Task',
22+
objectName: 'task',
2123
icon: 'play-circle',
2224
type: 'script',
2325
target: 'startTask',
@@ -30,6 +32,7 @@ export const StartTaskAction: Action = {
3032
export const DeferTaskAction: Action = {
3133
name: 'defer_task',
3234
label: 'Defer Task',
35+
objectName: 'task',
3336
icon: 'clock',
3437
type: 'modal',
3538
target: 'defer_task_modal',
@@ -56,6 +59,7 @@ export const DeferTaskAction: Action = {
5659
export const SetReminderAction: Action = {
5760
name: 'set_reminder',
5861
label: 'Set Reminder',
62+
objectName: 'task',
5963
icon: 'bell',
6064
type: 'modal',
6165
target: 'set_reminder_modal',
@@ -76,6 +80,7 @@ export const SetReminderAction: Action = {
7680
export const CloneTaskAction: Action = {
7781
name: 'clone_task',
7882
label: 'Clone Task',
83+
objectName: 'task',
7984
icon: 'copy',
8085
type: 'script',
8186
target: 'cloneTask',
@@ -88,6 +93,7 @@ export const CloneTaskAction: Action = {
8893
export const MassCompleteTasksAction: Action = {
8994
name: 'mass_complete',
9095
label: 'Complete Selected',
96+
objectName: 'task',
9197
icon: 'check-square',
9298
type: 'script',
9399
target: 'massCompleteTasks',
@@ -100,6 +106,7 @@ export const MassCompleteTasksAction: Action = {
100106
export const DeleteCompletedAction: Action = {
101107
name: 'delete_completed',
102108
label: 'Delete Completed',
109+
objectName: 'task',
103110
icon: 'trash-2',
104111
type: 'script',
105112
target: 'deleteCompletedTasks',
@@ -112,6 +119,7 @@ export const DeleteCompletedAction: Action = {
112119
export const ExportToCsvAction: Action = {
113120
name: 'export_csv',
114121
label: 'Export to CSV',
122+
objectName: 'task',
115123
icon: 'download',
116124
type: 'script',
117125
target: 'exportTasksToCSV',

packages/spec/src/compose-stacks.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,4 +486,19 @@ describe('composeStacks + defineStack integration', () => {
486486
// Re-validate the composed result through defineStack
487487
expect(() => defineStack(combined)).not.toThrow();
488488
});
489+
490+
it('should merge actions into objects across composed stacks', () => {
491+
const s1 = makeStack({
492+
objects: [{ name: 'task', fields: { title: { type: 'text' } } }],
493+
});
494+
const s2 = makeStack({
495+
actions: [{ name: 'complete_task', label: 'Complete', objectName: 'task' }],
496+
});
497+
498+
const combined = composeStacks([s1, s2]);
499+
expect(combined.objects![0].actions).toHaveLength(1);
500+
expect(combined.objects![0].actions![0].name).toBe('complete_task');
501+
// Top-level actions preserved
502+
expect(combined.actions).toHaveLength(1);
503+
});
489504
});

packages/spec/src/data/object.zod.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { z } from 'zod';
44
import { FieldSchema } from './field.zod';
55
import { ValidationRuleSchema } from './validation.zod';
66
import { StateMachineSchema } from '../automation/state-machine.zod';
7+
import { ActionSchema } from '../ui/action.zod';
78

89
/**
910
* API Operations Enum
@@ -329,6 +330,19 @@ const ObjectSchemaBase = z.object({
329330

330331
/** Key Prefix */
331332
keyPrefix: z.string().max(5).optional().describe('Short prefix for record IDs (e.g., "001" for Account)'),
333+
334+
/**
335+
* Object Actions
336+
*
337+
* Actions associated with this object. Populated automatically by `defineStack()`
338+
* when top-level actions specify `objectName` matching this object.
339+
* Can also be defined directly on the object.
340+
*
341+
* Aligns with Salesforce/ServiceNow patterns where actions are part of the
342+
* object schema, so API responses (e.g., `/api/v1/meta/objects/:name`)
343+
* include the action list without requiring downstream merge.
344+
*/
345+
actions: z.array(ActionSchema).optional().describe('Actions associated with this object (auto-populated from top-level actions via objectName)'),
332346
});
333347

334348
/**

packages/spec/src/stack.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,170 @@ describe('defineStack - Action Cross-Reference Validation', () => {
10371037
});
10381038
});
10391039

1040+
// ============================================================================
1041+
// Action → Object Auto-Merge — actions with objectName are merged into objects
1042+
// ============================================================================
1043+
1044+
describe('defineStack - Action Auto-Merge into Objects', () => {
1045+
const baseManifest = {
1046+
id: 'com.example.test',
1047+
name: 'test-project',
1048+
version: '1.0.0',
1049+
type: 'app' as const,
1050+
};
1051+
1052+
it('should merge actions with objectName into corresponding objects', () => {
1053+
const config = {
1054+
manifest: baseManifest,
1055+
objects: [
1056+
{ name: 'task', fields: { title: { type: 'text' as const } } },
1057+
],
1058+
actions: [
1059+
{ name: 'approve_task', label: 'Approve', objectName: 'task' },
1060+
],
1061+
};
1062+
1063+
const result = defineStack(config);
1064+
expect(result.objects![0].actions).toHaveLength(1);
1065+
expect(result.objects![0].actions![0].name).toBe('approve_task');
1066+
});
1067+
1068+
it('should merge multiple actions into the same object', () => {
1069+
const config = {
1070+
manifest: baseManifest,
1071+
objects: [
1072+
{ name: 'deal', fields: { amount: { type: 'number' as const } } },
1073+
],
1074+
actions: [
1075+
{ name: 'close_deal', label: 'Close Deal', objectName: 'deal' },
1076+
{ name: 'reopen_deal', label: 'Reopen Deal', objectName: 'deal' },
1077+
],
1078+
};
1079+
1080+
const result = defineStack(config);
1081+
expect(result.objects![0].actions).toHaveLength(2);
1082+
expect(result.objects![0].actions!.map(a => a.name)).toEqual(['close_deal', 'reopen_deal']);
1083+
});
1084+
1085+
it('should merge actions into different objects', () => {
1086+
const config = {
1087+
manifest: baseManifest,
1088+
objects: [
1089+
{ name: 'task', fields: { title: { type: 'text' as const } } },
1090+
{ name: 'project', fields: { name: { type: 'text' as const } } },
1091+
],
1092+
actions: [
1093+
{ name: 'complete_task', label: 'Complete', objectName: 'task' },
1094+
{ name: 'archive_project', label: 'Archive', objectName: 'project' },
1095+
],
1096+
};
1097+
1098+
const result = defineStack(config);
1099+
expect(result.objects![0].actions).toHaveLength(1);
1100+
expect(result.objects![0].actions![0].name).toBe('complete_task');
1101+
expect(result.objects![1].actions).toHaveLength(1);
1102+
expect(result.objects![1].actions![0].name).toBe('archive_project');
1103+
});
1104+
1105+
it('should not modify objects when no actions have objectName', () => {
1106+
const config = {
1107+
manifest: baseManifest,
1108+
objects: [
1109+
{ name: 'task', fields: { title: { type: 'text' as const } } },
1110+
],
1111+
actions: [
1112+
{ name: 'global_action', label: 'Global' },
1113+
],
1114+
};
1115+
1116+
const result = defineStack(config);
1117+
expect(result.objects![0].actions).toBeUndefined();
1118+
});
1119+
1120+
it('should preserve top-level actions array after merge', () => {
1121+
const config = {
1122+
manifest: baseManifest,
1123+
objects: [
1124+
{ name: 'task', fields: { title: { type: 'text' as const } } },
1125+
],
1126+
actions: [
1127+
{ name: 'approve_task', label: 'Approve', objectName: 'task' },
1128+
{ name: 'global_search', label: 'Search' },
1129+
],
1130+
};
1131+
1132+
const result = defineStack(config);
1133+
// Top-level actions are preserved
1134+
expect(result.actions).toHaveLength(2);
1135+
// Object has the merged action
1136+
expect(result.objects![0].actions).toHaveLength(1);
1137+
});
1138+
1139+
it('should append merged actions to existing object.actions', () => {
1140+
const config = {
1141+
manifest: baseManifest,
1142+
objects: [
1143+
{
1144+
name: 'task',
1145+
fields: { title: { type: 'text' as const } },
1146+
actions: [{ name: 'inline_action', label: 'Inline' }],
1147+
},
1148+
],
1149+
actions: [
1150+
{ name: 'merged_action', label: 'Merged', objectName: 'task' },
1151+
],
1152+
};
1153+
1154+
const result = defineStack(config);
1155+
expect(result.objects![0].actions).toHaveLength(2);
1156+
expect(result.objects![0].actions![0].name).toBe('inline_action');
1157+
expect(result.objects![0].actions![1].name).toBe('merged_action');
1158+
});
1159+
1160+
it('should work in non-strict mode', () => {
1161+
const config = {
1162+
manifest: baseManifest,
1163+
objects: [
1164+
{ name: 'task', fields: { title: { type: 'text' as const } } },
1165+
],
1166+
actions: [
1167+
{ name: 'approve_task', label: 'Approve', objectName: 'task' },
1168+
],
1169+
};
1170+
1171+
const result = defineStack(config, { strict: false });
1172+
expect(result.objects![0].actions).toHaveLength(1);
1173+
expect(result.objects![0].actions![0].name).toBe('approve_task');
1174+
});
1175+
1176+
it('should detect action objectName referencing undefined object', () => {
1177+
const config = {
1178+
manifest: baseManifest,
1179+
objects: [
1180+
{ name: 'task', fields: { title: { type: 'text' as const } } },
1181+
],
1182+
actions: [
1183+
{ name: 'approve_deal', label: 'Approve', objectName: 'nonexistent_object' },
1184+
],
1185+
};
1186+
1187+
expect(() => defineStack(config)).toThrow('cross-reference validation failed');
1188+
expect(() => defineStack(config)).toThrow('nonexistent_object');
1189+
});
1190+
1191+
it('should skip objectName validation when no objects are defined', () => {
1192+
const config = {
1193+
manifest: baseManifest,
1194+
actions: [
1195+
{ name: 'approve_deal', label: 'Approve', objectName: 'deal' },
1196+
],
1197+
};
1198+
1199+
// No objects defined, cross-ref validation is skipped
1200+
expect(() => defineStack(config)).not.toThrow();
1201+
});
1202+
});
1203+
10401204
// ============================================================================
10411205
// Action → Modal Cross-Reference Validation — ensures modal targets resolve to pages
10421206
// ============================================================================

0 commit comments

Comments
 (0)