データグリッドの一括操作エラー
datagrid-bulk-actions は 1 つのアクションを多数の行に対して実行します。このレシピはその 一部が失敗したときの話で、まず実行セマンティクスを明示することから 始まります。すべてロールバックした操作に「3 件成功、1 件失敗」と書くのは 間違った文だからです。 バージョニングポリシーの 対象です。
102 や 105 を含む選択を Archive してください。クロームは1 行のまま (件数・移動・失敗行だけ表示)で、理由別の内訳はグリッドの横に ドッキングしたパネルの Reasons (N) レールの奥で待ちます。パネルは ドラッグでもキーボードでもリサイズできます。両方が生きたままなのが 要点で、理由をクリックしても背後の行はそこにあります。
1 つの選択に対して 2 つのアクションがあります。102 / 105 / 108 は 「出荷済み」、107 は「権限なし」です。これらのどれかを通常の行と 一緒に選んでください:
- Archive(best-effort)は実行してから結果を報告します。失敗行は マーク付きで戻り、レポートが理由別に集約し、再試行は絞り込みリンク になります。
- Post to ledger(atomic)はまずプリフライトし、阻害行を除いて 実行する選択肢を出します。阻害行を含めたまま実行すると、サーバーは 拒否します — 行は変わらず、選択も残ったままです。
まずセマンティクスを選ぶ
Section titled “まずセマンティクスを選ぶ”| best-effort | atomic | |
|---|---|---|
| 適する操作 | 独立した項目(アーカイブ、タグ付け、通知) | 不変条件のある操作(仕訳、振替、権限) |
| 失敗時の応答 | 200 + 実際に起きたことを反映した行 | 409 / 422 + 行は変更なし |
| 失敗行 | data-attention="error" と理由でマーク | ステータスは変えないが、阻害行にはマークを付ける |
| 文言 | 「113 succeeded / 87 failed」 | 「Nothing was executed(2 件が条件を満たさない)」 |
| 選択状態 | 再試行対象は選択されたまま — 再試行可能な失敗を checked で返す | 保持する — チェックボックスを checked で再描画 |
| リカバリー | もう一度アクションを押す(失敗行だけに適用される) | 原因を直す、または阻害行を除いて再実行 |
**atomic ブランチでの選択保持は必須です。**ベースレシピの「選択は
構造的にクリアされる」が成り立つのはアクションが実行された場合だけ
です。拒否のついでに手作業で選んだ 200 行を消すのは、ユーザーが取り消せ
ないデータ損失です。選択の真実はチェックボックスなので、checked で
描画し返すだけで直ります。
**atomic ブランチでも阻害行にはマークを付けます。**以前のルールは
「何も変わっていないのでマークは嘘になる — atomic では一切マークしない」
でした。嘘になるのは「この行は失敗した」という主張です。マークが
言っているのはそれとは別のこと — この行は先に進めないです。これは
プリフライトでも、409 の拒否でも、best-effort の失敗後でも等しく真
です。行の性質であって試行の結果ではないからです。選択を変えても
陳腐化しません(「出荷済み」は選択と無関係に真のままです)。
マークがないと、レポートの行リンクを踏んで着地しても他の行と見分けが つかず、レポート唯一の誘導手段が無駄になります。
プリフライトが返すのは行ではなくレポートなので、マークは
<template> で包んだアウトオブバンドの行更新として同送します。
atomic で決してやってはいけないのはステータスの変更です。何も
実行していないので、Posted にも Archived にもなりません。
severity は「行が何を必要としているか」
Section titled “severity は「行が何を必要としているか」”data-attention の severity は、いつ判明したかではなく何を要求して
いるかで決めます。そうしないと、行の状況は何も変わっていないのに
ボタンを押す前は warning、押した後は error になってしまいます。
| severity | 意味 | 例 |
|---|---|---|
error | 直さないと進めない | 必須未入力、値が不正、状態違い(出荷済み)、権限なし |
warning | 判断が要る。値自体は正しい | 未来日の出荷日、規定超えの割引 — 確認付き警告の分岐 |
したがって必須入力チェックはどこに現れても error ですし、プリ
フライトの阻害行も error です。「まだエラーではない」のではなく、
「先に進めない行」だからです。
**部分失敗のときも再試行対象は選択されたままにします。**全行を未
チェックで返すと、アクションバーが消え(0 件で隠れる仕様)、ユーザーは
全行の中から失敗行を手作業で選び直すことになります — サーバーが既に
特定している行なのにです。再試行可能な失敗を checked で返せば、
再試行は同じボタンを 1 回押すだけになります。
再試行可能かどうかはサーバーの判断です:
| 失敗 | 後でチェック? | 理由 |
|---|---|---|
| 一時的(ロック、上流のタイムアウト、レート制限) | はい | 同じリクエストが成功しうる |
| 成功した行 | いいえ | やることが残っていない |
| 恒久的(状態違い、権限なし、入力不正) | いいえ | 再送しても同じエラーになる |
両者が混在するときはレポートで言明してください(「3 件は再試行可能、 2 件は先に修正が必要」)。そうしないと、部分的にチェックされたグリッド は不具合に見えます。
該当する全件に対して実行する
Section titled “該当する全件に対して実行する”行にチェックを付ける方式は、データより先に破綻します。4,873 件が該当する とき、ユーザーがやりたいのは「全部アーカイブ」であり、4,873 個の id は クエリストリングにもフォーム POST にも収まりません。プロキシを通ればなお さらです。
そこでアクションをクエリそのものとして表現できるようにします。
<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"> 該当する 4,873 件をすべてアーカイブ </button></form>scope=matching は「ids ではなくクエリに対して実行せよ」の意味です。
両者は排他で、両方を載せたリクエストはクライアントのバグなので 400 に
値します。
件数は確認の一部です
Section titled “件数は確認の一部です”「該当全件をアーカイブ」は目をつぶって押せる操作ではありません。ボタンは 件数を明示し、サーバーは実行前に数え直します。
| ケース | レスポンス |
|---|---|
| 件数がトークンと一致 | 実行し、通常どおり報告 |
| 件数が変わった | 409 + 変更前後の件数と、新しいトークンを持つボタン |
| トークンが無い・不明 | 409 — 数え直して再確認 |
トークンがこの操作を誠実にします。これがないと「4,873 件をすべて」は 実行時点で該当する件数に対して走り、ユーザーが同意した操作とは別物に なります。他人の編集や、深夜に切り替わった相対日付だけで動きます。
制約を 2 つ明記します。クエリ指定の一括操作も他と同様に再認可が必要 です(条件に、そのユーザーがもう読めないものが含まれうるため)。そして 一定の規模を超えたら、同期 POST ではなく進捗つきの非同期ジョブの領分です。
報告よりプリフライト
Section titled “報告よりプリフライト”atomic なアクションは実行前に検証します — 確認ステップが実行可否の レポートになります:
18 of 20 rows are executable; 2 are blocked (reason: period closed)[ Exclude 2 and run 18 ] [ Cancel ]GET /products/bulk/preflight?ids=…&action=… がこのフラグメントを
返します。「除いて実行」はサーバーが描画した隠し入力で実行可能な id
だけを送るため、原子性を保ったままユーザーに前進の道が残ります。
実行できる行が 1 つもないときはレポートがそう告げ、submit を描画
しません — 行き止まりは見えるべきで、無効化されたボタンの謎であって
はなりません。
レポートをどこに出すか
Section titled “レポートをどこに出すか”全高の一覧ページ ではクロームが固定で、グリッドが残りを取ります。したがって理由の数に 応じて高さが伸びるレポート領域は、グリッドをゼロまで押し潰します — しかもそのレポートが「直しに行け」と言っている当の行を隠して。
**クロームは O(1)。**データ量に応じて高さが伸びるものは、スクロール 領域かオーバーレイに置く。クロームには置かない。
面は 2 つ。選ぶ基準は「グリッド側に作業があるか」の一問です。
| セマンティクス | 面 |
|---|---|
| best-effort — 一部の行は変わった | クロームには1 行の要約。失敗行は data-attention="error" と自分のメッセージ行を持ち、失敗行だけ表示 (N) を添える |
| 理由別の内訳 | グリッドの横のドッキングパネル(hc-splitter)— サイドパネルが使うのは横の空間で、このレイアウトに余っているのは横 |
| atomic — 何も適用されていない | モーダルダイアログ。グリッド側に作業は無く、ユーザーが返すのは判断。塞ぐこと自体がメッセージ |
既定は畳んだ状態、ただし畳んでも消えません — 件数(Reasons (5))と
戻り道を持つレールになります。常時開いているパネルは、稀な出来事のために
毎日すべての画面から幅を取り続けます。閉じたら消えるパネルは行き止まりです。
そしてレスポンスは開きません。何が起きたかは要約が既に述べており、
グリッドの幅を渡すかどうかは行を読んでいる人の判断だからです。仕事そのものが
トリアージの画面は開いた状態で始めて構いません — それは画面の性質であって、
レポートの性質ではありません。開いているかどうかは作業状態なので、
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>**重要なのは「失敗行だけ表示」**です。グリッドそのものをレポートに 変え、条件バーや保存ビューと合成でき、その後の再実行は通常の 全選択→アクションのループになります。200 件失敗したときに成立する形は これだけです。(上のライブデモではこのリンクは例示です — 実アプリでは 失敗フィルタを適用したグリッド URL を指させます。)
要約はナビゲータでもある
Section titled “要約はナビゲータでもある”5,000 行に散った 12 件の失敗は待ち行列です。だから要約が移動手段も 持ちます — O(1) のままで。
<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>本物のフラグメントリンクなので Back もキーボードも効きます。
installDatagrid()
がフラグメントの指す行にアクティブセルを着地させます。カウンタと
2 つの href はレポートと同じ失敗リストからサーバーが描画するので、行と
パネルがずれることはありません。
行を名指しするのは ID、隣に
序数
を表示します。「137 行目」は失敗を語るときの言い方ですが、序数は
ソートや条件で動き、ID は動きません。行番号へ移動(?goto=137)は
口頭で伝えられた番号のためのもので、137 行目が今どこにあるかを知って
いるのはサーバーだけなので、解決もサーバーが行います。
グリッド上部の aria-live="polite" 領域 1 つで、行と一緒にアウト
オブバンドでスワップされます — プリフライトの行更新と同じく
<template> で包みます。そうしないと、tbody をターゲットにした
スワップに同乗する <div> はテーブルパーサーに里子に出されて
テーブルの外へ追い出されます。
csv-import の検証
レポートの型を再利用し、3 つの規則に従います。
- 行ではなく理由でグルーピング — 87 件の失敗では理由が行動の 単位です。平坦な一覧はレビューできません。
- インライン詳細に上限(理由ごとに 10 行が良い既定値)を設け、
上限と全件リンク(ページか CSV — ただの
<a href>)を必ずセットで 出します。上限のない 1 万件のレポートは壊れたページです。 - 名指しした行をすべてリンク: 同一ページなら
#row-101、別ページ なら/products?focus=101#row-101— 失敗はページをまたぐもので、 どのページにその行があるかを知っているのはサーバーです。
行リンクをクリックすると行がスクロール表示され、:target でマークされ、
グリッドのアクティブセルが移動
するのでキーボード利用者も到達します。行からは
<a href="#bulk-report">Details</a> で戻れます。
best-effort ではレポートに**「Filter to the failed rows」**も出します
— ただのフィルタ URL(?f-last-result=failed)で、
datagrid-filter /
saved-views と合成
します。再試行は通常の全選択 → アクションのループになり、新しい
クライアント状態は不要です。
行ごとの詳細
Section titled “行ごとの詳細”失敗行の理由は該当セル内に
hc-tooltip として
一度だけ描画し、セルが aria-describedby で指します。グリッドはその
ようなセルのオーバーフローツールチップを抑制するので、1 つの操作は
1 つの意味だけを運びます。ツールチップは「気になった行を確認する」
ための道具です — 理由をツールチップだけに置いてはいけません。
レビュー可能な面はレポートであり、タッチ・印刷・エクスポートに
ホバーはありません。
トーストは見出しであってペイロードではありません。件数とレポートへの
誘導に徹し、失敗が存在する間は自動で消えてはいけません —
ペイロードで duration: 0 を送ります(best-effort は warning、
拒否は error)。
プログレッシブエンハンスメント
Section titled “プログレッシブエンハンスメント”ベースレシピの 303 post/redirect/get がそのまま適用されます。同じ
レポートをリダイレクト先ページの先頭に描画してください(フラグメント
リンクはネイティブに動きます — アンカーである理由がまさにこれです)。
プリフライトは通常の中間ページになります。
アクセシビリティ
Section titled “アクセシビリティ”- レポート領域は丁寧に読み上げられ、フォーカスを奪いません。
- 行リンクはアクティブセル経由でグリッドにフォーカスを移します — 無言のスクロールだけのジャンプにはなりません。
- 失敗は決して色だけではありません: トーンと理由テキストと レポートの項目。
- プリフライトの行き止まりは、説明のない無効ボタンではなく理由を 描画します。
- datagrid-bulk-actions — 土台となるフォームの形。
- datagrid-edit-errors · datagrid-edit-conflict — 単一セルに対する同じ哲学。
- csv-import — レポートが 一般化した検証レポートの型。
- undo-delete — 取り消せる 破壊的操作では、確認ダイアログより猶予期間が優れます。