本文へスキップ

DataGrid

このページは英語から翻訳されています。 英語のページを読む

ステータス: zeyn::DataGrid、zeyn::server のグリッドエンドポイント、assets/zeyn/*.js(/_zeyn/zeyn.js として配信)で実装されています。

グリッドはサーバー上でレンダリングされます。行は、DataQuery を受け取り Page<E> を返すアクションから取得されます。ブラウザのランタイムはページを要求し、ユーザーの操作(並べ替え、フィルター、レイアウト、編集、アクション)を送信し、返ってきた HTML をその場所に配置します。すべての呼び出しはサーバー上で権限を検査し、クライアントが送るものは何も信用されません。

DataGrid::of(search_products)
    .mode(DataMode::Server)
    .paging(Paging::pages(25))
    .column(Product::code).width(120).pin_start().filter()
        .link(|p| Window::open(product_editor, ProductParams { id: Some(p.id) }))
    .column(Product::name).flex(1).filter().editable()
    .column(Product::unit).filter().editable().badge(|u| match u {
        Unit::Kg => Tone::Info,
        _ => Tone::Neutral,
    })
    .column(Product::price).filter().editable().avg().max()
    .column(Product::stock).filter().sum().progress(120)
    .column(Product::active).filter().check_icon().editable()
    .edit_with(save_product)
    .row_action(t!("stock.restock"), restock_product, |p| p.id)
    .bulk_action(t!("stock.deactivate"), deactivate_products, Product::id)
    .row_menu(|p| ContextMenu::new()
        .item(t!("stock.edit"), Window::open(product_editor, ProductParams { id: Some(p.id) }))
        .action(t!("stock.restock"), restock_product, p.id))
    .exportable()

列の合計

sum() と avg() は整数と金額に、min() と max() は日付、時刻、瞬間にも使え、count() はすべての列に使えます。それ以外の型に使うとコンパイルできません。合計は、ページだけでなく、フィルターに一致するすべての行を対象にします。

DataQuery::apply は、メモリ上のデータについて合計を計算します。SQL でページングするデータ層は query.aggregates を読み取り、1 回の集計クエリから Page::with_total(field, op, value) を追加します。グリッドは、列が宣言した合計だけを保持するため、クライアントが他の合計を要求することはできません。合計は、サーバー上でユーザーの言語に整形されます。整数の平均は小数点以下 2 桁(Value::Decimal)で、金額の平均は最小単位に四捨五入されます(0 から遠い方向への丸め)。

フィルターと並べ替え

filter() はフィルター行にフィルターを追加し、入力欄は列の型によって選ばれます。

列フィルタークエリ
テキスト含む、で始まる、等しいcontains、starts_with、equals
整数、金額から、まで1234.56 形式の from、to
日付から、まで2026-03-31 形式の from、to
瞬間(DateTime)から、まで(日付)ユーザーのタイムゾーンでの丸 1 日
時刻から、まで14:30
選択肢任意の選択肢を選ぶany_of
はい/いいえすべて、はい、いいえis

値は、ユーザーの言語に関係なく、固定の機械可読な形式で送られます。古い { "field", "contains" } の形も、引き続き動作します。サーバーは、filter() のない列へのフィルターと、グリッドにない列での並べ替えを破棄し、フィルター(32)、フィルターのテキスト(200 文字)、選択肢(100)、並べ替えキー(4)に上限を設けます。読み取れない値は無視されます。日付フィルターのタイムゾーンは、サーバー上のユーザー設定から取得されます。

見出しを Shift+クリックすると、その列が並べ替えキーに追加されます。見出しの数字が順序を示します。

列: 固定、サイズ変更、非表示、移動

pin_start() と pin_end() は、グリッドが横にスクロールしても列を一方の端に保ちます(片側につき最大 3 つ)。hidden() は、ユーザーが表示するまで列を省きます。すべての見出しには列メニューがあり(ボタン、右クリック、Shift+F10、またはコンテキストメニューキー)、並べ替え、固定、左右への移動、非表示、フィルター、列の選択、リセットができます。見出しの端はドラッグまたは矢印キーで列のサイズを変更します(大きく動かすには Shift)。見出しを、同じ固定グループの別の見出しの上へドラッグすると列が移動し、短い押下は引き続き並べ替えになります。すべて右から左の表記でも動作します。

保存済みビュー

ユーザーのレイアウト(表示する列の順序、幅、固定)、並べ替え、フィルターが DataGridView を構成します。App::datagrid_views(store) を使うと、テナント、画面、グリッドごとに、ユーザー単位でサーバーに保存され、ページは最初のページがすでに並べ替えとフィルターを適用された状態で戻ってきます。データベースには DataGridViewStore を実装します。MemoryViewStore は開発とテスト用です。ストアがない場合、ブラウザは localStorage にビューを保持します。サーバーは、受け取ったすべてのビューを、グリッド自身の列と制限で整えます。

セル

メソッド列表示内容
badge(|v| Tone)#[choice] の列挙型選択肢ごとの色付きバッジ
progress(max)数値、金額バーと数値
link(|row| Effect)すべてエフェクトを実行するリンクとしての値
check_icon()boolチェックマークまたはダッシュ。スクリーンリーダー向けの言葉付き
relative_time()DateTime「3 分前」。正確な時刻はツールチップ
cell(renderer)すべて独自の HTML(CellRenderer<T>)

CellRenderer はユーザーデータをエスケープしなければなりません。エクスポートとコピーはプレーンな値を使います。

行アクション、メニュー、一括アクション

row_action(label, action, |row| input) は、各行にボタンを追加します。row_menu(|row| ContextMenu) はコンテキストメニューを追加します。Screen::context_menu(...) は、画面の空白部分にコンテキストメニューを追加します。アクションを実行する項目は、そのアクションの権限を持つユーザーにだけ表示されます。

行の項目が実行されるとき、クライアントが送るのは行のキーと項目だけです。サーバーは、グリッドのアクションを通して行を読み込み直し、その行から項目を構築し、アクションの権限を検査し、インターセプターを実行してから、アクションを実行します。その行に対してもう存在しない項目(たとえば無効になったもの)は拒否されます。入力がクライアントから来ることはありません。

bulk_action(label, action, Model::key) は、選択バーにボタンを追加します。アクションはキーのフィールドの型である Vec<K> を受け取り、それ以外はコンパイルできません。「一致するすべての行」を選ぶと、サーバーがユーザーのフィルターを使って最大 10,000 件までキーを自分で収集します。1 つずつ選んだキーはクライアントから来るため、アクションは API のアクションと同じように、各レコードを検査しなければなりません。

それ以外の場所では、ブラウザ自身のコンテキストメニューが維持されます。App::browser_context_menu(false) は、テキスト入力と編集可能なテキスト以外でそれを無効にします。これは何も保護しません。ユーザーは依然としてページのソースを読み、開発者ツールを開き、スクリプトをオフにできます。さらに、スペルチェック、リンクのコピー、翻訳、支援技術も使えなくなります。既定のままにしてください。

インライン編集

Valid<E>(フォームと同じルール)を受け取るアクションを指定した edit_with(save) と、列への editable() です。キー、読み取り専用のフィールド、行のバージョンが編集可能になることはありません。編集可能なセルで F2 かダブルクリックをすると、その行の編集が始まります。Enter で保存、Escape で取り消し、Ctrl+Z またはトーストのボタンで直前の保存を元に戻します。

サーバーは、キーで行を読み込み直し、編集可能な列と行のバージョンだけを Form::read を通じて適用するため、値はフォームの場合と同じように読み取られ、検証されます。他の列の値は 403 で拒否されます。古いバージョンは 409 として返り、検証エラーは 422 として返って、それぞれのセルに表示されます。

マスター詳細

detail(|row| (screen, params)) を使うと、すべての行を、その行から構築された画面へと展開できます。画面は、ユーザーが開いたときにだけ読み込まれます。

DataGrid::of(search_today)
    .column(Order::id)
    .detail(|o| (order_lines, OrderParams { id: o.id }))

#[screen(permission = "sales.order.read")]
pub fn order_lines(params: OrderParams) -> Screen {
    Screen::window(t!("sales.order_lines", id = params.id.0.to_string())).body(
        DataGrid::of(search_lines)
            .scope(OrderLine::order_id, params.id)
            .column(OrderLine::product)
            .column(OrderLine::amount).sum()
            .detail(|l| (product_editor, ProductParams { id: Some(l.product_id) })),
    )
}

パラメータは画面によって型が決まる(ScreenDef::Params)ため、誤ったパラメータはコンパイルできません。詳細は完全な画面です。フォーム、入れ子のグリッド、あるいは独自の詳細を持つグリッドを、どの深さでも置けます。展開用の列は、その画面を開けるユーザーにだけ表示され、サーバーは詳細が読み込まれるときに、画面の権限を再度検査します。パラメータは、ウィンドウのものと同様にブラウザを経由するため、詳細の画面は、ユーザーが、示されたレコードを閲覧できるかどうかを検査しなければなりません。

scope(field, value) は、すべてのリクエストで、field が value に等しい行だけにグリッドを絞り込みます。フィルターは DataQuery::filters の先頭に置かれ、クライアントが取り除くことはできないため、入れ子のグリッドは、ブラウザが何を送っても親の行にとどまります。フィルターから SQL を構築するデータ層は、他のフィルターと同様にこれを読み取ります(数値と日付には上下限が等しい Filter::range、テキストには equals、選択肢には any_of、はい/いいえには is、空の値には blank)。

展開ボタン、その上での Enter、または行での右矢印と左矢印で、詳細を開閉します。閉じると取り除かれるため、次回は新しく読み込まれます。

仮想グリッドは、どの位置にもすぐ到達できるよう、すべての行を固定の高さに保ちます。展開ボタンは表示されません。詳細を持つグリッドには、ページングか無限ページングを使ってください。

グルーピング

group_by(field) は、サーバー上で行をグループ化します。もう一度呼び出すと、各グループの内側でグループ化します。

DataGrid::of(search_today)
    .paging(Paging::pages(20))
    .group_by(Order::city)
    .group_by(Order::status)
    .column(Order::amount).sum().avg()

グリッドは、グループごとに、列のラベル、値、行数、そのグループに対するすべての列の合計を、各列の下に並べた見出し行を表示します。グループを開くと(そのボタン、または右矢印と左矢印)、サブグループまたは行が 1 ページずつ読み込まれ、まだ残りがある場合は「もっと読み込む」の行が表示されます。閉じるとそれらは取り除かれます。最上位はグループに対してページングされるため、ページャーはグループを数えます。フッターは、一致するすべての行に対する合計を保持します。グループ内の行は、編集、行アクション、メニュー、選択、詳細など、その他のすべてを保ちます。

データ層は DataQuery::group に応答します。これが設定されている場合、Page::groups(グループごとの値、件数、クエリが求める合計)を、そのフィールドの並べ替えキーの方向で値の順に並べ、行と同様にページングして返し、total はグループを数えます。DataQuery::apply はメモリ上のデータに対してこれを行い、SQL は GROUP BY を使います。group はグリッドが設定し、クライアントから読み取られることはありません。クライアントが送るのは、開いたグループの値のパスだけです。サーバーはその長さを検査し、各値を、そのグループの列に対するフィルターに変換して、ユーザーのフィルターの前に置きます(空のグループには blank)。

ツリーデータ

tree(Model::parent) は、任意の親キーによって行をツリーとして表示します。親のない行が最上位に置かれ、ある行の子は、それが開かれたときに 1 ページずつ、最大 32 階層まで読み込まれます。

DataGrid::of(search_employees)
    .tree(Employee::manager_id)
    .column(Employee::name)
    .column(Employee::salary).sum()

最初の列がインデントとボタンを持ちます。どの行に子があるかを知るために、グリッドはページごとにアクションをもう一度呼び出します。親が、そのページのキーのいずれかである行を、親フィールドでグループ化して求めます(キーに対する Filter::any_of と DataQuery::group)。フィルターと並べ替えはすべての階層に適用され、フッターの合計は、一致するすべての行を対象にします。ツリーのグリッドはグループ化せず、行の詳細も表示しません。インライン編集の後も、行はツリー内の位置を保ちます。

仮想グリッドは、すべての行が固定の高さを持つため、グループ化もツリー表示もしません。

セル: ナビゲーション、コピー、貼り付け

行で右矢印を押す(またはセルをクリックする)と、スプレッドシートのようにセルカーソルがそこに置かれます。矢印キーで移動し、Shift と矢印(または Shift+クリック)で範囲を広げ、Home と End で行の端へ、Ctrl+Home と Ctrl+End でグリッドの最初と最後のセルへ移動します。Tab と Shift+Tab は行に沿って次の行へ移動し、端ではグリッドから出ます。最初のセルで左矢印を押すと、行に戻ります。Enter または F2 で編集可能なセルを編集し、Enter でセルの唯一のボタンまたはリンクを押し、Escape で範囲を解除します。範囲内のセルには aria-selected が付きます。

セルで Ctrl+C を押すと、範囲を、ユーザーに見えるとおりの値のタブ区切りテキストとしてコピーします。行で Ctrl+C を押すと、引き続き選択した行がコピーされます。

セルで Ctrl+V を押すと、スプレッドシートからのタブ区切りテキストを、カーソルの位置から貼り付けます。値が 1 つなら範囲全体を埋めます。変更されるのは編集可能なセルだけです(読み取り専用のセルへの貼り付けは、その旨を伝えます)。選択肢はラベルで貼り付けることができ、はいといいえはユーザーの言葉で指定します。変更された各行は、インライン編集の経路(行のバージョン付きの /_zeyn/grid/edit)で 1 行ずつ順に保存されるため、すべての行が、インライン編集と同様にサーバー上で読み取られ、検証され、検査されます。失敗した行はそのセルにエラーを表示し、他の行は保存されます。10 行を超えると先に確認を求め、500 行を超えると拒否され、Ctrl+Z は貼り付け全体を元に戻します。グリッドの編集、行アクション、一括アクション、メニューアクションは App::write_limit に数えられるため、大量の貼り付けはアプリの書き込み制限に達することがあります。

列の仮想化

40 列以上のグリッドは、固定列と表示範囲内の列(さらに両側に最大 6 列)だけをページに保持します。すべての列に固定の幅が与えられ、両側の省かれた列は 1 つのスペーサーセルで表されるため、幅、固定、スクロールはそのまま保たれます。行は、セルの完全なリストを保持するため、コピー、貼り付け、編集はすべての列を認識し、セルには aria-colindex が付きます。ウィンドウが移動するのは、その外側の列が表示範囲に入ったときだけです。サーバーは、依然として行のすべてのセルを送ります。これだけ横に広い仮想グリッド(VIRTUAL_WIDE_COLUMNS。選択、詳細、行アクションの列も数えます)は、最初のブロック全体を、ライブの 64 行ではなく <template data-zeyn-rows-later> の中に入れて送るため、ブラウザがすべての列を持つ行をレイアウトすることはありません。ギャラリーの横に広いグリッドでは、最長の起動タスクが 259 ms から 112 ms に、DOMContentLoaded が 402 ms から 228 ms に短縮されました(交互に 8 回読み込んだ中央値、リリースサーバー、負荷のあるマシン)。

ギャラリーの横に広いグリッド(62 列、100,000 行、仮想スクロール、デバッグサーバー、Chromium、1400 x 900 のウィンドウ、144 Hz のディスプレイ)で、列の仮想化の有無を比較して測定しました。

ページ内のセル数横方向、1 フレームあたり 60 px(p50 / p95)下方向、1 フレームあたり 60 px(p50 / p95)下方向、1 フレームあたり 2,000 px(p50 / p95)
なし2,9766.9 / 7.5 ms42.7 / 64.8 ms78.5 / 141 ms、ロングタスク 178 件
あり1,0567.0 / 28.8 ms24.6 / 41.7 ms28.9 / 48.9 ms、ロングタスク 7 件

下方向のスクロールは約 2 倍速くなり、ページが保持するセルは 3 分の 1 になります。横方向のスクロールは元から滑らかでした。ウィンドウが移動するフレームは最大 29 ms かかります。残りのコストは、すべての列を含む 200 行のブロックの解析であり、列の仮想化では変わりません。

エクスポート

exportable()(および任意で export_permission(...)、export_limit(n))を指定すると、ツールバーの Export::Csv ボタンが、一致するすべての行をダウンロードします。サーバーはグリッドのアクションを通してページを順に取得し、各ページをユーザーのレイアウトと言語で即座にストリーミングします。数式は無効化されます。既定では最大 100,000 行です。

Cargo の feature xlsx を使うと、ツールバーの Export::Xlsx ボタンが、同じ行を Excel ブックとしてダウンロードします(GET /_zeyn/grid/export?format=xlsx)。セルは型を保持します。数値、通貨の書式付きの数値としての金額、ユーザーのタイムゾーンでの本物の Excel の日付としての日付と日時、真偽値です。選択肢はラベルとして、マスクされたフィールドはマスクされたテキストとして書き出され、テキストは常にテキストなので、数式になることはありません。見出し行にはユーザーの言語のラベルが入り、太字で固定され、列はユーザーの順序と幅を保ちます。

ブックは、rust_xlsxwriter の一定メモリモードで書き出されます。各ページは届くたびに一時ファイルに書かれ(メモリで待機するのは最大 2 ページ)、圧縮されたブックは最後に応答へストリーミングされます。6 列 100,000 行のエクスポートでは、行の書き込み中のヒープは約 134 KiB のまま(10,000 行後も 100,000 行後も同じ)、圧縮中は 460 KiB で、ファイルは 3.4 MB でした。

Cargo の feature pdf を使うと、Export::Pdf は同じ行を一覧のレポートとして印刷します(最大 2,000 行)。PDF ドキュメント を参照してください。

選択

selectable()(bulk_action が暗黙的に有効にします)は、チェックボックスの列を追加します。Shift で範囲を選択し、Space でフォーカスした行を選択し、見出しのボックスでページを選択します。選択バーは、一致するすべての行を選択する選択肢を提供します。

アクセシビリティ

テーブルには role="grid"、aria-rowcount(仮想グリッド)、aria-colcount、aria-colindex、aria-sort があります。行はローヴィング tabindex 方式のタブストップを使います。アクティブな行の中のボタンには Tab で到達できます。グループ化されたグリッドとツリーのグリッドは role="treegrid" で、行に aria-level と aria-expanded が付きます。メニューは menuitem、menuitemcheckbox、menuitemradio を持つ role="menu" を使い、フォーカスは元の場所に戻ります。空の状態とエラー状態は通知されます(role="status")。

パフォーマンス

showcaseのアーカイブ(100 万行、仮想スクロール、デバッグサーバー、Chromium、144 Hz のディスプレイ)で、Performance API を使って測定しました。

スクロールフレーム p50フレーム p95最大ロングタスク
ホイールの速度(1 フレームあたり 60 px)13.9 ms20.9 ms34.6 ms0
高速(1 フレームあたり 2,000 px)14.1 ms27.8 ms54.9 ms0
リスト全体のスイープ13.9 ms27.8 ms48.6 ms0

DOM は 62 行(約 660 要素)を保持し、JS のヒープは 4 MB 付近にとどまります。同じアーカイブから 100,000 行をエクスポートすると、7 MB が 3.4 秒でストリーミングされました。

画面ビルダーを軽く保つ

リクエストはステートレスなので、グリッドへのリクエストのたびに、グリッドを含む画面が作り直されます。仮想グリッドが取得するブロックごと(200 行スクロールするたび)、ページ、並べ替え、フィルター、エクスポートごと、インライン編集と行アクションごとに、グリッドの action を実行する前に #[screen] 関数がもう一度実行されます。サーバーは、ノードパスでグリッドを見つけ、アクセスを確認するために画面を必要とします。これが、ユーザーごとの状態を保持しないことの代償です。

コンポーネントを並べるだけのビルダーなら、コストはマイクロ秒です。データベースに問い合わせるビルダーは、そのすべてのリクエストでその問い合わせのコストを払います。そこで次のようにします。

  • 行は、グリッドの action(DataGrid::of(search))で読み込みます。これはどのみちリクエストごとに 1 回実行され、ページ、並べ替え、フィルターを持つ DataQuery を受け取ります。
  • 選択肢や参照は、フィールド型(#[choice])、フォームの参照(Form::lookup。ユーザーが入力したときに自分の action に問い合わせます)、または上限付きキャッシュで保持するサービスに置き、ビルダーが実行するクエリには置きません。
  • 画面がレコードを読み込む必要がある場合(Form::of(record))は、大きいグリッドや仮想グリッドをその画面に置かないでください。専用の画面、詳細画面(DataGrid::detail)、またはウィンドウに置けば、そのブロックリクエストは何も読み込まない画面を作り直すだけで済みます。
  • 画面の params はビルダーに必要な入力なので、キーだけにとどめ、それによる絞り込みはグリッドの action に任せます(DataGrid::scope)。