Skip to content

Commit 4e8b2e3

Browse files
authored
Merge pull request #837 from objectstack-ai/copilot/add-handler-implementation-examples
2 parents 4216408 + 9ac5165 commit 4e8b2e3

File tree

16 files changed

+804
-6
lines changed

16 files changed

+804
-6
lines changed

content/docs/references/ui/action.mdx

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ const result = Action.parse(data);
3737
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'global_nav'>[]` | optional | Locations where this action is visible |
3838
| **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override |
3939
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api'>` || Action functionality type |
40-
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint |
41-
| **execute** | `string` | optional | Legacy execution logic |
40+
| **target** | `string` | conditional | URL, Script Name, Flow ID, Modal/Page Name, or API Endpoint. **Required** for `url`, `flow`, `modal`, and `api` types; recommended for `script`. |
41+
| **execute** | `string` | optional | ⚠️ **Deprecated** — Use `target` instead. Auto-migrated to `target` during parsing. Will be removed in a future version. |
4242
| **params** | `Object[]` | optional | Input parameters required from user |
4343
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
4444
| **confirmText** | `string \| Object` | optional | Confirmation message before execution |
@@ -51,6 +51,51 @@ const result = Action.parse(data);
5151
| **timeout** | `number` | optional | Maximum execution time in milliseconds for the action |
5252
| **aria** | `Object` | optional | ARIA accessibility attributes |
5353

54+
### Target Binding Rules
55+
56+
The `target` field is the canonical way to bind an action to its handler:
57+
58+
| Action Type | target | Description |
59+
| :--- | :--- | :--- |
60+
| `script` | Recommended | Function name to invoke (e.g. `completeTask`) |
61+
| `url` | **Required** | URL to navigate to |
62+
| `flow` | **Required** | Flow name to invoke (validated against defined flows) |
63+
| `modal` | **Required** | Page/modal name to open (validated against defined pages) |
64+
| `api` | **Required** | API endpoint to call |
65+
66+
### Examples
67+
68+
```typescript
69+
// Script action with handler target
70+
const action: Action = {
71+
name: 'complete_task',
72+
label: 'Mark Complete',
73+
type: 'script',
74+
target: 'completeTask', // ← references a registered handler function
75+
locations: ['record_header'],
76+
refreshAfter: true,
77+
};
78+
79+
// Flow action
80+
const flowAction: Action = {
81+
name: 'convert_lead',
82+
label: 'Convert Lead',
83+
type: 'flow',
84+
target: 'lead_conversion', // ← must match a defined flow name
85+
};
86+
87+
// Modal action
88+
const modalAction: Action = {
89+
name: 'defer_task',
90+
label: 'Defer Task',
91+
type: 'modal',
92+
target: 'defer_task_modal', // ← must match a defined page name
93+
};
94+
```
95+
96+
<Callout type="warn">
97+
**Migration Note:** The `execute` field is deprecated. If `execute` is provided without `target`, it is automatically migrated to `target` during schema parsing. Always use `target` in new code.
98+
</Callout>
5499

55100
---
56101

@@ -69,3 +114,25 @@ const result = Action.parse(data);
69114

70115
---
71116

117+
## Cross-Reference Validation
118+
119+
`defineStack()` validates action cross-references at build time:
120+
121+
- **`type: 'flow'`**`target` is checked against the `flows[]` collection (when flows are defined).
122+
- **`type: 'modal'`**`target` is checked against the `pages[]` collection (when pages are defined).
123+
124+
When the target collection is empty, validation is skipped because referenced items may come from plugins.
125+
126+
---
127+
128+
## Platform Comparison
129+
130+
| Capability | ObjectStack | Salesforce | ServiceNow | Power Platform |
131+
| :--- | :--- | :--- | :--- | :--- |
132+
| **Declarative actions** | `ActionSchema` with `target` binding | Lightning Actions (Quick Actions) | UI Actions / Client Scripts | Power Fx `OnSelect` |
133+
| **Action types** | `script`, `url`, `modal`, `flow`, `api` | URL, Flow, LWC, Visualforce | Client Script, UI Policy, Flow | Navigate, Patch, Launch |
134+
| **Handler binding** | `target` string → `engine.registerAction()` | Apex Controller `@AuraEnabled` | Script Include + GlideAjax | Power Automate Cloud Flow |
135+
| **Cross-ref validation** | Build-time (`defineStack`) | Deploy-time (Metadata API) | Update Set validation | Solution Checker |
136+
| **Modal integration** | `type: 'modal'` + page name target | `lightning:overlayLibrary` | GlideModal / GlideDialogWindow | `Navigate(Screen)` |
137+
| **Bulk operations** | `bulkEnabled` + `locations: ['list_toolbar']` | List Button + Mass Quick Action | List v3 Actions | Gallery `OnSelect` multi |
138+

examples/app-crm/objectstack.config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ import {
2626
RoleHierarchy,
2727
} from './src/sharing';
2828

29+
// ─── Action Handler Registration (runtime lifecycle) ────────────────
30+
// Handlers are wired separately from metadata. The `onEnable` export
31+
// is called by the kernel's AppPlugin after the engine is ready.
32+
// See: src/actions/register-handlers.ts for the full registration flow.
33+
import { registerCrmActionHandlers } from './src/actions/register-handlers';
34+
35+
/**
36+
* Plugin lifecycle hook — called by AppPlugin when the engine is ready.
37+
* This is where action handlers are registered on the ObjectQL engine.
38+
*/
39+
export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[]) => void } }) => {
40+
registerCrmActionHandlers(ctx.ql);
41+
};
42+
2943
export default defineStack({
3044
manifest: {
3145
id: 'com.example.crm',
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Case Action Handlers
5+
*
6+
* Handler implementations for actions defined in case.actions.ts.
7+
*
8+
* @example Registration:
9+
* ```ts
10+
* engine.registerAction('case', 'escalateCase', escalateCase);
11+
* engine.registerAction('case', 'closeCase', closeCase);
12+
* ```
13+
*/
14+
15+
interface ActionContext {
16+
record: Record<string, unknown>;
17+
user: { id: string; name: string };
18+
engine: {
19+
update(object: string, id: string, data: Record<string, unknown>): Promise<void>;
20+
};
21+
params?: Record<string, unknown>;
22+
}
23+
24+
/** Escalate a case to the escalation team */
25+
export async function escalateCase(ctx: ActionContext): Promise<void> {
26+
const { record, engine, user, params } = ctx;
27+
await engine.update('case', record._id as string, {
28+
is_escalated: true,
29+
escalation_reason: params?.reason as string,
30+
escalated_by: user.id,
31+
escalated_at: new Date().toISOString(),
32+
priority: 'urgent',
33+
});
34+
}
35+
36+
/** Close a case with a resolution */
37+
export async function closeCase(ctx: ActionContext): Promise<void> {
38+
const { record, engine, user, params } = ctx;
39+
await engine.update('case', record._id as string, {
40+
is_closed: true,
41+
resolution: params?.resolution as string,
42+
closed_by: user.id,
43+
closed_at: new Date().toISOString(),
44+
status: 'closed',
45+
});
46+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Contact Action Handlers
5+
*
6+
* Handler implementations for actions defined in contact.actions.ts.
7+
*
8+
* @example Registration:
9+
* ```ts
10+
* engine.registerAction('contact', 'markAsPrimaryContact', markAsPrimaryContact);
11+
* engine.registerAction('contact', 'sendEmail', sendEmail);
12+
* ```
13+
*/
14+
15+
interface ActionContext {
16+
record: Record<string, unknown>;
17+
user: { id: string; name: string };
18+
engine: {
19+
update(object: string, id: string, data: Record<string, unknown>): Promise<void>;
20+
insert(object: string, data: Record<string, unknown>): Promise<{ _id: string }>;
21+
find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
22+
};
23+
params?: Record<string, unknown>;
24+
}
25+
26+
/** Mark a contact as the primary contact for its account */
27+
export async function markAsPrimaryContact(ctx: ActionContext): Promise<void> {
28+
const { record, engine } = ctx;
29+
const accountId = record.account_id as string;
30+
31+
// Clear existing primary contacts on the same account
32+
const siblings = await engine.find('contact', { account_id: accountId, is_primary: true });
33+
for (const sibling of siblings) {
34+
await engine.update('contact', sibling._id as string, { is_primary: false });
35+
}
36+
37+
// Set current contact as primary
38+
await engine.update('contact', record._id as string, { is_primary: true });
39+
}
40+
41+
/** Send an email to a contact (modal form submission handler) */
42+
export async function sendEmail(ctx: ActionContext): Promise<{ activityId: string }> {
43+
const { record, engine, user, params } = ctx;
44+
const activity = await engine.insert('activity', {
45+
type: 'email',
46+
subject: params?.subject ? String(params.subject) : `Email to ${record.email}`,
47+
body: params?.body ? String(params.body) : '',
48+
contact_id: record._id as string,
49+
account_id: record.account_id as string,
50+
direction: 'outbound',
51+
status: 'sent',
52+
created_by: user.id,
53+
sent_at: new Date().toISOString(),
54+
});
55+
return { activityId: activity._id };
56+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Global Action Handlers
5+
*
6+
* Handler implementations for cross-domain actions defined in global.actions.ts.
7+
*
8+
* @example Registration:
9+
* ```ts
10+
* engine.registerAction('*', 'exportToCSV', exportToCSV);
11+
* engine.registerAction('*', 'logCall', logCall);
12+
* ```
13+
*/
14+
15+
interface ActionContext {
16+
record: Record<string, unknown>;
17+
user: { id: string; name: string };
18+
engine: {
19+
insert(object: string, data: Record<string, unknown>): Promise<{ _id: string }>;
20+
find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
21+
};
22+
params?: Record<string, unknown>;
23+
}
24+
25+
/** Export records of a given object to CSV format */
26+
export async function exportToCSV(ctx: ActionContext): Promise<string> {
27+
const { params, engine } = ctx;
28+
const objectName = (params?.objectName ?? 'account') as string;
29+
const records = await engine.find(objectName, {});
30+
if (records.length === 0) return '';
31+
32+
const keys = Object.keys(records[0]);
33+
const header = keys.join(',');
34+
const rows = records.map((r) => keys.map((k) => r[k] ?? '').join(','));
35+
return [header, ...rows].join('\n');
36+
}
37+
38+
/** Log a phone call as an activity record (modal form submission handler) */
39+
export async function logCall(ctx: ActionContext): Promise<{ activityId: string }> {
40+
const { record, engine, user, params } = ctx;
41+
const activity = await engine.insert('activity', {
42+
type: 'call',
43+
subject: params?.subject ? String(params.subject) : 'Untitled Call',
44+
duration_minutes: params?.duration ? Number(params.duration) : 0,
45+
notes: params?.notes ? String(params.notes) : '',
46+
related_to_id: record._id as string,
47+
direction: 'outbound',
48+
status: 'completed',
49+
created_by: user.id,
50+
call_date: new Date().toISOString(),
51+
});
52+
return { activityId: activity._id };
53+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Action Handler Implementations Barrel
5+
*
6+
* Re-exports all handler functions for registration via engine.registerAction().
7+
*/
8+
export { convertLead, addToCampaign } from './lead.handlers';
9+
export { cloneRecord, massUpdateStage } from './opportunity.handlers';
10+
export { escalateCase, closeCase } from './case.handlers';
11+
export { markAsPrimaryContact, sendEmail } from './contact.handlers';
12+
export { exportToCSV, logCall } from './global.handlers';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Lead Action Handlers
5+
*
6+
* Handler implementations for lead-domain actions defined in lead.actions.ts.
7+
* The `ConvertLeadAction` (type: flow) is handled by the flow engine;
8+
* `CreateCampaignAction` (type: modal) is handled by the UI modal system.
9+
*
10+
* This file provides the server-side logic backing these actions.
11+
*
12+
* @example Registration:
13+
* ```ts
14+
* engine.registerAction('lead', 'convertLead', convertLead);
15+
* ```
16+
*/
17+
18+
interface ActionContext {
19+
record: Record<string, unknown>;
20+
user: { id: string; name: string };
21+
engine: {
22+
update(object: string, id: string, data: Record<string, unknown>): Promise<void>;
23+
insert(object: string, data: Record<string, unknown>): Promise<{ _id: string }>;
24+
find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
25+
};
26+
params?: Record<string, unknown>;
27+
}
28+
29+
/** Convert a qualified lead into Account, Contact, and Opportunity records */
30+
export async function convertLead(ctx: ActionContext): Promise<{
31+
accountId: string;
32+
contactId: string;
33+
opportunityId: string;
34+
}> {
35+
const { record, engine, user } = ctx;
36+
37+
const account = await engine.insert('account', {
38+
name: record.company as string,
39+
website: record.website,
40+
industry: record.industry,
41+
created_by: user.id,
42+
});
43+
44+
const contact = await engine.insert('contact', {
45+
first_name: record.first_name,
46+
last_name: record.last_name,
47+
email: record.email,
48+
phone: record.phone,
49+
account_id: account._id,
50+
});
51+
52+
const opportunity = await engine.insert('opportunity', {
53+
name: `${record.company} - New Opportunity`,
54+
account_id: account._id,
55+
contact_id: contact._id,
56+
stage: 'prospecting',
57+
amount: record.estimated_value ?? 0,
58+
});
59+
60+
await engine.update('lead', record._id as string, {
61+
is_converted: true,
62+
status: 'converted',
63+
converted_account_id: account._id,
64+
converted_contact_id: contact._id,
65+
converted_opportunity_id: opportunity._id,
66+
});
67+
68+
return {
69+
accountId: account._id,
70+
contactId: contact._id,
71+
opportunityId: opportunity._id,
72+
};
73+
}
74+
75+
/** Add selected leads to a campaign */
76+
export async function addToCampaign(ctx: ActionContext): Promise<void> {
77+
const { params, engine } = ctx;
78+
const campaignId = params?.campaign as string;
79+
const leadIds = (params?.selectedIds ?? []) as string[];
80+
for (const leadId of leadIds) {
81+
await engine.insert('campaign_member', {
82+
campaign_id: campaignId,
83+
lead_id: leadId,
84+
status: 'sent',
85+
});
86+
}
87+
}

0 commit comments

Comments
 (0)