選んだ行にまとめて実行する
一覧にチェックボックスを出して、選んだ行に対してボタンを1回押す。
一覧を見ていて「この5件をまとめて承認したい」は業務では毎回来る。1件ずつ開いて承認するのは、件数が増えた瞬間に破綻する。
アクションに scope: selection と書くと、その画面の表にチェックボックスが出て、押したときに選んだ行がハンドラに渡る。
actions:
- id: approveSelected
type: plugin
plugin: approveOrders
label: 一括承認
scope: selection
confirm: { message: '{count} 件の受注を承認します' }選択のキーを別に持たない
「表を選択可能にする」キーと「このボタンは一括」キーを別々に用意すると、チェックボックスは出るのに何もできない表と、一括ボタンは出るのに選べない画面の2つが書けてしまう。どちらも書いた人の意図ではないので、宣言を1つにした。scope: selection のボタンが1つでも在れば表は選択可能になり、無ければならない。
押せない状態を作らない
選ぶまでボタンは押せない(選んだ件数がラベルに出る)。押しても何も起きないボタンは、「この画面は壊れている」と教えることになる。
行が入れ替わったら選択は消える
一番危ないのは「画面に無い行に対して実行できてしまう」こと。検索し直した・ページを変えた・実行後に読み直した、のどれでも選択は捨てる。実行できたら選択も解ける(同じ行に二度実行するのは、まず事故)。
まとめて何をするかは業務
実行できるのは type: plugin だけ。承認・締め・出荷確定は業務のロジックで、Framework は業務を持たない。渡すのは行そのもの(キーではない)で、呼び出しは1回。
'approveOrders': (ctx) async {
await api.approve([for (final r in ctx.records) r['orderNo']]); // API も1回
await (ctx.controller as ListController).load();
},キーだけ渡すと、ハンドラが判断のために件数ぶん読み直すことになる。状態や金額を見て弾きたい(「出荷済は承認しない」)のは一括では普通なので、最初から行を渡す。
一括の削除は無い
取り消せない操作は、事故が件数ぶん大きくなる。消すのは1件ずつ(行アクションの delete)で、scope: selection に type: delete を書いても実行されない。
表の無い画面(フォーム・ウィザード・ダッシュボード)に置いた場合も、plugin 以外の型に書いた場合も npx hatake validate が警告する。
確認の文に件数を出す
確認の文(confirm.message)に {count} と書くと、押す前に選んだ行の数が入る。
confirm: { message: '{count} 件の受注を承認します' }ボタンにも件数は出る(「一括承認(3 件)」)。それでも確認の文に書くのは、確認のダイアログがボタンの上にかぶるから。最後に読むのはこの文なので、ここに数が無いと「3件のつもりで30件」に気づけない。
走る前なので、入るのは {count} だけ。{failed} や {error} はまだ何も起きていないので埋まらず、文字のまま出る(npx hatake validate が先に言う)。結果を言う文言は onSuccess / onError の担当。
進み具合を出して、途中で止める
100件を押したときに「いま何件目か」が見えないと、押した人は待つしかない。何件ずつ渡すかを書くと、枠組みが回す側になる。
- id: approveSelected
type: plugin
plugin: approveOrders
label: 一括承認
scope: selection
batchSize: 20 # 20 件ずつ渡す
onError:
message: '{count} 件を承認しました({failed} 件だめ/{skipped} 件は実行していません)'進み具合と中断は、区切りが在るときだけの機能。 1回で全部渡してしまうと、どこまで進んだかを知っているのはハンドラの中だけで、枠組みには分からない(だから出せない)。区切りを枠組みが持つと、進み具合・中断・区切りごとの報告の合算がハンドラの手間ゼロで付いてくる(ハンドラは今まで通り「渡された行を処理する」だけ)。
| どうなるか | |
|---|---|
| 進み具合 | 「12 / 100 件」と件数で出る。閉じるボタンは出ない(終わるか、止めるか) |
| 残り時間 | ここまでの実測から「あと 30 秒くらい」。言えないうちは言わない |
| 中断 | まだ送っていない分を送らないだけ。送った分は動いている(取り消しではない) |
| 区切りが失敗 | 残りは送らない(同じ理由で失敗し続ける方が悪い) |
| 終わっていない行 | 選んだままにする=もう一度押せば続きから動く |
| 報告 | 区切りごとの報告を足し合わせて、1回ぶんとして出す |
止めた実行は成功ではないので onSuccess は動かない(選んだ行の一部は動いていない)。失敗が1件も無くて止めただけのときは、onError の文言ではなく枠組みの言葉で出る(「2 件を実行しました(3 件は実行していません。残りは選んだままなので、もう一度押せば続きます)」)── 「承認できませんでした」と出したら嘘になるので。
送っていない件数は文言に差し込める({skipped})。「実行していない」と「失敗した」は別で、前者はもう一度押せば動く相手、後者は直してからやり直す相手。
残り時間は「くらい」でしか言わない
出せる根拠は「ここまでの実測」しかない。だから1区切りも終わっていないうちは何も言わない(速すぎて1秒も測れていないときも言わない)。100件が1分なのか10分なのかは、1区切り終われば見当が付く。
出すときは多めに言う(切り上げ・10秒単位)。少なく言って待たされる方が、多めに言って早く終わるより悪い。後半の区切りが重ければ外れる数字なので、「くらい」を必ず付けて、当てにするものではないと分かる形にしておく。
中断したところから続けられる
止めたあと、終わっていない行は選んだままになる。だからもう一度押せば続きから動く(選び直しは人の手ではない)。
送り終わった行は選択から外れる。同じ行に二度実行するのは、まず事故なので。失敗した区切りの行は「終わっていない」側に数える ── 送ったけれど、動いたのかどうかは枠組みには分からないので、選んだままにしてもう一度押せるようにする。
残った行は、画面の外へ持ち出せる
選び直しは「いま・ここ」の話でしかない。検索し直す・ページを変える・画面を閉じる、のどれでも消える。ところが一括の失敗と中断はそこで終わりではなく、「担当に配る」「翌日やり直す」が続く。
そこで、失敗の通知と「どの行か」のダイアログに CSV に出すを付けてある。
| 何が出るか | 表の列(その人に見えている列だけ)+理由の列 |
| 理由の列 | 失敗した行はハンドラが書いた理由(無ければ「失敗しました」)、送っていない行は「実行していません」 |
| 何枚出るか | 1枚。失敗と未実行で分けない(次にやることは同じ=この行をどうにかする) |
| いつ出るか | 出す口(exportSink)を登録しているときだけボタンが出る(押しても何も起きないボタンは作らない) |
区切りが投げて終わったときも、そこまでに名指しされた失敗は紙に残る(投げた区切りより前の報告を落とさない)。落とすと「SO-4 は失敗したのに CSV に無い」になる。
ファイル名はボタンのラベルから(「一括承認_残り.csv」)。出す口は type: export と同じものを使うので、持ち出し方が2通りにならない(アプリ側が CSV をどうするかを1か所で決められる)。
何件ずつかは役割で変えられる
「本社は50件ずつでいいが、回線の細い拠点は5件ずつ」は現場の事情なので、定義に書ける。
scope: selection
batchSize:
default: 20
byRole: { branch: 5, admin: 100 }当てはまる役割が複数あれば、一番小さい件数が効く。 上限(maxRows)とは逆になる。上限は「やっていいことの広さ」なので役割を持つほど広がるが、区切りは「1回に押し付ける量」なので、安全な方に倒す(大きすぎる区切りは、待たされた末に落ちる)。
「区切らない」(all)は書けない。区切らない=進み具合も中断も無い、というのは batchSize を書かないことで既に言えるので、キーに2通りの意味を持たせない。押せない役割や、定義のどこにも出てこない役割名に書いた区切りは効かないので npx hatake validate が言う。
1回で動かせる件数を決める
「承認は20件まで」は業務の決めごとなので、定義に書ける。
scope: selection
maxRows: 20上限を超えて選んでいる間、ボタンは押せない。ラベルが「一括承認(80 件:20 件まで)」に なるので、押してから断られるのではなく、押す前に理由が読める。
選んだ行を切り詰めて実行することはしない。 80件選んで20件だけ動いたら、押した人は 残りの60件が動いていないことに気づけない。1回の操作が件数ぶん動くものは、動いたのか 動いていないのかが分かるほうが大事。
書かなければ上限は「画面に出ている行の数」になる(選べるのは表に出ている行だけなので)。 つまり table.pagination.pageSize がそのまま上限で、ページ送りを切っていれば全件。それが 危ないと思ったら上限を書く、という順番になる。
役割で変える
「担当は20件・管理者は上限なし」は業務の形なので、役割ごとに書ける。
maxRows:
default: 20 # 役割で決まらない人
byRole: { manager: 50, admin: all } # all は上限なし当てはまる役割が複数あれば、一番ゆるい上限が効く。 担当と管理者を兼ねている人は 管理者として扱う、ということ。roles が「どれか1つ当てはまれば見える」のと同じ考え方で、 役割は持っているほど広がる。
押せない役割(roles に無い役割)や、定義のどこにも出てこない役割名に上限を書いても 効かないので、npx hatake validate が言う。
サーバ側も同じ数で止める
画面の上限は早く気づかせるため。API を直接叩かれたら通ってしまうので、守る側でも 同じ定義から同じ数を読む。
const breach = checkBulkLimit(document, "approveSelected", rows.length, roles);
if (breach) return badRequest(breach.message); // 「1回に実行できるのは 20 件までです(80 件届きました)」Java も同じ(BulkLimits.check(...))。検証(FormValidator)を画面とバックエンドの 両方で回すのと同じ形で、画面で押せた操作が API で弾かれる(あるいはその逆)が 起きないように、3つのエディションが同じ数を出すことを共有フィクスチャで縛っている。
一括だけは、機械が厳しい
npx hatake advise は警告とは別の物差し(好みなので終了コードは変えない)。ただし一括だけは既定で厳しくしてある。1件ずつなら「押し間違えた」で済むのに、一括は1回の操作が件数ぶん動くので。
| 言うこと | なぜ |
|---|---|
確認(confirm)が無い | 押し間違いを最後に止められる唯一の関門。prompt を書いてあるなら、その OK が確認そのもの=言わない |
| 確認に件数が無い | 最後に読む文に数が無い({count} は埋まる) |
失敗の言い方(onError)が無い | 一括は途中まで進んで終わる(100件のうち3件だけ失敗する)。1件ずつのボタンには無い話。書かないと理由がそのまま出る(explain がそう読み返す) |
戻せない名前なのに danger が無い | 「破棄」「却下」の OK が普通のボタンに見える(名前からの推測なので、外れることがある) |
1回で動く件数が多い(maxRows が無い) | ページ送りを切ると全件が選べる=「全部選ぶ」が1回で全件を動かす操作になる。上限を書けば言わない |
1回で 100 件動くのに区切り(batchSize)が無い | 進み具合も残り時間も出ず、途中で止められない=押した人は終わるまで待つしかない。上限を書いてあっても言う(「200 件まで」は待つ人には「200 件を1回で渡す」と同じ)。書く件数の下書きは「1回で動く件数を5回に分ける件数」から作る |
roles で絞っていない | 誰でも押せる。型に関わらず一括は危ない側に数える(explain --roles の棚卸しでも同じ見方) |
行の状態で「そもそも押させない」なら enabledWhen。一括では選んだ行が全部満たすときだけ押せる(1件でも合わなければ押せない=選んだうちの一部だけが動くのを作らない)。合わない行が混ざっている間は、ボタンに「3 件:1 件は条件に合いません」と出る。
合わない規則は --rules で切れる(off)し、件数の目盛り(既定 100 件。上限の助言と区切りの助言で同じ数を使う)も変えられる。
読み返し(npx hatake explain)は「何回に分かれるか」まで言う(「20 件ずつ実行する。上限まで選ぶと 5 回に分かれる」)=サーバを何回叩くかが、定義を読まずに分かる。役割ごとの件数は explain --roles の棚卸しに出る(「1回 100 件まで・10 件ずつ」)。
直すと決めたものは --apply でその場に書き込める(npx hatake advise page.yaml --apply picks.json --write)。書く値は渡す側が決める(確認の文・1回に何件まで・誰に見せるかは業務の決めごと)。機械が決めるのは書く場所だけで、当てたあと「読める・別の問題が出ない・その助言が消える」ことを確かめている。
書けるキー
| キー | 書く場所 | 型 | 必須 | 既定値 | 有効なページ種別 | 説明 |
|---|---|---|---|---|---|---|
scope | action | string (page / selection) | 任意 | "page" | crud dashboard detail form master report search wizard | What the action runs on. 'page' (default) acts on the screen; 'selection' acts on the rows the user checked, and makes the table selectable. |
maxRows | action | object → action.maxRows / maxRows | 任意 | — | crud dashboard detail form master report search wizard | For scope: selection: how many rows one press may act on. A plain integer caps everyone; the object form caps per role. While more than the limit are picked the button is disabled and says the limit — it never truncates the selection, since acting on part of what was picked is the failure nobody notices. Omit to leave it unbounded, in which case the real limit is how many rows are on screen (table.pagination.pageSize). |
batchSize | action | object → action.batchSize / batchSize | 任意 | — | crud dashboard detail form master report search wizard | For scope: selection: how many rows to hand the handler per call. A plain integer is the same for everyone; the object form sets it per role. Absent = one call with every checked row. With it the framework owns the loop, so it can show how far it got, estimate what is left, and stop between batches (a stopped run reports what it did not send, and leaves the unfinished rows checked). |
default | batchSize | integer | 必須 | — | crud dashboard detail form master report search wizard | — |
default | maxRows | object → maxRows.default / maxRows.default | 必須 | — | crud dashboard detail form master report search wizard | A row limit: a positive count, or all for no limit. |
byRole | batchSize | object → batchSize.byRole | 任意 | — | crud dashboard detail form master report search wizard | Role name -> rows per call. A role that is not named falls back to default. There is no all here: not splitting means no progress and no stopping, which is what leaving batchSize out already says. |
byRole | maxRows | object → maxRows.byRole | 任意 | — | crud dashboard detail form master report search wizard | Role name -> limit. A role that is not named falls back to default. |
この表は spec/reference.json から生成している(JSON Schema が正)。手元では npx hatake reference <キー名> で同じものが引ける。
近い例
例は丸ごと写して直すのが一番速い。以下は CI で検証済み(そのまま動く形)。
| ファイル | 種別 | 画面 | どういうときに使うか |
|---|---|---|---|
roles_app.yaml | app | 人事管理 | 見せる相手を役割で変えたい(画面ごと隠す・列や項目だけ隠す・押せる人を絞る) |
よくある間違い
scope: selection に type: delete を書いて「一括削除」にする
なぜ駄目か 選んだ行にまとめて実行できるのは type: plugin だけなので、押しても実行されない。用意していないのは、取り消せない操作は事故が件数ぶん大きくなるから。1件ずつなら「押し間違えた」で済むが、全選択のあとの1回は戻せない。
こう直す 消すのは1件ずつ(table.rowActions の delete)。どうしてもまとめて消すなら type: plugin にして、消す条件と件数の上限をアプリ側で持つ(そこで確認と記録も残せる)。
page:
type: search
id: order_search
title: 受注照会
repository: orderRepository
key: orderNo
table:
rowActions: [delete]
columns:
- { field: orderNo, label: 受注番号 }
actions:
- { id: remove, type: delete, label: 削除, roles: [admin] }
- id: approveSelected
type: plugin
plugin: approveOrders
label: 一括承認
scope: selection
roles: [manager]
confirm: { message: 選んだ受注を承認します }spec/pitfalls.json から生成。各項目は CI で検証済み(間違いは本当に落ち、正しい方は本当に通る)。手元では npx hatake pitfalls <キー名>。
実物を見る
デモアプリの「受注照会」がこれを使っている。 デモを開く