ObjectUIObjectUI
Core

Enhanced Actions

Advanced action system with AJAX calls, chaining, conditions, and tracking

Enhanced Actions

ObjectUI's action system has been significantly enhanced to support complex workflows, including AJAX API calls, confirmation dialogs, action chaining, conditional execution, and comprehensive tracking.

Overview

The enhanced ActionSchema provides:

  • New action types: ajax, confirm, dialog
  • Action chaining: Execute multiple actions sequentially or in parallel
  • Conditional execution: a condition predicate gates whether an action runs
  • Notices: successMessage / errorMessage strings
  • Tracking: Event logging and analytics
  • Retry logic: Automatic retry with configurable backoff

Interactive Examples

Action Buttons

Action Button Variants

Confirmation Dialog Pattern

Confirmation Dialog

Confirm DeletionAre you sure you want to delete this record? This action cannot be undone.

Action Toolbar

Action Toolbar

Order #12345
Pending
Customer:Acme Corp
Amount:$1,250.00

Action Types

Ajax Actions

Execute API calls with full request configuration:

import type { ActionSchema } from '@object-ui/types';

const ajaxAction: ActionSchema = {
  type: 'action',
  label: 'Load Data',
  actionType: 'ajax',
  api: '/api/users',
  method: 'GET',
  headers: {
    'Authorization': 'Bearer token'
  },
  data: {
    filter: 'active'
  },
  successMessage: 'Data loaded successfully',
  errorMessage: 'Failed to load data'
};

Ajax Properties:

  • api - API endpoint URL
  • method - HTTP method (GET, POST, PUT, DELETE, PATCH)
  • data - Request body/payload
  • headers - Custom HTTP headers
  • timeoutMs - Request timeout in milliseconds (renamed from timeout)
  • retry - Retry configuration (maxAttempts, delayMs)

Confirm Actions

Show confirmation dialog before executing:

import type { ActionSchema } from '@object-ui/types';

const confirmAction: ActionSchema = {
  type: 'action',
  label: 'Delete Record',
  actionType: 'confirm',
  confirmText: 'Are you sure you want to delete this record?',
  api: '/api/records/123',
  method: 'DELETE'
};

Confirm Properties:

  • confirmText - Confirmation message shown in the dialog. This is the one confirm spelling — it is what the translation bundle addresses ({ns}.objects.{obj}._actions.{name}.confirmText), matching @objectstack/spec's action surface. A structured confirm object ({ title, message, ... }) was retired in objectui#4314: it had no translation key, so its dialogs could never be localized.

Dialog Actions

Open a modal or dialog:

import type { ActionSchema } from '@object-ui/types';

const dialogAction: ActionSchema = {
  type: 'action',
  label: 'Edit Details',
  actionType: 'dialog',
  dialog: {
    title: 'Edit Record',
    size: 'lg',
    content: {
      type: 'form',
      fields: [
        { name: 'name', type: 'input', label: 'Name' },
        { name: 'email', type: 'input', label: 'Email' }
      ]
    },
    actions: [
      {
        type: 'action',
        label: 'Save',
        actionType: 'ajax',
        api: '/api/records/123',
        method: 'PUT'
      }
    ]
  }
};

Action Params (input collection)

An action may declare params (spec ActionParamSchema) to collect user input in a dialog before it runs. Each param renders through the same field-widget renderer the object form uses, so a param of any form-supported field type — select, lookup, date, file, image, richtext, color, address, … — gets its real widget, not a text box (ADR-0059):

{
  "name": "approve",
  "label": "Approve",
  "params": [
    { "name": "comment", "type": "textarea", "label": "Comment", "required": true },
    { "name": "attachments", "type": "file", "multiple": true, "accept": ["application/pdf"] },
    { "name": "assignee", "field": "owner_id" },
    { "name": "notify", "type": "boolean", "label": "Notify the requester", "defaultValue": true }
  ]
}
  • Inline params declare name + type (any spec FieldType), plus widget config: options, multiple, accept, maxSize, placeholder, helpText, defaultValue.
  • Field-backed params declare field (+ optional objectOverride) and inherit label, type, options, lookup picker config, multiple, accept, and maxSize from the object's field definition; inline properties override. The same resolution runs when the action is named in a list's bulkActions and launched from the selection bar, so a field-backed lookup param gets the record picker there too. On the selection bar, the field's picker target, display field and dependsOn reach the picker; its other picker settings (lookupFilters, lookupColumns, …) do not yet.
  • required blocks submit while the value is empty; visible (a CEL predicate over features / current_user / data) hides a param entirely — e.g. gate a param on an opt-in server capability. There is no app root: objectui#8155 removed it, because neither ADR-0068 nor @objectstack/formula's SCOPE_ROOTS declares one.
  • Values are passed through to the action exactly as the widget emits them (number → number, date → YYYY-MM-DD, lookup → record id(s), file → the uploaded sys_file id(s); arrays when multiple). An upload whose adapter returns no file id is refused in the widget, so no inline file object is sent.

File/image params upload through the ambient UploadProvider; lookup/user params query through the surrounding SchemaRendererContext data source — no extra wiring per action. While a file/image upload is in flight the dialog's Confirm button is disabled (labelled "Uploading…"), so a param can't be submitted before its uploaded fileId is ready.

Action Chaining

Execute multiple actions in sequence or parallel:

import type { ActionSchema } from '@object-ui/types';

const chainedAction: ActionSchema = {
  type: 'action',
  label: 'Process Order',
  actionType: 'ajax',
  api: '/api/orders/process',
  method: 'POST',
  
  chain: [
    {
      type: 'action',
      label: 'Send Email',
      actionType: 'ajax',
      api: '/api/emails/send',
      method: 'POST'
    },
    {
      type: 'action',
      label: 'Update Inventory',
      actionType: 'ajax',
      api: '/api/inventory/update',
      method: 'PUT'
    },
    {
      type: 'action',
      label: 'Log Event',
      actionType: 'ajax',
      api: '/api/events/log',
      method: 'POST'
    }
  ],
  
  chainMode: 'sequential'  // or 'parallel'
};

Chain Modes:

  • sequential - Execute actions one after another (default)
  • parallel - Execute all actions simultaneously

Conditional Execution

condition is a gate, not a branch: the action executes only while the predicate holds.

import type { ActionSchema } from '@object-ui/types';

const gatedAction: ActionSchema = {
  type: 'action',
  label: 'Require Manager Approval',
  actionType: 'confirm',
  confirmText: 'Amount exceeds $1000. Manager approval needed.',

  // Offered only for amounts over 1000
  condition: '${data.amount > 1000}'
};

How the gate is written - four spellings, all read by the same evaluator:

SpellingExample
Booleancondition: false
Bare CEL predicatecondition: 'data.amount > 1000'
${...} templatecondition: '${data.amount > 1000}'
Normalized envelopecondition: { dialect: 'cel', source: 'data.amount > 1000' }

How the gate is read:

  • Declared and false - the action does not execute; the runner reports Action condition not met.
  • Declared and true - the action executes normally.
  • Not declared (key absent, or an empty predicate) - the action executes.

The envelope is what objectstack build emits for a compiled predicate, so authored metadata and built metadata both parse. This is the same predicate vocabulary visible and disabled carry.

Branching: one action per branch

There is no then / else - a branch is expressed as separate actions with mutually exclusive conditions. Each action is gated on its own, so exactly one of them runs:

import type { ActionSchema } from '@object-ui/types';

const approvalActions: ActionSchema[] = [
  {
    type: 'action',
    label: 'Require Manager Approval',
    actionType: 'confirm',
    confirmText: 'Amount exceeds $1000. Manager approval needed.',
    condition: '${data.amount > 1000}'
  },
  {
    type: 'action',
    label: 'Auto Approve',
    actionType: 'ajax',
    api: '/api/approve',
    method: 'POST',
    condition: '${data.amount <= 1000}'
  }
];

Sibling actions - a toolbar's actions, a table's rowActions / batchActions, or an event's action list - are each dispatched through their own gate, which is what makes this an either/or.

A chain step also carries its own condition, but a sequential chain stops at the first step whose condition does not hold (a blocked step reports Action condition not met, and sequential chaining stops on the first failure). So use a step condition to gate an optional follow-up, and sibling actions for an either/or.

Retired (objectui#3917): condition used to be documented here as { expression, then, else }. Nothing ever read expression, then or else. The runner has always read this key as the predicate above, and an object without a source reads as "no gate declared", so an action written that way ran unconditionally, with no error at author time and no diagnostic at runtime. The shape is now refused by ActionSchema's zod schema instead of being accepted and ignored.

Post-success behaviour

This legacy ActionSchema has no callback slot. The Phase-2 pair it used to declare — onSuccess / onFailure carrying an ActionCallback object ({ type: 'toast' | 'message' | 'redirect' | 'reload' | 'custom' | 'ajax' | 'dialog', message, url, api, … }) — was RETIRED (objectui#7068): nothing ever read it (the runner never consumed the shape), and @objectstack/spec's ActionSchema refuses it at publish. Both keys are never on the TypeScript face and named refusals on the Zod mirror, so an authored callback fails at the authoring site with the migration in the message.

What to write instead:

  • A notice — successMessage / errorMessage, plain strings (the runner surfaces successMessage as a toast after a successful action). An action whose handler can succeed in more than one way can also carry the spec's outcomeMessages (on UIActionSchema, for type: 'api' and type: 'script'): one message per snake_case outcome the handler returns in its answer. The success toast shows the outcomeMessages entry the answer's outcome names, else successMessage (objectui#11344). Both may read the handler's answer as result. An outcomeMessages entry writes it as ${result.*} — 'Environment ${result.name} archived' — the same scope onSuccess.navigate reads. successMessage writes it as a {{ }} hole — 'Environment {{ result.name }} updated' — and can also name the record the action ran against as {{ record.* }}; a ${…} token in it is shown as written (objectui#13476). A message in the server's answer is not shown unless the copy asks for it ({{ result.message }} in successMessage). Without either, the runner falls back to a generic "Action completed successfully". That fallback is text the runner writes itself, and so are the "Action failed" error toast for an error with no readable message, a parallel chain's "One or more parallel actions failed", and the label it hands your toast handler for an undoable success toast's Undo button (options.undo.label). Those are the strings it translates; an author's outcomeMessages / successMessage / errorMessage never go through the runner's translator. On a named action, @object-ui/react's useActionTextLocalizer resolves successMessage and each outcomeMessages entry (its bundle entry, else the inline locale map) before the action runs. <ActionProvider> and useActionRunner install the session's translator on their runner, and a runner you build yourself takes one through runner.setTranslator(t).
  • Post-success navigation — the spec's onSuccess block, { navigate, openIn }, declared on UIActionSchema and forwarded to the runner (objectui#5934). It is a spec key, not a member of this legacy type, so it is not shown in a fence here.
  • Follow-up work — chain (see Action Chaining): declared actions, not callbacks.

Action Tracking

Track actions for analytics:

import type { ActionSchema } from '@object-ui/types';

const trackedAction: ActionSchema = {
  type: 'action',
  label: 'Download Report',
  actionType: 'ajax',
  api: '/api/reports/download',
  
  tracking: {
    enabled: true,
    event: 'report_downloaded',
    metadata: {
      reportType: 'sales',
      format: 'pdf',
      dateRange: '2024-01'
    }
  }
};

Retry Logic

Automatically retry failed requests:

import type { ActionSchema } from '@object-ui/types';

const retryAction: ActionSchema = {
  type: 'action',
  label: 'Submit',
  actionType: 'ajax',
  api: '/api/submit',
  method: 'POST',
  
  timeoutMs: 30000,  // 30 seconds
  
  retry: {
    maxAttempts: 3,
    delayMs: 1000  // 1 second between retries
  }
};

Complete Example

A comprehensive action combining multiple features:

import type { ActionSchema } from '@object-ui/types';

const complexAction: ActionSchema = {
  type: 'action',
  label: 'Process Order',
  icon: 'shopping-cart',
  variant: 'default',
  
  // Confirm before processing
  actionType: 'confirm',
  confirmText: 'Process this order for {{ record.customer_name }}?',
  
  // Execution gate - this action is the premium path; the standard path is a
  // sibling action carrying the opposite predicate
  condition: '${data.totalAmount > 1000}',
  
  // Action chain
  chain: [
    {
      type: 'action',
      label: 'Send Confirmation Email',
      actionType: 'ajax',
      api: '/api/emails/order-confirmation',
      method: 'POST',
      data: {
        orderId: '${data.id}',
        customerEmail: '${data.customerEmail}'
      }
    },
    {
      type: 'action',
      label: 'Update Inventory',
      actionType: 'ajax',
      api: '/api/inventory/update',
      method: 'PUT',
      data: {
        items: '${data.items}'
      }
    },
    {
      type: 'action',
      label: 'Create Invoice',
      actionType: 'ajax',
      api: '/api/invoices/create',
      method: 'POST'
    }
  ],
  chainMode: 'sequential',
  
  // Notices
  successMessage: 'Order processed successfully!',
  errorMessage: 'Unable to process order. Please try again.',
  
  // Tracking
  tracking: {
    enabled: true,
    event: 'order_processed',
    metadata: {
      source: 'web_app',
      amount: '${data.totalAmount}'
    }
  },
  
  // Retry
  timeoutMs: 60000,
  retry: {
    maxAttempts: 3,
    delayMs: 2000
  },
  
  // Post-action behavior
  reload: true,
  close: true,
  redirect: '/orders/success'
};

Runtime Validation

import { ActionSchema } from '@object-ui/types/zod';

const myAction = {
  type: 'action',
  label: 'Delete Record',
  actionType: 'confirm',
  confirmText: 'Are you sure?',
};

const result = ActionSchema.safeParse(myAction);

if (result.success) {
  console.log('Valid action configuration');
} else {
  console.error('Validation errors:', result.error);
}

Use Cases

Enhanced Actions are ideal for:

  • API integration - Connect to backend services and external APIs
  • Multi-step processes - Execute complex workflows with multiple stages
  • Form submissions - Handle form data with validation and chained follow-up actions
  • Confirmation dialogs - Add safety checks for critical operations
  • Event tracking - Monitor user interactions for analytics
  • Batch operations - Process multiple items in sequence or parallel

Best Practices

  1. Use confirm for destructive actions - Always confirm delete, archive, etc.
  2. Provide clear feedback - Set successMessage / errorMessage so users learn what happened
  3. Chain related operations - Group logically related API calls
  4. Track important events - Enable tracking for business-critical actions
  5. Set appropriate timeouts - Don't let users wait indefinitely
  6. Retry transient failures - Use retry for network-related errors
  7. Keep chains short - Long chains can be hard to debug

On this page