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

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

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

4 питання

QueryBuilder - фільтр, у якому користувач сам складає умови з груп «І» / «АБО» з необмеженою вкладеністю: «статус - оплачено І (сума > 1000 АБО клієнт - VIP)». Це рівень «розширеного пошуку» CRM, без жодного рядка SQL з боку користувача.

use Filament\QueryBuilder\Constraints\BooleanConstraint;
use Filament\QueryBuilder\Constraints\DateConstraint;
use Filament\QueryBuilder\Constraints\NumberConstraint;
use Filament\QueryBuilder\Constraints\RelationshipConstraint;
use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\IsRelatedToOperator;
use Filament\QueryBuilder\Constraints\SelectConstraint;
use Filament\QueryBuilder\Constraints\TextConstraint;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\QueryBuilder;

->filters([
    QueryBuilder::make()
        ->constraints([
            TextConstraint::make('customer_name'),
            NumberConstraint::make('total'),
            BooleanConstraint::make('is_paid'),
            DateConstraint::make('created_at'),
            SelectConstraint::make('status')
                ->options(OrderStatus::class)
                ->multiple(),
            RelationshipConstraint::make('tags')
                ->multiple()
                ->selectable(
                    IsRelatedToOperator::make()
                        ->titleAttribute('name')
                        ->searchable()
                        ->multiple(),
                ),
            NumberConstraint::make('items.qty')->integer(),
        ]),
], layout: FiltersLayout::AboveContent)

Що дає кожне обмеження: готовий набір операторів для свого типу - «містить», «починається з», «більше», «між датами», «є / немає пов'язаних записів» тощо. Можна перевизначити список операторів і написати власні обмеження й оператори.

Коли QueryBuilder доречний:

  • аналітики й менеджери, яким потрібні довільні комбінації умов, а не заздалегідь передбачені фільтри;
  • таблиці з десятками полів, де окремий фільтр на кожне перетворив би панель фільтрів на стіну.

Коли краще звичайні фільтри: для 3-5 типових сценаріїв («мої», «неоплачені», «за місяць») звичайні SelectFilter/TernaryFilter зрозуміліші і швидші в роботі.

Що варто врахувати:

  • продуктивність. Користувач може зібрати умову, під яку немає жодного індексу: LIKE по тексту, OR між різними колонками, whereHas по великих зв'язках. На великих таблицях варто обмежити набір обмежень полями з індексами;
  • зв'язки перетворюються на whereHas - підзапити, які можуть бути дорогими;
  • місце. Вкладені групи потребують простору - фільтри краще винести над таблицею (FiltersLayout::AboveContent);
  • збереження: у поєднанні з persistFiltersInSession() складний фільтр не доведеться збирати щоразу.

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

Таблиці Filament спочатку будувалися навколо Eloquent, але з методом records() джерелом рядків може бути будь-що: масив, колекція, відповідь стороннього API.

use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Http;

public function table(Table $table): Table
{
    return $table
        ->records(function (int $page, int $recordsPerPage, ?string $sortColumn, ?string $sortDirection, ?string $search): LengthAwarePaginator {
            $response = Http::baseUrl('https://api.example.com')
                ->get('/invoices', [
                    'page' => $page,
                    'per_page' => $recordsPerPage,
                    'sort' => $sortColumn,
                    'direction' => $sortDirection,
                    'q' => $search,
                ])
                ->throw()
                ->json();

            return new LengthAwarePaginator(
                collect($response['data'])->keyBy('id'),
                total: $response['total'],
                perPage: $recordsPerPage,
                currentPage: $page,
            );
        })
        ->columns([
            TextColumn::make('number')->searchable(),
            TextColumn::make('amount')->money('UAH')->sortable(),
        ]);
}

Головна відмінність - усе вручну. Вбудовані пошук, сортування, фільтри й пагінація Filament генерують SQL. З власними даними їх немає кому виконати, тому параметри ін'єктуються в records():

  • $page, $recordsPerPage - для пагінації (повернути LengthAwarePaginator);
  • $sortColumn, $sortDirection - для сортування;
  • $search - для пошуку;
  • $filters - масив станів фільтрів.

Ключі масиву - ідентифікатори записів. Їх треба робити стабільними й унікальними (keyBy('id')): за ними Livewire відрізняє рядки між оновленнями, а дії отримують потрібний запис.

У колбеках колонок і дій запис - масив, а не модель: fn (array $record) => ....

Що врахувати:

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

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

За замовчуванням масова дія завантажує всі вибрані моделі в пам'ять і передає Eloquent-колекцію в action(). Коли користувач натискає «вибрати все» на таблиці з 200 000 рядків, це легко перевищує ліміт пам'яті PHP.

Порційна обробка:

use Filament\Actions\BulkAction;
use Illuminate\Support\LazyCollection;

BulkAction::make('archive')
    ->chunkSelectedRecords(250)
    ->action(function (LazyCollection $records) {
        $records->each->update(['archived_at' => now()]);
    })

Записи підтягуються пачками, $records стає LazyCollection - пам'ять стала, а код майже не змінюється.

Без завантаження моделей взагалі:

use Illuminate\Support\Collection;

BulkAction::make('archive')
    ->fetchSelectedRecords(false)
    ->action(fn (Collection $records) => Order::whereKey($records)->update(['archived_at' => now()]))

Тут $records - лише ідентифікатори, а оновлення - один SQL-запит.

Ціна fetchSelectedRecords(false). Filament завантажує моделі не просто так:

  • щоб перевірити політику для кожного запису (authorizeIndividualRecords('update'));
  • щоб спрацювали події моделі (updating, deleted, спостерігачі, журнал активності, очищення кешу, Scout-індексація).

Масовий update()/delete() через запит обходить і те, і те. Для DeleteBulkAction це означає: без deleting-подій не видаляться файли, не оновиться пошуковий індекс, не запишеться аудит.

Як обирати:

Потреба Підхід
потрібні політики й події, записів сотні за замовчуванням
потрібні політики й події, записів тисячі chunkSelectedRecords()
чиста операція над даними, подій немає fetchSelectedRecords(false)
дуже довга операція поставити джобу в чергу з ідентифікаторами

Звіт про часткові невдачі. Якщо частина записів не обробилася, $action->reportBulkProcessingFailure() додасть причину в підсумкове сповіщення («3 з 50 замовлень не вдалося архівувати»), а authorizeIndividualRecords() так само повідомляє про записи, відкинуті політикою.

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

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

Сторінка списку ресурсу - Livewire-компонент, тож її тестують через livewire() з хелперами Filament.

use App\Filament\Resources\Orders\Pages\ListOrders;
use App\Models\Order;
use App\Models\User;
use Filament\Actions\Testing\TestAction;

use function Pest\Livewire\livewire;

beforeEach(fn () => $this->actingAs(User::factory()->admin()->create()));

it('lists, searches and sorts orders', function () {
    $orders = Order::factory()->count(5)->create();

    livewire(ListOrders::class)
        ->assertCanSeeTableRecords($orders)
        ->searchTable($orders->first()->number)
        ->assertCanSeeTableRecords($orders->take(1))
        ->assertCanNotSeeTableRecords($orders->skip(1));

    livewire(ListOrders::class)
        ->sortTable('total', 'desc')
        ->assertCanSeeTableRecords($orders->sortByDesc('total'), inOrder: true);
});

it('filters by status', function () {
    $paid = Order::factory()->paid()->count(2)->create();
    $new = Order::factory()->count(3)->create();

    livewire(ListOrders::class)
        ->filterTable('status', 'paid')
        ->assertCanSeeTableRecords($paid)
        ->assertCanNotSeeTableRecords($new);
});

it('archives an order from the row action', function () {
    $order = Order::factory()->create();

    livewire(ListOrders::class)
        ->callAction(TestAction::make('archive')->table($order))
        ->assertNotified();

    expect($order->fresh()->archived_at)->not->toBeNull();
});

Масові дії - спершу вибрати рядки:

livewire(ListOrders::class)
    ->selectTableRecords($orders->pluck('id')->all())
    ->callAction(TestAction::make('archive')->table()->bulk());

Пастки:

  • автентифікація обов'язкова - без actingAs() тест отримає редирект на вхід чи 403, і зовсім не те, що перевіряє;
  • deferLoading() - таблиця з відкладеним завантаженням не містить рядків, доки не викликати ->loadTable(). Без цього assertCanSeeTableRecords падає на, здавалося б, робочому коді;
  • збережений у сесії стан (фільтри, сортування) може перетікати між викликами livewire() в одному тесті;
  • видимість дій для різних ролей варто тестувати окремо: assertActionHidden(TestAction::make('delete')->table($order)) для користувача без прав;
  • тестувати варто свою логіку (власні фільтри, дії, скоупи), а не те, що Filament уміє сортувати колонку.

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