Custom Entry
PandaPanel\Infolists\Components\CustomEntry menampilkan sebuah nilai menggunakan komponen Vue buatan Anda sendiri. Gunakan ini ketika tidak ada satu pun dari sepuluh tipe entry bawaan yang sesuai dengan cara nilai perlu ditampilkan — misalnya gauge, sparkline, peta, atau diff.
Ini adalah cara berbeda untuk menggambar sebuah nilai, bukan cara berbeda untuk mengambil datanya: entry tetap me-resolve nilai dari record di server, lalu komponen menerima hasilnya sebagai data.
Contoh custom entry minimal
Dibutuhkan dua file. Sisi PHP menentukan nama komponen dan menyediakan nilainya:
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Infolists\Components\CustomEntry;
CustomEntry::make('score')
->label('Health score')
->component('Panels/Admin/Entries/Gauge')
->config(['max' => 100])
->state(static fn (Model $record): array => [
'value' => $record->getAttribute('health_score'),
'trend' => $record->getAttribute('health_trend'),
]);2
3
4
5
6
7
8
9
10
11
Sisi Vue menggambarkannya di resources/js/pages/Panels/Admin/Entries/Gauge.vue:
<script setup lang="ts">
import { computed } from 'vue';
/**
* The value is whatever the PHP entry resolved, so it arrives as untyped
* JSON and is narrowed here rather than asserted.
*/
const props = defineProps<{
value: unknown;
config: Record<string, unknown>;
}>();
const max = computed(() =>
typeof props.config.max === 'number' ? props.config.max : 100,
);
const reading = computed(() => {
const value = props.value;
if (typeof value !== 'object' || value === null) {
return null;
}
const { value: score } = value as { value?: unknown };
return typeof score === 'number' ? score : null;
});
const percent = computed(() =>
reading.value === null
? 0
: Math.min(100, Math.round((reading.value / max.value) * 100)),
);
</script>
<template>
<div v-if="reading !== null" class="flex items-center gap-2">
<div class="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary"
:style="{ width: `${percent}%` }"
/>
</div>
<span class="tabular-nums">{{ reading }} / {{ max }}</span>
</div>
<span v-else class="text-muted-foreground">—</span>
</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
43
44
45
46
47
Jalankan build, lalu halaman view akan menampilkan gauge pada posisi entry tersebut.
API
CustomEntry mewarisi Entry, sehingga label(), placeholder(), helperText(), columnSpan(), columnSpanFull(), formatUsing(), visible(), dan action() tetap bekerja seperti pada entry lainnya. Class ini menambahkan tiga kemampuan:
| Method | Signature | Default |
|---|---|---|
component() | component(string $component): self | '' — tidak ada yang dapat di-resolve, sehingga placeholder ditampilkan |
config() | config(array $config): self | [] |
state() | state(Closure $callback): self | null |
type() | type(): EntryType | EntryType::Custom |
toValue() | toValue(Model $record): mixed | Hasil state(), atau attribute yang berhasil di-resolve |
component()
Key untuk registry build-time — yaitu path di bawah resources/js/pages/, tanpa extension:
| File | Nama |
|---|---|
resources/js/pages/Panels/Admin/Entries/Gauge.vue | Panels/Admin/Entries/Gauge |
resources/js/pages/Panels/App/Entries/Sparkline.vue | Panels/App/Entries/Sparkline |
Bukan markup, bukan filesystem path, dan tidak pernah dibentuk dari nilai request — aturan yang sama berlaku pada custom column, field, dan widget.
config()
Berisi opsi statis yang dibaca komponen. Nilainya diserialisasi sebagai JSON, sehingga hanya boleh berisi data, bukan behavior:
CustomEntry::make('score')
->component('Panels/Admin/Entries/Gauge')
->config(['max' => 10, 'unit' => 'points', 'showTrend' => true]);2
3
Closure di dalam config() tidak akan bertahan saat encoding. Semua nilai yang bergantung pada record harus dibentuk melalui state().
state()
Membangun nilai dari keseluruhan record, bukan hanya dari satu attribute. Gunakan ini ketika renderer membutuhkan lebih banyak data daripada yang tersedia di satu kolom:
public function state(Closure $callback): self
// Closure(Model): mixed2
use Illuminate\Database\Eloquent\Model;
CustomEntry::make('activity')
->component('Panels/Admin/Entries/Sparkline')
->state(static fn (Model $record): array => $record->logins()
->latest()
->limit(30)
->pluck('count')
->all());2
3
4
5
6
7
8
9
Tanpa state(), entry me-resolve namanya seperti entry lain — melalui data_get() lalu formatUsing() — sehingga custom renderer untuk sebuah kolom biasa tidak membutuhkan callback tambahan.
CustomEntry::make('meta')->component('Panels/Admin/Entries/MetaCard');state() dan formatUsing() adalah dua alternatif, bukan rangkaian. Jika state() ditentukan, hasilnya menjadi nilai akhir dan formatUsing() tidak dijalankan.
Contract komponen
InfolistEntry.vue menggambar label, helper text, dan tombol action. Komponen Anda hanya bertanggung jawab menggambar nilai. Komponen menerima tiga prop:
| Prop | Tipe | Makna |
|---|---|---|
entry | CustomEntryDefinition | Seluruh definisi entry — name, label, placeholder, columnSpan, action |
value | unknown | Nilai yang dikembalikan state() atau attribute yang berhasil di-resolve |
config | Record<string, unknown> | Nilai yang dideklarasikan melalui config() |
Komponen tidak meng-emits apa pun. Infolist bersifat read-only, sehingga tidak ada update:modelValue dan tidak ada state yang ditulis kembali — inilah perbedaan utama antara custom entry dan custom field.
Lakukan narrowing terhadap semua nilai yang dibaca. Props dikirim sebagai JSON tanpa tipe runtime; shape yang tidak sesuai sebaiknya menampilkan em dash, bukan melempar error dan merusak halaman view.
Gunakan definisi TypeScript berikut jika Anda ingin typing eksplisit:
import type { CustomEntryDefinition } from '@/panel/types/infolist';
defineProps<{
entry: CustomEntryDefinition;
value: unknown;
config: Record<string, unknown>;
}>();2
3
4
5
6
7
Lokasi pencarian komponen
Registry berada di resources/js/panel/forms/registry.ts dan digunakan bersama oleh custom field, custom form layout, custom entry, serta custom action modal. Pola glob di dalamnya berfungsi sebagai allowlist build-time:
resources/js/pages/Panels/**/Fields/*.vue ← CustomField
resources/js/pages/Panels/**/Schemas/*.vue ← CustomComponent
resources/js/pages/Panels/**/Entries/*.vue ← CustomEntry
resources/js/pages/Panels/**/Modals/*.vue ← custom action modals2
3
4
Satu registry digunakan alih-alih empat registry berbeda yang menyampaikan aturan sama: sebuah nama hanya dapat me-resolve komponen yang pernah dilihat build. Tempat nama tersebut dideklarasikan tidak mengubah aturan itu. Komponen yang tidak ikut ter-compile tidak dapat dijangkau, apa pun input yang dikirim.
Komponen dimuat on-demand. Custom entry relatif jarang digunakan, sehingga membundel semuanya sejak awal hanya akan membebani setiap halaman view yang tidak membutuhkannya.
Glob hanya dapat melihat komponen Anda karena source Vue panel berada di aplikasi sendiri — dipublish melalui php artisan panel:install atau php artisan vendor:publish --tag=panda-panel-assets. Lihat Frontend assets.
Ketika nama komponen tidak dapat di-resolve
Entry menampilkan placeholder-nya, atau em dash jika placeholder tidak dideklarasikan. Tidak ada exception yang dilempar: satu typo pada nama komponen tidak boleh menjatuhkan seluruh halaman view, dan entry lain tetap dapat dirender.
Di development, registry memberi warning satu kali per nama di console browser dan menyebutkan directory tempat file seharusnya berada. Di production warning tidak ditampilkan — ini adalah masalah build, dan pesan console di panel production tidak membantu pengguna. Tiga penyebab umumnya adalah typo, file berada di luar directory yang di-glob, atau build belum dijalankan ulang.
Menambahkan tipe entry baru sepenuhnya
CustomEntry adalah extension point yang didukung. Menambahkan EntryType baru berarti mengubah framework dan membutuhkan tiga perubahan yang harus masuk bersamaan:
- Class PHP yang mewarisi
Entrydan mengembalikan case baru dariPandaPanel\Infolists\Enums\EntryType. - Definisi TypeScript yang ditambahkan ke union
EntryDefinitiondiresources/js/panel/types/infolist.ts. - Branch baru pada
resources/js/panel/infolists/InfolistEntry.vue.
Switch pada renderer bersifat exhaustive terhadap union tersebut. Definisi tanpa branch menghasilkan compile error. InfolistEntryTest juga membaca file TypeScript dan gagal ketika case PHP EntryType tidak pernah masuk ke union — sesuatu yang tidak bisa dilihat compiler PHP. Semua kebutuhan aplikasi normal dapat dicapai melalui CustomEntry tanpa menyentuh bagian framework ini.
Testing
Entry dapat diserialisasi tanpa page maupun request:
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Infolists\Components\CustomEntry;
it('sends a registry key and its state', function (): void {
$entry = CustomEntry::make('score')
->component('Panels/Admin/Entries/Gauge')
->config(['max' => 10])
->state(static fn (Model $record): int => 7);
$definition = $entry->toArray(new Project);
expect($definition['type'])->toBe('custom')
->and($definition['componentName'])->toBe('Panels/Admin/Entries/Gauge')
->and($definition['config'])->toBe(['max' => 10])
->and($definition['value'])->toBe(7);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Perhatikan bahwa key-nya adalah componentName, bukan component — component sudah dipakai sebagai node discriminant dan nilainya selalu 'entry' untuk setiap entry.
Catatan
- Nama komponen bukan path filesystem. Tidak boleh diawali
@/, tidak boleh diakhiri.vue, dan tidak boleh dibentuk dari nilai request. - Build ulang setelah menambahkan komponen. Glob dievaluasi pada build-time; file baru tidak terlihat sampai Vite memprosesnya.
state()berjalan per record di server. Query di dalamnya berarti query tambahan pada halaman view — pada satu record mungkin wajar, tetapi tetap merupakan query, dan relation biasanya lebih murah jika di-eager load.- Nilai harus dapat melewati
json_encode. Model yang dikembalikan daristate()akan diserialisasi sebagai attributes-nya, termasuk attribute yang kebetulan sedang dimuat. Lebih aman kembalikan array yang hanya berisi data yang benar-benar dibutuhkan komponen. visible()tetap berlaku. Custom entry yang hidden tidak diserialisasi sama sekali, sehingga komponennya bahkan tidak diminta untuk merender.- Action di samping custom entry tetap bekerja normal. Action digambar oleh wrapper, bukan oleh komponen Anda, dan dijalankan melalui endpoint infolist seperti action lain. Lihat Actions in infolists.