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

Senior: питання на співбесіді з теми «Форми й схеми Filament»

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

4 питання

Кожне live()-поле після зміни робить запит на сервер, і за замовчуванням перерендерюється весь Livewire-компонент - вся форма з усіма секціями, повторювачами, опціями селектів. На формі з сотнею полів це сотні мілісекунд на кожну зміну, і інтерфейс відчувається «важким».

1. Перемальовувати лише те, що залежить від зміни:

TextInput::make('name')
    ->live(onBlur: true)
    ->partiallyRenderComponentsAfterStateUpdated(['email']);   // лише поле email

TextInput::make('quantity')
    ->live(debounce: 500)
    ->partiallyRenderAfterStateUpdated()                       // лише саме поле
    ->belowContent(fn (Get $get): string => 'Разом: ' . $get('quantity') * $get('price'));

2. Не рендерити взагалі, якщо потрібна лише серверна дія:

TextInput::make('search')
    ->live(debounce: 300)
    ->skipRenderAfterStateUpdated()
    ->afterStateUpdated(fn (?string $state) => /* записати в лог, кеш тощо */ null);

3. Перенести логіку в браузер - без запиту зовсім:

Select::make('role')
    ->options(['user' => 'Користувач', 'staff' => 'Персонал']);

Toggle::make('is_admin')
    ->hiddenJs(<<<'JS'
        $get('role') !== 'staff'
        JS);

TextInput::make('name')
    ->afterStateUpdatedJs(<<<'JS'
        $set('slug', ($state ?? '').toLowerCase().replaceAll(' ', '-'))
        JS);

hiddenJs(), visibleJs(), afterStateUpdatedJs() виконуються в Alpine на клієнті миттєво.

Безпека JS-варіантів: рядок з JavaScript виконується в браузері, тож ніколи не вставляйте в нього дані користувача конкатенацією - це XSS. Використовувати $state і $get() як значення безпечно.

4. Інші джерела повільності:

  • options() із запитом без кешу - перераховуються на кожному рендері. Для довгих списків - searchable() з getSearchResultsUsing();
  • Repeater на сотні елементів - кожен рендериться повністю. Тут краще relation manager;
  • важкі замикання у label(), helperText(), visible() - обчислюються на кожному рендері, зокрема запити в базу;
  • preload() великих зв'язків.

Як шукати вузьке місце: вкладка Network - розмір і час відповіді запиту livewire/update; Laravel Debugbar чи Telescope - запити в базу під час одного оновлення форми.

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

RichEditor у Filament 5 побудований на TipTap. За замовчуванням він зберігає HTML, а з json() - структурований JSON у форматі TipTap (модель потребує касту array).

use Filament\Forms\Components\RichEditor;

RichEditor::make('content')
    ->json()
    ->fileAttachmentsDisk('s3')
    ->fileAttachmentsDirectory('posts')
    ->fileAttachmentsVisibility('private');

Головна загроза - XSS. Редактор надсилає на сервер сирий HTML, і зловмисник може перехопити запит і підставити будь-яку розмітку з <script> чи onerror. Тобто вміст редактора - недовірені дані, навіть якщо його вводять лише адміністратори.

Як виводити:

  • у компонентах Filament (TextColumn::make('content')->html(), TextEntry з html()/markdown()) вміст санітизується автоматично;
  • у власному Blade - це ваша відповідальність:
{!! str($post->content)->sanitizeHtml() !!}
  • для JSON-вмісту, приватних зображень чи власних блоків - RichContentRenderer, що теж санітизує:
use Filament\Forms\Components\RichEditor\RichContentRenderer;

RichContentRenderer::make($post->content)
    ->fileAttachmentsDisk('s3')
    ->fileAttachmentsVisibility('private')
    ->toHtml();

Нюанс санітайзера: він пропускає атрибути style (потрібні для кольору тексту, підсвітки, розміру зображень). Тож CSS на кшталт position: fixed чи background: url(...) переживе очищення. Для вмісту від сторонніх користувачів (коментарі, профілі) варто налаштувати суворіший санітайзер.

Зображення в редакторі:

  • за замовчуванням зберігаються публічно - так вміст можна вивести будь-де простим посиланням;
  • з private у HTML зберігається не URL, а ідентифікатор файлу в атрибуті data-id, і тимчасові підписані URL генеруються під час рендеру. Тому приватні зображення обов'язково виводити через RichContentRenderer з тими самими налаштуваннями диска;
  • data-id теж приходить від клієнта: якщо на тому самому диску лежать чужі приватні файли, підмінений ідентифікатор може «підтягнути» чужий файл. Окремий диск чи каталог під вкладення редактора зменшує ризик.

HTML чи JSON: HTML простіше виводити й шукати; JSON зручніший, коли вміст треба програмно обробляти (власні блоки, теги злиття, рендер в інші формати).

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

Власне поле потрібне, коли жоден вбудований компонент не підходить: вибір точки на карті, редактор розкладу, інтеграція сторонньої JS-бібліотеки.

php artisan make:filament-form-field LocationPicker

Клас поля:

use Closure;
use Filament\Forms\Components\Field;

class LocationPicker extends Field
{
    protected string $view = 'filament.forms.components.location-picker';

    protected float | Closure | null $zoom = null;

    public function zoom(float | Closure | null $zoom): static
    {
        $this->zoom = $zoom;

        return $this;
    }

    public function getZoom(): ?float
    {
        return $this->evaluate($this->zoom);   // підтримка замикань з утилітами
    }
}

Шаблон поля:

<x-dynamic-component :component="$getFieldWrapperView()" :field="$field">
    <div
        x-data="{ state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')") }} }"
        x-init="initMap($refs.map, state, {{ $getZoom() ?? 10 }})"
    >
        <div x-ref="map" class="h-64"></div>
    </div>
</x-dynamic-component>

Ключові моменти:

  • $getStatePath() - шлях до властивості Livewire, де живе значення поля (data.location). Саме через нього поле читає й пише стан;
  • $applyStateBindingModifiers() - щоб поле поважало live(), live(onBlur: true) тощо, як вбудовані. Без цього ->live() на вашому полі нічого не змінить;
  • $getFieldWrapperView() - стандартна обгортка з підписом, підказкою й помилками валідації;
  • $this->evaluate() в гетері - дозволяє передавати не лише значення, а й замикання з Get, $record, $operation.

Чого не робити:

  • поле - не Livewire-компонент. Публічні властивості й методи класу поля в Blade недоступні як wire:model чи wire:click. Потрібну конфігурацію віддають через гетери ($getZoom());
  • щоб викликати PHP-метод поля з JavaScript, його позначають атрибутом #[ExposedLivewireMethod] і викликають через $wire.callSchemaComponentMethod(). Без атрибута метод викликати не можна - це захист від виконання довільних методів;
  • важкі JS-бібліотеки не варто підключати глобально на кожну сторінку панелі - Filament уміє асинхронно завантажувати Alpine-компоненти через систему ресурсів, лише там, де поле є.

Валідація працює як для будь-якого поля: ->required(), ->rules([...]) на рівні PHP.

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

Сторінки створення й редагування ресурсу - Livewire-компоненти, і Filament додає до livewire() хелпери для форм.

Створення запису:

use App\Filament\Resources\Posts\Pages\CreatePost;
use App\Filament\Resources\Posts\Pages\EditPost;
use App\Models\Post;
use App\Models\User;

use function Pest\Laravel\assertDatabaseHas;
use function Pest\Livewire\livewire;

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

it('creates a post', function () {
    livewire(CreatePost::class)
        ->fillForm([
            'title' => 'Нова стаття',
            'slug' => 'nova-stattia',
            'status' => 'draft',
        ])
        ->call('create')
        ->assertHasNoFormErrors()
        ->assertNotified()
        ->assertRedirect();

    assertDatabaseHas(Post::class, ['slug' => 'nova-stattia']);
});

Валідація:

it('rejects a duplicate slug', function () {
    Post::factory()->create(['slug' => 'taken']);

    livewire(CreatePost::class)
        ->fillForm(['title' => 'X', 'slug' => 'taken'])
        ->call('create')
        ->assertHasFormErrors(['slug' => 'unique']);
});

Редагування й заповнення форми:

it('fills the edit form', function () {
    $post = Post::factory()->create();

    livewire(EditPost::class, ['record' => $post->getRouteKey()])
        ->assertSchemaStateSet([
            'title' => $post->title,
        ])
        ->fillForm(['title' => 'Оновлено'])
        ->call('save')
        ->assertHasNoFormErrors();

    expect($post->refresh()->title)->toBe('Оновлено');
});

Видимість і реактивність:

livewire(CreatePost::class)
    ->fillForm(['status' => 'draft'])
    ->assertFormFieldHidden('published_at')
    ->fillForm(['status' => 'scheduled'])
    ->assertFormFieldVisible('published_at');

Майстри: goToNextWizardStep() і assertWizardCurrentStep(2); помилки кроку перевіряються тим самим assertHasFormErrors().

На що звернути увагу:

  • тестуйте валідацію Select: Filament за замовчуванням не приймає значення поза списком варіантів, і тест підтверджує, що підставлений id не пройде;
  • права: окремий тест, що користувач без політики create отримує 403 на сторінці створення;
  • mutateFormDataBeforeCreate() перевіряється через стан бази: чи записався user_id, якого у формі немає;
  • ключ запису передається як getRouteKey(), а не id, якщо ресурс використовує slug у маршрутах.

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