Пакет · mui-data-grid

Быстрый старт

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

Что будем строить

Две таблицы: статьи и их авторы.

text
articles                          authors
--------                          -------
id            BIGINT  PK          id    INT     PK
author_id     INT     FK→authors  name  VARCHAR
title         VARCHAR
views         INT
is_published  BOOL
created_at    TIMESTAMP

В браузере — таблица с колонками «Заголовок», «Просмотры», «Автор» и «Создано». Каждая фильтруется и сортируется, данные приходят страницами по 25 строк.

Шаг 1. Запрос

Запрос таблицы состоит из двух частей: конверт грида (страница, размер страницы, модель сортировки, модель фильтров) и ваши собственные параметры. Конверт уже описан в MuiGridRequest — наследуйтесь и добавьте только своё.

app/Article/ArticleGridRequest.php
<?php

namespace App\Article;

use Flytachi\Winter\Kernel\Http\Request\Validation\ListOf;
use Flytachi\Winter\Kernel\Http\Request\Validation\Positive;
use Flytachi\Winter\Kernel\Http\Request\Validation\Valid;
use Flytachi\Winter\MuiDataGrid\Entity\MGFilterModel;
use Flytachi\Winter\MuiDataGrid\Entity\MGSortItem;
use Flytachi\Winter\MuiDataGrid\MuiGridRequest;

class ArticleGridRequest extends MuiGridRequest
{
  public function __construct(
      #[Positive]
      public ?int $authorId = null,      // ваш собственный фильтр

      // конверт грида — объявляем заново и передаём наверх
      int $page = 0,
      int $pageSize = 20,
      #[ListOf(MGSortItem::class)] array $sortModel = [],
      #[Valid] MGFilterModel $filterModel = new MGFilterModel(),
  ) {
      parent::__construct($page, $pageSize, $sortModel, $filterModel);
  }
}

Зачем объявлять конверт заново

Параметры конструктора в PHP не наследуются: чтобы дочерний класс принимал page, sortModel и остальное, их нужно перечислить ещё раз и передать в parent::__construct(). Атрибуты #[ListOf] и #[Valid] на этих параметрах обязательно сохранять — иначе гидратор не будет знать, как собирать вложенные типы. Разбор всех тонкостей — в Запросе и ответе.

Шаг 2. Схема

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

php
use Flytachi\Winter\MuiDataGrid\Schema\FilterType;
use Flytachi\Winter\MuiDataGrid\Schema\GridColumn;
use Flytachi\Winter\MuiDataGrid\Schema\GridSchema;

$schema = 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(),
  GridColumn::for('createdAt',  'a.created_at')->filterable(FilterType::Date)->sortable(),
)->defaultOrder('a.created_at DESC, a.id DESC');

Читается построчно: поле title, которое присылает браузер, соответствует выражению a.title; по нему разрешено фильтровать строковыми операторами и сортировать. Поля, не перечисленного здесь, для таблицы не существует.

defaultOrder() — порядок, который применяется, когда пользователь ничего не отсортировал. Уникальный «хвост» (a.id DESC) в нём обязателен: без него строки с одинаковой датой могут переставляться между страницами, и пользователь увидит дубли или пропуски.

Шаг 3. Сервис

Базовый запрос — ваш: SELECT, JOIN и бизнесовые условия. Библиотека накладывает поверх фильтры, сортировку и пагинацию.

app/Article/ArticleService.php
<?php

namespace App\Article;

use Flytachi\Winter\Cdo\Qb;
use Flytachi\Winter\MuiDataGrid\MuiGrid;
use Flytachi\Winter\MuiDataGrid\MuiGridResponse;
use Flytachi\Winter\MuiDataGrid\Schema\FilterType;
use Flytachi\Winter\MuiDataGrid\Schema\GridColumn;
use Flytachi\Winter\MuiDataGrid\Schema\GridSchema;

class ArticleService
{
  public function grid(ArticleGridRequest $request): MuiGridResponse
  {
      $repo = ArticleRepository::instance('a')
          ->select('a.id, a.title, a.views, a.created_at, au.name author_name')
          ->joinLeft(AuthorRepository::instance('au'), 'au.id = a.author_id')
          ->where(Qb::eq('a.is_published', true));

      if ($request->authorId) {
          $repo->andWhere(Qb::eq('a.author_id', $request->authorId));
      }

      return MuiGrid::wrap($repo, $request, $this->schema());
  }

  private function schema(): GridSchema
  {
      return 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(),
          GridColumn::for('createdAt',  'a.created_at')->filterable(FilterType::Date)->sortable(),
      )->defaultOrder('a.created_at DESC, a.id DESC');
  }
}

Обратите внимание на $request->authorId: это ваш фильтр, а не колонка таблицы, поэтому он применяется к базовому запросу и в схеме не упоминается. Разница между этими двумя видами фильтров разобрана в Фильтрах вне грида.

Шаг 4. Контроллер

app/Article/ArticleController.php
<?php

namespace App\Article;

use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson;
use Flytachi\Winter\Kernel\Http\Request\Validation\Valid;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Route\Annotation\PostMapping;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;

#[RequestMapping('articles')]
class ArticleController
{
  public function __construct(private ArticleService $service)
  {
  }

  #[PostMapping]
  public function grid(
      #[Valid] #[RequestJson] ArticleGridRequest $request
  ): ResponseEntity {
      return ResponseEntity::ok(
          $this->service->grid($request)->toArray()
      );
  }
}

Грид — это POST: модель фильтров и модель сортировки приходят телом JSON, а не строкой запроса.

Шаг 5. Проверяем

bash
curl -X POST http://localhost:8080/articles \
-H 'Content-Type: application/json' \
-d '{
      "page": 0,
      "pageSize": 25,
      "sortModel": [{ "field": "views", "sort": "desc" }],
      "filterModel": {
        "logicOperator": "and",
        "items": [{ "field": "title", "operator": "contains", "value": "winter" }]
      }
    }'

Результат

json
{
"rowCount": 134,
"rows": [
  { "id": 12, "title": "Winter internals", "views": 9001, "created_at": "2026-03-04 10:12:00", "author_name": "Ada" }
]
}

rowCount — сколько всего строк подходит под фильтр (для пагинатора таблицы), rows — текущая страница. Проверьте заодно, что защита работает: попросите отфильтровать по полю, которого нет в схеме, — вернётся 400.

bash
curl -X POST http://localhost:8080/articles \
-H 'Content-Type: application/json' \
-d '{"filterModel":{"items":[{"field":"is_published","operator":"is","value":false}]}}'
json
{ "message": "Column 'is_published' is not allowed for filtering" }

Шаг 6. Подключаем таблицу

В серверном режиме таблица отдаёт своё состояние наружу, а данные принимает готовыми.

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

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

<DataGrid
rows={rows}
rowCount={rowCount}
paginationMode="server"
sortingMode="server"
filterMode="server"
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
onSortModelChange={setSortModel}
onFilterModelChange={setFilterModel}
/>

Значения field у колонок таблицы должны совпадать с именами полей в схеме — это тот же контракт, только со стороны браузера. Подробности, включая режимы загрузки и обработку ошибок, — в Подключении таблицы.

Что дальше