dispatchAction
dispatchAction(action, context) is the unified action dispatcher for
v2 manifest actions. It dispatches an action object against a runtime
context — every dispatch path is non-throwing; errors surface as
console.warn so a misconfigured action never crashes the host.
Import
import { dispatchAction } from '@conduction/nextcloud-vue'
Signature
function dispatchAction(
action: {
type?: 'handler' | 'open-modal' | 'open-page' | 'navigate' | 'object-op'
| 'export' | 'open-form' | 'refresh' | 'api-call' | 'agent' | 'toggle',
// type-specific fields:
handler?: string, // type='handler'; type='export' — confirm handler name
args?: any[], // type='handler'
target?: string, // type='open-modal' | 'open-page' | 'navigate'
props?: object, // type='open-modal'
op?: 'patch' | 'delete' | 'create', // type='object-op'
values?: object, // type='object-op' — patch merge / create payload
confirm?: boolean, // type='object-op' — confirm-gating intent (host-consumed)
entities?: { id, label }[], // type='export' — selectable entity types (optional)
formats?: (string | { id, label })[], // type='export' — offered formats (optional)
description?: string, // type='export' — pre-translated dialog description
url?: string, // type='api-call' — app endpoint (token-interpolated, @objectId or {objectId})
method?: 'POST' | 'PUT', // type='api-call' — default POST
payload?: object, // type='api-call' — JSON body, DEEP @-token resolution (preferred over params)
params?: object, // type='api-call' — legacy JSON body, shallow (1-level) @-token grammar
download?: boolean, // type='api-call' — request a blob response + trigger a file download (default false)
filename?: string, // type='api-call' download only — fallback filename (token-interpolated)
successMessage?: string, // type='api-call' — success toast text
errorMessage?: string, // type='api-call' — error toast text
refresh?: boolean, // type='api-call' | 'agent' — bump cn:page:refresh (default true; default FALSE when download:true)
agent?: string, // type='agent' (REQUIRED) — hermiq agent uuid
skill?: string, // type='agent' — optional skill id
prompt?: string, // type='agent' — optional prompt (inline @-token interpolation)
resultField?: string, // type='agent' — object field the result writes to
register?: string, // type='agent' | 'open-form' — OR register slug (agent: defaults to @register)
schema?: string, // type='agent' | 'open-form' — OR schema slug (agent: defaults to @schema)
objectId?: string, // type='agent' — target object id (defaults to @objectId)
},
context: {
router?: VueRouter, // required for 'open-page' + 'navigate'
registry?: Record<string, { kind, component }>, // required for 'open-modal'
handlers?: Record<string, Function>, // required for 'handler'
openModal?: (key: string, props?: object) => void, // required for 'open-modal'
openExport?: (action: object) => void, // required for 'export' (CnPageRenderer pre-binds it)
openForm?: (action: object) => void, // required for 'open-form' (the header-actions surface provides it)
objectStore?: ObjectStore, // required for 'object-op' (useObjectStore shape)
source?: { register, schema }, // required for 'object-op' — the widget's source
row?: object, // required for 'object-op' patch/delete (row-scoped)
tokenCtx?: object, // 'api-call' | 'agent' — { objectId?, object?, register?, schema?, workspace?, config? }
},
): void | Promise<object | boolean | null> // object-op / api-call / agent return a promise
When action.type is missing it's treated as "handler" for v1
backward compatibility.
Dispatch types
handler (default)
Calls context.handlers[action.handler](...action.args). Warns and
no-ops if the handler isn't registered.
dispatchAction(
{ handler: 'createSource', args: [{ name: 'New' }] },
{ handlers: { createSource: (data) => { /* ... */ } } },
)
open-modal
Resolves context.registry[action.target] and calls
context.openModal(action.target, action.props). Warns if the
target is not registered as a modal kind.
dispatchAction(
{ type: 'open-modal', target: 'edit-source', props: { id: '1' } },
{ registry: customComponents, openModal: cnOpenModal },
)
open-page / navigate
Both push to context.router. navigate is the literal router.push;
open-page resolves the manifest page by id, then router.pushes to
its route name. Warns and no-ops if the router or target is missing.
dispatchAction(
{ type: 'navigate', target: '/sources' },
{ router: $router },
)
object-op
Declarative mutation of an OpenRegister object (ADR-049), dispatched via
the shared object store: saveObject for op: 'patch' (the row merged
with values) and op: 'create' (values as a new object), and
deleteObject for op: 'delete' — always against
context.source.register / context.source.schema (a matching
already-registered store type is reused; otherwise a
<register>/<schema> type is registered on the fly).
The manifest declares intent only: authorization-shaped fields on
the action (role, allow, …) are never consulted — OpenRegister RBAC
is the single authority, a forbidden mutation is rejected server-side,
and the store only mutates its caches on success, so a rejected write
surfaces (via the returned null / false and the store's
errors[type]) with no local state change.
confirm is consumed by the rendering host, not by the dispatcher:
CnWidgetObjectTable routes delete (always) and patch/create
(on confirm: true) through CnConfirmDialog before calling
dispatchAction.
const result = await dispatchAction(
{ type: 'object-op', op: 'patch', values: { status: 'accepted' } },
{ objectStore: useObjectStore(), source: { register: 'pipelinq', schema: 'case' }, row },
)
if (result === null) { /* rejected — surface the store error, mutate nothing */ }
export
Opens the shared export launcher (CnMassExportDialog) via
context.openExport(action) — CnPageRenderer pre-binds openExport
into the cnDispatchAction context and mounts the dialog, configured
from the action's entities[] (optional entity-type picker),
formats[] (bare ids like "csv" are lifted to { id, label };
omitted = the dialog's Excel/CSV defaults), and description.
On confirm, the dialog's payload ({ format, entity? }) routes to the
action's handler resolved against the manifest actions map (the
same registry type: "handler" actions use); the handler performs the
actual download (e.g. an app's ExportService) and its promise drives
the dialog's success/error result phase. A missing handler surfaces as
a dialog error — never a silent success.
{
"id": "report-export",
"label": "Download report",
"type": "export",
"description": "CSV / Excel / JSON reports for funders and stakeholders.",
"entities": [
{ "id": "leads", "label": "Leads" },
{ "id": "requests", "label": "Requests" }
],
"formats": ["excel", "csv", "json"],
"handler": "exportReport"
}
open-form (Wave 3, #91)
Opens the schema-driven create dialog via context.openForm(action) —
the header-actions surface (CnActionButtons)
provides openForm and mounts a CnAdvancedFormDialog for the action's
schema, mirroring how export delegates to CnMassExportDialog. On
save the surface optionally navigates to the action's onSuccessRoute.
{ "id": "new-lead", "label": "New lead", "type": "open-form", "schema": "lead", "onSuccessRoute": "Leads" }
onSuccessRoute deep-linking (#91). On a successful save the surface
navigates via buildOnSuccessRoute(onSuccessRoute, saved), which merges the
saved object's id (saved.id → saved.uuid → saved['@self'].id) into the
route params so the navigation can land on the created object's detail page.
onSuccessRoute is either:
- a string route NAME →
{ name, params: { id } }. A route with no:idsegment simply ignores the extra param, so a bare name keeps working unchanged (backward compatible). - an object
{ name, paramField?, objectParam? }→ the id lands underparamField(defaultid), and — whenobjectParamis set — the whole saved object is passed under that param key too (so aprops: truedetail route renders the record without a refetch).
{
"id": "new-lead", "label": "New lead", "type": "open-form", "schema": "lead",
"onSuccessRoute": { "name": "LeadDetail", "paramField": "leadId" }
}
refresh (Wave 3, #91)
Emits the page-level cn:page:refresh event-bus signal — every
endpoint-bound / bus-subscribed widget on the page force-refetches past
its short-TTL cache (the same signal the page overflow menu's Refresh
item broadcasts). No context is required.
{ "id": "refresh", "label": "Refresh", "type": "refresh" }
api-call (Wave 3, #91)
POST/PUT a configured app endpoint, toast the outcome via
@nextcloud/dialogs (showSuccess / showError), then — unless
refresh: false (or, for a download call, unless refresh: true) —
bump cn:page:refresh. The url interpolates @objectId /
@object.<field> / @workspace.<key> / @config.<key> tokens, as well
as the literal {objectId} brace placeholder. Returns a promise of
{ ok, data?, error? }. confirm gating is the rendering surface's job
(object-op precedent) — the dispatcher runs after any confirmation.
Request body — payload vs params. payload is the preferred
field: its values resolve the SAME @-token grammar (@me, @today±Nd,
@objectId, @object.<field>, @workspace.<key>?/@config.<key>?)
recursively at any nesting depth — objects, arrays, and arrays of
objects all resolve, so a body like
{ "dataRefs": [{ "register": "crm", "schema": "lead", "id": "@objectId" }] }
resolves the nested @objectId correctly. The legacy params field
still works unchanged for back-compat, but only resolves tokens ONE
level deep (the flat filter-map shape) — prefer payload for anything
with nested objects/arrays. Either way, a required (non-?) token left
unresolved anywhere in the body blocks the call (error toast) rather
than sending the literal token string to the server. When both are set,
payload wins.
download: true requests the response as a binary blob
(responseType: 'blob') and triggers a browser file download instead of
treating the body as JSON. The filename comes from the response's
Content-Disposition header, else the token-interpolated filename,
else 'download.pdf'. The success toast still shows; unlike a normal
api-call, the page does not auto-refresh afterwards unless
refresh: true is set explicitly.
{
"id": "approve",
"label": "Approve run",
"type": "api-call",
"url": "/apps/shillinq/api/payment-runs/@objectId/approve",
"confirm": true,
"successMessage": "Payment run approved",
"visibleWhen": { "field": "state", "op": "eq", "value": "pending" }
}
Generate-and-download a Filinq PDF for the current detail-page object:
{
"id": "generate-pdf",
"label": "Generate PDF",
"type": "api-call",
"url": "/apps/docudesk/api/documents/generate",
"method": "POST",
"payload": {
"template": "invoice",
"dataRefs": [{ "register": "crm", "schema": "lead", "id": "@objectId" }]
},
"download": true,
"filename": "[email protected]",
"successMessage": "Document generated"
}
agent (hermiq#41)
Run a governed hermiq agent against the current page's OpenRegister
object. A first-class companion to api-call: the author declares "run
agent here" and the dispatcher wires the URL and body for them. It POSTs
{ "register": "...", "schema": "...", "objectId": "...", "resultField": "...", "skill": "...", "prompt": "..." }
to POST /apps/hermiq/api/agents/{agent}/run-on-object — hermiq's
object-RBAC-scoped endpoint that dispatches the governed
AgentRunRequestedEvent (the same governed recipe every other agent
trigger uses). register / schema / objectId default to the page's
@register / @schema / @objectId object context (a detail page
provides all three); declare them explicitly to override. prompt
interpolates the shared @-token grammar inline (@objectId,
@object.<field>, @workspace.<key>, @config.<key>) so it can be
grounded on the page object.
Fail-closed and graceful:
- an unresolved required
@objectId(or a missing agent / register / schema) blocks the call — a literal@objectIdis never sent (same guard asapi-call's payload work); - on the server's 202 it toasts
successMessage(default'Run queued') and — unlessrefresh: false— bumpscn:page:refresh; - a 403 / 404 with a structured error body fail-closes with that message;
- a 404 with no structured body is read as "hermiq is not installed"
and toasts a graceful
'Agent runtime unavailable'— the dispatcher never hard-requires hermiq.
Returns a promise of { ok, data?, error? }. confirm gating is the
rendering surface's job (object-op / api-call precedent). type:"api-call"
remains the fallback when you need a bespoke body or a non-hermiq endpoint.
{
"id": "summarise",
"label": "Summarise with AI",
"type": "agent",
"agent": "b2c3d4e5-…",
"skill": "summarise-v1",
"prompt": "Summarise @object.title for a busy account manager.",
"resultField": "aiSummary",
"confirm": true,
"successMessage": "Summary queued",
"visibleWhen": { "field": "state", "op": "eq", "value": "open" }
}
register / schema / objectId are omitted above — they default to the
detail page's object context. On a page with no object context, declare
them explicitly.
toggle (Wave 3, #91) — not dispatched
A toggle is a stateful two-way control (GET state on mount, write
on click) rendered by the header-actions surface, not a one-shot action —
dispatchAction warns and no-ops on it. See
CnActionButtons for the config.
When you'd call it directly
Usually you don't — CnPageRenderer calls dispatchAction for you
when a manifest-declared action fires (action button click, table
row action, etc.) and injects the runtime context. Direct calls make
sense in custom widget code that wants to delegate an action through
the manifest action vocabulary without hand-rolling the dispatch
switch.
Spec
- REQ-MVR-011 (manifest-v2-renderer) — unified actions dispatcher
- ADR-036 Decision 7 — action dispatch vocabulary