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.
Live demo
Section titled “Live demo”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.
Choose the semantics first
Section titled “Choose the semantics first”| best-effort | atomic | |
|---|---|---|
| Fits | independent items (archive, tag, notify) | invariants (postings, transfers, permissions) |
| Failure response | 200 + rows reflecting what happened | 409 / 422 + rows unchanged |
| Failed rows | marked data-attention="error" + reason | statuses unchanged, blocked rows still marked |
| Copy | “113 succeeded / 87 failed” | “Nothing was executed (2 rows do not qualify)” |
| Selection | the retry set stays selected — retryable failures come back checked | preserved — re-render checkboxes checked |
| Recovery | press 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.
Severity: what does the row need?
Section titled “Severity: what does the row need?”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.
| Severity | Means | Examples |
|---|---|---|
error | something must change before this can proceed | required value missing, invalid input, wrong state, not permitted |
warning | someone must decide; the value itself is fine | a 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:
| Failure | Checked after? | Why |
|---|---|---|
| transient (lock held, upstream timeout, rate limit) | yes | the same request can succeed |
| succeeded | no | nothing left to do |
| permanent (wrong state, not permitted, invalid data) | no | re-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.
Acting on everything that matches
Section titled “Acting on everything that matches”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.
The count is part of the confirmation
Section titled “The count is part of the confirmation”“Archive all matching” is not safe to press blind, so the button says the number and the server re-counts before executing:
| Case | Response |
|---|---|
| count matches the token | execute; report as usual |
| count has changed | 409 + the old and new counts, and a button carrying a fresh token |
| token missing or unknown | 409 — 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.
Pre-flight beats reporting
Section titled “Pre-flight beats reporting”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.
Where the report goes
Section titled “Where the report goes”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?
| Semantics | Surface |
|---|---|
| best-effort — some rows changed | a one-line summary in the chrome, the failing rows carrying data-attention="error" and their own message, and Show only failed (N) |
| the grouped breakdown | a docked panel (hc-splitter) beside the grid — a side panel spends horizontal space, which this layout has |
| atomic — nothing was applied | a 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.)
The summary is also the navigator
Section titled “The summary is also the navigator”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.
The report
Section titled “The report”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:
- Group by reason, not by row — at 87 failures the reason is the actionable unit; a flat list is not reviewable.
- 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. - Link every named row:
#row-101on this page,/products?focus=101#row-101for 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.
Per-row detail
Section titled “Per-row detail”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).
Progressive enhancement
Section titled “Progressive enhancement”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.
Accessibility
Section titled “Accessibility”- 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.
Related
Section titled “Related”- datagrid-bulk-actions — the form shape this builds on.
- datagrid-edit-errors · datagrid-edit-conflict — the same philosophy for a single cell.
- csv-import — the validation-report shape the report generalizes.
- undo-delete — for reversible destructive work, a grace period beats a confirm dialog.