Перейти к содержимому

Таблица данных (Data Grid)

Эта страница переведена с английского. Читать страницу на английском

Статус: реализовано в zeyn::DataGrid, в endpoint-ах таблицы в zeyn::server и в assets/zeyn/*.js (отдаются как /_zeyn/zeyn.js).

Таблица рендерится на сервере. Её строки приходят из action, который принимает DataQuery и возвращает Page<E>. Браузерный runtime запрашивает страницы, отправляет действия пользователя (сортировка, фильтры, раскладка, правки, действия) и подставляет полученный 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 и добавляет Page::with_total(field, op, value) из одного агрегатного запроса. Таблица оставляет только те итоги, которые объявлены её столбцами, поэтому клиент не может запросить другие. Итоги форматируются на сервере на языке пользователя. Средние значения целых чисел имеют два десятичных знака (Value::Decimal), а средние значения денег округляются от нуля (half away from zero) до наименьшей единицы.

Фильтры и сортировка

filter() добавляет фильтр в строку фильтров, а поле ввода выбирается по типу столбца:

СтолбецФильтрЗапрос
Текстсодержит, начинается с, равноcontains, starts_with, equals
Целое число, деньгиот, доfrom, to в виде 1234.56
Датаот, доfrom, to в виде 2026-03-31
Момент (DateTime)от, до (даты)целый день в часовом поясе пользователя
Времяот, до14:30
Выбор (Choice)любой из вариантовany_of
Да/нетвсе, да, нетis

Значения передаются в фиксированной машинной форме, независимо от языка пользователя. Старая форма { "field", "contains" } по-прежнему работает. Сервер отбрасывает фильтры по столбцам без filter() и сортировку по столбцам, которых нет в таблице, а также ограничивает число фильтров (32), длину текста фильтра (200 символов), число вариантов выбора (100) и число ключей сортировки (4). Значение, которое не удаётся прочитать, игнорируется. Часовой пояс для фильтров по датам берётся из настроек пользователя на сервере.

Shift+щелчок по заголовку добавляет этот столбец в ключи сортировки. Число в заголовке показывает порядок.

Столбцы: закрепление, размер, скрытие, перемещение

pin_start() и pin_end() удерживают столбец у одного края при горизонтальной прокрутке таблицы (не более трёх с каждой стороны). hidden() убирает столбец, пока пользователь его не покажет. У каждого заголовка есть меню столбца (кнопка, щелчок правой кнопкой, Shift+F10 или клавиша контекстного меню): сортировка, закрепление, перемещение влево и вправо, скрытие, фильтр, выбор столбцов и сброс. Край заголовка меняет ширину столбца перетаскиванием или стрелками (Shift для больших шагов). Перетаскивание заголовка на другой заголовок той же закреплённой группы перемещает столбец, а короткое нажатие по-прежнему сортирует. Всё это работает и в режиме справа налево.

Сохранённые представления

Раскладка пользователя (показанные столбцы по порядку, ширины, закрепления), сортировка и фильтры образуют DataGridView. С App::datagrid_views(store) он сохраняется на сервере для каждого пользователя, арендатора (tenant), экрана и таблицы, а страница возвращается уже в нём, с первой страницей, отсортированной и отфильтрованной. Реализуйте DataGridViewStore для базы данных; MemoryViewStore предназначен для разработки и тестов. Без хранилища браузер держит представление в localStorage. Сервер очищает каждое получаемое представление по собственным столбцам и лимитам таблицы.

Ячейки

МетодСтолбцыПоказывает
badge(|v| Tone)enum с #[choice]цветной бейдж для каждого варианта
progress(max)числа, деньгиполосу и число
link(|row| Effect)любыезначение как ссылку, запускающую 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(...) добавляет его в пустое место экрана. Пункты, запускающие action, показываются только пользователям с правом на этот action.

Когда пункт строки запускается, клиент отправляет только ключ строки и пункт. Сервер заново загружает строку через action таблицы, строит пункт из этой строки, проверяет право на action, запускает перехватчики (interceptors), а затем сам action. Пункт, которого для строки больше нет (например, ставший недоступным), отклоняется. Входные данные никогда не приходят от клиента.

bulk_action(label, action, Model::key) добавляет кнопку в панель выбора. Action принимает Vec<K>, где K это тип поля ключа; всё остальное не компилируется. Выбор «всех подходящих строк» позволяет серверу самому собрать ключи с фильтрами пользователя, до 10 000. Ключи, выбранные по одному, приходят от клиента, поэтому action должен проверять каждую запись так же, как action API.

Во всех остальных местах браузер сохраняет своё контекстное меню. App::browser_context_menu(false) отключает его везде, кроме текстовых полей и редактируемого текста. Это ничего не защищает: пользователи по-прежнему могут читать исходный код страницы, открывать инструменты разработчика или отключать скрипты. Кроме того, пропадают проверка орфографии, копирование ссылки, перевод и вспомогательные технологии. Оставьте значение по умолчанию.

Редактирование на месте

edit_with(save) с action, принимающим Valid<E> (то же правило, что и у форм), и editable() для столбцов. Ключи, поля только для чтения и версии строк никогда не становятся редактируемыми. F2 или двойной щелчок по редактируемой ячейке начинает редактирование строки; Enter сохраняет, Escape отменяет, Ctrl+Z или кнопка в уведомлении отменяет последнее сохранение.

Сервер заново загружает строку по ключу и применяет только редактируемые столбцы и версию строки через Form::read, так что значения читаются и проверяются так же, как в форме. Значение для любого другого столбца отклоняется с 403. Устаревшая версия возвращается как 409, а ошибки валидации возвращаются как 422 и показываются в своих ячейках.

Главная и подчинённая строки (Master detail)

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), поэтому неверные параметры не компилируются. Detail это полноценный экран: форма, вложенная таблица или таблица со своими detail, на любую глубину. Столбец с кнопкой раскрытия показывается только пользователям, которым разрешено открывать экран, а сервер повторно проверяет право на экран при загрузке detail. Параметры проходят через браузер так же, как у окна, поэтому экран detail должен проверять, что пользователю разрешено видеть запись, на которую они указывают.

scope(field, value) ограничивает таблицу строками, у которых field равно value, при каждом запросе. Этот фильтр стоит первым в DataQuery::filters, и клиент не может его убрать, так что вложенная таблица остаётся в пределах своей родительской строки, что бы ни прислал браузер. Слой данных, строящий SQL из фильтров, читает его как любой другой (Filter::range с равными границами для чисел и дат, equals для текста, any_of для выбора, is для да/нет, blank для пустых значений).

Кнопка раскрытия, Enter на ней или Right и Left на строке открывают и закрывают detail. Закрытие удаляет его, поэтому в следующий раз он загружается заново.

Виртуальные таблицы держат каждую строку одной фиксированной высоты, чтобы к любой позиции можно было перейти сразу; кнопки раскрытия они не показывают. Для таблиц с detail используйте страницы или бесконечную подгрузку.

Группировка

group_by(field) группирует строки на сервере; вызовите его снова, чтобы группировать внутри каждой группы:

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

Таблица показывает строку заголовка для каждой группы с названием столбца, значением, числом строк и итогами всех столбцов по этой группе под соответствующим столбцом. Открытие группы (её кнопкой или Right и Left) загружает её подгруппы или строки по одной странице, со строкой «Загрузить ещё», если остались ещё строки; закрытие удаляет их. Верхний уровень разбивается на страницы по группам, поэтому пейджер считает группы. Нижняя строка хранит итоги по всем подходящим строкам. Строки внутри групп сохраняют всё остальное: редактирование, действия строк, меню, выбор и detail.

Слой данных отвечает на DataQuery::group: когда он задан, возвращается Page::groups (значение, число и итоги, которые запрашивает запрос, для каждой группы), отсортированные по значению в направлении ключа сортировки по этому полю и разбитые на страницы как строки, где total считает группы. DataQuery::apply делает это для данных в памяти; SQL использует GROUP BY. group устанавливается таблицей и никогда не читается от клиента. Клиент отправляет только путь значений групп, которые он открывает; сервер проверяет его длину и превращает каждое значение в фильтр по столбцу этой группы, перед фильтрами пользователя (blank для пустой группы).

Древовидные данные

tree(Model::parent) показывает строки в виде дерева по необязательному ключу родителя: строки без родителя наверху, а дочерние строки загружаются при открытии строки, по странице за раз, не более чем на 32 уровня.

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

Первый столбец несёт отступ и кнопку. Чтобы узнать, у каких строк есть дочерние, таблица на каждую страницу делает ещё один запрос к своему action: строки, родитель которых входит в ключи страницы, сгруппированные по полю родителя (Filter::any_of по ключам и DataQuery::group). Фильтры и сортировка применяются на каждом уровне; итоги внизу охватывают все подходящие строки. Древовидные таблицы не группируют и не показывают detail строк. После редактирования на месте строка остаётся на своём месте в дереве.

Виртуальные таблицы не группируют и не показывают деревья, так как у каждой строки там одна фиксированная высота.

Ячейки: навигация, копирование и вставка

Right на строке (или щелчок по ячейке) ставит в неё курсор ячейки, как в электронной таблице. Стрелки перемещают его, Shift со стрелкой (или Shift+щелчок) расширяет диапазон, Home и End ведут к краям строки, а Ctrl+Home и Ctrl+End к первой и последней ячейке таблицы. Tab и Shift+Tab движутся вдоль строки и дальше к следующей, а на краях покидают таблицу. Left на первой ячейке возвращает к строке. Enter или F2 редактирует редактируемую ячейку, Enter нажимает единственную кнопку или ссылку ячейки, Escape снимает диапазон. Ячейки в диапазоне получают aria-selected.

Ctrl+C на ячейке копирует диапазон как текст с разделителями табуляции, со значениями в том виде, в каком их видит пользователь. Ctrl+C на строке по-прежнему копирует выбранные строки.

Ctrl+V на ячейке вставляет текст с разделителями табуляции из электронной таблицы, начиная с курсора; одно значение заполняет весь диапазон. Меняются только редактируемые ячейки (вставка в ячейки только для чтения сообщает об этом), варианты выбора можно вставлять по подписи, да и нет на языке пользователя. Каждая изменённая строка сохраняется через путь редактирования на месте (/_zeyn/grid/edit с версией строки), одна за другой, поэтому каждая строка читается, проверяется и контролируется на сервере как при редактировании на месте; строка с ошибкой показывает свои ошибки в своих ячейках, а остальные сохраняются. Для более чем 10 строк запрашивается подтверждение, более 500 отклоняются, а Ctrl+Z отменяет всю вставку. Правки таблицы, действия строк, массовые действия и действия меню учитываются в App::write_limit, поэтому большая вставка может упереться в лимит записи приложения.

Виртуализация столбцов

Таблица с 40 столбцами и более держит в странице только закреплённые столбцы и столбцы в области видимости (плюс до шести с каждой стороны). Каждый столбец получает фиксированную ширину, а одна ячейка-распорка заменяет пропущенные столбцы с каждой стороны, так что ширины, закрепления и прокрутка остаются прежними. Строки сохраняют полный список ячеек, поэтому копирование, вставка и редактирование видят каждый столбец, а ячейки несут aria-colindex. Окно сдвигается только тогда, когда в область видимости попадает столбец вне его. Сервер по-прежнему отправляет каждую ячейку строки. Такая широкая виртуальная таблица (VIRTUAL_WIDE_COLUMNS, считая столбцы выбора, детализации и действий строки) отправляет весь первый блок внутри <template data-zeyn-rows-later> вместо 64 живых строк, так что браузер никогда не раскладывает строку со всеми столбцами: на широкой таблице галереи самая долгая задача запуска сократилась с 259 до 112 мс, а DOMContentLoaded с 402 до 228 мс (медианы 8 чередующихся загрузок, release-сервер, загруженная машина).

Измерено на широкой таблице галереи (62 столбца, 100 000 строк, виртуальная прокрутка, debug-сервер, Chromium, окно 1400 на 900, дисплей 144 Гц), с виртуализацией столбцов и без неё:

Ячеек в страницеВ сторону, 60 px за кадр (p50 / p95)Вниз, 60 px за кадр (p50 / p95)Вниз, 2000 px за кадр (p50 / p95)
Без2 9766,9 / 7,5 мс42,7 / 64,8 мс78,5 / 141 мс, 178 долгих задач
С1 0567,0 / 28,8 мс24,6 / 41,7 мс28,9 / 48,9 мс, 7 долгих задач

Прокрутка вниз примерно вдвое быстрее, а страница держит треть ячеек. Прокрутка в сторону и так была плавной; кадры, в которых окно сдвигается, стоят до 29 мс. Остальная стоимость приходится на разбор блоков по 200 строк со всеми столбцами, чего виртуализация столбцов не меняет.

Экспорт

exportable() (и, по желанию, export_permission(...), export_limit(n)) позволяет кнопке Export::Csv на панели инструментов скачать все подходящие строки. Сервер постранично обходит action таблицы и сразу передаёт каждую страницу потоком, в раскладке и на языке пользователя. Формулы обезвреживаются. По умолчанию до 100 000 строк.

С Cargo-feature xlsx кнопка Export::Xlsx на панели инструментов скачивает те же строки как книгу Excel (GET /_zeyn/grid/export?format=xlsx). Ячейки сохраняют свои типы: числа, деньги как число с денежным форматом, даты и даты со временем как настоящие даты Excel в часовом поясе пользователя и логические значения. Варианты выбора записываются как их подписи, маскированные поля как их маскированный текст, а текст всегда остаётся текстом, поэтому ничто не становится формулой. Строка заголовков содержит подписи на языке пользователя, выделена жирным и закреплена, а столбцы сохраняют порядок и ширину пользователя.

Книга записывается с rust_xlsxwriter в режиме постоянной памяти: каждая страница по мере прихода уходит во временный файл (в памяти ждут не более двух страниц), а сжатая книга в конце передаётся потоком в ответ. Экспорт 100 000 строк из шести столбцов держал кучу около 134 KiB при записи строк (столько же после 10 000 и после 100 000 строк) и 460 KiB при сжатии; файл занял 3,4 МБ.

С Cargo-feature pdf кнопка Export::Pdf печатает те же строки как отчёт-список (не более 2000 строк); см. PDF documents.

Выбор

selectable() (подразумевается в bulk_action) добавляет столбец с флажками. Shift выбирает диапазон, Space выбирает строку в фокусе, а флажок в заголовке выбирает страницу. Панель выбора предлагает все подходящие строки.

Доступность

У таблицы есть role="grid", aria-rowcount (виртуальные таблицы), aria-colcount, aria-colindex и aria-sort. Строки используют перемещаемую точку табуляции (roving tab stop). Кнопки внутри активной строки достижимы клавишей Tab. Сгруппированные и древовидные таблицы имеют role="treegrid" с aria-level и aria-expanded на строках. Меню используют role="menu" с menuitem, menuitemcheckbox и menuitemradio, а фокус возвращается туда, где он был. Пустое состояние и состояния ошибки озвучиваются (role="status").

Производительность

Измерено на архиве showcase (1 000 000 строк, виртуальная прокрутка, debug-сервер, Chromium, дисплей 144 Гц) с помощью Performance API:

ПрокруткаКадр p50Кадр p95Макс.Долгие задачи
Скорость колеса (60 px за кадр)13,9 мс20,9 мс34,6 мс0
Быстрая (2000 px за кадр)14,1 мс27,8 мс54,9 мс0
Проходы по всему списку13,9 мс27,8 мс48,6 мс0

DOM содержит 62 строки (около 660 элементов), а JS-куча остаётся около 4 МБ. Экспорт 100 000 строк из того же архива передал потоком 7 МБ за 3,4 секунды.

Держите построитель экрана дешёвым

Запросы не хранят состояния, поэтому каждый запрос таблицы заново строит экран, в котором она лежит: каждый блок, который загружает виртуальная таблица (каждые 200 прокрученных строк), каждая страница, сортировка, фильтр и экспорт, каждое встроенное редактирование и каждое действие строки снова запускают функцию #[screen] перед действием таблицы. Серверу нужен экран, чтобы найти таблицу по пути узла и проверить доступ, так что это цена отказа от состояния на пользователя.

Построитель, который только раскладывает компоненты, стоит микросекунды. Построитель, который обращается к базе данных, платит за этот запрос при каждом из таких запросов. Поэтому:

  • Загружайте строки в действии таблицы (DataGrid::of(search)). Оно всё равно выполняется один раз за запрос и получает DataQuery со страницей, сортировкой и фильтрами.
  • Варианты выбора и справочники относятся к типу поля (#[choice]), к поиску формы (Form::lookup, который обращается к своему действию, когда пользователь печатает) или к сервису, который держит их в кэше с ограничением, а не к запросу, который выполняет построитель.
  • Когда экран должен загрузить запись (Form::of(record)), не кладите на него большие или виртуальные таблицы: вынесите их на отдельный экран, в экран детализации (DataGrid::detail) или в окно, чтобы их запросы блоков перестраивали экран, который ничего не загружает.
  • Параметры экрана это входные данные построителя; ограничьте их ключами и пусть действие таблицы фильтрует по ним (DataGrid::scope).