Пакет · mui-data-grid

Подключение таблицы

В серверном режиме таблица перестаёт что-либо вычислять сама: она отдаёт наружу своё состояние и принимает готовую страницу строк. Эта страница — про то, как соединить её с эндпоинтом так, чтобы контракт сошёлся с обеих сторон.

Включить серверный режим

По умолчанию DataGrid пагинирует, сортирует и фильтрует данные, которые у неё уже есть в памяти. Три пропса переводят её в режим «за это отвечает сервер»:

tsx
<DataGrid
paginationMode="server"
sortingMode="server"
filterMode="server"
rowCount={rowCount}
{...rest}
/>

rowCount обязателен: без общего числа строк пагинатор не знает, сколько страниц показывать, и решит, что данные закончились на первой. Именно это значение возвращает MuiGridResponse::$rowCount.

Три режима включаются независимо

Забыть один из трёх — типичная ошибка. Если оставить filterMode клиентским, таблица отфильтрует уже пришедшую страницу: пользователь наберёт условие и увидит пустоту, хотя на сервере совпадения есть.

Отправить состояние

Состояние таблицы приходит тремя коллбэками. Собираем их в одно тело запроса:

tsx
const [rows, setRows] = useState([]);
const [rowCount, setRowCount] = useState(0);
const [loading, setLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 25 });
const [sortModel, setSortModel] = useState([]);
const [filterModel, setFilterModel] = useState({ items: [] });

useEffect(() => {
const controller = new AbortController();
setLoading(true);

fetch('/articles', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  signal: controller.signal,
  body: JSON.stringify({
    page: paginationModel.page,
    pageSize: paginationModel.pageSize,
    sortModel,
    filterModel,
  }),
})
  .then((r) => r.json())
  .then((data) => { setRowCount(data.rowCount); setRows(data.rows); })
  .finally(() => setLoading(false));

return () => controller.abort();
}, [paginationModel, sortModel, filterModel]);

AbortController здесь не украшение: пользователь листает страницы быстрее, чем отвечает сервер, и без отмены ответы приходят вперемешку — таблица показывает предыдущую страницу поверх текущей.

Контракт имён

Значение field у колонки таблицы и имя поля в схеме — одна и та же строка.

tsx
const columns = [
{ field: 'title',      headerName: 'Заголовок', flex: 1 },
{ field: 'views',      headerName: 'Просмотры', type: 'number' },
{ field: 'authorName', headerName: 'Автор' },
];
php
GridSchema::make(
  GridColumn::for('title',      'a.title')->filterable(FilterType::String)->sortable(),
  GridColumn::for('views',      'a.views')->filterable(FilterType::Number)->sortable(),
  GridColumn::for('authorName', 'au.name')->filterable(FilterType::String)->sortable(),
);

Расхождение проявляется по-разному в зависимости от операции: фильтр по незнакомому полю вернёт 400, а сортировка по нему молча не сработает — таблица будет выглядеть исправной, но клик по заголовку ничего не изменит. Причины этой асимметрии — в Ментальной модели.

Совпадать должны имена, а не типы

type: 'number' в колонке таблицы влияет только на то, какие операторы предложит интерфейс. Сервер полагается на FilterType в схеме, поэтому шлюз по типу работает даже для запросов в обход интерфейса.

Идентификатор строки

DataGrid требует, чтобы у каждой строки было уникальное поле id. Если ваш SELECT его не возвращает или называет иначе — укажите таблице, где его искать:

tsx
<DataGrid rows={rows} getRowId={(row) => row.uuid} />

Проще всего этого избежать: включить первичный ключ в select(), даже если колонки для него в таблице нет. Он поедет в браузер, но показан не будет.

php
->select('a.id, a.title, a.views, au.name author_name')

Придать строкам форму

Четвёртый аргумент wrap() — маппер, который прогоняется по строкам текущей страницы (не по всей таблице). Это правильное место, чтобы превратить плоскую строку из базы в тот объект, который удобен фронтенду:

php
final readonly class ArticleRow
{
  public function __construct(
      public int $id,
      public string $title,
      public ?array $author,
  ) {
  }

  public static function from(object $r): self
  {
      return new self(
          id: $r->id,
          title: $r->title,
          author: $r->author_id ? ['id' => $r->author_id, 'name' => $r->author_name] : null,
      );
  }
}

return MuiGrid::wrap($repo, $request, $schema, fn ($r) => ArticleRow::from($r));

Результат

json
{
"rowCount": 134,
"rows": [
  { "id": 12, "title": "Winter internals", "author": { "id": 3, "name": "Ada" } }
]
}

Вложенное поле в таблице показывается через valueGetter:

tsx
{ field: 'authorName', headerName: 'Автор', valueGetter: (v, row) => row.author?.name }

Маппер не меняет фильтрацию

Имена полей в схеме относятся к SQL, а не к результату маппера. Если маппер переименовал author_name в author.name, фильтровать всё равно нужно по authorName — по тому имени, которое объявлено в схеме.

Обработка отказов

Отклонённый фильтр приходит как 400 с текстом. Показать его пользователю полезнее, чем молча оставить пустую таблицу:

tsx
.then(async (r) => {
if (!r.ok) throw new Error((await r.json()).message ?? 'Не удалось загрузить данные');
return r.json();
})
.then((data) => { setRowCount(data.rowCount); setRows(data.rows); })
.catch((e) => setError(e.message))

На практике 400 от грида означает одно из двух: интерфейс прислал поле, которого нет в схеме, или оператор, не разрешённый для типа колонки. И то и другое — расхождение конфигураций, а не действие пользователя, поэтому такие ошибки полезно логировать.

Что дальше