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 уміє сортувати колонку.