diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index cd7a933f46..a2f6b5e657 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -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 | @@ -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 +}; +``` + + +**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. + --- @@ -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 | + diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index e6fadf1096..71e2c7417b 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -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', diff --git a/examples/app-crm/src/actions/handlers/case.handlers.ts b/examples/app-crm/src/actions/handlers/case.handlers.ts new file mode 100644 index 0000000000..a0d45d37fe --- /dev/null +++ b/examples/app-crm/src/actions/handlers/case.handlers.ts @@ -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; + user: { id: string; name: string }; + engine: { + update(object: string, id: string, data: Record): Promise; + }; + params?: Record; +} + +/** Escalate a case to the escalation team */ +export async function escalateCase(ctx: ActionContext): Promise { + 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 { + 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', + }); +} diff --git a/examples/app-crm/src/actions/handlers/contact.handlers.ts b/examples/app-crm/src/actions/handlers/contact.handlers.ts new file mode 100644 index 0000000000..15918901e7 --- /dev/null +++ b/examples/app-crm/src/actions/handlers/contact.handlers.ts @@ -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; + user: { id: string; name: string }; + engine: { + update(object: string, id: string, data: Record): Promise; + insert(object: string, data: Record): Promise<{ _id: string }>; + find(object: string, query: Record): Promise>>; + }; + params?: Record; +} + +/** Mark a contact as the primary contact for its account */ +export async function markAsPrimaryContact(ctx: ActionContext): Promise { + 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 }; +} diff --git a/examples/app-crm/src/actions/handlers/global.handlers.ts b/examples/app-crm/src/actions/handlers/global.handlers.ts new file mode 100644 index 0000000000..a1ec40564b --- /dev/null +++ b/examples/app-crm/src/actions/handlers/global.handlers.ts @@ -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; + user: { id: string; name: string }; + engine: { + insert(object: string, data: Record): Promise<{ _id: string }>; + find(object: string, query: Record): Promise>>; + }; + params?: Record; +} + +/** Export records of a given object to CSV format */ +export async function exportToCSV(ctx: ActionContext): Promise { + 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 }; +} diff --git a/examples/app-crm/src/actions/handlers/index.ts b/examples/app-crm/src/actions/handlers/index.ts new file mode 100644 index 0000000000..bc8252885b --- /dev/null +++ b/examples/app-crm/src/actions/handlers/index.ts @@ -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'; diff --git a/examples/app-crm/src/actions/handlers/lead.handlers.ts b/examples/app-crm/src/actions/handlers/lead.handlers.ts new file mode 100644 index 0000000000..4d426d0e4e --- /dev/null +++ b/examples/app-crm/src/actions/handlers/lead.handlers.ts @@ -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; + user: { id: string; name: string }; + engine: { + update(object: string, id: string, data: Record): Promise; + insert(object: string, data: Record): Promise<{ _id: string }>; + find(object: string, query: Record): Promise>>; + }; + params?: Record; +} + +/** 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 { + 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', + }); + } +} diff --git a/examples/app-crm/src/actions/handlers/opportunity.handlers.ts b/examples/app-crm/src/actions/handlers/opportunity.handlers.ts new file mode 100644 index 0000000000..49d4abdc1e --- /dev/null +++ b/examples/app-crm/src/actions/handlers/opportunity.handlers.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Opportunity Action Handlers + * + * Handler implementations for actions defined in opportunity.actions.ts. + * + * @example Registration: + * ```ts + * engine.registerAction('opportunity', 'cloneRecord', cloneRecord); + * ``` + */ + +interface ActionContext { + record: Record; + user: { id: string; name: string }; + engine: { + update(object: string, id: string, data: Record): Promise; + insert(object: string, data: Record): Promise<{ _id: string }>; + find(object: string, query: Record): Promise>>; + }; + params?: Record; +} + +/** Clone an opportunity record */ +export async function cloneRecord(ctx: ActionContext): Promise<{ _id: string }> { + const { record, engine } = ctx; + const { _id, created_at, updated_at, ...fields } = record as Record; + return engine.insert('opportunity', { + ...fields, + name: `Copy of ${fields.name ?? 'Untitled'}`, + stage: 'prospecting', + }); +} + +/** Mass update opportunity stage for selected records */ +export async function massUpdateStage(ctx: ActionContext): Promise { + const { params, engine } = ctx; + const newStage = params?.stage as string; + const ids = (params?.selectedIds ?? []) as string[]; + for (const id of ids) { + await engine.update('opportunity', id, { stage: newStage }); + } +} diff --git a/examples/app-crm/src/actions/index.ts b/examples/app-crm/src/actions/index.ts index 39d4d3b345..d614d6977e 100644 --- a/examples/app-crm/src/actions/index.ts +++ b/examples/app-crm/src/actions/index.ts @@ -2,6 +2,12 @@ /** * Action Definitions Barrel + * + * Exports action metadata definitions only. Used by `Object.values()` in + * objectstack.config.ts to auto-collect all action declarations for defineStack(). + * + * **Handler functions** are exported from `./handlers/` — see register-handlers.ts + * for the complete registration flow. */ export { EscalateCaseAction, CloseCaseAction } from './case.actions'; export { MarkPrimaryContactAction, SendEmailAction } from './contact.actions'; diff --git a/examples/app-crm/src/actions/register-handlers.ts b/examples/app-crm/src/actions/register-handlers.ts new file mode 100644 index 0000000000..5787c385d8 --- /dev/null +++ b/examples/app-crm/src/actions/register-handlers.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Action Handler Registration — Enterprise CRM + * + * Demonstrates the complete lifecycle for wiring action handler functions + * to their declarative action definitions across all CRM domains. + * + * ## Architecture + * + * ``` + * ┌──────────────────────┐ + * │ objectstack.config │ defineStack() — declarative metadata only + * │ ├─ actions[] │ Action definitions with `target` strings + * │ ├─ objects[] │ Object schemas + * │ └─ flows[] │ Flow definitions + * └──────────┬───────────┘ + * │ AppPlugin.start() + * ▼ + * ┌──────────────────────┐ + * │ onEnable(ctx) │ Plugin lifecycle hook + * │ └─ ctx.ql │ ObjectQL engine reference + * └──────────┬───────────┘ + * │ registerCrmActionHandlers(ctx.ql) + * ▼ + * ┌──────────────────────┐ + * │ engine.registerAction │ Binds target → handler function + * │ ('lead', │ + * │ 'convertLead', │ ← matches action target string + * │ convertLead) │ ← actual function implementation + * └──────────────────────┘ + * ``` + * + * ## Action Type → Handler Mapping + * + * | Action Type | Handler Location | Registration | + * |-------------|-----------------|--------------| + * | `script` | `*.handlers.ts` | `engine.registerAction()` | + * | `modal` | `*.handlers.ts` | `engine.registerAction()` — processes modal form | + * | `flow` | Flow engine | Auto-resolved via `flows[]` definition | + * | `url` | Browser/UI | No server handler needed | + * | `api` | API routes | Auto-resolved via `apis[]` definition | + * + * @see handlers/ — Handler function implementations per domain + */ + +import { + convertLead, + addToCampaign, + cloneRecord, + massUpdateStage, + escalateCase, + closeCase, + markAsPrimaryContact, + sendEmail, + exportToCSV, + logCall, +} from './handlers'; + +/** + * Register all CRM action handlers on the ObjectQL engine. + * + * @param engine - The ObjectQL engine instance + * + * @example Usage in plugin lifecycle: + * ```ts + * import { registerCrmActionHandlers } from './src/actions/register-handlers'; + * + * export const onEnable = async (ctx: { ql: ObjectQL }) => { + * registerCrmActionHandlers(ctx.ql); + * }; + * ``` + */ +export function registerCrmActionHandlers(engine: { + registerAction(objectName: string, actionName: string, handler: (...args: unknown[]) => unknown): void; +}): void { + // ─── Lead Domain ─────────────────────────────────────────────────── + // ConvertLeadAction (type: flow) — also has server-side handler for + // programmatic conversion outside the screen flow. + engine.registerAction('lead', 'convertLead', convertLead); + // CreateCampaignAction (type: modal) — processes campaign assignment form + engine.registerAction('lead', 'addToCampaign', addToCampaign); + + // ─── Opportunity Domain ──────────────────────────────────────────── + // CloneOpportunityAction (type: script) + engine.registerAction('opportunity', 'cloneRecord', cloneRecord); + // MassUpdateStageAction (type: modal) — processes stage selection form + engine.registerAction('opportunity', 'massUpdateStage', massUpdateStage); + + // ─── Case Domain ─────────────────────────────────────────────────── + // EscalateCaseAction (type: modal) — processes escalation reason form + engine.registerAction('case', 'escalateCase', escalateCase); + // CloseCaseAction (type: modal) — processes resolution form + engine.registerAction('case', 'closeCase', closeCase); + + // ─── Contact Domain ──────────────────────────────────────────────── + // MarkPrimaryContactAction (type: script) + engine.registerAction('contact', 'markAsPrimaryContact', markAsPrimaryContact); + // SendEmailAction (type: modal) — processes email composer form + engine.registerAction('contact', 'sendEmail', sendEmail); + + // ─── Global (cross-domain) ───────────────────────────────────────── + // ExportToCsvAction (type: script) — wildcard '*' applies to all objects + engine.registerAction('*', 'exportToCSV', exportToCSV); + // LogCallAction (type: modal) — processes call log form + engine.registerAction('*', 'logCall', logCall); +} diff --git a/examples/app-todo/objectstack.config.ts b/examples/app-todo/objectstack.config.ts index f64a4c9f70..a7608f6bd8 100644 --- a/examples/app-todo/objectstack.config.ts +++ b/examples/app-todo/objectstack.config.ts @@ -11,6 +11,20 @@ import * as flows from './src/flows'; import * as apps from './src/apps'; import * as translations from './src/translations'; +// ─── 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 { registerTaskActionHandlers } 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 } }) => { + registerTaskActionHandlers(ctx.ql); +}; + export default defineStack({ manifest: { id: 'com.example.todo', diff --git a/examples/app-todo/src/actions/index.ts b/examples/app-todo/src/actions/index.ts index 532733b7a6..803bc9f29d 100644 --- a/examples/app-todo/src/actions/index.ts +++ b/examples/app-todo/src/actions/index.ts @@ -2,6 +2,12 @@ /** * Action Definitions Barrel + * + * Exports action metadata definitions only. Used by `Object.values()` in + * objectstack.config.ts to auto-collect all action declarations for defineStack(). + * + * **Handler functions** are exported from `./handlers/` — see register-handlers.ts + * for the complete registration flow. */ export { CompleteTaskAction, diff --git a/examples/app-todo/src/actions/register-handlers.ts b/examples/app-todo/src/actions/register-handlers.ts new file mode 100644 index 0000000000..4338d2f3ab --- /dev/null +++ b/examples/app-todo/src/actions/register-handlers.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Action Handler Registration — Todo App + * + * Demonstrates the complete lifecycle for wiring action handler functions + * to their declarative action definitions. This is the bridge between: + * + * 1. **Action Definitions** (task.actions.ts) — declarative metadata with + * `target` string references (e.g., `target: 'completeTask'`). + * + * 2. **Handler Functions** (task.handlers.ts) — actual implementations that + * execute business logic (e.g., `async function completeTask(ctx) {...}`). + * + * 3. **Registration** (this file) — binds handler functions to the engine so + * the runtime can resolve `target` strings to executable code. + * + * ## How It Works + * + * ObjectStack separates action *declaration* (metadata) from action *execution* + * (handler). The `defineStack()` config only includes declarative metadata. + * Handlers are registered at runtime via the plugin lifecycle: + * + * ``` + * ┌─────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ + * │ task.actions.ts │ │ task.handlers.ts │ │ register-handlers.ts │ + * │ (metadata) │ │ (implementation) │ │ (wiring / lifecycle) │ + * │ │ │ │ │ │ + * │ target: │───▶│ export function │───▶│ engine.registerAction │ + * │ 'completeTask' │ │ completeTask() │ │ ('task','completeTask',│ + * │ │ │ │ │ completeTask) │ + * └─────────────────┘ └──────────────────┘ └────────────────────────┘ + * ``` + * + * ## When Does Registration Happen? + * + * In the ObjectStack kernel lifecycle, the AppPlugin calls `onEnable(ctx)` + * on each app bundle after the engine is ready. This is where you register + * action handlers: + * + * ```ts + * // objectstack.config.ts (runtime integration) + * export const onEnable = (ctx) => registerTaskActionHandlers(ctx.ql); + * ``` + * + * @see task.actions.ts — Action metadata definitions + * @see task.handlers.ts — Handler function implementations + */ + +import { + completeTask, + startTask, + cloneTask, + deferTask, + setReminder, + massCompleteTasks, + deleteCompletedTasks, + exportTasksToCSV, +} from './task.handlers'; + +/** + * Register all task action handlers on the ObjectQL engine. + * + * Called during the plugin `onEnable` lifecycle phase when the kernel + * makes the data engine available to the app. + * + * @param engine - The ObjectQL engine instance (from `ctx.ql`) + * + * @example Usage in plugin lifecycle: + * ```ts + * import { registerTaskActionHandlers } from './src/actions/register-handlers'; + * + * export const onEnable = async (ctx: { ql: ObjectQL }) => { + * registerTaskActionHandlers(ctx.ql); + * }; + * ``` + */ +export function registerTaskActionHandlers(engine: { + registerAction(objectName: string, actionName: string, handler: (...args: unknown[]) => unknown): void; +}): void { + // ─── Script-type actions (server-side handlers) ──────────────────── + engine.registerAction('task', 'completeTask', completeTask); + engine.registerAction('task', 'startTask', startTask); + engine.registerAction('task', 'cloneTask', cloneTask); + engine.registerAction('task', 'massCompleteTasks', massCompleteTasks); + engine.registerAction('task', 'deleteCompletedTasks', deleteCompletedTasks); + engine.registerAction('task', 'exportTasksToCSV', exportTasksToCSV); + + // ─── Modal-type actions (server-side form submission handlers) ───── + // These process the params collected by the modal UI before the + // engine updates the record. The modal target (e.g. 'defer_task_modal') + // tells the UI which modal page to open; the handler below processes + // the submitted form data. + engine.registerAction('task', 'deferTask', deferTask); + engine.registerAction('task', 'setReminder', setReminder); +} diff --git a/examples/app-todo/src/actions/task.handlers.ts b/examples/app-todo/src/actions/task.handlers.ts new file mode 100644 index 0000000000..771cee766b --- /dev/null +++ b/examples/app-todo/src/actions/task.handlers.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Task Action Handlers + * + * Example handler implementations for actions defined in task.actions.ts. + * Each handler is registered via `engine.registerAction()` and referenced + * by name through the action's `target` field. + * + * @example Registration (in a plugin or config bootstrap): + * ```ts + * engine.registerAction('task', 'completeTask', completeTask); + * engine.registerAction('task', 'startTask', startTask); + * ``` + */ + +// ─── Handler Context (simplified for example purposes) ────────────── +interface ActionContext { + /** The record being acted upon */ + record: Record; + /** Current authenticated user */ + user: { id: string; name: string }; + /** Data engine for CRUD operations */ + engine: { + update(object: string, id: string, data: Record): Promise; + insert(object: string, data: Record): Promise<{ _id: string }>; + find(object: string, query: Record): Promise>>; + delete(object: string, ids: string[]): Promise; + }; + /** Action parameters (from user input / params) */ + params?: Record; +} + +/** Mark a single task as complete */ +export async function completeTask(ctx: ActionContext): Promise { + const { record, engine, user } = ctx; + await engine.update('task', record._id as string, { + status: 'completed', + completed_at: new Date().toISOString(), + completed_by: user.id, + }); +} + +/** Mark a task as in-progress */ +export async function startTask(ctx: ActionContext): Promise { + const { record, engine } = ctx; + await engine.update('task', record._id as string, { + status: 'in_progress', + started_at: new Date().toISOString(), + }); +} + +/** Clone a task (duplicate with reset status) */ +export async function cloneTask(ctx: ActionContext): Promise<{ _id: string }> { + const { record, engine } = ctx; + const { _id, created_at, updated_at, completed_at, completed_by, ...fields } = record as Record; + return engine.insert('task', { + ...fields, + status: 'not_started', + subject: `Copy of ${fields.subject ?? 'Untitled'}`, + }); +} + +/** Mark all selected tasks as complete (bulk) */ +export async function massCompleteTasks(ctx: ActionContext): Promise { + const { params, engine, user } = ctx; + const ids = (params?.selectedIds ?? []) as string[]; + const now = new Date().toISOString(); + for (const id of ids) { + await engine.update('task', id, { + status: 'completed', + completed_at: now, + completed_by: user.id, + }); + } +} + +/** Delete all completed tasks */ +export async function deleteCompletedTasks(ctx: ActionContext): Promise { + const { engine } = ctx; + const completed = await engine.find('task', { status: 'completed' }); + const ids = completed.map((r) => r._id as string); + if (ids.length > 0) { + await engine.delete('task', ids); + } +} + +/** Defer a task by updating its due date (modal form submission handler) */ +export async function deferTask(ctx: ActionContext): Promise { + const { record, engine, params } = ctx; + await engine.update('task', record._id as string, { + due_date: params?.new_due_date ? String(params.new_due_date) : null, + defer_reason: params?.reason ? String(params.reason) : null, + status: 'waiting', + }); +} + +/** Set a reminder on a task (modal form submission handler) */ +export async function setReminder(ctx: ActionContext): Promise { + const { record, engine, params } = ctx; + await engine.update('task', record._id as string, { + reminder_date: params?.reminder_date ? String(params.reminder_date) : null, + has_reminder: true, + }); +} + +/** Export tasks to CSV format */ +export async function exportTasksToCSV(ctx: ActionContext): Promise { + const { engine } = ctx; + const tasks = await engine.find('task', {}); + const header = 'subject,status,priority,category,due_date'; + const rows = tasks.map((t) => + [t.subject, t.status, t.priority, t.category, t.due_date ?? ''].join(','), + ); + return [header, ...rows].join('\n'); +} diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 0c59772781..de2ff7bb60 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -1038,9 +1038,69 @@ describe('defineStack - Action Cross-Reference Validation', () => { }); // ============================================================================ -// Example-Level Strict Validation — mirrors examples/app-todo & examples/app-crm +// Action → Modal Cross-Reference Validation — ensures modal targets resolve to pages // ============================================================================ +describe('defineStack - Modal Cross-Reference Validation', () => { + const baseManifest = { + id: 'com.example.test', + name: 'test-project', + version: '1.0.0', + type: 'app' as const, + }; + + const makePage = (name: string) => ({ + name, + label: name, + regions: [{ name: 'main', components: [] }], + }); + + it('should detect modal action referencing undefined page', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + pages: [makePage('existing_page')], + actions: [ + { name: 'open_modal', label: 'Open Modal', type: 'modal' as const, target: 'nonexistent_page' }, + ], + }; + + expect(() => defineStack(config)).toThrow('cross-reference validation failed'); + expect(() => defineStack(config)).toThrow('nonexistent_page'); + }); + + it('should pass when modal action references a defined page', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + pages: [makePage('defer_task_modal')], + actions: [ + { name: 'defer_task', label: 'Defer Task', type: 'modal' as const, target: 'defer_task_modal' }, + ], + }; + + expect(() => defineStack(config)).not.toThrow(); + }); + + it('should skip modal validation when no pages are defined', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + actions: [ + { name: 'open_modal', label: 'Open Modal', type: 'modal' as const, target: 'some_modal' }, + ], + }; + + expect(() => defineStack(config)).not.toThrow(); + }); +}); + describe('defineStack - Example-Level Strict Validation', () => { it('should validate a Todo-style app config (strict mode)', () => { const todoConfig = { diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ccbaa5febd..75ef8ec454 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -400,9 +400,9 @@ 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. + // Validate action → flow/modal cross-references + // Note: When no flows/pages are defined (size === 0), targets are not validated + // because the referenced items may be provided by a plugin. // This is consistent with dashboard/page/report validation in navigation. if (config.actions) { const flowNames = new Set(); @@ -412,6 +412,13 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } + const pageNames = new Set(); + if (config.pages) { + for (const page of config.pages) { + pageNames.add(page.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)) { @@ -419,6 +426,13 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { `Action '${action.name}' references flow '${action.target}' which is not defined in flows.`, ); } + + // Validate modal-type actions reference a defined page + if (action.type === 'modal' && action.target && pageNames.size > 0 && !pageNames.has(action.target)) { + errors.push( + `Action '${action.name}' references page '${action.target}' (via modal target) which is not defined in pages.`, + ); + } } }