> ## Documentation Index
> Fetch the complete documentation index at: https://docs.revoengine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Reactive Custom Widgets

> Build forms, drill-down views and job actions with partial refreshes that preserve input state.

A reactive Custom Widget lets an operator filter a chart, open a record, configure a process and start a Job from a dashboard or a private Assistant card. It extends the existing `return [[cell]]` format: add stable IDs and declarative events to the cells that need interaction. Existing text, charts and tables remain valid.

Use [Dashboards](https://docs.revoengine.com/operate/dashboards.md) for the surrounding layout and saved widget settings. Use a reusable [Component](https://docs.revoengine.com/build/components.md) when an action or view needs its own reviewed revision.

## Connect inputs, events and destinations

| Property | Purpose |
| - | - |
| `id` | Stable identity of an interactive cell or form field. |
| `INPUT` | A text, textarea, number, date, select or checkbox control. |
| `FORM` | INPUT `fields`, a `submitLabel` and `on.submit`. |
| `BUTTON` | A label and `on.click` action. |
| `on` | Maps an event to one declarative action. |
| `widgetTargets` | Named destinations configured in **Widget destinations (JSON)**. |
| `actionTargets` | Named executors configured in **Action components (JSON)**. |
| `refresh` | A cell's interval and optional target IDs. |

Keep IDs unique across the widget, including form fields. Events and bindings are JSON data, not JavaScript callbacks or expressions. Start an ID with a letter, then use letters, digits, underscores, dots or hyphens, up to 100 characters. Keep generated source self-contained: serializing a function does not preserve variables from its surrounding scope.

| Action | Behavior |
| - | - |
| `REFRESH` | Rerun the current source for `target: 'cellId'`, an ID array, or `'*'`. Optional `set` commits mapped input values first. |
| `OPEN_WIDGET` | Open a named widget destination with mapped `input`. Default `mode: 'replace'` retains a Back frame; `panel` opens a side panel; `inline` requires an existing `hostId`. |
| `EXECUTE` | Run a named action once, optionally wait for completion, refresh cells and apply a cooldown. |
| `BACK` | Return to the previous drill-down frame. Pending actions must finish first. |

Targets reuse the normal Inline or Component source configuration. A child view inherits its parent's target maps unless it supplies its own. Configure executable destinations in the saved widget; a clicked row supplies arguments, not arbitrary code or Component IDs.

## Form → Job → one refreshed tile

This example has two cells: a form and a recent-jobs tile. Submit commits the form, starts a Job, polls that exact Job and refreshes only `jobResult`. The form remains mounted with its values.

Prepare an active Job template that accepts a `batch` input. Its worker reads that value from `api.input().templateInputs.batch`. Prepare two Components with a visible element named `Data`: one starts the Job, the other reads its status. The examples below supply their bodies.

### 1. Configure the widget

Choose **Inline code**, JavaScript, and set **Inputs** to:

```json theme={null}
{ "templateId": "YOUR_JOB_TEMPLATE_ID" }
```

In **Action components (JSON)**, register the two Components. Replace the identifiers with your verified resources; keep the same template ID in both settings:

```json theme={null}
{
  "startBatch": {
    "sourceType": "COMPONENT",
    "componentId": "YOUR_START_COMPONENT_ID",
    "element": "Data",
    "inputs": "{\"templateId\":\"YOUR_JOB_TEMPLATE_ID\"}",
    "readOnly": false,
    "timeout": 30,
    "memory": 128
  },
  "batchStatus": {
    "sourceType": "COMPONENT",
    "componentId": "YOUR_STATUS_COMPONENT_ID",
    "element": "Data",
    "inputs": "{}",
    "readOnly": true,
    "timeout": 30,
    "memory": 128
  }
}
```

View sources may return the cell matrix directly, or wrap it in `{ matrix }` or `{ rows }`. This applies to root views, drill targets and partial refreshes in both chat and dashboards. Action receipts are separate objects and are not view output.

These JSON maps are properties of the widget configuration, not cells in its returned matrix. Configured `inputs` is JSON text in both source modes. The widget parses it once, so `api.input()` receives the object. Action bindings override target defaults; a completion probe receives its own configured inputs and `waitFor.input` mappings. You can pin a target's Component digest when a reviewed revision must remain fixed.

### 2. Return the form and result tile

Paste this into the widget's Inline code:

```js theme={null}
const input = api.input() ?? {};
const ui = input.$widget ?? {};
const wanted = id => (ui.targetIds ?? ['*']).some(target => target === '*' || target === id);
const rows = [];

if (wanted('jobForm')) rows.push([{
  id: 'jobForm',
  type: 'FORM',
  label: 'Process a batch',
  submitLabel: 'Start job',
  fields: [{
    id: 'batch', type: 'INPUT', control: 'text',
    label: 'Batch', value: 'BATCH-001', required: true, maxLength: 100
  }],
  on: { submit: {
    type: 'EXECUTE',
    target: 'startBatch',
    input: { batch: { from: 'input', id: 'batch' } },
    waitFor: {
      target: 'batchStatus',
      input: { jobId: { from: 'result', path: 'jobId' } },
      path: 'status', equals: 'FINISHED',
      failed: ['ERROR', 'CANCELLED', 'EXPIRED'],
      intervalSeconds: 2, timeoutSeconds: 120
    },
    refresh: 'jobResult',
    cooldownSeconds: 3
  } }
}]);

if (wanted('jobResult')) {
  const page = await api.getInstanceData('Jobs', {
    filter: { jobTemplateId: { equals: input.templateId } },
    fields: ['jobId', 'status', 'scheduledFor'],
    sort: ['-scheduledFor'], take: 5
  });
  const lines = (page?.data ?? []).map(job => '- ' + job.jobId + ': **' + job.status + '**');
  rows.push([{
    id: 'jobResult', type: 'TEXT',
    value: '### Recent jobs\n' + (lines.join('\n') || 'No runs yet.')
  }]);
}
return rows;
```

The tile shows the latest five runs of this template, including runs from other operators. Completion polling is separately tied to the exact `jobId` returned by this submission.

On a partial refresh, return exactly the requested IDs. Check `targetIds` before expensive queries: refreshing one rendered cell still executes the source body, so the source decides which work to skip.

### 3. Start the Job in the write Component

Use this body in the starting Component's `Data` element. It validates the input and records a bounded duplicate-dispatch guard before enqueueing:

```js theme={null}
const input = api.input() ?? {};
const requestId = input.$widget?.requestId;
if (typeof input.templateId !== 'string' || !input.templateId ||
    typeof input.batch !== 'string' || !input.batch.trim() || input.batch.length > 100 ||
    typeof requestId !== 'string' || !requestId) {
  throw new Error('A template, batch and widget request ID are required.');
}

const key = 'widget-batch:' + input.templateId + ':' + requestId;
const claimed = await api.setInstanceCacheIfNotExists(key, { status: 'STARTING' }, 3600);
if (!claimed) {
  const receipt = await api.getInstanceCache(key);
  if (receipt?.jobId) return receipt;
  throw new Error('The earlier dispatch is unconfirmed. Reconcile it before retrying.');
}

const jobId = await api.triggerJob(input.templateId, { batch: input.batch.trim(), requestId });
const receipt = { jobId };
await api.setInstanceCache(key, receipt, 3600);
return receipt;
```

The one-hour cache guard protects repeated delivery of the same dispatch ID while the key exists. It is not a transaction with Job creation or business-level exactly-once execution. If enqueueing succeeds but saving its receipt fails, reconcile the Job using the supplied `requestId`; do not automatically enqueue another one. A deliberate new submit has a new dispatch ID. Enforce business idempotency in the worker for operations that must occur only once.

### 4. Read the exact Job in the status Component

```js theme={null}
const jobId = api.input()?.jobId;
if (typeof jobId !== 'string' || !jobId) throw new Error('A jobId is required.');
const page = await api.getInstanceData('Jobs', {
  filter: { jobId: { equals: jobId } },
  fields: ['jobId', 'status'], take: 1
});
return { status: page?.data?.[0]?.status ?? 'UNKNOWN' };
```

`Jobs` list reads use a restricted projection. Query the confirmed `jobId` with `fields: ['jobId', 'status']`; `output` and `inputs` are not supported list fields. Show the confirmed status or fetch results from a separately verified business resource. Test the result branch with an existing authorized job before publishing.

The submit control and its form inputs stay locked while the action runs, while confirmation is pending, and through the configured cooldown. A successful enqueue is not completion. A terminal failure releases the lock with an error; a timeout or network failure leaves the outcome uncertain and never replays the write automatically. When a receipt is available, the button can check status again. A cooldown is a client-side throttle, not a durable server lock or lease.

Reads in this example and all status probes are read-only. Keep reusable view Components in restricted mode: an initial Component render without configured target maps retains its existing restricted-mode setting, even if its result later introduces interactive controls. Configured target maps and subsequent interactive refreshes force read-only execution. Writes through an action require explicit `readOnly: false` on the registered action target and the caller's normal execution permissions. The UI does not grant access; validate business inputs and authorization in the write path.

## Open a detail view and return

Add this destination in **Widget destinations (JSON)**. Its Inline body is complete and reads the selected order ID from its input:

```json theme={null}
{
  "orderDetail": {
    "sourceType": "INLINE",
    "codeType": "CODE_JS",
    "code": "return [[{ id: 'detail', type: 'TEXT', value: 'Order: ' + api.input().orderId }], [{ id: 'back', type: 'BUTTON', label: 'Back', on: { click: { type: 'BACK' } } }]];",
    "inputs": "{}",
    "readOnly": true,
    "timeout": 30,
    "memory": 128
  }
}
```

Use this source in the parent widget. Clicking a row replaces the current view while retaining its frame; Back restores the parent and its selected keys.

```js theme={null}
return [[{
  id: 'orders', type: 'TABLE', rowKey: 'id',
  selection: { mode: 'multiple' },
  data: [{ id: 'order-1' }, { id: 'order-2' }],
  definition: [{ field: 'id', headerName: 'Order', type: 'TEXT' }],
  on: { rowClick: {
    type: 'OPEN_WIDGET', target: 'orderDetail', mode: 'replace',
    input: { orderId: { from: 'event', path: 'row.id' } }
  } }
}]];
```

Use `mode: 'panel'` to open the destination beside its parent, or `mode: 'inline'` with an existing parent cell ID in `hostId` to mount it inside that cell. These modes start independent state: BACK navigates a retained replacement frame, not a parent in a different surface. Dismiss a panel with Escape or an outside click. For deeper drill-down, register another named destination and pass the next record ID explicitly.

## Preserve or refill inputs

Initial rendering receives the configured inputs unchanged. Later interactive reads receive that JSON object plus `$widget`:

| Field | Meaning |
| - | - |
| `targetIds` | Cells requested by this execution. |
| `reason` | `initial`, `manual`, `action` or `interval`. |
| `values` | Committed INPUT values by ID. |
| `selection` | Selected table keys, keyed by table ID. |
| `event` | Event data, such as a clicked row or chart datum. |
| `actionResult` | Receipt of the last confirmed action in this frame; retained for refresh and Back, cleared on remount. |
| `requestId` | Identifier of the read or action dispatch. |

Read current values from `$widget.values` before falling back to configured `api.input()` defaults. `set` changes declared INPUT state, not the configured arguments. An `input` binding refers only to an actual INPUT cell in the current frame. In a child view without controls, read passed arguments from `api.input()` and forward scalar values in the next action's `input` map.

Do not supply the reserved `$widget` key in configured interactive inputs. Form fields commit together on submit. A standalone INPUT commits on change by default; optional `commit: 'blur'` and `debounceMs` control when it commits.

`valueMode: 'initial'` is the default: returned defaults do not replace an existing value. Use `valueMode: 'sync'` when low-code should refill an input, such as a delivery method derived from a selected country. A synchronized value applies only when no newer edit or pending draft exists. Ordinary refresh keeps drafts and mounted renderer instances; stale responses cannot overwrite newer committed state.

State belongs to the mounted widget. Back retains previous drill-down frames, including drafts and selection, for up to 12 levels. A page reload initializes a new widget; panels and inline destinations have independent state.

## Charts, records and multiple selection

Bindings can read `input`, `event`, `selection` or an action `result`. Paths select existing properties and are not evaluated expressions.

```js theme={null}
return [[{
  id: 'orders', type: 'TABLE', rowKey: 'id',
  selection: { mode: 'multiple' },
  data: [{ id: 'order-1', customer: 'Example customer' }],
  definition: [{ field: 'customer', headerName: 'Customer', type: 'TEXT' }],
  on: { rowClick: {
    type: 'OPEN_WIDGET', target: 'orderDetail', mode: 'panel',
    input: { orderId: { from: 'event', path: 'row.id' } }
  } },
  buttons: [{
    id: 'process', label: 'Process selected', requiresSelection: true,
    on: { click: {
      type: 'EXECUTE', target: 'processOrders',
      input: { ids: { from: 'selection', id: 'orders', path: 'keys' } },
      refresh: 'orders'
    } }
  }]
}]];
```

Register `widgetTargets.orderDetail` and `actionTargets.processOrders` before using this example. The latter must finish its work before returning, or provide a receipt and a `waitFor` probe as in the Job example. Table keys must be unique strings or finite numbers. Checking a row changes selection independently of opening it. A refresh retains selected keys only while they are present in the returned table data.

A chart's `on.pointClick` can use `{ from: 'event', path: 'datum.status' }`. For example, a REFRESH action can `set` an existing status INPUT and request only the table. Declare that INPUT first: `set` does not create missing fields. TEXT and BUTTON cells use `on.click`; tables also support `on.selectionChange`.

Use `refresh: { intervalSeconds: 30 }` on a cell for its own timer, optionally with `targets: ['anotherCell']`. Intervals range from 1 to 86400 seconds. Hidden drill-down views pause their reads; overlapping interval reads are skipped. The full widget Refresh setting remains available separately.

## Limits and next steps

A widget accepts up to 64 rows, 64 cells per row and 256 elements overall, including at most 64 INPUT fields per FORM and 16 toolbar buttons per TABLE. Each target map accepts up to 32 entries, with at most four nested target levels.

Custom TABLE filtering operates on the returned data in the browser; it is not server pagination. Bound queries and make partial datasets visible. Private [Assistant cards](https://docs.revoengine.com/ai/assistant.md#charts-diagrams-and-tables-in-answers) use the same reactive runtime when they include source configuration. Shared and report views are inactive snapshots. Dynamic conversion to a dashboard preserves the source, configured inputs and action/navigation targets; fixed-snapshot conversion removes interactions and disables controls.

Continue with [Jobs](https://docs.revoengine.com/operate/jobs.md) for execution lifecycle and [Dashboards](https://docs.revoengine.com/operate/dashboards.md) for saved layout and sharing.

## Interactive cards in Assistant

Ask the Assistant for a form, a Job action, a result tile and a drill-down. Ask it to validate the card before displaying it. This preflight executes the read-only initial view and validates the complete widget contract without saving or displaying a card. Creating the card after preflight executes and validates the source again; the renderer then displays that saved result.

Initial validation does not prove partial refreshes, drill-down destinations, registered actions or browser rendering. Test each view branch in read-only Sandbox with representative `$widget.targetIds` and `$widget.values`; assert the exact returned cell IDs and values, including permitted zero and empty inputs. These probes must not launch the registered write actions. Verify those actions separately within the intended test scope.

The existing Custom Widget matrix is unchanged. The generator can return `{ matrix, summary, widgetConfig }`; `widgetConfig` registers the same `widgetTargets` and `actionTargets` used in dashboard settings. A single self-contained generator becomes the read-only view source automatically. If generation uses libraries or multiple elements, provide a complete reusable `INLINE` or `COMPONENT` source instead.

The card first displays the validated result without executing it again. Subsequent interactions execute the registered sources with the viewer's current permissions. Generating a card does not start its Job: a write needs a registered action with `readOnly: false` and an operator interaction. View sources and completion probes remain read-only. Fullscreen retains the mounted form and drafts.

This complete generator body demonstrates **form → Job → refresh one tile**, followed by a clickable detail view with **Back**. Replace `VERIFIED_JOB_TEMPLATE_ID` with a verified active Job template. The template must validate inputs and enforce the business operation's idempotency. The result tile reads the exact confirmed Job using `$widget.actionResult.jobId`. Registered source syntax and initial bindings are checked before publication; each nested source must be a valid independent function body.

```javascript theme={null}
const ui = api.input()?.$widget ?? {};
const wanted = id => (ui.targetIds ?? ['*']).some(target => target === '*' || target === id);
const matrix = [];
if (wanted('form')) matrix.push([{
  id: 'form', type: 'FORM', submitLabel: 'Start job',
  fields: [{ id: 'quantity', type: 'INPUT', control: 'number', label: 'Quantity', value: 2, required: true, min: 1, max: 100 }],
  on: { submit: { type: 'EXECUTE', target: 'launch', input: { quantity: { from: 'input', id: 'quantity' } },
    waitFor: { target: 'status', input: { jobId: { from: 'result', path: 'jobId' } }, path: 'status', equals: 'FINISHED', failed: ['ERROR','CANCELLED','EXPIRED'], intervalSeconds: 2, timeoutSeconds: 120 },
    refresh: 'total', cooldownSeconds: 3 } }
}]);
if (wanted('total')) {
  const jobId = ui.actionResult?.jobId;
  const job = jobId ? (await api.getInstanceData('Jobs', { filter: { jobId: { equals: jobId } }, fields: ['jobId','status'], take: 1 })).data[0] : null;
  matrix.push([{ id: 'total', type: 'TEXT', value: job ? job.jobId + ': ' + job.status : 'No confirmed job yet.',
    on: { click: { type: 'OPEN_WIDGET', target: 'details', mode: 'replace', input: { quantity: { from: 'input', id: 'quantity' } } } } }]);
}
return { matrix, summary: 'Submit a job, then refresh only the total tile.', widgetConfig: {
  actionTargets: {
    launch: { sourceType: 'INLINE', codeType: 'CODE_JS', readOnly: false,
      code: "const quantity = api.input().quantity; if (!Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error('Invalid quantity'); return { jobId: await api.triggerJob('VERIFIED_JOB_TEMPLATE_ID', { quantity }) };" },
    status: { sourceType: 'INLINE', codeType: 'CODE_JS', readOnly: true,
      code: "const result = await api.getInstanceData('Jobs', { filter: { jobId: { equals: api.input().jobId } }, fields: ['jobId','status'], take: 1 }); if (!result.data.length) throw new Error('Job not found'); return result.data[0];" }
  },
  widgetTargets: { details: { sourceType: 'INLINE', codeType: 'CODE_JS', readOnly: true,
    code: "const quantity=api.input().quantity; return [[{id:'detail',type:'TEXT',value:'Quantity: '+quantity}],[{id:'back',type:'BUTTON',label:'Back',on:{click:{type:'BACK'}}},{id:'more',type:'BUTTON',label:'More',on:{click:{type:'OPEN_WIDGET',target:'deeper',mode:'replace',input:{quantity}}}}]];" },
    deeper: { sourceType: 'INLINE', codeType: 'CODE_JS', readOnly: true,
      code: "return [[{id:'deep',type:'TEXT',value:'Received quantity: '+api.input().quantity}],[{id:'back',type:'BUTTON',label:'Back',on:{click:{type:'BACK'}}}]];" } }
} };
```

The source branches on `$widget.targetIds` before reading data. After confirmed completion, only `total` is returned, so the form remains mounted and retains its values. Clicking that tile opens `details`, then More opens a second level using the received quantity. Back restores each retained frame. The worker receives `quantity` in `api.input().templateInputs`.

For horizontal bar charts, use a number x-axis and a category y-axis, or omit explicit axes. Text category values on a number axis are rejected before the card is published.

Chat artifacts allow at most 8 rows, 4 cells per row, 5000 flat records per chart/table, 12 table columns and 2 MB for rows plus sources. Each target map allows 32 entries, with at most four nested target levels. Inline source code is limited to 120000 characters. Source `inputs` is JSON object text without `$widget`; timeout is 5–600 seconds and memory is 32–1024 MB. These limits do not grant execution access.

Before sharing a result, verify the real form submit, exact Job completion, retained inputs after partial refresh, two drill levels with Back, and dynamic dashboard conversion. A validated artifact alone does not prove browser rendering or Job success. A page reload restores configured defaults, not unsaved form drafts. Shared/report readers see disabled controls and cannot run actions from the card.

### Ordered button commands

For calculator keypads or other stateful commands, use `on.click: { type: "REFRESH", target: "display", concurrency: "queue" }`. Each queued click reads the preceding merged state; `$widget.event.sourceId` identifies the key. A synchronized INPUT can retain the display value between clicks. Only return the requested display cell; the keypad remains mounted. The per-frame queue permits 64 pending commands. Overflow discards pending commands and shows an error. Errors, manual refresh, nonqueued refresh actions, remount and Back cancellation discard pending commands. The default is latest overlapping read wins. This does not queue writes or persist a calculation across a page reload.

## Dashboard header controls

In the widget settings, **Widget header** exposes **Show refresh** (on by default)
and **Show performance** (off by default). Existing widgets use these defaults
without a migration. The optional boolean fields are `showRefresh` and
`showPerformance` inside `specificCustomWidgetConfig`, or `widgetConfig` when
generating a chat card. Dynamic conversion preserves both settings.

Hiding refresh removes the dashboard header button only: timers, cell actions
and the error recovery button still work. Hiding performance leaves error,
warning and log indicators visible. `refreshTime` configures a whole-widget timer;
per-cell `refresh.intervalSeconds` remains independent. When neither is configured,
the legacy fallback refreshes the whole widget every 6000 seconds (100 minutes).
Per-cell timers suppress that fallback. Timer reads pause while the document or
replacement frame is hidden. These controls do not change access permissions or read-only behavior.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.