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

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

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

4 питання

ImportAction дає користувачу завантажити CSV, зіставити його колонки з полями моделі й імпортувати рядки у фоні. Рядки, що не пройшли валідацію, збираються в окремий CSV «невдалих рядків» для завантаження.

Підготовка:

php artisan make:queue-batches-table
php artisan make:notifications-table
php artisan vendor:publish --tag=filament-actions-migrations
php artisan migrate
php artisan make:filament-importer Product --generate

Імпорт використовує пакети джоб (job batches) і сповіщення в базі даних - без воркера черги нічого не відбудеться.

Імпортер:

use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;

class ProductImporter extends Importer
{
    protected static ?string $model = Product::class;

    public static function getColumns(): array
    {
        return [
            ImportColumn::make('sku')->requiredMapping()->rules(['required', 'max:32']),
            ImportColumn::make('name')->requiredMapping()->rules(['required', 'max:255']),
            ImportColumn::make('price')->numeric()->rules(['numeric', 'min:0']),
            ImportColumn::make('category')->relationship(resolveUsing: 'slug'),
        ];
    }

    public function resolveRecord(): ?Product
    {
        // оновлювати наявні товари за SKU, нові - створювати
        return Product::firstOrNew(['sku' => $this->data['sku']]);
    }
}
ImportAction::make()
    ->importer(ProductImporter::class)
    ->chunkSize(250)
    ->maxRows(50_000);

CSV ділиться на частини (за замовчуванням по 100 рядків), кожна обробляється окремою джобою.

Ризики й як їх закрити:

  • немає авторизації кожного запису. Імпорт не викликає політики: хто може запустити імпорт, той створить чи оновить будь-який запис, який поверне resolveRecord(). Для недовірених користувачів перевірки треба додати в хуки beforeCreate() / beforeUpdate() імпортера;
  • resolveRecord() за слабким ключем (назва, email без нормалізації) - дублікати чи перезапис чужих даних;
  • CSV-формули. У файлі невдалих рядків значення лишаються як були. Комірка =HYPERLINK(...) чи =cmd|..., відкрита в Excel, може виконатися як формула. Варто попереджати користувачів або очищати значення, що починаються з =, +, -, @;
  • файл невдалих рядків за замовчуванням може завантажити лише користувач, що запустив імпорт. Власна політика ImportPolicy замінює цю логіку повністю - перевірку автора треба повторити в ній;
  • розмір. maxRows() і розумний chunkSize() захищають від файлу на мільйони рядків, що «покладе» чергу;
  • кастинг перед валідацією - ціни з комою (12,50) чи дати в дд.мм.рррр треба привести в castStateUsing(), інакше всі рядки впадуть на валідації.

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

ExportAction формує CSV чи XLSX у фоні: користувач вибирає колонки, отримує сповіщення, коли файл готовий, і завантажує його. Під капотом - пакети джоб і сповіщення в базі даних.

php artisan make:filament-exporter Order --generate
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;

class OrderExporter extends Exporter
{
    protected static ?string $model = Order::class;

    public static function getColumns(): array
    {
        return [
            ExportColumn::make('number'),
            ExportColumn::make('customer.name')->label('Клієнт'),
            ExportColumn::make('items_count')->counts('items'),
            ExportColumn::make('total'),
            ExportColumn::make('created_at'),
        ];
    }
}
use Filament\Actions\ExportAction;
use Filament\Actions\Exports\Enums\ExportFormat;
use Illuminate\Database\Eloquent\Builder;

ExportAction::make()
    ->exporter(OrderExporter::class)
    ->formats([ExportFormat::Xlsx, ExportFormat::Csv])
    ->modifyQueryUsing(fn (Builder $query) => $query->whereBelongsTo(auth()->user()->team))
    ->fileDisk('s3')
    ->chunkSize(500)
    ->maxRows(100_000);

Звідки беруться дані. У таблиці експорт бере поточний запит таблиці - з пошуком, фільтрами й сортуванням. Користувач експортує те, що бачить у списку (усі сторінки).

Безпека:

  • політики для кожного запису не перевіряються. Якщо таблиця показує лише «свої» записи завдяки скоупу ресурсу - добре. Але експорт поза таблицею (з кнопки на сторінці) бере всю модель. Обмеження варто дублювати в modifyQueryUsing() або в modifyQuery() експортера;
  • диск. Готові файли зберігаються на диску. Filament навмисно уникає публічного диска (якщо диск за замовчуванням public і є local, експорт піде в local), але на продакшені краще явно вказати приватний s3/r2;
  • CSV-формули. Значення з бази потрапляють у файл як є. Текст, що починається з =, +, -, @ і вводився користувачами, Excel може виконати. Захист - formatStateUsing() на колонках з довільним текстом (наприклад, префікс ');
  • чутливі колонки (телефони, email, суми) варто не додавати в експортер «про всяк випадок» - користувач вибирає з того, що ви дозволили.

Продуктивність:

  • експорт ділиться на частини (за замовчуванням по 100 рядків) - для простих рядків chunkSize() можна збільшити, для важких зменшити;
  • зв'язки й агрегати в колонках варто жадібно завантажити в modifyQuery() експортера, інакше кожна частина робить N+1;
  • maxRows() не дасть випадково поставити в чергу мільйонний експорт.

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

У Filament три способи доставити сповіщення, і вони розв'язують різні задачі:

Спосіб Куди Зберігається Коли отримує
send() поточний користувач, через сесію ні на наступному рендері
sendToDatabase($user) таблиця notifications так при опитуванні (30 с) чи через вебсокет
broadcast($user) вебсокет-канал користувача ні миттєво, якщо сторінка відкрита

Broadcast-сповіщення - тимчасовий «тост», що з'являється в браузері користувача в реальному часі, але ніде не зберігається. Якщо вкладка закрита - сповіщення втрачено.

Notification::make()
    ->title('Експорт готовий')
    ->success()
    ->broadcast($user);

Налаштування (окрім звичайного broadcasting у Laravel - Reverb чи Pusher):

  1. опублікувати конфіг Filament (php artisan vendor:publish --tag=filament-config);
  2. у config/filament.php розкоментувати й заповнити розділ broadcasting.echo;
  3. переконатися, що в .env є потрібні змінні VITE_*;
  4. мати запущені воркер черги (сповіщення ставиться в чергу) і вебсокет-сервер.

Поєднання найкращого з двох світів - сповіщення в базі даних з миттєвою доставкою:

Notification::make()
    ->title('Новий відгук на вакансію')
    ->sendToDatabase($recruiter, isEventDispatched: true);

Запис зберігається в базі (видно в дзвіночку й пізніше), а подія через вебсокет змушує панель одразу підтягнути нові сповіщення, без очікування опитування. Після цього опитування можна вимкнути: databaseNotificationsPolling(null).

Що обирати:

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

Пастки:

  • приватні канали авторизуються через routes/channels.php - перевірте, що користувач отримує лише свій канал;
  • черга й вебсокет-сервер - дві окремі точки відмови: без воркера сповіщення не відправиться, без Reverb - не дійде;
  • опитування на сотнях вкладок часто дорожче за один вебсокет-сервер - на великих панелях broadcast окупається.

Докладніше в документації: Broadcast-сповіщення

Дії тестують через livewire() на тій сторінці чи компоненті, де вони розміщені.

Дія на сторінці (наприклад, у шапці сторінки редагування):

use App\Filament\Resources\Orders\Pages\EditOrder;
use App\Models\Order;

use function Pest\Livewire\livewire;

it('cancels an order', function () {
    $order = Order::factory()->paid()->create();

    livewire(EditOrder::class, ['record' => $order->getRouteKey()])
        ->callAction('cancel', data: ['reason' => 'Клієнт передумав'])
        ->assertHasNoFormErrors()
        ->assertNotified('Замовлення скасовано');

    expect($order->refresh()->status)->toBe(OrderStatus::Cancelled);
});

Дія в рядку таблиці - через TestAction:

use Filament\Actions\Testing\TestAction;

livewire(ListOrders::class)
    ->callAction(TestAction::make('cancel')->table($order), data: ['reason' => '...']);

// масова дія
livewire(ListOrders::class)
    ->selectTableRecords($orders->pluck('id')->all())
    ->callAction(TestAction::make('cancel')->table()->bulk());

Валідація даних модального вікна:

livewire(EditOrder::class, ['record' => $order->getRouteKey()])
    ->callAction('cancel', data: ['reason' => ''])
    ->assertHasFormErrors(['reason' => 'required']);

Зупинка дії ($action->halt() у хуку):

livewire(EditOrder::class, ['record' => $shipped->getRouteKey()])
    ->callAction('cancel')
    ->assertActionHalted('cancel');

Видимість і права:

$this->actingAs(User::factory()->support()->create());

livewire(ListOrders::class)
    ->assertActionHidden(TestAction::make('refund')->table($order))
    ->assertActionVisible(TestAction::make('view')->table($order));

Сповіщення:

  • ->assertNotified() / ->assertNotified('Заголовок') / ->assertNotNotified() - для сповіщень через сесію;
  • сповіщення в базі даних - це звичайні сповіщення Laravel: Notification::fake() (фасад Laravel) і assertSentTo($user, \Filament\Notifications\DatabaseNotification::class).

Що варто перевіряти обов'язково:

  • заборону, а не лише успіх: користувач без прав не бачить дію, а підроблений виклик нічого не змінює в базі;
  • побічні ефекти через фейки: Mail::fake(), Queue::fake(), Bus::fake(), - а не реальні листи й джоби;
  • для дій у шапці таблиці - TestAction::make('export')->table() без запису.

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