6 · Merapikan form dan tabel
Tujuan: form yang bisa diisi tanpa menebak-nebak, dan tabel yang benar-benar bisa dipakai menemukan sebuah produk.
Keduanya adalah schema: deskripsi PHP tentang apa yang dirender. Validasi, sorting, pencarian, dan filter berjalan di server; Vue merender apa yang diberikan kepadanya. Karena itulah field yang tidak pernah Anda deklarasikan tidak bisa dikirim, dan kolom sorting yang tidak pernah dideklarasikan diabaikan alih-alih diteruskan ke query builder.
Lakukan ini
Form-nya
Ganti isi app/Panels/Admin/Resources/Products/Forms/ProductForm.php:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Forms;
use PandaPanel\Forms\Components\DateTimePicker;
use PandaPanel\Forms\Components\NumberInput;
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Forms\Layouts\Section;
final class ProductForm
{
public static function configure(FormSchema $schema): FormSchema
{
return $schema
->columns(2)
->schema([
Section::make('Produk')
->description('Yang dilihat pelanggan.')
->columns(2)
->schema([
TextInput::make('name')
->label('Nama')
->required()
->maxLength(255)
->placeholder('Lampu meja bambu'),
TextInput::make('sku')
->label('SKU')
->required()
->maxLength(64)
->helperText('Harus unik. Dipakai di faktur.')
->rules(['alpha_dash']),
Textarea::make('description')
->label('Deskripsi')
->rows(4)
->columnSpanFull(),
]),
Section::make('Ketersediaan')
->columns(2)
->schema([
NumberInput::make('price')
->label('Harga (Rp)')
->required()
->min(0)
->step(0.01)
->placeholder('0.00'),
Select::make('status')
->options([
'draft' => 'Draf',
'published' => 'Terbit',
'archived' => 'Diarsipkan',
])
->default('draft')
->required(),
DateTimePicker::make('published_at')
->label('Terbitkan pada')
->helperText('Kosongkan untuk terbit segera.')
->columnSpanFull(),
]),
]);
}
}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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
Tabelnya
Ganti isi app/Panels/Admin/Resources/Products/Tables/ProductsTable.php:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Tables;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Actions\DeleteAction;
use PandaPanel\Actions\DeleteBulkAction;
use PandaPanel\Actions\EditAction;
use PandaPanel\Actions\ViewAction;
use PandaPanel\Tables\Columns\BadgeColumn;
use PandaPanel\Tables\Columns\DateTimeColumn;
use PandaPanel\Tables\Columns\NumberColumn;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\Enums\BadgeColor;
use PandaPanel\Tables\Enums\SortDirection;
use PandaPanel\Tables\Filters\SelectFilter;
use PandaPanel\Tables\Filters\TernaryFilter;
use PandaPanel\Tables\TableSchema;
final class ProductsTable
{
public static function configure(TableSchema $table): TableSchema
{
return $table
->columns([
TextColumn::make('name')
->label('Nama')
->searchable()
->sortable()
->toggleable(false),
TextColumn::make('sku')
->label('SKU')
->searchable(individually: true),
BadgeColumn::make('status')
->labels([
'draft' => 'Draf',
'published' => 'Terbit',
'archived' => 'Diarsipkan',
])
->colors([
'draft' => BadgeColor::Warning,
'published' => BadgeColor::Success,
'archived' => BadgeColor::Info,
])
->sortable(),
NumberColumn::make('price')->label('Harga')->prefix('Rp')->decimals(2)->sortable(),
DateTimeColumn::make('published_at')->label('Terbit')->sortable(),
])
->filters([
SelectFilter::make('status')->options([
'draft' => 'Draf',
'published' => 'Terbit',
'archived' => 'Diarsipkan',
]),
TernaryFilter::make('published_at')
->nullable()
->labels('Sudah terbit', 'Belum terbit', 'Semua'),
])
->defaultSort('created_at', SortDirection::Descending)
->searchPlaceholder('Cari nama atau SKU...')
->perPageOptions([10, 25, 50, 100])
->defaultPerPage(25)
->recordActions([
ViewAction::make(ProductResource::class),
EditAction::make(ProductResource::class),
DeleteAction::make(ProductResource::class),
])
->bulkActions([
DeleteBulkAction::make(ProductResource::class),
])
->emptyState(
heading: 'Belum ada produk',
description: 'Tambahkan yang pertama, atau bersihkan filternya.',
icon: 'package',
);
}
}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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
Muat ulang /admin/products. Tidak perlu build — kedua berkas ini PHP, dan skemanya dibangun ulang pada setiap request.
Apa yang barusan Anda pakai
Tata letak hanya soal dua angka
Kontainer dibagi oleh columns(); field menyatakan berapa bagian yang diambilnya.
Section::make('Produk')->columns(2)->schema([
TextInput::make('name'), // satu kolom
TextInput::make('sku'), // satu kolom
Textarea::make('description')->columnSpanFull(), // satu baris penuh
]);2
3
4
5
columnSpanFull(), bukan columnSpan(2): angka yang berarti "semuanya" adalah milik kontainer, sehingga field yang menuliskannya secara eksplisit diam-diam akan menjadi setengah baris begitu ada orang mengubah section itu menjadi empat kolom.
Jumlah kolom bersifat responsif, dan angka yang Anda tulis adalah jumlah untuk layar lebar — columns(2) menjadi satu kolom di ponsel. Angka di atas empat dipangkas menjadi empat, karena renderer-nya memakai kelas Tailwind literal untuk satu sampai empat.
Validasinya milik Laravel
TextInput::make('sku')->required()->maxLength(64)->rules(['alpha_dash']);required() pada field adalah penanda UX sekaligus aturan validasi; menghapusnya lewat browser tidak mengubah apa pun, karena aturannya dibangun ulang di server dari skema yang sama. Hanya field yang dideklarasikan yang divalidasi, dan hanya field yang dehydrate yang disimpan — jadi kunci tambahan di body request dibuang, bukan ikut ter-mass-assign.
Daftar opsi Select sendiri adalah whitelist. status menjadi required|in:"draft","published","archived", sehingga nilai yang tidak pernah ditawarkan skema itu tidak sah — bukan sekadar tak terduga.
Skema tabel juga whitelist
Sorting, pencarian, filter, dan visibilitas kolom hanya membaca apa yang dideklarasikan kolom atau filter. Sisanya di URL diabaikan, bukan diteruskan ke query builder.
| Yang Anda tulis | Yang diizinkan |
|---|---|
->searchable() | Kolom itu ikut dalam kotak pencarian global |
->searchable(individually: true) | Sekaligus mendapat input pencarian per kolom |
->sortable() | ?sort=sku diterima; tanpa ini, diabaikan |
->toggleable(false) | Kolom tidak pernah bisa disembunyikan, bagaimanapun request memintanya |
filters([...]) | Hanya nama filter ini yang dibaca dari query string |
Dari mana action-nya berasal
ViewAction, EditAction, DeleteAction, dan DeleteBulkAction adalah action bawaan yang sudah tahu cara menemukan halaman resource dan bertanya ke policy-nya. Memberi array tidak kosong ke bulkActions() otomatis menyalakan pemilihan baris, karena bulk action tanpa cara memilih tidak ada gunanya.
Coba sendiri
Buat tiga atau empat produk dengan status berbeda, lalu:
- Ketik potongan nama di kotak pencarian — URL bertambah
?search=, dan server melakukan query ulang. - Urutkan berdasarkan harga. Coba ubah URL menjadi
?sort=description— tidak terjadi apa-apa, karena kolom itu tidak pernah mendeklarasikansortable(). - Filter ke Terbit, lalu muat ulang halaman. Filternya ada di URL, jadi tampilan itu bisa dibagikan.
- Pilih dua baris dan hapus sekaligus.
Pastikan berhasil
Tabel menampilkan badge status berwarna, harga dengan dua desimal dan awalan Rp, kotak pencarian yang berfungsi, serta dua filter. Form punya dua section, grid dua kolom, dan menolak menyimpan produk tanpa nama atau dengan SKU ganda.
Kalau gagal
| Gejala | Penyebab | Perbaikan |
|---|---|---|
PanelSchemaException: duplicate column | Nama yang sama dideklarasikan dua kali | Nama kolom adalah kunci untuk sel, visibilitas, pencarian, dan sorting sekaligus |
PanelSchemaException: unknown default sort | defaultSort() menyebut kolom yang tidak ada | Pemeriksaan ini berjalan saat halaman dirender, bukan saat barisnya ditulis |
| Field muncul tapi tidak pernah tersimpan | Dua field memakai satu nama | Hanya satu nilai yang bertahan sampai penyimpanan; itulah sebabnya pemeriksaan ini ada |
defaultPerPage(20) diam-diam memakai 10 | 20 tidak ada di perPageOptions() | Tambahkan ke daftarnya, atau pilih yang sudah ada |
| Badge tampil abu-abu | Nilai tersimpan bukan kunci di colors() | Samakan kuncinya dengan isi kolom sebenarnya |
Filter tidak pernah mempersempit pencarian record
Filter tinggal di query tabel, bukan di Resource::query(). Record yang tersaring keluar dari daftar tetap bisa dibuka lewat URL — dan itu benar, karena otorisasi hidup di policy, bukan di filter.
Berikutnya
Panelnya sudah enak dibaca. Sekarang buat dia mengerjakan sesuatu.
→ 7 · Action dan widget dasbor
Baca juga
- FormSchema basics dan seluruh jenis field
- TableSchema basics, Columns, Filters
- Form layouts —
Section,Grid,Tabs,Wizard - Validation