Custom Fields
PandaPanel\Forms\Components\CustomField adalah field yang dirender menggunakan komponen Vue buatan Anda sendiri. Selain cara tampilnya, field ini tetap bekerja seperti field biasa: divalidasi menggunakan rules yang dideklarasikan schema, di-dehydrate seperti field lainnya, serta dapat disembunyikan atau diberi kondisi. Gunakan CustomField ketika tidak ada control bawaan yang sesuai, misalnya rating bintang, pemilih lokasi pada peta, atau color ramp.
Contoh minimal
Deklarasikan field:
use PandaPanel\Forms\Components\CustomField;
use PandaPanel\Forms\FormSchema;
public static function form(FormSchema $schema): FormSchema
{
return $schema->schema([
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->config(['max' => 5])
->rules(['integer', 'between:1,5'])
->required(),
]);
}2
3
4
5
6
7
8
9
10
11
12
13
Buat komponennya di resources/js/pages/Panels/Admin/Fields/StarRating.vue:
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
modelValue: unknown;
config: Record<string, unknown>;
disabled?: boolean;
error?: string;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: number] }>();
/** Value datang sebagai JSON tanpa type yang pasti, jadi lakukan narrowing, bukan assertion. */
const value = computed(() =>
typeof props.modelValue === 'number' ? props.modelValue : 0,
);
const max = computed(() =>
typeof props.config.max === 'number' ? props.config.max : 5,
);
const stars = computed(() =>
Array.from({ length: max.value }, (_, index) => index + 1),
);
</script>
<template>
<div class="flex gap-1">
<button
v-for="star in stars"
:key="star"
type="button"
:disabled="disabled"
:aria-label="`${star} of ${max}`"
class="text-lg"
:class="star <= value ? 'text-primary' : 'text-muted-foreground'"
@click="emit('update:modelValue', star)"
>
★
</button>
</div>
</template>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
Build ulang asset, lalu field akan dapat dirender.
npm run build # atau: npm run devCustomField
| Method | Signature | Default |
|---|---|---|
make() | static make(string $name): static | |
component() | component(string $component): self | '' |
config() | config(array<string, mixed> $config): self | [] |
type() menghasilkan FieldType::Custom, yang diserialisasi sebagai custom. Selain key yang dimiliki field biasa, field ini juga membawa componentName dan config.
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->config(['max' => 5])
->toArray(null, 'create');
// ['type' => 'custom', 'componentName' => 'Panels/Admin/Fields/StarRating', 'config' => ['max' => 5], …]2
3
4
5
config() menerima scalar dan array seperti value lain yang diserialisasi. Data ini hanya dibaca oleh komponen Anda; framework tidak menginterpretasikan isinya.
Karena CustomField adalah turunan Field, seluruh API pada Field tetap dapat digunakan:
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\CustomField;
use PandaPanel\Forms\Enums\ConditionOperator;
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->label('Editorial rating')
->helperText('One to five.')
->default(3)
->columnSpan(2)
->rules(['integer', 'between:1,5'])
->visibleWhen('status', ConditionOperator::Equals, 'published')
->dehydrateStateUsing(static fn (mixed $value, ?Model $record): int => (int) $value);2
3
4
5
6
7
8
9
10
11
12
13
Custom field tidak mendeklarasikan validation rule bawaan. Semua rule yang dibutuhkan harus diberikan melalui rules() atau rulesUsing(). Lihat Validation.
Contract komponen
Panel membungkus komponen Anda dengan FieldWrapper. Wrapper tersebut merender label, helper text, tanda required, dan pesan error. Komponen custom cukup bertanggung jawab menggambar control-nya. Props yang diterima:
| Prop | Type | Arti |
|---|---|---|
field | seluruh definisi field | Nama, label, placeholder, kondisi, dan semua metadata yang diserialisasi |
modelValue | unknown | Value saat ini |
config | Record<string, unknown> | Data yang dideklarasikan melalui config() |
disabled | boolean | Hasil dari disabled() atau disabledOn() |
error | string | undefined | Pesan error field, jika ada |
Komponen mengirim satu event:
emit('update:modelValue', value);Value yang Anda emit menjadi value yang disimpan form, dikirim saat submit, dan divalidasi. Lakukan narrowing terhadap semua data yang dibaca: props melintasi boundary sebagai JSON tanpa type runtime. Jika shape tidak sesuai, lebih aman merender control kosong daripada melempar exception di tengah form.
Lokasi komponen ditemukan
Registry merupakan allowlist pada saat build. resources/js/panel/forms/registry.ts melakukan glob terhadap empat direktori:
resources/js/pages/Panels/**/Fields/*.vue ← CustomField
resources/js/pages/Panels/**/Schemas/*.vue ← CustomComponent
resources/js/pages/Panels/**/Entries/*.vue ← custom infolist entries
resources/js/pages/Panels/**/Modals/*.vue ← custom action modals2
3
4
Nama yang dikirim PHP adalah path relatif di bawah pages/, tanpa extension:
| File | Nama |
|---|---|
resources/js/pages/Panels/Admin/Fields/StarRating.vue | Panels/Admin/Fields/StarRating |
resources/js/pages/Panels/App/Schemas/Banner.vue | Panels/App/Schemas/Banner |
Nama tersebut adalah registry key, bukan markup dan bukan filesystem path. Aturan ini sama seperti custom column dan widget. Alasannya juga sama: nama yang dapat ditentukan dari data request dapat menjadi jalan untuk merender sesuatu yang tidak pernah masuk build. Nama selalu berasal dari komponen PHP yang terdaftar; lookup registry menjadi lapisan pengaman kedua.
Komponen dimuat saat dibutuhkan. Custom field biasanya jarang digunakan, sehingga memasukkan seluruh komponen custom ke main chunk akan menambah ukuran bundle bahkan pada Page yang tidak menggunakannya.
Custom layouts
PandaPanel\Forms\Layouts\CustomComponent adalah pasangan CustomField untuk kebutuhan content dan layout, bukan input.
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Layouts\CustomComponent;
CustomComponent::make('Panels/Admin/Schemas/Banner')
->config(['dismissible' => true])
->schema([TextInput::make('name')]);2
3
4
5
6
CustomComponent tidak memiliki value dan tidak mengirim data sendiri, tetapi dapat menampung komponen lain. Field di dalamnya berperilaku sama seperti jika diletakkan pada layout biasa. Child components dirender oleh Panel dan diberikan sebagai default slot, sehingga komponen Anda hanya menentukan penempatannya tanpa perlu mengetahui cara merender field tersebut:
<script setup lang="ts">
defineProps<{ config: Record<string, unknown> }>();
</script>
<template>
<section class="rounded-lg border p-4">
<p class="mb-3 text-sm text-muted-foreground">
Fill this in carefully.
</p>
<div class="flex flex-col gap-4">
<slot />
</div>
</section>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
Komponen yang tidak menggunakan slot berarti memang tidak menampilkan field apa pun di dalamnya. Itu tetap merupakan penggunaan yang valid.
Ketika nama komponen tidak ditemukan
| Kondisi | Yang dirender |
|---|---|
CustomField dengan nama tidak dikenal | Wrapper tetap dirender, sementara control diganti pesan This field has no renderer. |
CustomComponent dengan nama tidak dikenal | Child components tetap dirender tanpa wrapper custom |
Keduanya tidak melempar exception. Satu typo pada nama komponen tidak boleh menjatuhkan seluruh form, sehingga field lain tetap dapat diedit. Pada CustomComponent, child fields tetap dirender karena wrapper hanya dekorasi; field di dalamnya tetap merupakan bagian form.
Pada development, registry memberi warning satu kali untuk setiap nama yang tidak ditemukan dan menyebutkan direktori tempat file seharusnya berada. Pada production warning tersebut tidak ditampilkan. Ini adalah masalah build, dan console message di Panel production tidak menyelesaikan masalahnya. Tiga penyebab utamanya: typo, file berada di luar direktori yang di-glob, atau asset belum di-build ulang.
Menambahkan field type baru ke framework
CustomField adalah extension point yang didukung untuk application. Menambahkan FieldType baru berarti mengubah framework dan membutuhkan tiga perubahan yang harus masuk bersamaan:
- Class PHP yang extends
Fielddan mengembalikan caseFieldTypebaru. - Definisi TypeScript yang ditambahkan ke union di
resources/js/panel/types/form.ts. - Branch renderer di
resources/js/panel/forms/FormField.vue.
Switch renderer bersifat exhaustive terhadap union TypeScript. Definisi tanpa renderer akan menjadi compile error. Selain itu FormFieldTypeTest membaca file TypeScript dan menggagalkan test ketika case PHP FieldType belum masuk ke union—hal yang tidak dapat dilihat compiler lintas bahasa. Semua kebutuhan extension pada application normal sudah dapat dicapai melalui CustomField tanpa menyentuh mekanisme tersebut.
Catatan
- Nama komponen bukan path. Nama tidak boleh diawali
@/, tidak boleh diakhiri.vue, dan tidak boleh dibentuk dari value request. - Build ulang setelah menambahkan komponen. Glob dievaluasi pada build time; file baru tidak terlihat sampai Vite membuild-nya.
- Source Vue Panel menjadi bagian application Anda. File dipublish ke
resources/js/melaluiphp artisan panel:installatauphp artisan vendor:publish --tag=panda-panel-assets. Karena itulah registry glob dapat melihat komponen milik application.php artisan panel:assetsmelaporkan published file yang tertinggal setelah package di-update. Lihat Frontend assets. config()adalah data, bukan behavior. Nilainya diserialisasi menjadi JSON; Closure tidak akan bertahan.- Prop
fieldberisi seluruh definisi. Gunakanfield.nameuntuk input id,field.placeholder, ataufield.conditionsjika dibutuhkan control custom. Namun visibility sudah diterapkan sebelum komponen Anda dirender. - Custom field dapat menggunakan
live(). Perubahan value dikirim dengan mekanisme yang sama seperti built-in field, sehinggaafterStateUpdated()dan schema rebuild tetap bekerja. - Validation tetap milik schema. Batasan input di custom control hanyalah kenyamanan UX; rule PHP-lah yang menentukan valid atau tidak.