Dyrected
Model ContentOperational Views

Actions

Verbs for operational views — row, bulk, and header actions with confirm, input forms, and Cloud-safe mutations.

Views answer "what do I see." Actions answer "what can I do without opening the form." A defineAction is a single verb — Check In, Mark Paid, Assign Table, Send Reminders — that can surface as a row button, a card button, a bulk-bar button, or a view-header button.

By the end you'll know the 8 keys on ActionConfig, how mutation vs handler decides Cloud-safe vs self-hosted, and how to wire type, confirm, and fields so the button lands in the right place.

Visual outcome

All mutations run through the collection's beforeChange and afterChange hook pipeline, ensuring access checks and validation are never bypassed.

Action examples

1. Cloud-safe row action (declarative mutation)

Define a row action using a JSON mutation object:

import { defineAction } from "@dyrected/core";

export const checkInAction = defineAction({
  name: "checkIn",
  label: "Check In",
  icon: "UserCheck",
  type: "row",
  confirm: "Confirm guest check-in at the door?",
  mutation: { checkedIn: true, checkedInAt: "now()" },
});

The "now()" expression is evaluated on the server to an ISO timestamp. Declarative mutations are serializable, making them fully compatible with Dyrected Cloud and syncable across environments.

2. Row action with an input dialog

If an action requires user input (like selecting a seat, entering notes, or recording a payment), declare fields to automatically generate an input modal.

When triggered on a row, the dialog automatically prefills with the document's current values, allowing editors to inspect and tweak existing data rather than typing from scratch:

import { defineAction, defineNumberField, defineTextField } from "@dyrected/core";

export const assignTableAction = defineAction({
  name: "assignTable",
  label: "Assign Table",
  submitLabel: "Save Seating", // Customizes modal button (defaults to "Run")
  icon: "Table2",
  type: "row",
  fields: [
    defineNumberField({ name: "tableNumber", label: "Table Number", required: true }),
    defineTextField({ name: "seatingNotes", label: "Seating Notes" }),
  ],
  mutation: { tableNumber: "input.tableNumber", seatingNotes: "input.seatingNotes" },
});

Expressions like "input.tableNumber" extract values submitted through the dialog.

3. Dynamic defaults and live modal reactivity

Action modal fields support the exact same reactivity engine and hooks as document edit forms:

  • Dynamic defaultValue (Initial Prefill): Compute initial field values using targeted record data (doc), bulk records (docs), or the authenticated editor (user).
  • Live admin.hooks.onChange: Automatically recalculate calculated totals, summary strings, or reference codes as the editor types inside the modal.
  • Dynamic admin.hooks.options: Cascading dropdown choices that update live as sibling inputs change.
  • Conditional Visibility (admin.condition): Dynamically show or hide modal inputs based on sibling choices or targeted row properties.

Here is a complete example of a payment recording action modal with dynamic prefilling, live total calculation, and conditional cheque inputs:

import {
  defineAction,
  defineNumberField,
  defineSelectField,
  defineTextField,
  expr,
} from "@dyrected/core";

export const recordPaymentAction = defineAction({
  name: "recordPayment",
  label: "Record Payment",
  submitLabel: "Process Payment",
  icon: "CreditCard",
  type: "row",
  fields: [
    defineSelectField({
      name: "paymentMethod",
      label: "Payment Method",
      required: true,
      defaultValue: "transfer",
      options: [
        { label: "Bank Transfer", value: "transfer" },
        { label: "Cash", value: "cash" },
        { label: "Cheque", value: "cheque" },
      ],
    }),
    defineTextField({
      name: "chequeNumber",
      label: "Cheque Number",
      admin: {
        // Shows only when 'cheque' is selected in the modal
        condition: expr.sibling("paymentMethod").equals("cheque"),
      },
    }),
    defineNumberField({
      name: "amount",
      label: "Amount (₦)",
      required: true,
      // 1. Initial prefill: Remaining balance from the targeted record
      defaultValue: ({ doc }) => (doc?.totalDue || 0) - (doc?.amountPaid || 0),
    }),
    defineNumberField({
      name: "balanceAfter",
      label: "Remaining Balance After Payment (₦)",
      admin: {
        readOnly: true,
        // 2. Live reactivity: Updates continuously as the editor edits 'amount'
        hooks: {
          onChange: ({ siblingData, doc }) => {
            const remaining = (doc?.totalDue || 0) - (doc?.amountPaid || 0);
            return Math.max(0, remaining - (Number(siblingData.amount) || 0));
          },
        },
      },
    }),
  ],
  mutation: {
    amountPaid: "doc.amountPaid + input.amount",
    paymentMethod: "input.paymentMethod",
    chequeNumber: "input.chequeNumber",
    lastPaymentAt: "now()",
  },
});

4. Bulk and header actions

Define actions that operate across multiple selected documents or view-wide:

import { defineAction } from "@dyrected/core";

export const markSelectedPaidAction = defineAction({
  name: "markSelectedPaid",
  label: "Mark Paid",
  submitLabel: "Confirm Payment",
  type: "bulk",
  mutation: { asoebiStatus: "paid", asoebiPaidAt: "now()" },
});

export const sendReminderAction = defineAction({
  name: "sendReminder",
  label: "Send Reminders",
  submitLabel: "Send Message",
  icon: "BellRing",
  type: "header",
  confirm: "Send a payment reminder to all guests with pending requests?",
  mutation: { remindedAt: "now()" },
});

Collection actions without a view

Actions don't have to belong to a view. Add an actions array directly on the collection for verbs that work anywhere one document is in scope — most notably the read-only Detail View, where they render in the header toolbar (type: "header") or inline via displayAction(name) in the detail schema:

import { defineAction, defineCollection, displayAction, displaySection } from "@dyrected/core";

const markShippedAction = defineAction({
  name: "markShipped",
  label: "Mark Shipped",
  type: "header",
  mutation: { status: "shipped" },
});

export const Orders = defineCollection({
  slug: "orders",
  fields: [/* ... */],
  actions: [markShippedAction],
  detail: [
    displaySection("Fulfillment", [displayAction("markShipped", { span: 4 })]),
  ],
});

Collection actions run through a view-less route (POST /api/collections/:slug/actions/:action), and the SDK call drops the view segment:

await client.collection("orders").runCollectionAction("markShipped", { id: order.id });

The same action definition can also be referenced from a defineView's own actions — the two are not mutually exclusive.

4. Custom TypeScript handler

In self-hosted projects where Dyrected runs inside your Node.js server, you can supply an async handler function to run arbitrary server logic or communicate with external APIs (like Stripe or Slack):

import { defineAction } from "@dyrected/core";

export const chargeGuestAction = defineAction({
  name: "chargeGuest",
  label: "Charge Outfit",
  submitLabel: "Process Charge",
  icon: "CreditCard",
  type: "row",
  handler: async ({ doc, input }) => {
    // Run custom server code or call third-party payment APIs
    return {
      asoebiStatus: "paid",
      paidAt: new Date().toISOString(),
    };
  },
});

The data returned from handler automatically merges into the document through the collection's validation and hook pipeline.

View-wide header action handlers

Header actions (type: "header") run at the view or collection level rather than against a single row. Because there is no single target row, the handler receives doc: null and docs: []:

import { defineAction } from "@dyrected/core";

export const syncReportsAction = defineAction({
  name: "syncReports",
  label: "Sync Reports",
  type: "header",
  confirm: "Recompute and synchronize all daily reports?",
  handler: async ({ input, user, collection }) => {
    // doc is null because header actions are view-wide operations
    const result = await syncExternalReports({ triggeredBy: user?.email });
    return { recomputed: result.count };
  },
});

Header action handlers can accept user input through fields, enforce permissions with access, and return custom payloads to the caller (or return void to default to { success: true }). If the handler throws an error, the admin toast displays your error message directly.

Throwing friendly errors from a handler

When a handler cannot complete, throw an error with a message written for the editor who will read it. @dyrected/core provides classes that carry the right HTTP status, so you never hand-roll status codes:

import { defineAction, defineTextField, ValidationError } from "@dyrected/core";

export const refundOrderAction = defineAction({
  name: "refund",
  label: "Refund Order",
  type: "row",
  fields: [defineTextField({ name: "refundReason", label: "Refund reason" })],
  handler: async ({ doc, input }) => {
    if (!input.refundReason) {
      throw new ValidationError("Enter a refund reason so support can follow up.");
    }
    return { status: "refunded" };
  },
});

Available classes: ValidationError (400) for input the editor can fix, ForbiddenError (403) for permission, NotFoundError (404) for missing records, ConflictError (409) for state conflicts, and the DyrectedError(message, statusCode) base class for anything else. A plain Error without a status still answers 500, so existing handlers keep working.

The server answers with your status and message. The SDK surfaces that message on the thrown error, and the admin shows "<Action label> failed" with your message underneath, so editors see guidance instead of a generic failure. Bulk runs evaluate each document separately, so a failure on one row is reported against that row without hiding the rest.

Configuration options

OptionTypeDescription
namestring requiredUnique identifier used for ordering, logging, and SDK triggers.
labelstring requiredButton text displayed to editors.
submitLabelstringCustom label for the modal confirmation / submission button (defaults to "Run" for form dialogs, "Confirm" for confirmation modals).
iconstringLucide icon name (e.g. "UserCheck", "Table2").
type"row" | "bulk" | "header"Where the button appears. Defaults to "row".
confirmstringPrompt text that triggers a confirmation modal before execution.
fieldsField[]Input fields rendered in a modal before the action runs. For row actions, fields automatically prefill from the targeted document.
mutationRecord<string, unknown>Declarative mutation object with expressions ("now()", "input.fieldName", "doc.fieldName").
accessAccessConfigPermissions controlling who can see or run the action.
handler(ctx) => Promise<Partial<Doc>>Custom async TypeScript handler for server-side workflows and third-party API integration.

Button placement and ordering

  • Row actions (type: "row"): Surface on table rows, kanban cards, calendar detail sheets, and gallery card tiles.
  • Bulk actions (type: "bulk"): Appear automatically in the floating bulk action bar when one or more items are selected.
  • Header actions (type: "header"): Positioned at the top of the view alongside search and filter controls.

To customize the order and visibility of built-in vs custom buttons on a view:

defineView({
  slug: "attending-guests",
  label: "Attending Guests",
  actions: [checkInAction],
  features: { delete: false }, // Hides the built-in delete button
  actionOrder: ["checkIn", "view", "edit"], // Places custom Check In first
});

Self-hosted runtime ownership

Because self-hosted projects run directly inside your application backend, handler functions let you orchestrate complex multi-step transactions, call email gateways, or trigger background job queues while still routing the update through Dyrected's security and hook boundaries.

Next steps

On this page

Dyrected| Cloud

Get your backend ready in minutes

Use a managed database, storage, APIs, and admin dashboard without setting up the infrastructure yourself.

Set Up My Backend