Repeater
PandaPanel\Forms\Components\Repeater menyimpan list entry yang semuanya memiliki satu sub-schema yang sama. Child field di dalam repeater bukan field top-level form — masing-masing adalah field milik setiap item. Karena itu validation berada pada path seperti items.*.title, dan dehydration dijalankan satu kali untuk setiap entry. Gunakan Repeater ketika record menyimpan list dengan bentuk yang sama, misalnya line items, opening hours, atau daftar link. Jika setiap entry dapat memiliki bentuk berbeda, gunakan Builder.
Contoh minimal
use PandaPanel\Forms\Components\NumberInput;
use PandaPanel\Forms\Components\Repeater;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
FormSchema::make()->schema([
Repeater::make('line_items')
->schema([
TextInput::make('description')->required(),
NumberInput::make('quantity')->integer()->min(1)->default(1),
])
->minItems(1)
->columnSpanFull(),
]);2
3
4
5
6
7
8
9
10
11
12
13
14
Value berupa list of maps, sehingga attribute perlu di-cast menjadi array:
protected function casts(): array
{
return ['line_items' => 'array'];
}2
3
4
Mengapa child field bukan field milik form
Repeater::fields() hanya mengembalikan repeater itu sendiri. Jika child field ikut dikembalikan, FormSchema akan memvalidasi description sebagai top-level field dan mencoba menyimpannya ke column bernama description. Seluruh nested behavior adalah responsibility Repeater, sehingga child fields diekspos melalui method terpisah:
/** @return list<Field> the repeater alone */
public function fields(): array
/** @return list<Field> the fields of one entry */
public function itemFields(): array2
3
4
5
itemFields() melakukan flatten terhadap seluruh component dari schema(), termasuk layout di dalam satu entry, sampai mendapatkan field yang sebenarnya.
Method
public function schema(array $components): self // array<array-key, FormComponent>
public function minItems(int $min): self // default: null, clamped to >= 0
public function maxItems(int $max): self // default: null, clamped to >= 1
public function reorderable(bool $reorderable = true): self // default: true
public function collapsible(bool $collapsible = true): self // default: false
public function addable(bool $addable = true): self // default: true
public function deletable(bool $deletable = true): self // default: true
public function addLabel(string $label): self // default: 'Add item'
public function columns(int $columns): self // default: 1, clamped to 1..4
public function itemLabel(Closure $callback): self // default: none2
3
4
5
6
7
8
9
10
| Method | Default | Dampak |
|---|---|---|
schema() | [] | components yang mengedit satu entry |
minItems() | null | menambahkan min:n; frontend berhenti menawarkan Remove saat batas bawah tercapai |
maxItems() | null | menambahkan max:n; frontend berhenti menawarkan Add saat batas atas tercapai |
reorderable() | true | menampilkan tombol up/down |
collapsible() | false | menampilkan collapse toggle |
addable() | true | menampilkan tombol add |
deletable() | true | menampilkan tombol remove |
addLabel() | 'Add item' | label tombol add |
columns() | 1 | diserialisasi, tetapi lihat Gotchas |
itemLabel() | — | heading setiap entry |
use PandaPanel\Forms\Components\Repeater;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\TimePicker;
use PandaPanel\Forms\Layouts\Grid;
Repeater::make('opening_hours')
->schema([
Grid::make(3)->schema([
TextInput::make('day'),
TimePicker::make('opens_at'),
TimePicker::make('closes_at'),
]),
])
->minItems(1)
->maxItems(7)
->collapsible()
->addLabel('Add a day')
->columnSpanFull();2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
itemLabel()
/** @param Closure(array<string, mixed> $entry, int $index): ?string $callback */
public function itemLabel(Closure $callback): self2
Heading untuk sebuah entry di-resolve di server dari value entry itu sendiri, sehingga item dapat dinamai berdasarkan data yang dimilikinya tanpa frontend harus memahami bentuk data tersebut:
use PandaPanel\Forms\Components\Repeater;
use PandaPanel\Forms\Components\TextInput;
Repeater::make('line_items')
->schema([TextInput::make('description')])
->itemLabel(static fn (array $entry, int $index): ?string => is_string($entry['description'] ?? null)
&& $entry['description'] !== ''
? $entry['description']
: 'Line '.($index + 1));2
3
4
5
6
7
8
9
Jika callback mengembalikan null, browser fallback ke Item N. Behavior yang sama berlaku jika itemLabel() sama sekali tidak dideklarasikan; dalam kasus tersebut itemLabels diserialisasi sebagai empty list.
Validasi
Validation dihasilkan pada dua level dari field declarations yang sama:
use PandaPanel\Forms\Components\NumberInput;
use PandaPanel\Forms\Components\Repeater;
use PandaPanel\Forms\Components\TagsInput;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
FormSchema::make()
->schema([
Repeater::make('items')
->minItems(1)
->maxItems(5)
->schema([
TextInput::make('title')->required()->maxLength(80),
NumberInput::make('quantity')->integer()->min(1),
TagsInput::make('labels'),
]),
])
->validationRules();
// [
// 'items' => ['nullable', 'array', 'min:1', 'max:5'],
// 'items.*.title' => ['required', 'string', 'max:80'],
// 'items.*.quantity' => ['nullable', 'integer', 'min:1'],
// 'items.*.labels' => ['nullable', 'array'],
// 'items.*.labels.*' => ['string', 'max:50'],
// ]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
nestedRules() menghasilkan key items.*.…, yaitu path yang hanya dapat diturunkan oleh field pemilik children:
public function nestedRules(?Model $record = null): arrayChild field yang juga memvalidasi list dapat menghasilkan level ketiga seperti items.*.labels.* melalui elementRules() miliknya sendiri.
Error kembali menggunakan key yang sama. RepeaterField.vue menghapus prefix seperti items.0. sebelum meneruskannya ke entry, sehingga error tampil pada field yang menghasilkan error, bukan hanya pada repeater secara keseluruhan.
Dehydration
public function mutate(mixed $value, ?Model $record): mixedSetiap entry di-dehydrate oleh field yang mendeskripsikannya. Key yang tidak pernah dideklarasikan sub-schema dibuang, sama seperti behavior pada top-level form:
use PandaPanel\Forms\Components\Repeater;
use PandaPanel\Forms\Components\TextInput;
$field = Repeater::make('items')->schema([TextInput::make('title')]);
$field->mutate([['title' => 'One', 'injected' => 'nope']], null);
// [['title' => 'One']]2
3
4
5
6
7
Untuk setiap entry dan setiap field di dalamnya, tiga pertanyaan yang sama seperti top-level schema dijalankan kembali: isDehydrated(), shouldDehydrate(), dan getDehydrateKey(). Karena itu dehydrated(false) pada child mencegah value keluar dari semua entry, sedangkan dehydrateTo('label') mengganti nama key di dalam setiap entry.
Submitted value yang bukan array menjadi []. Member yang bukan array dilewati.
Hydration
protected function castForForm(mixed $value): arrayField menerima array yang setiap member-nya juga array. Member non-array dibuang. Setiap entry adalah plain map, bukan model. Karena itu item schema diserialisasi tanpa record: field di dalam entry membaca default() miliknya, bukan model attribute.
emptyItem
Blank entry yang ditambahkan frontend dibangun dari formValue(null) milik setiap field, bukan dibuat sendiri oleh Vue:
use PandaPanel\Forms\Components\NumberInput;
use PandaPanel\Forms\Components\Repeater;
use PandaPanel\Forms\Components\TextInput;
Repeater::make('items')
->schema([
TextInput::make('title'),
NumberInput::make('quantity')->default(1),
])
->toArray(null, 'create')['emptyItem'];
// ['title' => null, 'quantity' => 1]2
3
4
5
6
7
8
9
10
11
12
Data yang dikirim ke frontend
interface RepeaterFieldDefinition extends BaseFieldDefinition {
type: 'repeater';
schema: FormComponentDefinition[];
minItems: number | null;
maxItems: number | null;
reorderable: boolean;
collapsible: boolean;
addable: boolean;
deletable: boolean;
addLabel: string;
columns: number;
/** One label per current entry, or empty when none were declared. */
itemLabels: string[];
emptyItem: Record<string, unknown>;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
schema diserialisasi satu kali menggunakan toArray(null, 'create'), lalu ordinary component renderer menjalankannya terhadap value masing-masing entry. Karena itu field di dalam repeater berperilaku sama seperti field biasa, termasuk declarative conditions yang membaca entry tersebut, bukan top-level form:
use PandaPanel\Forms\Components\Repeater;
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Enums\ConditionOperator;
Repeater::make('contacts')->schema([
Select::make('kind')->options(['email' => 'Email', 'phone' => 'Phone']),
TextInput::make('address')->visibleWhen('kind', ConditionOperator::Equals, 'email'),
TextInput::make('number')->visibleWhen('kind', ConditionOperator::Equals, 'phone'),
]);2
3
4
5
6
7
8
9
10
kind pada contoh tersebut berarti kind milik entry yang sedang dirender, sehingga nama field di sub-schema tetap sederhana dan tidak menggunakan nested path.
Repeater atau relation manager
Repeater menyimpan list di dalam satu column. Jika entry sebenarnya adalah row pada table lain, gunakan relation manager: setiap entry akan memiliki record, policy, table, dan actions sendiri. Gunakan Repeater ketika list tersebut merupakan bagian dari record utama, misalnya line item invoice yang disnapshot saat invoice diterbitkan, bukan data yang memiliki lifecycle independen.
Hal yang perlu diperhatikan
columns() diserialisasi tetapi tidak dirender. RepeaterField.vue menumpuk component entry dalam satu column dan tidak membaca property columns. Gunakan Grid di dalam item schema jika membutuhkan layout multi-column:
use PandaPanel\Forms\Components\Repeater;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Layouts\Grid;
Repeater::make('items')->schema([
Grid::make(2)->schema([
TextInput::make('title'),
TextInput::make('sku'),
]),
]);2
3
4
5
6
7
8
9
10
Item label tidak mengikuti keystroke secara live. Label di-resolve server-side ketika schema diserialisasi. Entry yang namanya berubah di browser tetap menggunakan label lama sampai form dibangun ulang atau di-reload.
min: dan max: menghitung jumlah entry. Rules tersebut berlaku pada array. Jika ingin membatasi value child seperti quantity, letakkan rule pada child field sehingga menjadi items.*.quantity.
Required repeater menolak empty list. required() pada array berarti minimal satu entry. minItems(1) menyatakan requirement yang sama dengan pesan yang lebih jelas. Menggunakan keduanya tetap aman.
live() pada child field belum terhubung. Form-state endpoint membangun ulang top-level schema berdasarkan flat form values, sedangkan field di dalam entry bukan bagian dari map tersebut. Declarative conditions tetap bekerja karena dievaluasi di browser terhadap entry.
hiddenOn() di dalam entry tidak memberikan behavior page-aware yang berguna. Item schema selalu diserialisasi sebagai page 'create', apa pun page tempat repeater berada. Page-aware visibility sebaiknya diletakkan pada Repeater itu sendiri.
Duplicate name hanya diperiksa pada top level. FormSchema memastikan nama field unik pada component yang langsung dimilikinya, sedangkan Repeater hanya melaporkan dirinya sendiri. Dua child dengan nama sama di satu repeater tidak terdeteksi oleh check tersebut. Tetap gunakan nama unik karena entry adalah map dan value kedua akan menimpa value pertama.
Reorder hanya mengubah urutan list. Tidak ada position column dan tidak ada sort key; list disimpan sesuai urutan submitted value.
Lihat juga
- Builder — entry dengan bentuk berbeda-beda
- Relation Managers — ketika entry merupakan row terpisah
- Layouts —
GriddanSectiondi dalam entry - Validation
- Visibility — conditions di dalam entry
- Forms and Schemas