busy-office-ui
Menu

Pattern: bulk actions

Who uses it: an AP clerk clearing a queue — select many, act once. What "done" looks like: every selected record either changed or explained why it didn't. The failure mode this pattern exists to prevent is the silent one: an action that reports success while quietly skipping rows. Not for changing one record — a single row's fields belong on the object page or record-detail; this pattern's whole cost (out-of-band summary, per-row reasons, the transient-state rule) only pays for itself once more than one record is changing at once.

The state after a partial failure

Invoice #Vendor AmountStatus
INV-10234 Acme Supply Co. $4,208.00 Approved
INV-10235 Globex Industrial $56,000.00 Pending Needs a second approver over $20,000.00
INV-10236 Initech GmbH $730.25 Approved
INV-10237 Umbrella Logistics $2,110.00 Rejected Already rejected

Zero new components: the summary is an alert, the failed rows use data-row-state="error", and each reason is a badge — in words, beside the status, so the tint is never the only signal. A clerk can see which two rows need them and why without opening anything.

Anatomy

  1. Selection (not in the demo above — see the list report for the live checkboxes) — row checkboxes carrying name/value, so the selection POSTs natively; initDataTables() keeps the count O(1) at scale.
  2. Toolbar action (same — see the list report) — bulk buttons appear only when something is selected (toolbar).
  3. Summary region — an empty container above the table that the response fills out of band.
  4. Per-row result — the error state plus the reason, rendered by the same row template as everything else.

Data contract

RequestReturns
POST /invoices/bulk-approve
(ids as repeated form fields)
the affected <tbody> plus an out-of-band summary block — one request, both truths
…all succeededsame shape, a success alert; do not special-case the happy path into a different mechanism
…nothing selectedthe button should not have been reachable; if it was, return the list unchanged and say so
…some rows fail (partial or total)still 200, not a 4xx — per-row failure is data (the States table's Partial/All failed rows), never a transport-level error; a genuine 5xx or timeout is the one case that IS a failure response, and gets the plain retry-alert treatment, same as any other request on this screen
<!-- The response to a bulk POST does TWO things: swap the rows, and
     deliver the summary out of band. One request, both truths. -->
<tbody id="rows">
  <tr id="row-INV-10235" data-row-state="error">
    …cells…
    <td>
      <span class="bo-badge bo-badge--warning">Pending</span>
      <!-- the reason in WORDS — the row tint is never the only signal -->
      <span class="bo-badge bo-badge--danger">Needs a second approver over $20,000.00</span>
    </td>
  </tr>
</tbody>

<div id="bulk-result" hx-swap-oob="innerHTML">
  <div class="bo-alert bo-alert--warning" role="alert">
    <p><strong>18 approved, 2 could not be.</strong> The rows below carry the reason.</p>
  </div>
</div>

Selection is client state: a swap that replaces rows clears it, which is the honest behaviour after a bulk action — the user's next decision should start from the new truth.

The rule that makes it honest

// Partial-failure state is TRANSIENT. Clear last round's reasons before
// computing this one, or a row keeps advertising an error that is no
// longer true — the list starts lying about the record.
records.forEach((r) => { delete r.bulkError; });

const failed = [];
let ok = 0;
for (const r of selected) {
  if (r.status !== "Pending")   { failed.push([r, "Already " + r.status.toLowerCase()]); continue; }
  if (r.amount > LIMIT)         { failed.push([r, "Needs a second approver"]); continue; }
  r.status = "Approved"; ok += 1;
}
for (const [r, reason] of failed) r.bulkError = reason;   // rendered per row

Found by building this against the reference app: without the clear, a row kept showing "needs a second approver" after a colleague had approved it. A stale failure reason is worse than none — it sends someone to fix a problem that no longer exists.

Mass change — the honest answer to "update 200 records"

Select N rows, set one field, in one validated operation. This is the request that usually arrives as "we need editable cells", and it is worth pushing back on: 200 hand-edits is 200 chances to fumble a value, and it leaves no record of what was intended. One operation is simpler and safer, and it produces a single audit entry.

<!-- ONE form, ONE selection, two endpoints. formaction re-points the same
     checked ids at the mass-change handler, so there is no second selection
     to keep in sync. The safe action stays the form's submit button, so the
     keyboard path (Enter from any row) still cannot reach a destructive one. -->
<form id="po-bulk" hx-post="/pos/bulk-approve" hx-target="#rows" hx-swap="outerHTML">
  <div class="bo-data-table__bulk-actions" role="group" aria-label="Bulk actions">
    <button class="bo-btn" type="submit">Approve selected</button>
    <button class="bo-btn bo-btn--secondary" type="button"
            data-dialog-trigger="mass-cc">Change cost centre…</button>
  </div>
  …table with name="id" row checkboxes…
</form>

<!-- The dialog's field JOINS the form above via form="po-bulk"; its submit
     button re-targets with formaction. -->
<input class="bo-input bo-input--code" name="cc" form="po-bulk" placeholder="CC-4021">
<button class="bo-btn" type="submit" form="po-bulk"
        formaction="/pos/mass-change">Change selected</button>

Same contract as the action above — rows plus an out-of-band summary, per-row reasons in words, the TRANSIENT-state rule. No new component and no second selection: formaction re-points the same checked ids at a different endpoint, and the dialog's field joins the table's form with form="po-bulk".

Validate the operation before you touch a row

  • A bad target value is not a per-row failure. "CC-nonsense" is the whole request being wrong, so it returns 422 with a document-level message and changes nothing — not 200 identical row errors the user has to read past.
  • Per-row rules still apply per row. A purchase order that is already approved needs a reversal, not a re-cost, so it is reported in its own row with that reason while the others go through.
  • Say the count and the value. "1 moved to CC-9002, 1 could not be" — a bare "done" hides which half happened.
  • No-op rows are failures worth naming. A row already on the target cost centre is reported ("Already on CC-9002") rather than counted as changed, because a count that includes no-ops is a lie about what the operation did.

This is M3 of the four data maintenance patterns in DESIGN.md; the working implementation is in examples/po-app.

States

StateWhat ships
All succeededa success alert with the count; rows re-render in their new status
Partial failurecounts BOTH ways in one sentence, and every failed row carries its own reason
All failedsame shape — never a bare "action failed" with no per-row detail
Nothing selectedbulk actions are hidden until a selection exists, so this should be unreachable
No permissionthe bulk buttons are absent, not disabled (permissions)
Stale selectiona record decided by someone else since the page loaded fails with "already approved" — that is the partial-failure path doing its job, not an error
Long-runningpast a few seconds, return a job id and poll; a frozen button is not a progress indicator
No JSfully functional — this page has no JS dependency; the rows, checkboxes and bulk buttons in the real screen are plain form controls that POST natively (see the Markup section's hx-post sample — the underlying <form> submits without htmx too). What is lost without JS is the out-of-band summary swap and htmx's per-id focus restoration described in the keyboard walkthrough; a plain form submit reloads the page with the same result instead of swapping in place

Keyboard walkthrough

  1. Tab to the select-all checkbox, Space to take the page; the live region announces the count.
  2. Tab through rows, Space to deselect the ones you are not ready for.
  3. Back to the toolbar, where the first selection has revealed the bulk Enter, from wherever you are in the list — the rows and the bulk buttons are one <form>, so native implicit submission runs the action without any journey back to the toolbar. Only the safe action is type="submit"; a destructive bulk action stays type="button" and needs a deliberate click.
  4. Focus stays on the checkbox you pressed — but only because each one carries an id. The swap replaces those very rows, and htmx restores focus solely to a matching id; without it the clerk is dropped on <body> at the top of the document.
  5. The summary is role="alert", so the outcome is announced without moving focus; the failed rows are still in document order below it.

Components used

complexity 3 of 4

↑↓ move Enter open Esc close