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

Middle: питання на співбесіді з теми «Дії, інфолисти й віджети»

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

5 питань

CreateAction і EditAction відкривають модальне вікно з формою і зберігають запис. Кожен етап можна змінити.

Дані перед збереженням:

use Filament\Actions\CreateAction;

CreateAction::make()
    ->mutateDataUsing(function (array $data): array {
        $data['user_id'] = auth()->id();

        return $data;
    })

Для EditAction є ще mutateRecordDataUsing() - змінити дані запису перед заповненням форми.

Власний процес збереження:

use Illuminate\Database\Eloquent\Model;

CreateAction::make()
    ->using(function (array $data, string $model): Model {
        return app(CreateOrder::class)->handle($data);   // доменна дія замість $model::create()
    })

Хуки життєвого циклу:

CreateAction::make()
    ->beforeFormFilled(fn () => ...)
    ->afterFormValidated(fn () => ...)
    ->before(fn () => ...)          // перед збереженням
    ->after(fn (Model $record) => ...)   // після збереження

Зупинити процес:

use Filament\Notifications\Notification;

EditAction::make()
    ->before(function (EditAction $action, Order $record) {
        if ($record->isShipped()) {
            Notification::make()
                ->warning()
                ->title('Відправлене замовлення змінювати не можна')
                ->send();

            $action->halt();
        }
    })

halt() проти cancel():

  • halt() - зупинити збереження, але лишити модальне вікно відкритим з введеними даними. Користувач може виправити й спробувати ще раз;
  • cancel() - зупинити дію повністю й закрити вікно.

Що варто знати:

  • сповіщення про успіх змінюють через successNotificationTitle() чи successNotification(), а редирект - через successRedirectUrl();
  • after() виконується після збереження, але в тому ж запиті. Довгі операції (листи, інтеграції) варто ставити в чергу, інакше вікно «зависне»;
  • сторінки ресурсу мають свої аналоги: mutateFormDataBeforeCreate(), beforeCreate(), afterCreate(), handleRecordCreation() на класі сторінки;
  • CreateAction має «Створити ще один» - для моделей, де це не має сенсу, вимикається createAnother(false).

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

Сповіщення в базі даних зберігаються в таблиці notifications Laravel і показуються в панелі в окремому вікні з дзвіночком - навіть якщо користувача не було онлайн у момент відправки.

Налаштування:

php artisan make:notifications-table
php artisan migrate
// панель
$panel
    ->databaseNotifications()
    ->databaseNotificationsPolling('30s');

Відправка:

use Filament\Actions\Action;
use Filament\Notifications\Notification;

Notification::make()
    ->title('Новий відгук на вакансію')
    ->body("{$candidate->name} відгукнувся на «{$vacancy->title}».")
    ->actions([
        Action::make('open')
            ->label('Відкрити')
            ->url(VacancyResource::getUrl('edit', ['record' => $vacancy]))
            ->markAsRead(),
    ])
    ->sendToDatabase($recruiter);

Можна і через звичайний клас сповіщення Laravel, повернувши з toDatabase() результат Notification::make()->...->getDatabaseMessage().

Чому сповіщення не з'являються - типові причини:

  1. Немає воркера черги. Клас сповіщення Filament реалізує ShouldQueue, тож запис у базу виконується в черзі. Без запущеного queue:work (чи Horizon) сповіщення просто чекають у черзі.
  2. Не ввімкнено databaseNotifications() у тій панелі, де очікують дзвіночок.
  3. Опитування. Нові сповіщення підтягуються раз на 30 секунд (за замовчуванням). Миттєво - лише з вебсокетами: sendToDatabase($user, isEventDispatched: true) плюс налаштований Laravel Echo.
  4. PostgreSQL: колонка data у міграції має бути json().
  5. UUID у моделі користувача: у міграції потрібні uuidMorphs('notifiable').

Що варто знати:

  • сповіщення адресуються конкретній моделі - щоб повідомити всіх адміністраторів, передайте колекцію: sendToDatabase(User::admins()->get());
  • опитування раз на 30 секунд на кожній відкритій вкладці кожного користувача - теж навантаження; на великих панелях вебсокети вигідніші;
  • старі сповіщення варто чистити (планувальником), інакше таблиця росте без меж.

Докладніше в документації: Сповіщення в базі даних

Графіки у Filament малюються бібліотекою Chart.js. Віджет повертає дані в її форматі.

php artisan make:filament-widget OrdersChart --chart
use Filament\Widgets\ChartWidget;

class OrdersChart extends ChartWidget
{
    protected ?string $heading = 'Замовлення';

    protected ?string $pollingInterval = null;   // історичним даним опитування не потрібне

    public ?string $filter = 'month';

    protected function getFilters(): ?array
    {
        return [
            'week' => 'Тиждень',
            'month' => 'Місяць',
            'year' => 'Рік',
        ];
    }

    protected function getData(): array
    {
        $days = match ($this->filter) {
            'week' => 7,
            'year' => 365,
            default => 30,
        };

        $rows = Order::query()
            ->where('created_at', '>=', now()->subDays($days))
            ->selectRaw('DATE(created_at) as day, COUNT(*) as total')
            ->groupBy('day')
            ->orderBy('day')
            ->pluck('total', 'day');

        return [
            'datasets' => [
                ['label' => 'Замовлення', 'data' => $rows->values()->all()],
            ],
            'labels' => $rows->keys()->all(),
        ];
    }

    protected function getType(): string
    {
        return 'line';
    }
}

Типи графіків: line, bar, pie, doughnut, radar, polarArea, bubble, scatter. Опції Chart.js - через getOptions().

Готове рішення для часових рядів - пакет flowframe/laravel-trend, який рекомендує документація: Trend::model(Order::class)->between(...)->perDay()->count() повертає значення й дні без даних (агрегат з GROUP BY дні без замовлень просто пропускає, і графік виходить нерівномірним).

Пастки:

  • $filter - публічна властивість Livewire, її значення приходить з браузера. Використовувати її лише через match з відомими варіантами, а не підставляти в запит напряму;
  • опитування за замовчуванням - кожні 5 секунд. Агрегат за рік щоп'ять секунд на кожному відкритому дашборді - марне навантаження. Для історичних графіків опитування вимикають;
  • важкі агрегати - кешувати з ключем, що містить фільтр, або рахувати заздалегідь у таблицю статистики;
  • DATE(created_at) групує за часовим поясом бази - якщо база в UTC, а користувачі в Києві, межі днів «зсунуться».

Докладніше в документації: Віджети-графіки

Коли на дашборді кілька віджетів, зручно мати один набір фільтрів (період, регіон, менеджер), що діє на всі одразу.

1. Власна сторінка дашборда з формою фільтрів:

namespace App\Filament\Pages;

use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;

class Dashboard extends BaseDashboard
{
    use HasFiltersForm;

    public function filtersForm(Schema $schema): Schema
    {
        return $schema
            ->components([
                Section::make()
                    ->schema([
                        DatePicker::make('startDate'),
                        DatePicker::make('endDate'),
                        Select::make('region')->options(Region::class),
                    ])
                    ->columns(3)
                    ->columnSpanFull(),
            ]);
    }
}

Стандартний дашборд треба замінити цим класом (прибрати Dashboard::class зі сторінок панелі чи вимкнути автовиявлення стандартного).

2. Віджети читають фільтри через трейт:

use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class SalesOverview extends StatsOverviewWidget
{
    use InteractsWithPageFilters;

    protected function getStats(): array
    {
        $query = Order::query()
            ->when($this->pageFilters['startDate'] ?? null, fn ($q, $date) => $q->whereDate('created_at', '>=', $date))
            ->when($this->pageFilters['endDate'] ?? null, fn ($q, $date) => $q->whereDate('created_at', '<=', $date))
            ->when($this->pageFilters['region'] ?? null, fn ($q, $region) => $q->where('region', $region));

        return [
            Stat::make('Замовлень', $query->count()),
            Stat::make('Виручка', Number::currency((clone $query)->sum('total'), 'UAH', 'uk')),
        ];
    }
}

Коли фільтри змінюються, віджети перезавантажуються з новими значеннями.

Варіанти:

  • фільтри можна винести в модальне вікно за кнопкою (HasFiltersAction) - дашборд лишається компактним;
  • $this->pageFilters можна зберігати в сесії, щоб вибраний період не скидався при поверненні.

Пастки:

  • $pageFilters - сирі дані з браузера. Перевіряйте й приводьте їх (дати, значення зі списку), а не підставляйте в whereRaw;
  • повторне використання запиту - count() і sum() на тому самому білдері працюють, але при модифікаціях краще clone, щоб умови не накопичувалися;
  • кожна зміна фільтра перераховує всі віджети - для важких метрик потрібне кешування з ключем від фільтрів.

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

Способи обмежити дію:

use Filament\Actions\Action;

// показати лише тим, кому можна
Action::make('refund')
    ->visible(fn (Order $record): bool => auth()->user()->can('refund', $record))
    ->action(fn (Order $record) => $record->refund());

// через політику моделі
Action::make('refund')
    ->authorize('refund')
    ->action(fn (Order $record) => $record->refund());

// показати вимкненою з поясненням з політики
Action::make('refund')
    ->authorize('refund')
    ->authorizationTooltip();

// обмежити частоту
Action::make('resendInvoice')
    ->rateLimit(5);   // спроб на хвилину з однієї IP-адреси

Чи захищає прихована кнопка? Так, і це важлива відмінність Filament від «голого» Livewire. Дії викликаються через Livewire-запит, який можна підробити. Але перед виконанням Filament перевіряє дію на сервері: якщо visible() повертає false (або hidden() - true) чи дія disabled(), вона вважається вимкненою і не виконується. Тобто умова видимості працює і як перевірка доступу.

Порівняйте зі звичайним Livewire-компонентом: публічний метод можна викликати з консолі браузера, навіть якщо кнопки на сторінці немає, - там авторизацію треба писати в самому методі.

Готові дії в ресурсах (EditAction, DeleteAction, DeleteBulkAction) самі перевіряють відповідні методи політики (update, delete, deleteAny) - викликати authorize() для них не потрібно.

Пастки:

  • умова має залежати від запису. visible(auth()->user()->isAdmin()) у таблиці перевіряє роль, але не те, чи може цей адміністратор чіпати цей запис (інший тенант, чужий відділ);
  • масові дії - для перевірки кожного вибраного запису потрібен authorizeIndividualRecords('refund'), інакше перевіряється лише загальний доступ;
  • дія з url() - це просто посилання: захищати треба маршрут, на який воно веде, а не кнопку;
  • rateLimit() рахує спроби для комбінації дії, запису й сторінки - від повторних кліків захищає, але не замінює серверних обмежень на дорогі операції (листи, платежі).

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