Builder
PandaPanel\Forms\Components\Builder menyimpan list entry yang dapat memiliki bentuk berbeda. Setiap entry membawa nama block yang dideklarasikan field. Nama block itulah yang menentukan sub-schema mana yang mengedit entry, field mana yang melakukan dehydration, dan apakah entry tersebut dianggap valid untuk tetap disimpan. Gunakan Builder ketika record menyimpan content seperti susunan paragraph, quote, dan image. Jika setiap entry memiliki satu bentuk yang sama, gunakan Repeater.
Contoh minimal
use PandaPanel\Forms\Components\Builder;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Forms\Support\Block;
FormSchema::make()->schema([
Builder::make('content')->blocks([
Block::make('paragraph')->schema([
Textarea::make('body')->rows(4),
]),
Block::make('quote')->schema([
Textarea::make('body')->rows(2),
TextInput::make('attribution'),
]),
]),
]);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Stored value berbentuk list map {type, data}, sehingga model membutuhkan array cast:
protected function casts(): array
{
return ['content' => 'array'];
}2
3
4
Bentuk value
[
['type' => 'paragraph', 'data' => ['body' => 'Hello']],
['type' => 'quote', 'data' => ['body' => 'Well.', 'attribution' => 'Ada']],
]2
3
4
type adalah bagian utama dari safety field ini. Saat value masuk, castForForm() membuang entry yang bukan array, tidak memiliki string type, atau merujuk block yang tidak pernah dideklarasikan Builder. Saat value keluar, mutate() melakukan check yang sama lalu melakukan dehydration terhadap data menggunakan field milik block tersebut. Key yang tidak dideklarasikan block dibuang seperti pada form biasa:
use PandaPanel\Forms\Components\Builder;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Support\Block;
$field = Builder::make('content')->blocks([
Block::make('paragraph')->schema([TextInput::make('body')]),
]);
$field->mutate([
['type' => 'paragraph', 'data' => ['body' => 'Hello', 'injected' => 'nope']],
['type' => 'unknown', 'data' => ['body' => 'Hello']],
], null);
// [['type' => 'paragraph', 'data' => ['body' => 'Hello']]]2
3
4
5
6
7
8
9
10
11
12
13
14
Mendeklarasikan block
public function blocks(array $blocks): selfBlock adalah PandaPanel\Forms\Support\Block. Nama block ikut disimpan dalam setiap entry, sehingga nama tersebut harus dianggap sebagai bagian dari data format. Mengganti nama block membuat seluruh stored entry yang masih menggunakan nama lama menjadi tidak dikenal.
final class Block
{
public static function make(string $name): self;
/** @param array<array-key, FormComponent> $components */
public function schema(array $components): self;
public function label(string $label): self;
public function icon(string $icon): self;
public function getName(): string;
public function getLabel(): string;
/** @return list<Field> */
public function fields(): array;
/** @return array<string, mixed> */
public function emptyData(): array;
/** @param array<string, mixed> $data */
public function dehydrate(array $data, ?Model $record): array;
/** @return array<string, mixed> */
public function toArray(): array;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use PandaPanel\Forms\Components\FileUpload;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Layouts\Grid;
use PandaPanel\Forms\Support\Block;
Block::make('image')
->label('Image with caption')
->icon('photo')
->schema([
Grid::make(2)->schema([
FileUpload::make('path')->image()->directory('content'),
TextInput::make('caption'),
]),
]);2
3
4
5
6
7
8
9
10
11
12
13
14
label() default ke Str::headline($name), sehingga image_with_caption menjadi “Image With Caption” jika label tidak diberikan. icon() menerima icon registry key, bukan path. Registry yang sama digunakan table columns dan navigation items; nama yang tidak terdaftar tidak merender icon.
schema() dapat menerima seluruh form component, termasuk layouts. Block::fields() melakukan flatten terhadap component tersebut dan hasil inilah yang digunakan emptyData() dan dehydrate().
emptyData()
Ini adalah blank entry yang dimasukkan frontend ketika user memilih sebuah block dari picker. Data dibangun di server menggunakan formValue(null) dari setiap field, bukan diciptakan Vue sendiri. Karena itu default() pada field di dalam Builder bekerja sama seperti field di luar Builder.
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Toggle;
use PandaPanel\Forms\Support\Block;
Block::make('callout')
->schema([
TextInput::make('body'),
Toggle::make('dismissible')->default(true),
])
->emptyData();
// ['body' => null, 'dismissible' => true]2
3
4
5
6
7
8
9
10
11
12
Batas jumlah item dan control
public function minItems(int $min): self // clamped to >= 0
public function maxItems(int $max): self // clamped to >= 1
public function reorderable(bool $reorderable = true): self
public function collapsible(bool $collapsible = true): self
public function addLabel(string $label): self2
3
4
5
| Opsi | Default | Dampak |
|---|---|---|
minItems | null | menambahkan min:n pada field rules; frontend berhenti menawarkan Remove di bawah batas tersebut |
maxItems | null | menambahkan max:n; frontend berhenti menawarkan Add di atas batas tersebut |
reorderable | true | menampilkan tombol up/down pada setiap entry |
collapsible | true | menampilkan collapse toggle pada setiap entry |
addLabel | 'Add block' | label tombol yang membuka block picker |
use PandaPanel\Forms\Components\Builder;
Builder::make('content')
->blocks([/* ... */])
->minItems(1)
->maxItems(20)
->reorderable(false)
->collapsible()
->addLabel('Add section');2
3
4
5
6
7
8
9
Perhatikan default-nya: Builder bersifat reorderable sekaligus collapsible jika tidak dikonfigurasi. Repeater default reorderable tetapi tidak collapsible.
Mencari block berdasarkan nama
public function block(string $name): ?BlockMethod mengembalikan declared block berdasarkan nama atau null. Lookup yang sama digunakan oleh mutate() dan validateEntries(), dan dibuat public agar page maupun test dapat menanyakan hal yang sama:
use PandaPanel\Forms\Components\Builder;
use PandaPanel\Forms\Support\Block;
$builder = Builder::make('content')->blocks([Block::make('quote')]);
$builder->block('quote')?->getLabel(); // 'Quote'
$builder->block('missing'); // null2
3
4
5
6
7
Validasi
Rules untuk Builder itu sendiri berasal dari typeRules():
use PandaPanel\Forms\Components\Builder;
use PandaPanel\Forms\FormSchema;
FormSchema::make()
->schema([Builder::make('content')->minItems(1)->maxItems(10)])
->validationRules();
// ['content' => ['nullable', 'array', 'min:1', 'max:10']]2
3
4
5
6
7
8
Itulah seluruh rules yang dihasilkan schema. Builder tidak menambahkan nested rules. Rules untuk entry ketiga bergantung pada type yang dinyatakan entry ketiga, dan kondisi tersebut tidak dapat direpresentasikan sebagai flat Laravel rule set. Builder sengaja tidak mengimplementasikan nestedRules(), sehingga content.*.data.body tidak pernah muncul dalam FormSchema::validationRules().
Sebagai gantinya Builder menyediakan method khusus untuk memvalidasi setiap entry:
/** @return array<string, list<string>> errors keyed by path */
public function validateEntries(mixed $value, ?Model $record = null): array2
Method berjalan melalui submitted list, mencari block milik setiap entry, memvalidasi data dengan field yang dideklarasikan block, lalu mengembalikan error menggunakan dotted key yang sama seperti yang dirender frontend, misalnya content.0.data.body. Entry yang bukan array, tidak memiliki string type, atau merujuk undeclared block menghasilkan error pada content.0.type.
use PandaPanel\Forms\Components\Builder;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Support\Block;
$builder = Builder::make('content')->blocks([
Block::make('quote')->schema([TextInput::make('body')->required()]),
]);
$builder->validateEntries([
['type' => 'quote', 'data' => ['body' => '']],
['type' => 'nope', 'data' => []],
]);
// [
// 'content.0.data.body' => ['The body field is required.'],
// 'content.1.type' => ['This block is not one this field offers.'],
// ]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Framework tidak memanggil validateEntries() secara otomatis. Jika content setiap block wajib divalidasi, hubungkan method ini ke hook afterValidate() pada page:
use Illuminate\Validation\ValidationException;
use PandaPanel\Forms\Components\Builder;
use PandaPanel\Resources\Pages\CreateRecord;
final class CreatePost extends CreateRecord
{
protected static string $resource = PostResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function afterValidate(array $data): array
{
$field = $this->schema()->field('content');
if ($field instanceof Builder) {
$errors = $field->validateEntries($data['content'] ?? []);
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
return $data;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Error key sesuai dengan path yang digunakan BuilderField.vue, sehingga message tampil pada field yang benar di dalam entry, bukan hanya pada Builder secara keseluruhan.
Data yang dikirim ke frontend
extraArray() menambahkan key berikut pada standard field definition:
| Key | Type | Catatan |
|---|---|---|
blocks | BlockDefinition[] | masing-masing memiliki name, label, icon, schema, emptyData |
minItems | number | null | |
maxItems | number | null | |
reorderable | boolean | |
collapsible | boolean | |
addLabel | string | 'Add block' jika tidak diatur |
Setiap schema milik block diserialisasi menggunakan toArray(null, 'create') — tanpa record dan selalu sebagai create page — karena sebuah entry adalah plain map, bukan model. Karena itu field di dalam block membaca default() miliknya, bukan model attribute.
Hal yang perlu diperhatikan
Seluruh block diserialisasi lengkap pada setiap render. Builder dengan dua belas block mengirim dua belas sub-schema meskipun current value hanya menggunakan beberapa block, karena picker harus mampu menawarkan semuanya. Jaga block schema tetap kecil.
Nama block adalah bagian dari data. Nama tersebut disimpan di setiap entry. Mengubah Block::make('quote') menjadi Block::make('quotation') membuat seluruh existing quote entry tidak dikenal, dan unknown entry akan dibuang tanpa error pada save berikutnya. Migrasikan stored data terlebih dahulu.
hiddenOn() dan visibleOn() di dalam block tidak memberikan page-aware behavior yang berguna. Block schema selalu diserialisasi sebagai page 'create', apa pun page tempat Builder berada. Page-aware visibility sebaiknya ditempatkan pada Builder itu sendiri.
live() di dalam block belum terhubung. Form-state endpoint membangun ulang top-level schema dari flat form values, sedangkan field yang berada di dalam block entry bukan bagian dari map tersebut.
Column span di dalam block di-resolve terhadap container milik block. Block tidak memiliki columns() sendiri. Gunakan Grid di dalam block jika membutuhkan layout lebih dari satu column.
Lihat juga
- Repeater — satu bentuk yang diulang, bukan beberapa bentuk berbeda
- File Upload — field yang umum digunakan pada media block
- Forms and Schemas
- Validation
- Layouts —
GriddanSectiondi dalam block - Component Registries — tempat icon name di-resolve
- Resource Lifecycle Hooks — tempat
afterValidate()dijalankan