Skip to content

Datagrid bulk errors

datagrid-bulk-actions runs one action over many rows. This recipe is what happens when some of them fail — and it starts by making the execution semantics explicit, because “3 archived, 1 failed” is the wrong sentence for an operation that rolled everything back. Covered by the versioning policy.

Archive a selection containing 102 or 105: the chrome keeps one line — the count, the moves, the failed-only filter — and the grouped breakdown waits behind the Reasons (N) rail on the panel docked beside the grid, which you can drag or resize from the keyboard. Both stay live, which is the point: click a reason and the rows behind it are still there.

Two actions over one selection. 102 / 105 / 108 are “shipped” and 107 is “not yours” — pick one of those together with a normal row:

  • Archive (best-effort) runs, then reports what happened: the failed rows come back marked, the report groups them by reason, and the retry is a filter link.
  • Post to ledger (atomic) pre-flights first and offers to exclude the blockers; force it through with a blocked row and the server refuses — rows unchanged, your selection still there.
ProductStatus
Product 101Active
Product 102Active
Product 103Active
Product 104Active
Product 105Active
Product 106Active
Product 107Active
Product 108Active
best-effortatomic
Fitsindependent items (archive, tag, notify)invariants (postings, transfers, permissions)
Failure response200 + rows reflecting what happened409 / 422 + rows unchanged
Failed rowsmarked data-attention="error" + reasonstatuses unchanged, blocked rows still marked
Copy“113 succeeded / 87 failed”Nothing was executed (2 rows do not qualify)”
Selectionthe retry set stays selected — retryable failures come back checkedpreserved — re-render checkboxes checked
Recoverypress the action again (it applies to the failures alone)fix, or exclude the blockers and re-run

Selection preservation is mandatory in the atomic branch. The base recipe’s “the selection clears by construction” holds only when the action ran; a refusal that also wipes 200 hand-picked rows is data loss the user cannot undo. The checkboxes are the selection truth, so rendering them checked is the entire fix.

Mark the blocked rows in the atomic branch too. The rule used to be “never mark — nothing changed, so marking would lie”. What would lie is a claim that the row failed; the mark says something else: this row cannot proceed. That is equally true in the pre-flight, in the 409 refusal and after a best-effort failure, because it is a fact about the row, not about the attempt — and it does not go stale when the selection changes (“already shipped” stays true either way). Without it, the report’s row links land on a row that looks like every other row.

The pre-flight answers a report, not rows, so it carries the marks as out-of-band row updates wrapped in <template>. What must still never happen in the atomic branch is a status change: nothing ran, so nothing is Posted.

data-attention takes its severity from what the row requires, not from when it was discovered — otherwise the same unchanged row is warning before the button and error after it.

SeverityMeansExamples
errorsomething must change before this can proceedrequired value missing, invalid input, wrong state, not permitted
warningsomeone must decide; the value itself is finea future ship date, a discount above policy — the confirmable-warning branch

A required-field check is therefore error wherever it surfaces, and so is a pre-flight blocker: it is not “not yet an error”, it is a row that cannot proceed.

A partial failure must leave the retry set selected. Re-rendering every row unchecked hides the actions bar (it hides at zero) and makes the user hand-pick the failures out of a full grid — the very rows the server already identified. Render retryable failures checked and the retry is one press of the same button.

Retryable is the server’s judgement:

FailureChecked after?Why
transient (lock held, upstream timeout, rate limit)yesthe same request can succeed
succeedednonothing left to do
permanent (wrong state, not permitted, invalid data)nore-submitting reproduces the error

Say so in the report when the two differ (“3 can be retried; 2 need a change first”) — otherwise a partially-checked grid reads as a bug.

Ticking rows stops working before the data does. When 4,873 rows match, the operation the user wants is “archive all of them” — and 4,873 ids fit in neither a querystring nor a form post, let alone through a proxy.

So the action may be expressed as the query itself:

<form method="post" action="/products/bulk">
<input type="hidden" name="f-status" value="open">
<input type="hidden" name="f-ship-from" value="@week-start">
<input type="hidden" name="scope" value="matching">
<input type="hidden" name="count-token" value="ct_9f2c1a">
<button class="hc-button" type="submit" name="action" value="archive">
Archive all 4,873 matching
</button>
</form>

scope=matching says act on the query, not on ids. The two are mutually exclusive — a request carrying both is a client bug worth a 400.

“Archive all matching” is not safe to press blind, so the button says the number and the server re-counts before executing:

CaseResponse
count matches the tokenexecute; report as usual
count has changed409 + the old and new counts, and a button carrying a fresh token
token missing or unknown409 — re-count and re-confirm

The token is what makes this honest. Without it, “archive all 4,873” executes against however many rows exist at execution time — a different operation from the one the user agreed to. Someone else’s edit, or a relative date rolling over at midnight, is enough to move it.

Two limits worth stating: a query-scoped action is re-authorised like any other (the conditions may include something the user may no longer read), and past some size it belongs in an async job with progress rather than a synchronous POST.

Atomic actions validate before they execute — the confirm step becomes an executability report:

18 of 20 rows are executable; 2 are blocked (reason: period closed)
[ Exclude 2 and run 18 ] [ Cancel ]

GET /products/bulk/preflight?ids=…&action=… answers that fragment. “Exclude and run” submits only the executable ids as server-rendered hidden inputs, so the atomic guarantee survives while the user still has a way forward. When nothing is executable the report says so and renders no submit — a dead end must be visible, not a disabled mystery.

On the full-height list page the chrome is fixed and the grid takes what is left. A report region whose height grows with the number of reasons therefore squeezes the grid to nothing — on the exact screen whose rows it is telling the user to go and fix.

The chrome is O(1). Anything whose height grows with the data lives in the scrolling area, or in an overlay — never in the chrome.

Two surfaces, chosen by one question: is there work in the grid?

SemanticsSurface
best-effort — some rows changeda one-line summary in the chrome, the failing rows carrying data-attention="error" and their own message, and Show only failed (N)
the grouped breakdowna docked panel (hc-splitter) beside the grid — a side panel spends horizontal space, which this layout has
atomic — nothing was applieda modal dialog: there is no work in the grid, and the user owes a decision. Blocking is the message

Collapsed is the default, and collapsed still shows — a rail with the count (Reasons (5)) and the way back in. An always-open panel taxes every screen every day for a rare event; a panel that vanishes when closed is a dead end. And the response does not open it: the summary already said what happened, and giving away the grid’s width is the reader’s decision. A screen whose job is triage may start open — that is a property of the screen, not of the report. Whether it is open is workspace state: remember it per user, not in the URL.

<div id="bulk-report" aria-live="polite">
<div class="hc-alert" data-variant="warning" role="status">
<p class="hc-alert__body">
<strong>12 of 40 rows could not be updated.</strong>
<a href="/orders?f-last-result=failed">Show only failed (12)</a> ·
<a href="/orders/bulk/report">Review reasons</a>
</p>
</div>
</div>

Show only failed is the important affordance: it turns the grid into the report, composing with the conditions bar and saved views, after which retrying is the ordinary select-all → action loop. At two hundred failures it is the only shape that works. (In the live demo above the link is illustrative — a real app points it at the grid URL with the failure filter applied.)

Twelve failures scattered through five thousand rows is a queue, so the summary carries the moves — and stays O(1) doing it:

<p class="hc-alert__body">
<strong>12 of 40 rows could not be updated.</strong>
<a href="#row-4903">Previous</a>
<span>Error 3 of 12 — row 137</span>
<a href="#row-5012">Next</a> ·
<a href="/orders?f-last-result=failed">Show only failed (12)</a>
</p>

Real fragment links, so Back works and the keyboard works; installDatagrid() lands the active cell on the row a fragment names. The counter and both hrefs are server-rendered from the same failure list the report shows, so the line and the panel cannot drift.

Name rows by id and show the ordinal next to it — row 137 is how a failure gets discussed, but ordinals move when the sort or the conditions change and ids do not. A Go to row control (?goto=137) covers the number somebody read out loud; the server resolves it to the page that contains it, because only the server knows where row 137 currently is.

One aria-live="polite" region above the grid, swapped out of band with the rows — <template>-wrapped like the pre-flight’s row updates, because a <div> riding a tbody-targeted swap is otherwise foster-parented out of the table by the parser. It reuses the csv-import validation report shape and follows three rules:

  1. Group by reason, not by row — at 87 failures the reason is the actionable unit; a flat list is not reviewable.
  2. Cap the inline detail (10 named rows per reason is a good default) and pair the cap with a full-list link (a page or a CSV — an ordinary <a href>). An uncapped report of 10 000 failures is a broken page.
  3. Link every named row: #row-101 on this page, /products?focus=101#row-101 for a row on another page — failures span pages, and the server knows which page holds the row.

Clicking a row link scrolls it into view, marks it with :target, and moves the grid’s active cell so keyboard users arrive too. The row links back with <a href="#bulk-report">Details</a>.

For best-effort, the report also offers “Filter to the failed rows” — a plain filter URL (?f-last-result=failed) composing with datagrid-filter / saved-views. Retrying is then the ordinary select-all → action loop, with no new client state.

A failed row’s reason is rendered once as an hc-tooltip inside the relevant cell, which points at it with aria-describedby. The grid suppresses its overflow tooltip on such cells, so one gesture carries one meaning. Tooltips are for inspecting a row you noticed — never put a reason only in a tooltip; the report is the reviewable surface, and touch, print and export have no hover.

The toast is the headline, not the payload: counts plus a pointer to the report, and it must not auto-dismiss while failures exist — send duration: 0 in the payload (warning for best-effort, error for a refusal).

The base recipe’s 303 post/redirect/get applies — render the same report at the top of the redirected page (the fragment links work natively, which is exactly why they are anchors), and the pre-flight becomes an ordinary intermediate page.

  • The report region announces politely; it never steals focus.
  • Row links move focus into the grid via the active cell — no silent scroll-only jumps.
  • Failure is never colour alone: tone plus reason text plus the report entry.
  • The pre-flight’s dead-end case renders reasons instead of a disabled button with no explanation.