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
71 changes: 69 additions & 2 deletions content/docs/references/ui/action.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,8 +37,8 @@ const result = Action.parse(data);
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'global_nav'>[]` | optional | Locations where this action is visible |
| **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override |
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api'>` | ✅ | Action functionality type |
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint |
| **execute** | `string` | optional | Legacy execution logic |
| **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`. |
| **execute** | `string` | optional | ⚠️ **Deprecated** — Use `target` instead. Auto-migrated to `target` during parsing. Will be removed in a future version. |
| **params** | `Object[]` | optional | Input parameters required from user |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
| **confirmText** | `string \| Object` | optional | Confirmation message before execution |
Expand All@@ -51,6 +51,51 @@ const result = Action.parse(data);
| **timeout** | `number` | optional | Maximum execution time in milliseconds for the action |
| **aria** | `Object` | optional | ARIA accessibility attributes |

### Target Binding Rules

The `target` field is the canonical way to bind an action to its handler:

| Action Type | target | Description |
| :--- | :--- | :--- |
| `script` | Recommended | Function name to invoke (e.g. `completeTask`) |
| `url` | **Required** | URL to navigate to |
| `flow` | **Required** | Flow name to invoke (validated against defined flows) |
| `modal` | **Required** | Page/modal name to open (validated against defined pages) |
| `api` | **Required** | API endpoint to call |

### Examples

```typescript
// Script action with handler target
const action: Action = {
name: 'complete_task',
label: 'Mark Complete',
type: 'script',
target: 'completeTask', // ← references a registered handler function
locations: ['record_header'],
refreshAfter: true,
};

// Flow action
const flowAction: Action = {
name: 'convert_lead',
label: 'Convert Lead',
type: 'flow',
target: 'lead_conversion', // ← must match a defined flow name
};

// Modal action
const modalAction: Action = {
name: 'defer_task',
label: 'Defer Task',
type: 'modal',
target: 'defer_task_modal', // ← must match a defined page name
};
```

<Callout type="warn">
**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.
</Callout>

---

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

---

## Cross-Reference Validation

`defineStack()` validates action cross-references at build time:

- **`type: 'flow'`** — `target` is checked against the `flows[]` collection (when flows are defined).
- **`type: 'modal'`** — `target` is checked against the `pages[]` collection (when pages are defined).

When the target collection is empty, validation is skipped because referenced items may come from plugins.

---

## Platform Comparison

| Capability | ObjectStack | Salesforce | ServiceNow | Power Platform |
| :--- | :--- | :--- | :--- | :--- |
| **Declarative actions** | `ActionSchema` with `target` binding | Lightning Actions (Quick Actions) | UI Actions / Client Scripts | Power Fx `OnSelect` |
| **Action types** | `script`, `url`, `modal`, `flow`, `api` | URL, Flow, LWC, Visualforce | Client Script, UI Policy, Flow | Navigate, Patch, Launch |
| **Handler binding** | `target` string → `engine.registerAction()` | Apex Controller `@AuraEnabled` | Script Include + GlideAjax | Power Automate Cloud Flow |
| **Cross-ref validation** | Build-time (`defineStack`) | Deploy-time (Metadata API) | Update Set validation | Solution Checker |
| **Modal integration** | `type: 'modal'` + page name target | `lightning:overlayLibrary` | GlideModal / GlideDialogWindow | `Navigate(Screen)` |
| **Bulk operations** | `bulkEnabled` + `locations: ['list_toolbar']` | List Button + Mass Quick Action | List v3 Actions | Gallery `OnSelect` multi |

14 changes: 14 additions & 0 deletions examples/app-crm/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,20 @@ import {
RoleHierarchy,
} from './src/sharing';

// ─── Action Handler Registration (runtime lifecycle) ────────────────
// Handlers are wired separately from metadata. The `onEnable` export
// is called by the kernel's AppPlugin after the engine is ready.
// See: src/actions/register-handlers.ts for the full registration flow.
import { registerCrmActionHandlers } from './src/actions/register-handlers';

/**
* Plugin lifecycle hook — called by AppPlugin when the engine is ready.
* This is where action handlers are registered on the ObjectQL engine.
*/
export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[]) => void } }) => {
registerCrmActionHandlers(ctx.ql);
};

export default defineStack({
manifest: {
id: 'com.example.crm',
Expand Down
46 changes: 46 additions & 0 deletions examples/app-crm/src/actions/handlers/case.handlers.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Case Action Handlers
*
* Handler implementations for actions defined in case.actions.ts.
*
* @example Registration:
* ```ts
* engine.registerAction('case', 'escalateCase', escalateCase);
* engine.registerAction('case', 'closeCase', closeCase);
* ```
*/

interface ActionContext {
record: Record<string, unknown>;
user: { id: string; name: string };
engine: {
update(object: string, id: string, data: Record<string, unknown>): Promise<void>;
};
params?: Record<string, unknown>;
}

/** Escalate a case to the escalation team */
export async function escalateCase(ctx: ActionContext): Promise<void> {
const { record, engine, user, params } = ctx;
await engine.update('case', record._id as string, {
is_escalated: true,
escalation_reason: params?.reason as string,
escalated_by: user.id,
escalated_at: new Date().toISOString(),
priority: 'urgent',
});
}

/** Close a case with a resolution */
export async function closeCase(ctx: ActionContext): Promise<void> {
const { record, engine, user, params } = ctx;
await engine.update('case', record._id as string, {
is_closed: true,
resolution: params?.resolution as string,
closed_by: user.id,
closed_at: new Date().toISOString(),
status: 'closed',
});
}
56 changes: 56 additions & 0 deletions examples/app-crm/src/actions/handlers/contact.handlers.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Contact Action Handlers
*
* Handler implementations for actions defined in contact.actions.ts.
*
* @example Registration:
* ```ts
* engine.registerAction('contact', 'markAsPrimaryContact', markAsPrimaryContact);
* engine.registerAction('contact', 'sendEmail', sendEmail);
* ```
*/

interface ActionContext {
record: Record<string, unknown>;
user: { id: string; name: string };
engine: {
update(object: string, id: string, data: Record<string, unknown>): Promise<void>;
insert(object: string, data: Record<string, unknown>): Promise<{ _id: string }>;
find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
};
params?: Record<string, unknown>;
}

/** Mark a contact as the primary contact for its account */
export async function markAsPrimaryContact(ctx: ActionContext): Promise<void> {
const { record, engine } = ctx;
const accountId = record.account_id as string;

// Clear existing primary contacts on the same account
const siblings = await engine.find('contact', { account_id: accountId, is_primary: true });
for (const sibling of siblings) {
await engine.update('contact', sibling._id as string, { is_primary: false });
}

// Set current contact as primary
await engine.update('contact', record._id as string, { is_primary: true });
}

/** Send an email to a contact (modal form submission handler) */
export async function sendEmail(ctx: ActionContext): Promise<{ activityId: string }> {
const { record, engine, user, params } = ctx;
const activity = await engine.insert('activity', {
type: 'email',
subject: params?.subject ? String(params.subject) : `Email to ${record.email}`,
body: params?.body ? String(params.body) : '',
contact_id: record._id as string,
account_id: record.account_id as string,
direction: 'outbound',
status: 'sent',
created_by: user.id,
sent_at: new Date().toISOString(),
});
return { activityId: activity._id };
}
53 changes: 53 additions & 0 deletions examples/app-crm/src/actions/handlers/global.handlers.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Global Action Handlers
*
* Handler implementations for cross-domain actions defined in global.actions.ts.
*
* @example Registration:
* ```ts
* engine.registerAction('*', 'exportToCSV', exportToCSV);
* engine.registerAction('*', 'logCall', logCall);
* ```
*/

interface ActionContext {
record: Record<string, unknown>;
user: { id: string; name: string };
engine: {
insert(object: string, data: Record<string, unknown>): Promise<{ _id: string }>;
find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
};
params?: Record<string, unknown>;
}

/** Export records of a given object to CSV format */
export async function exportToCSV(ctx: ActionContext): Promise<string> {
const { params, engine } = ctx;
const objectName = (params?.objectName ?? 'account') as string;
const records = await engine.find(objectName, {});
if (records.length === 0) return '';

const keys = Object.keys(records[0]);
const header = keys.join(',');
const rows = records.map((r) => keys.map((k) => r[k] ?? '').join(','));
return [header, ...rows].join('\n');
}

/** Log a phone call as an activity record (modal form submission handler) */
export async function logCall(ctx: ActionContext): Promise<{ activityId: string }> {
const { record, engine, user, params } = ctx;
const activity = await engine.insert('activity', {
type: 'call',
subject: params?.subject ? String(params.subject) : 'Untitled Call',
duration_minutes: params?.duration ? Number(params.duration) : 0,
notes: params?.notes ? String(params.notes) : '',
related_to_id: record._id as string,
direction: 'outbound',
status: 'completed',
created_by: user.id,
call_date: new Date().toISOString(),
});
return { activityId: activity._id };
}
12 changes: 12 additions & 0 deletions examples/app-crm/src/actions/handlers/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Action Handler Implementations Barrel
*
* Re-exports all handler functions for registration via engine.registerAction().
*/
export { convertLead, addToCampaign } from './lead.handlers';
export { cloneRecord, massUpdateStage } from './opportunity.handlers';
export { escalateCase, closeCase } from './case.handlers';
export { markAsPrimaryContact, sendEmail } from './contact.handlers';
export { exportToCSV, logCall } from './global.handlers';
87 changes: 87 additions & 0 deletions examples/app-crm/src/actions/handlers/lead.handlers.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Lead Action Handlers
*
* Handler implementations for lead-domain actions defined in lead.actions.ts.
* The `ConvertLeadAction` (type: flow) is handled by the flow engine;
* `CreateCampaignAction` (type: modal) is handled by the UI modal system.
*
* This file provides the server-side logic backing these actions.
*
* @example Registration:
* ```ts
* engine.registerAction('lead', 'convertLead', convertLead);
* ```
*/

interface ActionContext {
record: Record<string, unknown>;
user: { id: string; name: string };
engine: {
update(object: string, id: string, data: Record<string, unknown>): Promise<void>;
insert(object: string, data: Record<string, unknown>): Promise<{ _id: string }>;
find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
};
params?: Record<string, unknown>;
}

/** Convert a qualified lead into Account, Contact, and Opportunity records */
export async function convertLead(ctx: ActionContext): Promise<{
accountId: string;
contactId: string;
opportunityId: string;
}> {
const { record, engine, user } = ctx;

const account = await engine.insert('account', {
name: record.company as string,
website: record.website,
industry: record.industry,
created_by: user.id,
});

const contact = await engine.insert('contact', {
first_name: record.first_name,
last_name: record.last_name,
email: record.email,
phone: record.phone,
account_id: account._id,
});

const opportunity = await engine.insert('opportunity', {
name: `${record.company} - New Opportunity`,
account_id: account._id,
contact_id: contact._id,
stage: 'prospecting',
amount: record.estimated_value ?? 0,
});

await engine.update('lead', record._id as string, {
is_converted: true,
status: 'converted',
converted_account_id: account._id,
converted_contact_id: contact._id,
converted_opportunity_id: opportunity._id,
});

return {
accountId: account._id,
contactId: contact._id,
opportunityId: opportunity._id,
};
}

/** Add selected leads to a campaign */
export async function addToCampaign(ctx: ActionContext): Promise<void> {
const { params, engine } = ctx;
const campaignId = params?.campaign as string;
const leadIds = (params?.selectedIds ?? []) as string[];
for (const leadId of leadIds) {
await engine.insert('campaign_member', {
campaign_id: campaignId,
lead_id: leadId,
status: 'sent',
});
}
}
Loading