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

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

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

4 питання

Таблиця Filament будується з Eloquent-запиту ресурсу, а колонки описують, що показати з кожного запису:

use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;

public static function configure(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('title')
                ->searchable()
                ->sortable(),
            TextColumn::make('author.name')
                ->label('Автор')
                ->searchable(),
            IconColumn::make('is_featured')
                ->boolean(),
            TextColumn::make('created_at')
                ->dateTime('d.m.Y H:i')
                ->sortable(),
        ])
        ->defaultSort('created_at', 'desc');
}

Що тут відбувається:

  • TextColumn::make('title') - ім'я колонки збігається з атрибутом моделі;
  • крапкова нотація author.name - значення зі зв'язку. Filament сам додає жадібне завантаження author, тож N+1 тут немає;
  • searchable() - у таблиці з'являється поле пошуку, і Filament додає where ... like по цій колонці (зокрема крізь зв'язок);
  • sortable() - клік по заголовку сортує запит.

Колонки з обчисленим значенням. Якщо колонка показує аксесор чи результат state(), її в базі немає, і пошук «у лоб» не спрацює. Треба вказати справжні колонки:

TextColumn::make('full_name')
    ->searchable(['first_name', 'last_name'])

Пастки:

  • пошук через LIKE '%...%' по великій таблиці повільний - краще робити пошуковими лише потрібні колонки, а для великих обсягів підключити Scout;
  • state(fn ($record) => $record->orders()->sum('total')) виконує окремий запит на кожен рядок. Для агрегатів є спеціальні методи (counts(), sum()), що працюють одним запитом;
  • для довгого тексту - limit(50) і wrap(), щоб таблиця не роз'їжджалася.

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

Фільтри описують у ->filters([...]). Кожен фільтр отримує Eloquent-запит і звужує його.

Основні типи:

use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Database\Eloquent\Builder;

->filters([
    // чекбокс: увімкнено - діє query()
    Filter::make('is_featured')
        ->label('Лише рекомендовані')
        ->query(fn (Builder $query): Builder => $query->where('is_featured', true)),

    // список значень, можна кілька
    SelectFilter::make('status')
        ->options([
            'draft' => 'Чернетка',
            'published' => 'Опубліковано',
        ])
        ->multiple(),

    // за зв'язком, з пошуком
    SelectFilter::make('author')
        ->relationship('author', 'name')
        ->searchable()
        ->preload(),

    // три стани: так / ні / усі
    TernaryFilter::make('email_verified_at')
        ->nullable(),

    // для моделей з SoftDeletes
    TrashedFilter::make(),
])

Власний фільтр - будь-які поля форми плюс query() з масивом $data, наприклад діапазон дат з двох DatePicker.

Чому фільтр не діє одразу. За замовчуванням зміни фільтрів відкладені: користувач вибирає кілька значень і тисне «Застосувати» - таблиця перезавантажується один раз. Це економить запити. Щоб фільтри працювали миттєво:

$table->deferFilters(false);

Корисне:

  • ->persistFiltersInSession() - фільтри зберігаються між відвідуваннями сторінки;
  • ->default() на фільтрі - активний одразу;
  • filtersLayout: FiltersLayout::AboveContent - показати фільтри над таблицею, а не в випадному меню.

Пастка: query() фільтра загортається в окрему групу where (...), щоб orWhere не зламав інші фільтри. Тому прибрати глобальний скоуп (наприклад, SoftDeletingScope) з query() не вийде - для цього є baseQuery().

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

TextColumn уміє форматувати значення без зміни самих даних.

use Filament\Tables\Columns\TextColumn;

TextColumn::make('status')
    ->badge()
    ->color(fn (string $state): string => match ($state) {
        'draft' => 'gray',
        'paid' => 'success',
        'refunded' => 'danger',
        default => 'warning',
    }),

TextColumn::make('total')
    ->money('UAH'),                    // 1 250,00 ₴ з урахуванням локалі

TextColumn::make('price_cents')
    ->money('UAH', divideBy: 100),     // ціни, що зберігаються в копійках

TextColumn::make('created_at')
    ->dateTime('d.m.Y H:i'),

TextColumn::make('last_login_at')
    ->since(),                         // «3 години тому»

TextColumn::make('title')
    ->description(fn (Post $record): string => $record->excerpt)
    ->limit(60),

Найчистіше рішення для статусів - enum. Якщо атрибут моделі кастується в enum, що реалізує HasLabel і HasColor, колонка з badge() сама бере підпис і колір:

use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;

enum OrderStatus: string implements HasLabel, HasColor
{
    case New = 'new';
    case Paid = 'paid';

    public function getLabel(): string
    {
        return match ($this) {
            self::New => 'Нове',
            self::Paid => 'Оплачено',
        };
    }

    public function getColor(): string
    {
        return match ($this) {
            self::New => 'warning',
            self::Paid => 'success',
        };
    }
}

Той самий enum потім працює в SelectFilter::make('status')->options(OrderStatus::class) і в полях форми. Підписи статусів живуть в одному місці, а не розкидані по match у різних ресурсах.

Пастки:

  • formatStateUsing() змінює лише відображення: пошук і сортування працюють по сирому значенню з бази;
  • html() чи markdown() санітизують вміст, але formatStateUsing(), що повертає HtmlString, - ні: так легко вивести неекрановані дані користувача.

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

Масові дії (bulk actions) виконуються над кількома вибраними рядками. Щойно в таблиці з'являється хоча б одна така дія, біля кожного рядка з'являється чекбокс.

У Filament 5 їх кладуть у toolbarActions() (або headerActions()), часто згрупованими у випадне меню:

use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Illuminate\Database\Eloquent\Collection;

->toolbarActions([
    BulkActionGroup::make([
        DeleteBulkAction::make(),

        BulkAction::make('publish')
            ->label('Опублікувати')
            ->requiresConfirmation()
            ->action(fn (Collection $records) => $records->each->update(['status' => 'published']))
            ->deselectRecordsAfterCompletion(),
    ]),
])

$records - Eloquent-колекція вибраних моделей.

Дії над окремим рядком - окремо, у recordActions():

use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;

->recordActions([
    EditAction::make(),
    DeleteAction::make(),
])

Корисні налаштування:

  • ->checkIfRecordIsSelectableUsing(fn (Order $record): bool => $record->status !== 'archived') - деякі рядки вибрати не можна;
  • ->maxSelectableRecords(100) - обмеження кількості;
  • ->selectCurrentPageOnly() - не давати одним кліком вибрати всі сторінки.

Пастки:

  • $records->each->update(...) - окремий запит на кожен запис. Для сотень рядків це прийнятно, для десятків тисяч - ні (є chunkSelectedRecords() і fetchSelectedRecords(false));
  • права: DeleteBulkAction у ресурсі перевіряє політику deleteAny. Для власних дій перевірку прав треба додати самому, наприклад authorizeIndividualRecords('update') - тоді записи, які користувач змінювати не може, просто не потраплять у $records;
  • після дії вибір рядків за замовчуванням лишається - deselectRecordsAfterCompletion() прибирає його.

Докладніше в документації: Масові дії