Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Middle: питання на співбесіді з теми «Таблиці Filament»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Найчастіша причина повільної таблиці в адмінці - запит на кожен рядок для підрахунку пов'язаних записів. Filament має для цього методи, які додають агрегат в основний запит (через withCount, withSum тощо).

use Filament\Tables\Columns\TextColumn;
use Illuminate\Database\Eloquent\Builder;

// кількість
TextColumn::make('orders_count')
    ->counts('orders')
    ->sortable(),

// кількість зі скоупом
TextColumn::make('orders_count')
    ->counts(['orders' => fn (Builder $query) => $query->where('status', 'paid')]),

// чи є хоча б один
TextColumn::make('orders_exists')
    ->exists('orders'),

// сума, середнє, мінімум, максимум
TextColumn::make('orders_sum_total')
    ->sum('orders', 'total')
    ->money('UAH'),

TextColumn::make('reviews_avg_rating')
    ->avg('reviews', 'rating')
    ->numeric(decimalPlaces: 1),

Ім'я колонки обов'язково за конвенцією Laravel: {зв'язок}_count, {зв'язок}_exists, {зв'язок}_{функція}_{поле}. Саме під таким ім'ям Eloquent кладе результат у модель.

Чому не state():

// N+1: окремий SELECT COUNT на кожен рядок сторінки
TextColumn::make('orders')
    ->state(fn (Customer $record): int => $record->orders()->count()),

На сторінці з 50 рядками - 50 додаткових запитів. Агрегатні методи дають один запит з підзапитами, і по таких колонках ще й можна сортувати.

Інші джерела N+1 у таблицях:

  • крапкова нотація (author.name) Filament завантажує жадібно сам;
  • але зв'язки, до яких звертаються всередині колбеків description(), color(), url(), - ні. Їх треба підвантажити явно:
->modifyQueryUsing(fn (Builder $query) => $query->with(['author.media', 'category']))

Як ловити: Model::preventLazyLoading() у локальному оточенні - лінивий доступ до незавантаженого зв'язку кидає виняток, і проблема видна одразу, а не на продакшені.

Докладніше в документації: Колонки: агрегати зв'язків

Підсумки (summaries) - рядок під таблицею з агрегатами по колонках. Їх додають до колонки методом summarize():

use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\Summarizers\Average;
use Filament\Tables\Columns\Summarizers\Count;
use Filament\Tables\Columns\Summarizers\Range;
use Filament\Tables\Columns\Summarizers\Sum;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Database\Query\Builder;

TextColumn::make('total')
    ->money('UAH')
    ->summarize([
        Sum::make()->money('UAH')->label('Разом'),
        Average::make()->money('UAH')->label('Середній чек'),
    ]),

TextColumn::make('created_at')
    ->date()
    ->summarize(Range::make()->minimalDateTimeDifference()),

IconColumn::make('is_paid')
    ->boolean()
    ->summarize(
        Count::make()->query(fn (Builder $query) => $query->where('is_paid', true))->label('Оплачено'),
    ),

Чотири вбудовані підсумовувачі: Sum, Average, Count, Range (мінімум-максимум, зокрема для дат). Власний - через Summarizer::make()->using(...).

Як вони рахуються:

  • окремим SQL-агрегатом по запиту таблиці з урахуванням пошуку й фільтрів;
  • якщо сторінок кілька, показуються два рядки: підсумок поточної сторінки й підсумок по всіх записах;
  • query() звужує набір лише для цього підсумку (наприклад, рахувати тільки оплачені).

Підсумки по групах. Якщо рядки згруповані (defaultGroup('status')), підсумок з'являється під кожною групою. А groupsOnly() ховає самі рядки, лишаючи тільки групи з підсумками - вийде простий звіт прямо в адмінці.

Пастки:

  • перша колонка таблиці не може мати підсумків - у ній виводиться підпис рядка підсумків;
  • підсумки працюють по колонці в базі. Для колонки з аксесором чи state() SQL-агрегат порахувати нема з чого;
  • кожен підсумок - окремий агрегатний запит на кожне оновлення таблиці. На великих таблицях без індексів під фільтри це відчутно;
  • гроші краще зберігати в копійках цілим числом і показувати через money(divideBy: 100), щоб сума не накопичувала похибку float.

Докладніше в документації: Підсумки таблиць

Групування показує рядки блоками зі спільним заголовком: замовлення за статусом, задачі за проєктом, події за днем.

Групування за замовчуванням:

$table->defaultGroup('status');

Вибір групування користувачем:

use Filament\Tables\Grouping\Group;

$table
    ->groups([
        'status',
        Group::make('author.name')
            ->label('Автор')
            ->collapsible(),
        Group::make('created_at')
            ->label('День')
            ->date(),
    ])
    ->defaultGroup('status');
  • за зв'язком - крапкова нотація (author.name);
  • за датою - date() групує по дню й ігнорує час, інакше кожна мітка часу стала б окремою групою;
  • collapsible() - групи можна згортати.

Заголовок і опис групи:

Group::make('status')
    ->getTitleFromRecordUsing(fn (Order $record): string => $record->status->getLabel())
    ->titlePrefixedWithLabel(false)

Для enum з HasLabel заголовок береться з підпису автоматично.

Як це працює. Filament сортує запит за полем групи і розбиває поточну сторінку на блоки. Тобто групування - це сортування плюс заголовки, а не GROUP BY. Звідси наслідки:

  • група може «розірватися» між сторінками пагінації: частина замовлень зі статусом «нове» на одній сторінці, частина на наступній;
  • групування за зв'язком сортує через приєднання таблиці зв'язку - на великих обсягах варто мати індекси;
  • власне сортування користувача діє всередині групи.

Поєднання з підсумками. Підсумовувачі колонок (summarize()) показують агрегат під кожною групою, а groupsOnly() ховає рядки й лишає тільки заголовки груп з підсумками - готовий звіт «скільки й на яку суму в кожному статусі».

Масовий вибір у групі: selectGroupsOnly() дозволяє масово вибирати рядки лише в межах однієї групи - корисно, коли масова дія має сенс тільки для однорідних записів.

Докладніше в документації: Групування рядків

Колонки, які можна ховати:

TextColumn::make('email')
    ->toggleable(),

TextColumn::make('id')
    ->toggleable(isToggledHiddenByDefault: true),   // прихована, але доступна

У таблиці з'являється менеджер колонок, де користувач вмикає й вимикає їх. reorderableColumns() дозволяє ще й міняти порядок.

Стан колонок зберігається в сесії за замовчуванням - після переходу на іншу сторінку й назад користувач бачить свій набір. Вимкнути: ->persistColumnsInSession(false).

Що ще можна зберігати в сесії (за замовчуванням вимкнено):

$table
    ->persistFiltersInSession()
    ->persistSortInSession()
    ->persistSearchInSession()
    ->persistColumnSearchesInSession();

Типовий сценарій: менеджер відфільтрував замовлення, відкрив одне, відредагував і повернувся до списку - фільтри на місці, а не скинуті.

Глобальні налаштування для всіх таблиць - у сервіс-провайдері:

use Filament\Tables\Table;

Table::configureUsing(function (Table $table): void {
    $table
        ->persistFiltersInSession()
        ->paginationPageOptions([10, 25, 50]);
});

Пастки й нюанси:

  • сесія і URL - різні речі. На сторінці списку ресурсу пошук, сортування, фільтри й групування й так синхронізуються з query string - таким посиланням можна поділитися. Сесія потрібна для іншого: щоб стан відновився, коли користувач повертається на сторінку без параметрів в адресі, наприклад через меню. У власних Livewire-компонентах з таблицею синхронізації з URL за замовчуванням немає;
  • кілька таблиць на сторінці (основна плюс relation managers чи віджети) мають власні ключі стану. Для власних Livewire-компонентів з кількома таблицями знадобиться queryStringIdentifier(), щоб параметри URL не конфліктували;
  • збережений фільтр може «загубити» записи: користувач відфільтрував, забув і через день думає, що даних немає. Помітні індикатори активних фільтрів і кнопка скидання тут дуже доречні;
  • приховані колонки не потрапляють у вивід, але запит для них не змінюється - важкі агрегати в прихованій колонці все одно виконуються, якщо додані через modifyQueryUsing.

Докладніше в документації: Керування колонками