Custom Columns
PandaPanel\Tables\Columns\CustomColumn adalah table column yang dirender oleh Vue component buatan Anda sendiri. Gunakan ketika value yang ingin ditampilkan bukan sekadar text, number, badge, date, image, icon, atau color — misalnya progress bar, sparkline, dua label bertumpuk, atau health indicator.
PHP class tetap menentukan apa cell tersebut. Nama component berasal dari class, bukan dari request, dan frontend me-resolve-nya melalui glob saat build. Artinya component yang tidak pernah terlihat oleh build tidak dapat diakses, apa pun nama yang dikirim ke browser.
Contoh minimal yang berfungsi
Deklarasikan column:
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Tables\Columns\CustomColumn;
use PandaPanel\Tables\TableSchema;
public static function table(TableSchema $schema): TableSchema
{
return $schema->columns([
CustomColumn::make('accountAge')
->label('Account age')
->component('Panels/Admin/Columns/AccountAge')
->state(static fn (Model $record): array => [
'days' => (int) $record->created_at->diffInDays(now()),
'label' => $record->created_at->diffForHumans(),
]),
]);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Buat component pada path yang persis sesuai di bawah resources/js/pages/:
<!-- resources/js/pages/Panels/Admin/Columns/AccountAge.vue -->
<script setup lang="ts">
import { computed } from 'vue';
/** Apa pun yang dikembalikan `state()`, sebagai JSON tanpa type. */
const props = defineProps<{ state: unknown }>();
const reading = computed(() => {
const value = props.state;
if (typeof value !== 'object' || value === null) {
return null;
}
const { days, label } = value as { days?: unknown; label?: unknown };
return typeof days === 'number' && typeof label === 'string'
? { days, label }
: null;
});
</script>
<template>
<span v-if="reading" class="whitespace-nowrap">{{ reading.label }}</span>
<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
npm run build # atau: npm run devClass
CustomColumn extends PandaPanel\Tables\Columns\Column, sehingga seluruh method Column yang sudah Anda kenal tetap dapat digunakan. Dua konfigurasi khususnya adalah component dan state.
| Member | Signature | Default |
|---|---|---|
type() | public function type(): ColumnType | ColumnType::Custom |
component() | component(string $component): self | '' |
state() | state(Closure $callback): self | null — fallback ke attribute |
toCell() | toCell(Model $record): mixed | state closure, atau resolveValue() |
component()
public function component(string $component): selfSebuah build-time registry key: path di bawah resources/js/pages/, tanpa extension .vue. Bukan markup, bukan filesystem path, dan bukan class name.
CustomColumn::make('health')->component('Panels/Admin/Columns/HealthBar');
// me-resolve resources/js/pages/Panels/Admin/Columns/HealthBar.vue2
Default-nya adalah empty string. Nilai tersebut tidak me-resolve component apa pun dan column akan menampilkan placeholder. Tidak ada exception — table dengan satu column yang hanya menampilkan placeholder masih merupakan table yang dapat digunakan, dan failure mode ini lebih baik daripada seluruh page gagal render.
state()
/**
* @param Closure(Model): mixed $callback
*/
public function state(Closure $callback): self2
3
4
Membangun isi cell dari seluruh record, bukan dari satu attribute:
use App\Models\Order;
use PandaPanel\Tables\Columns\CustomColumn;
CustomColumn::make('fulfilment')
->component('Panels/Admin/Columns/Fulfilment')
->state(static fn (Order $record): array => [
'shipped' => $record->shipped_items,
'total' => $record->total_items,
'late' => $record->due_at?->isPast() ?? false,
]);2
3
4
5
6
7
8
9
10
Apa pun yang dikembalikan callback tersebut menjadi prop state pada component. Nilainya harus dapat diserialisasi menjadi scalar, array, atau null seperti cell lainnya. Closure, model, dan enum tidak melintasi wire.
Tanpa state(), column menggunakan resolveValue() — yaitu attribute yang namanya diberikan ke make(), mendukung dot notation untuk relation, menerapkan default(), dan menjalankan formatUsing() jika dideklarasikan:
CustomColumn::make('profile.score')
->component('Panels/Admin/Columns/ScoreRing');
// state adalah $record->profile->score2
3
Inherited methods yang penting
Semua method pada Column berlaku untuk CustomColumn. Berikut yang paling berpengaruh terhadap bagaimana component Anda digunakan frontend:
| Method | Signature | Efek |
|---|---|---|
label | label(string $label): static | text header; default headline dari nama |
placeholder | placeholder(string $placeholder): static | ditampilkan jika component tidak dapat di-resolve |
default | default(mixed $default): static | menggantikan attribute null sebelum state() |
formatUsing | formatUsing(Closure $callback): static | berlaku pada attribute path, bukan return dari state() |
alignment | alignment(Alignment|string $alignment): static | start, center, end, justify |
headerAlignment | headerAlignment(Alignment|string $alignment): static | default mengikuti alignment cell |
width | width(string $width): static | CSS length yang diterapkan inline — tidak dibangun sebagai Tailwind class |
visible | visible(bool $visible = true): static | mengeluarkan column dari definition |
toggleable | toggleable(bool $toggleable = true): static | menawarkan column pada column manager |
frozen | frozen(ColumnPin|bool $pin = true): static | pin ke salah satu sisi ketika table scroll |
sortable | sortable(bool $sortable = true, ?string $column = null): static | membutuhkan database column nyata; berikan namanya jika berbeda dari nama custom column |
sortUsing | sortUsing(Closure $callback): static | untuk sorting yang tidak dapat dijelaskan hanya dengan column name |
searchable | searchable(bool $searchable = true, ?array $columns = null, bool $individually = false): static | prinsip yang sama — search dijalankan di SQL |
tooltip | tooltip(Closure|string $tooltip): static | tooltip per-row |
extraAttributes | extraAttributes(Closure|array $attributes): static | cell attributes per-row |
url | url(Closure $callback): static | menjadikan cell link |
action | action(Action $action): static | menjadikan cell pemicu action |
Sorting dan searching perlu dipahami dengan jelas: keduanya terjadi pada database terhadap column. state() berjalan di PHP setelah row diambil, sehingga value hasil computation tidak dapat langsung digunakan query untuk sorting. Gunakan sortUsing() jika urutan harus dijelaskan sebagai query.
Prop yang diterima component
Tepat satu prop:
defineProps<{ state: unknown }>();DataTableCell.vue merendernya seperti berikut:
<component :is="customComponent" v-if="customComponent" :state="value" />
<span v-else class="text-muted-foreground">{{ placeholder }}</span>2
Prop menggunakan unknown, bukan shape yang langsung di-assert. Ini disengaja karena value melintasi wire sebagai JSON. Component sebaiknya melakukan narrowing sendiri; jika shape tidak sesuai, tampilkan fallback. Dengan begitu satu row yang malformed tidak menjatuhkan seluruh table.
Component di-load on demand menggunakan defineAsyncComponent. Custom column umumnya jarang digunakan; memasukkan semuanya ke main chunk akan menambah ukuran bundle untuk page yang bahkan tidak memakai custom column.
Jika Anda ingin prop dengan shape terdefinisi, tetap jadikan entry point unknown lalu narrow sendiri:
<script setup lang="ts">
import { computed } from 'vue';
interface Fulfilment {
shipped: number;
total: number;
late: boolean;
}
const props = defineProps<{ state: unknown }>();
function isFulfilment(value: unknown): value is Fulfilment {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.shipped === 'number' &&
typeof candidate.total === 'number' &&
typeof candidate.late === 'boolean'
);
}
const fulfilment = computed(() =>
isFulfilment(props.state) ? props.state : null,
);
</script>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
Serialized definition
CustomColumn::toArray() menghasilkan base column definition ditambah satu key component:
[
'name' => 'accountAge',
'label' => 'Account age',
'type' => 'custom',
'sortable' => false,
'searchable' => false,
'individuallySearchable' => false,
'visible' => true,
'toggleable' => true,
'alignment' => 'start',
'headerAlignment' => 'start',
'placeholder' => null,
'headerTooltip' => null,
'wrapHeader' => false,
'width' => null,
'frozen' => null,
'component' => 'Panels/Admin/Columns/AccountAge',
]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
TypeScript mirror:
export interface CustomColumnDefinition extends BaseColumnDefinition {
type: 'custom';
component: string;
}
/** State custom column adalah apa pun yang memang diharapkan component-nya. */
export type CustomCell = unknown;2
3
4
5
6
7
Lokasi component
Registry menggunakan satu pattern import.meta.glob:
resources/js/pages/Panels/**/Columns/*.vueimport {
resolveColumnComponent,
hasColumnComponent,
} from '@/panel/tables/registry';
hasColumnComponent('Panels/Admin/Columns/AccountAge'); // boolean
resolveColumnComponent('Panels/Admin/Columns/AccountAge'); // loader atau null2
3
4
5
6
7
| Function | Signature |
|---|---|
hasColumnComponent | (name: string) => boolean |
resolveColumnComponent | (name: string) => (() => Promise<{ default: Component }>) | null |
Registry key adalah path di bawah pages/ tanpa extension, sehingga component() ditulis sebagai Panels/Admin/Columns/AccountAge. Ada dua konsekuensi penting:
- Component di lokasi lain — misalnya
resources/js/components/atau nested directory sepertiColumns/Parts/— tidak cocok dengan glob dan tidak akan di-resolve. Pattern berakhir dengan*.vue, bukan**/*.vue. - Glob merupakan allowlist saat build. Nama yang tidak pernah masuk hasil compile tidak dapat digunakan, apa pun yang dikirim request.
Component seam lain pada table: empty states
Table juga dapat mengganti seluruh empty state dengan custom component menggunakan registry dengan pola serupa:
use PandaPanel\Tables\TableSchema;
public static function table(TableSchema $schema): TableSchema
{
return $schema->emptyStateComponent('Panels/Admin/EmptyStates/NoOrders');
}2
3
4
5
6
<!-- resources/js/pages/Panels/Admin/EmptyStates/NoOrders.vue -->
<script setup lang="ts">
defineProps<{
emptyState: {
heading: string;
description: string | null;
icon: string | null;
component: string | null;
actions: unknown[];
};
}>();
</script>2
3
4
5
6
7
8
9
10
11
12
| Bagian | Value |
|---|---|
| PHP | TableSchema::emptyStateComponent(string $component): self |
| Glob | resources/js/pages/Panels/**/EmptyStates/*.vue |
| Resolver | resolveEmptyStateComponent(name: string) dari @/panel/tables/registryEmptyStates |
| Prop | emptyState — serialized empty state, termasuk heading, description, icon, dan actions |
| Unknown name | table menggunakan ordinary empty state bawaan |
Ketika nama component tidak dapat di-resolve
Cell menampilkan placeholder — default em dash — bukan melempar exception. Satu typo pada nama component tidak boleh menjatuhkan seluruh table, dan column lain pada row tetap dapat dibaca.
Column registry tidak menulis development warning; behavior warning hanya tersedia pada form dan widget registry. Jika custom column selalu menampilkan placeholder, periksa dalam urutan ini:
- spelling dan case pada
component()dibandingkan path file; - pastikan file merupakan direct child dari directory
Columns/di bawahresources/js/pages/Panels/; - pastikan build dijalankan setelah file dibuat.
Anda juga dapat mengecek registry langsung dari component atau browser console ketika development:
import { hasColumnComponent } from '@/panel/tables/registry';
hasColumnComponent('Panels/Admin/Columns/AccountAge');2
3
Gotchas
state()berjalan per row di PHP setelah query selesai. Closure yang mengakses relation pada setiap row dapat membuat N+1. Eager-load relation dari resource query.- Return dari
state()tidak melaluiformatUsing().formatUsing()berlaku terhadap attribute path yang dibacaresolveValue(); state closure sudah dianggap sebagai jawaban final. - Sorting dan searching adalah SQL.
sortable()pada computed column dapat mencoba order terhadap attribute yang tidak ada. Berikan real database column atau gunakansortUsing(). - Glob tidak menggunakan alias. Pattern di
registry.tsbersifat relatif (../../pages/Panels/**/Columns/*.vue) karena pada Vite dev server aliased glob dapat me-resolve ke kosong sementara production build bekerja — failure mode yang sangat sulit didiagnosis. - File baru membutuhkan rebuild.
import.meta.globdievaluasi saat build. Dev server mendeteksi file baru, tetapi production bundle yang dibuat sebelum file tersebut ada tidak akan mengenalnya. - Tidak ada renderable content yang melintasi wire. Server hanya mengirim nama dan data. PHP tidak dapat mengirim markup, template, atau component, dan itu memang design-nya.
- Column width diterapkan inline, bukan sebagai class.
width('12rem')menjadi style karena interpolated Tailwind class tidak akan tersedia di bundle.