Custom Fields
Ada empat tempat di mana sebuah schema dapat menunjuk Vue component buatan Anda sendiri sebagai pengganti component bawaan: field pada form, layout pada form, entry pada infolist, dan content pada modal milik action. Keempatnya menggunakan build-time registry yang sama, sehingga aturannya juga sama: nama component adalah path di bawah resources/js/pages/, tanpa extension, dan file harus berada di directory yang sesuai agar terlihat saat build.
Gunakan halaman ini ketika tidak ada control bawaan yang cocok: star rating, map picker, color ramp, summary card khusus di dalam form, atau penjelasan tambahan di atas field untuk action yang berisiko.
Contoh minimal yang berfungsi
use PandaPanel\Forms\Components\CustomField;
use PandaPanel\Forms\FormSchema;
public static function form(FormSchema $schema): FormSchema
{
return $schema->schema([
CustomField::make('rating')
->label('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
14
<!-- 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 melintasi wire sebagai JSON, sehingga perlu di-narrow, bukan di-assert. */
const value = computed(() =>
typeof props.modelValue === 'number' ? props.modelValue : 0,
);
const max = computed(() =>
typeof props.config.max === 'number' ? props.config.max : 5,
);
</script>
<template>
<div class="flex gap-1">
<button
v-for="star in max"
:key="star"
type="button"
:disabled="disabled"
:aria-label="`${star} of ${max}`"
: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
npm run build # atau: npm run devEmpat integration seam
| Seam | PHP class | Directory | Contoh registry key |
|---|---|---|---|
| Field | PandaPanel\Forms\Components\CustomField | Panels/{Panel}/Fields/ | Panels/Admin/Fields/StarRating |
| Layout | PandaPanel\Forms\Layouts\CustomComponent | Panels/{Panel}/Schemas/ | Panels/Admin/Schemas/PricingCard |
| Entry | PandaPanel\Infolists\Components\CustomEntry | Panels/{Panel}/Entries/ | Panels/Admin/Entries/Timeline |
| Modal content | PandaPanel\Actions\Support\Modal::content() | Panels/{Panel}/Modals/ | Panels/Admin/Modals/DeleteWarning |
Satu registry melayani keempatnya:
import {
resolveFormComponent,
hasFormComponent,
} from '@/panel/forms/registry';
hasFormComponent('Panels/Admin/Fields/StarRating'); // boolean
resolveFormComponent('Panels/Admin/Fields/StarRating'); // loader atau null2
3
4
5
6
7
| Function | Signature |
|---|---|
hasFormComponent | (name: string) => boolean |
resolveFormComponent | (name: string) => (() => Promise<{ default: Component }>) | null |
Glob menggabungkan empat pattern:
resources/js/pages/Panels/**/Fields/*.vue
resources/js/pages/Panels/**/Schemas/*.vue
resources/js/pages/Panels/**/Entries/*.vue
resources/js/pages/Panels/**/Modals/*.vue2
3
4
Satu registry lebih tepat daripada empat registry terpisah yang akan mengulang aturan yang sama. Hal yang sama pada semua seam tersebut adalah sebuah nama me-resolve ke component yang sudah dilihat build; lokasi tempat nama itu dideklarasikan tidak mengubah aturan resolusinya.
CustomField
Field yang digambar oleh component Anda. Selain cara render, ia tetap field biasa — validation mengikuti rule schema, dehydration bekerja seperti field lain, dan field tetap dapat di-hidden atau diberi kondisi.
use PandaPanel\Forms\Components\CustomField;
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->config(['max' => 5, 'allowHalf' => false]);2
3
4
5
| Member | Signature | Default |
|---|---|---|
make | static make(string $name): static | inherited dari Field |
type() | type(): FieldType | FieldType::Custom |
component() | component(string $component): self | '' |
config() | config(array $config): self | [] |
config()
/**
* @param array<string, mixed> $config
*/
public function config(array $config): self2
3
4
Berisi setting yang dibaca component dan diserialisasi langsung. Gunakan scalar, array, dan null seperti serialized value lain — ini adalah configuration, bukan behavior:
CustomField::make('location')
->component('Panels/Admin/Fields/MapPicker')
->config([
'center' => ['lat' => -6.2, 'lng' => 106.8],
'zoom' => 11,
'tiles' => config('services.maps.style'),
]);2
3
4
5
6
7
Pemanggilan terakhir menang; config() melakukan replace, bukan merge.
Semua capability bawaan Field
CustomField extends PandaPanel\Forms\Components\Field, sehingga method berikut bekerja tanpa perubahan:
use PandaPanel\Forms\Enums\ConditionOperator;
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->label('Overall rating')
->helperText('One to five.')
->required()
->disabled()
->default(3)
->columnSpan(2)
->rules(['integer', 'between:1,5'])
->visibleWhen('published', ConditionOperator::Truthy)
->hiddenOn(['create'])
->live(onBlur: true, debounce: 500)
->inlineLabel()
->dehydrateStateUsing(static fn (mixed $state): int => (int) $state);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Validation tetap menjadi authority milik server. Component custom yang kebetulan tidak dapat menghasilkan invalid value hanyalah convenience; rule pada field tetap merupakan constraint yang sebenarnya.
Prop yang diterima component
CustomFieldRenderer.vue membungkus component Anda dengan FieldWrapper standar — label, required marker, helper text, dan error message — lalu mengirim lima prop:
defineProps<{
field: CustomFieldDefinition;
modelValue: unknown;
config: Record<string, unknown>;
disabled: boolean;
error?: string;
}>();
defineEmits<{ 'update:modelValue': [value: FormValue] }>();2
3
4
5
6
7
8
9
| Prop | Type | Catatan |
|---|---|---|
field | CustomFieldDefinition | seluruh serialized field: name, label, placeholder, helperText, required, disabled, columnSpan, conditions, live, validation, componentName, config |
modelValue | unknown | current working value |
config | Record<string, unknown> | value dari config() — object yang sama dengan field.config |
disabled | boolean | harus dihormati; server juga menolak value dari disabled field |
error | string | undefined | validation message; wrapper sudah bertanggung jawab merendernya |
Emit update:modelValue untuk mengubah value. Jangan merender label, required marker, atau error sendiri karena wrapper sudah menggambarnya.
TypeScript definition:
export interface CustomFieldDefinition extends BaseFieldDefinition {
type: 'custom';
componentName: string;
config: Record<string, unknown>;
}2
3
4
5
Ketika component tidak dapat di-resolve
Wrapper tetap dirender, tetapi control diganti satu baris fallback:
This field has no renderer.Field lain di sekitarnya tetap dapat diedit karena satu typo pada nama component tidak boleh menjatuhkan seluruh form. Pada development, registry juga menulis satu warning per nama:
[panel] The form component [Panels/Admin/Fields/Typo] is not in the build-time
registry, so a fallback is drawn instead. It has to live under
resources/js/pages/Panels/{Panel}/ — check the path and the spelling, then rebuild.2
3
CustomComponent — custom layout
Counterpart dari CustomField untuk arrangement, bukan input. Layout ini dapat tetap berisi field biasa dan semua field tersebut berperilaku sama seperti di layout lainnya.
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Layouts\CustomComponent;
CustomComponent::make('Panels/Admin/Schemas/PricingCard')
->config(['currency' => 'IDR'])
->schema([
TextInput::make('price')->required(),
TextInput::make('compare_at_price'),
]);2
3
4
5
6
7
8
9
| Member | Signature | Default |
|---|---|---|
make | static make(string $component): self | registry key diberikan pada constructor |
schema | schema(array $components): self | [] |
config | config(array $config): self | [] |
children | children(): array | components yang berada di dalam layout |
fields | fields(): array | semua field di bawahnya, sudah di-flatten |
Perbedaannya dengan CustomField: pada CustomComponent, nama component menjadi argument make(), bukan dipasang melalui component() terpisah.
Serialized node:
[
'component' => 'custom',
'componentName' => 'Panels/Admin/Schemas/PricingCard',
'config' => ['currency' => 'IDR'],
'schema' => [/* children yang masing-masing sudah diserialisasi */],
]2
3
4
5
6
Component Anda menerima config dan default slot yang berisi children yang sudah dirender:
<!-- resources/js/pages/Panels/Admin/Schemas/PricingCard.vue -->
<script setup lang="ts">
defineProps<{ config: Record<string, unknown> }>();
</script>
<template>
<section class="rounded-lg border p-4">
<h3 class="mb-3 text-sm font-medium">
Pricing ({{ config.currency }})
</h3>
<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
16
Component yang tidak menggunakan slot berarti memilih untuk tidak menampilkan field di dalamnya; itu tetap behavior yang valid.
Layout yang tidak terdaftar tetap merender children-nya. Wrapper hanyalah decoration; field di dalamnya adalah form yang sebenarnya. Kehilangan seluruh field hanya karena component wrapper di-rename akan menjadi failure mode yang jauh lebih buruk daripada hanya kehilangan frame tampilannya.
CustomEntry — custom infolist entry
use App\Models\Order;
use PandaPanel\Infolists\Components\CustomEntry;
CustomEntry::make('timeline')
->label('Fulfilment timeline')
->component('Panels/Admin/Entries/Timeline')
->config(['compact' => true])
->state(static fn (Order $record): array => $record->events
->map(static fn ($event): array => [
'at' => $event->created_at->toIso8601String(),
'label' => $event->label,
])
->all());2
3
4
5
6
7
8
9
10
11
12
13
| Member | Signature | Default |
|---|---|---|
make | static make(string $name): static | inherited dari Entry |
type() | type(): EntryType | EntryType::Custom |
component() | component(string $component): self | '' |
config() | config(array $config): self | [] |
state() | state(Closure $callback): self | null — fallback ke attribute |
toValue() | toValue(Model $record): mixed | state closure, atau resolveValue() |
Inherited dari Entry: label(), placeholder(), helperText(), columnSpan(), columnSpanFull(), formatUsing(), visible(), action().
Custom entry adalah cara lain untuk menggambar value, bukan mekanisme lain untuk mengambil value. state() tersedia untuk renderer yang membutuhkan data lebih kaya daripada satu column biasa.
Component menerima tiga prop:
defineProps<{
entry: CustomEntryDefinition;
value: unknown;
config: Record<string, unknown>;
}>();2
3
4
5
export interface CustomEntryDefinition extends BaseEntryDefinition {
type: 'custom';
value: unknown;
componentName: string;
config: Record<string, unknown>;
}2
3
4
5
6
Jika nama component tidak dikenali, entry menampilkan placeholder atau em dash.
Modal::content() — custom content pada action dialog
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Support\Modal;
Action::make('archive')
->label('Archive')
->modal(function (Modal $modal): void {
$modal
->heading('Archive this order')
->content('Panels/Admin/Modals/ArchiveWarning', [
'retentionDays' => 30,
]);
});2
3
4
5
6
7
8
9
10
11
12
Action::modal(Closure $callback): static memberikan modal milik action kepada callback untuk dikonfigurasi; return value callback diabaikan.
/**
* @param array<string, mixed> $config
*/
public function content(string $component, array $config = []): self2
3
4
Nama component merupakan build-time registry key di bawah Panels/{Panel}/Modals/, bukan markup. Content dirender di atas content modal lain, sehingga action dengan form dapat menjelaskan konsekuensinya sebelum user melihat field yang mengatur action tersebut.
Component menerima:
defineProps<{
config: Record<string, unknown>;
action: ActionDefinition;
}>();2
3
4
ActionModal.vue mengirim kedua prop tersebut dan menambahkan class mb-4. Nama component yang tidak terdaftar menghasilkan tidak ada custom content, bukan exception — dialog dan form di bawahnya tetap berfungsi.
Serialized modal membawa nama component pada componentName, bersama config, heading, description, submitLabel, cancelLabel, width, slideOver, dan setting lainnya.
Lokasi file yang valid
Nama directory menentukan kecocokan glob; directory lain tidak dipindai:
resources/js/pages/Panels/Admin/Fields/StarRating.vue → Panels/Admin/Fields/StarRating
resources/js/pages/Panels/Admin/Schemas/PricingCard.vue → Panels/Admin/Schemas/PricingCard
resources/js/pages/Panels/Admin/Entries/Timeline.vue → Panels/Admin/Entries/Timeline
resources/js/pages/Panels/Admin/Modals/ArchiveWarning.vue → Panels/Admin/Modals/ArchiveWarning2
3
4
Segment {Panel} hanyalah convention, bukan rule. Glob-nya adalah pages/Panels/**/{Fields,…}/*.vue, sehingga kedalaman apa pun dapat digunakan selama nama directory jenis component sesuai. Menjaga satu directory per panel membuat ownership component lebih mudah dipahami.
Setiap pattern berakhir dengan *.vue, bukan **/*.vue: hanya direct child dari directory jenis tersebut yang diregistrasikan. Fields/Inputs/StarRating.vue tidak akan terdeteksi.
Gotchas
- Component di-load on demand. Keempat seam menggunakan
defineAsyncComponent, karena custom component relatif jarang dan tidak perlu menambah main chunk pada setiap page yang tidak menggunakannya. - File baru membutuhkan rebuild.
import.meta.globdievaluasi saat build. Ini adalah penyebab paling umum component yang sudah ada di filesystem tetapi tetap menampilkan fallback. - Glob menggunakan relative path, bukan alias. Pada Vite dev server, aliased glob dapat me-resolve menjadi kosong sementara production build bekerja, sehingga semua custom component gagal di development tetapi tiba-tiba berfungsi setelah build — failure mode yang sangat buruk.
- Case-sensitive.
Panels/Admin/Fields/starRatingdanPanels/Admin/Fields/StarRatingadalah key berbeda. Pada filesystem case-insensitive, kesalahan dapat baru terlihat di CI. config()melakukan replace. Dua pemanggilan tidak di-merge; konfigurasi terakhir yang digunakan.- Custom field tetap divalidasi server-side.
rules()adalah constraint yang sebenarnya. Component yang tidak menyediakan UI untuk invalid value hanyalah convenience. - Jangan menggambar label dua kali.
FieldWrappersudah merender label, required marker, helper text, dan error. CustomComponent::make()menerima nama component, sedangkanCustomField::make()menerima nama field. Syntax terlihat mirip tetapi artinya berbeda.- Tidak ada renderable content yang melintasi wire. PHP hanya mengirim nama component dan config yang dapat diserialisasi. Server tidak mengirim markup atau template; design inilah yang membuat build-time allowlist dapat dipercaya.