Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.mejik.web.id/llms.txt

Use this file to discover all available pages before exploring further.

Dokumentasi Implementasi Tree DataTable

Dokumentasi ini menjelaskan perubahan dari implementasi DataTable standar (flat) menjadi Tree DataTable (hierarkis) yang mampu menampilkan data dengan struktur induk-anak (parent-child).

Perbedaan Utama: Flat DataTable vs. Tree DataTable

Perbedaan mendasar terletak pada cara data disiapkan di backend (Laravel) dan cara data dirender di frontend (Vue & TanStack Table). 1. Struktur Data (Backend)
  • Flat DataTable:
  • Struktur: Backend mengirimkan data dalam bentuk array datar (flat array) dari hasil paginasi Laravel. Contoh: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }].
  • Query: Menggunakan ->paginate() untuk membatasi jumlah data per halaman. Logika query sangat sederhana.
  • Tree DataTable:
  • Struktur: Backend mengirimkan data dalam bentuk array yang sudah tersusun secara hierarkis (nested). Setiap objek data memiliki properti _children yang berisi array dari anak-anaknya. Contoh: [{ id: 1, name: 'Parent', _children: [{ id: 2, name: 'Child' }] }].
  • Query: Tidak lagi menggunakan paginasi. Controller akan mengambil semua data yang relevan lalu membangun struktur pohon di memori server sebelum dikirim ke frontend.
  • Logika Pencarian:
  • Pada flat data, pencarian hanya menampilkan baris yang cocok.
  • Pada tree data, pencarian akan menemukan baris yang cocok, lalu menelusuri ke atas (ancestors) untuk menyertakan semua induknya hingga ke akar (root). Semua induk dari hasil pencarian akan ditandai _expanded: true agar otomatis terbuka dan menampilkan hasil yang relevan.
2. Logika Komponen (Frontend)
  • Flat DataTable:
  • Komponen: Menggunakan satu komponen DataTable.vue yang generik.
  • TanStack Table: Konfigurasi dasar hanya menggunakan getCoreRowModel().
  • Rendering: Setiap baris dirender secara sekuensial tanpa ada indentasi atau tombol expand/collapse.
  • Tree DataTable:
  • Komponen: Menggunakan komponen baru TreeDataTable.vue yang dirancang khusus untuk data hierarkis.
  • TanStack Table: Menggunakan fitur tambahan:
  • getSubRows: (row) => row._children: Memberi tahu tabel di mana properti anak berada.
  • getExpandedRowModel(): Mengaktifkan fungsionalitas untuk membuka/menutup baris.
  • Mengelola state baru yaitu expanded.
  • Rendering:
  • Indentasi: Setiap baris anak akan memiliki padding-left untuk menunjukkan kedalamannya (row.depth).
  • Tombol Expand/Collapse: Kolom utama (misalnya name) kini memiliki tombol Chevron yang hanya muncul jika baris tersebut memiliki anak (row.getCanExpand()).
  • Paginasi: Dihilangkan, karena seluruh struktur pohon ditampilkan sekaligus. Footer tabel disederhanakan untuk hanya menampilkan total data dan jumlah baris yang dipilih.
3. Interaksi Pengguna
  • Flat DataTable: Interaksi utama adalah sorting, filtering, dan paginasi.
  • Tree DataTable: Interaksi utama adalah membuka dan menutup cabang pohon untuk menjelajahi data. Ditambah, ada aksi baru seperti [+ Add Child] yang spesifik untuk konteks hierarki.

Implementasi Kode Lengkap

Berikut adalah kumpulan semua file kode yang telah dimodifikasi dan dibuat untuk implementasi Tree DataTable.

1. Backend: MemberController.php

Controller ini diubah untuk membangun struktur data pohon dari data flat di database berdasarkan relasi parentId.
<?php

namespace App\Http\Controllers\Directory;

use App\Http\Controllers\Core\AdminController;
use App\Models\Directory\Member;
use Illuminate\Http\Request;
use Inertia\Inertia;
use MongoDB\Laravel\Eloquent\Builder as QueryBuilder;
use Illuminate\Support\Collection;

class MemberController extends AdminController
{
    public function __construct()
    {
        // --- REQUIRED BY AdminController ---
        $this->modelClass = Member::class;
        $this->entity = 'Member';
        $this->namespace = 'App\Http\Controllers\Directory';

        // --- Controller Specific Settings ---
        parent::__construct();

        $this->inertia_page_index = 'Directory/Member/Index';
        $this->inertia_page_create = 'Directory/Member/Create';
        $this->inertia_page_edit = 'Directory/Member/Edit';
        $this->inertia_page_view = 'Directory/Member/View';
        $this->controller_base_route = 'directory.member';
        $this->controller_index_route = 'directory.member.index';
        $this->auth_entity = 'all-members';
        $this->searchable_fields = ['roleName', 'name', 'initial', 'email', 'phone', 'mobile', 'address', 'areaName', 'cluster', 'vendorCode', 'idNumber', 'username', 'jobTitleCode'];
        $this->sortable_fields = ['createdAt', 'created_at', 'updated_at'];
        $this->validator_create = [];
        $this->validator_update = [];
    }

    /**
     * Overriding the parent index method to handle tree data structure.
     */
    public function index(Request $request) // : \Inertia\Response
    {
        $this->breadcrumbs = array_merge(parent::$breadcrumbs ?? [], [
            ['title' => 'Directory', 'href' => route('directory.dashboard.index')],
            ['title' => 'Member List', 'href' => route($this->controller_index_route)],
        ]);

        // Get the base query with filters, search, etc. applied from the parent
        $query = $this->applyIndexQueryFilters($request);

        // Check if a search term is present
        $isSearching = $request->filled('search');

        $data = [];
        $total = 0;

        if ($isSearching) {
            // If searching, we build a partial tree containing only the search results and their ancestors.
            $matchingItems = $query->get();
            $total = $matchingItems->count();
            if ($total > 0) {
                $data = $this->buildTreeFromSearchResults($matchingItems);
            }
        } else {
            // If not searching, we build the full tree from top-level nodes.
            // We ignore pagination for the tree view, so we get all items.
            $allItems = $this->modelClass::query()->get();
            $total = $allItems->count();
            if ($total > 0) {
                // Building tree from top-level items (parentId is null or not exists)
                $data = $this->buildTree($allItems, null);
            }
        }

        // The data object passed to Inertia is now a simple array, not a paginator.
        $this->data_object = [
            'data' => $data,
            'meta' => [
                'total' => $total, // Provide total for display
            ],
        ];

        // This is a simplified way to render the Inertia page, bypassing parent::index() pagination logic.
        return Inertia::render($this->inertia_page_index, [
            'breadcrumbs' => $this->breadcrumbs,
            'filters' => $request->all('search', 'trashed'),
            'data_object' => $this->data_object,
            'data_object_extra' => $this->data_object_extra,
            'extra_props' => $this->extra_props,
            'page_title' => 'Manage ' . $this->entity,
            'entity' => $this->entity,
            'controller_index_route' => $this->controller_index_route,
        ]);
    }

    /**
     * Recursively builds a tree structure from a flat collection of items.
     *
     * @param Collection $elements A collection of all items.
     * @param string|null $parentId The ID of the parent element to find children for.
     * @return array The tree structure.
     */
    protected function buildTree(Collection &$elements, $parentId = null): array
    {
        $branch = [];
        // Make sure to handle both null and non-existent parentId
        $children = $elements->filter(function ($item) use ($parentId) {
            return $item->parentId == $parentId;
        });

        foreach ($children as $element) {
            $element->_children = $this->buildTree($elements, $element->id);
            // Set initial expanded state to false if it has children
            $element->_expanded = false;
            $branch[] = $element;
        }

        return $branch;
    }

    /**
     * Builds a filtered tree containing only search results and their ancestors.
     * All ancestors are marked as expanded to ensure visibility.
     *
     * @param Collection $matchingItems The items that matched the search query.
     * @return array The filtered tree structure.
     */
    protected function buildTreeFromSearchResults(Collection $matchingItems): array
    {
        // Get all members from DB to easily find parents.
        $allMembers = $this->modelClass::query()->get()->keyBy('id');

        $requiredItems = new Collection();
        $processedIds = [];

        foreach ($matchingItems as $item) {
            $currentItem = $item;
            // Traverse up to the root
            while ($currentItem && !isset($processedIds[$currentItem->id])) {
                $processedIds[$currentItem->id] = true;
                // Mark all parents as expanded
                $currentItem->_expanded = true;
                $requiredItems->put($currentItem->id, $currentItem);

                if ($currentItem->parentId && $allMembers->has($currentItem->parentId)) {
                    $currentItem = $allMembers->get($currentItem->parentId);
                } else {
                    $currentItem = null; // Reached the top
                }
            }
        }

        // Remove the _expanded flag from the actual search results (leaf nodes in this context)
        foreach($matchingItems as $item) {
             if ($requiredItems->has($item->id)) {
                 $requiredItems->get($item->id)->_expanded = false;
             }
        }

        return $this->buildTree($requiredItems, null);
    }

    // ... all other methods like create, store, edit, view, additionalQuery etc. remain unchanged.
}

2. Frontend: columns.ts

Definisi kolom dimodifikasi untuk menambahkan tombol expand/collapse pada kolom name dan menambahkan event handler untuk tombol + Add Child.
import type { ColumnDef } from '@tanstack/vue-table';
import type { Member } from './schema';
import DataTableColumnHeader from '@/components/custom/table/components/DataTableColumnHeader.vue';
import DataTableRowActions from '../components/DataTableRowActions.vue';
import { Checkbox } from '@/components/ui/checkbox';
import { formatBoolean, formatDate, renderImage } from '@/utils/formatters';
import { h } from 'vue';
import { Button } from '@/components/ui/button';
import { ChevronRight, ChevronDown } from 'lucide-vue-next';

export const columns: ColumnDef<Member>[] = [
    {
        id: 'select',
        header: ({ table }) => h(Checkbox, {
            'modelValue': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'),
            'onUpdate:modelValue': (value: boolean) => table.toggleAllPageRowsSelected(!!value),
            'aria-label': 'Select all',
            'class': 'translate-y-0.5'
        }),
        cell: ({ row }) => h(Checkbox, {
            'modelValue': row.getIsSelected(),
            'onUpdate:modelValue': (value: boolean) => row.toggleSelected(!!value),
            'aria-label': 'Select row',
            'class': 'translate-y-0.5'
        }),
        size: 50,
        maxSize: 70,
        headerStyle: 'width: 50px;max-width: 50px;',
        enableSorting: false,
        enableHiding: false
    },
    {
        id: 'actions',
        header: () => h('div', { class: 'text-center pr-2' }, 'Actions'),
        cell: ({ row, table }) => h(DataTableRowActions, {
            row,
            onAddChild: () => table.options.meta?.onAddChild(row.original)
        }),
        enableSorting: false,
        enableHiding: false,
        size: 150,
        maxSize: 150,
        headerStyle: 'width: 125px;max-width: 125px;',
    },
    {
        accessorKey: 'avatar',
        header: () => '',
        cell: ({ row }) => renderImage(row.getValue('avatar'), {}),
        enableSorting: false,
        enableColumnFilter: false,
    },
    {
        accessorKey: 'name',
        header: () => 'Name',
        cell: ({ row }) => {
            const canExpand = row.getCanExpand();
            const isExpanded = row.getIsExpanded();

            return h('div', {
                class: 'flex items-center',
                style: { paddingLeft: `${row.depth * 1.5}rem` } // Indentation
            }, [
                canExpand
                    ? h(Button, {
                        variant: 'ghost',
                        size: 'sm',
                        class: 'p-1 h-7 w-7 mr-2',
                        onClick: row.getToggleExpandedHandler()
                    }, () => h(isExpanded ? ChevronDown : ChevronRight, { class: 'h-4 w-4' }))
                    : h('span', { class: 'w-7 mr-2' }), // Placeholder for alignment
                h('span', { class: 'text-200 text-left' }, String(row.getValue('name') ?? '-')),
            ]);
        },
        enableSorting: false,
        enableColumnFilter: false,
    },
    {
        accessorKey: 'email',
        header: () => 'Email',
        cell: ({ row }) => h('span', { class: 'text-250 text-left' }, String(row.getValue('email') ?? '-')),
        enableSorting: false,
        enableColumnFilter: false,
    },
    // ... all other columns are the same
    {
        accessorKey: 'mobile',
        header: () => 'Mobile',
        cell: ({ row }) => h('span', { class: 'text-200 text-left' }, String(row.getValue('mobile') ?? '-')),
        enableSorting: false,
        enableColumnFilter: false,
    },
    {
        accessorKey: 'updated_at',
        header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Updated' }),
        cell: ({ row }) => formatDate(row.getValue('updated_at'), {}),
        enableColumnFilter: false,
    }
];

3. Frontend: components/DataTableRowActions.vue (Modifikasi)

Komponen aksi baris ditambahkan prop onAddChild untuk menangani event klik pada menu + Add Child.
<script setup lang="ts">
import { MoreHorizontal } from 'lucide-vue-next';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';

const props = defineProps({
  row: {
    type: Object,
    required: true,
  },
  onAddChild: {
    type: Function,
    required: true
  }
});
</script>

<template>
  <DropdownMenu>
    <DropdownMenuTrigger as-child>
      <Button variant="ghost" class="flex h-8 w-8 p-0 data-[state=open]:bg-muted">
        <MoreHorizontal class="h-4 w-4" />
        <span class="sr-only">Open menu</span>
      </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent align="end" class="w-[160px]">
      <DropdownMenuItem @click="onAddChild">
        + Add Child
      </DropdownMenuItem>
      <!-- Other actions like Edit, Delete, etc. -->
      <DropdownMenuItem>Edit</DropdownMenuItem>
      <DropdownMenuItem>Delete</DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
</template>

4. Frontend: TreeDataTable.vue (Komponen Baru)

Ini adalah komponen utama yang baru, yang meng-handle rendering tabel hierarkis.
<script setup lang="ts" generic="TData, TValue">
import type { ColumnDef, ExpandedState } from '@tanstack/vue-table';
import {
    FlexRender,
    getCoreRowModel,
    getExpandedRowModel,
    useVueTable,
} from '@tanstack/vue-table';
import { ref, watch, computed } from 'vue';
import { valueUpdater } from '@/lib/utils';
import {
    Table,
    TableBody,
    TableCell,
    TableHead,
    TableHeader,
    TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';

const props = defineProps<{
    columns: ColumnDef<TData, TValue>[];
    data: TData[];
    meta: {
        total?: number;
    };
}>();

const emit = defineEmits(['addChild']);

const onAddChild = (row: TData) => {
    emit('addChild', row);
};

// Fungsi untuk membangun state expanded awal dari data
const getInitialExpandedState = (data: any[]): ExpandedState => {
    const expanded: ExpandedState = {};
    const traverse = (nodes: any[]) => {
        nodes.forEach(node => {
            if (node._expanded && node._children && node._children.length > 0) {
                expanded[node.id] = true; // Asumsi setiap baris memiliki properti 'id' yang unik
            }
            if (node._children) {
                traverse(node._children);
            }
        });
    };
    traverse(data);
    return expanded;
};


const rowSelection = ref({});
const expanded = ref<ExpandedState>(getInitialExpandedState(props.data));

watch(() => props.data, (newData) => {
  // Reset state expanded ketika data berubah (misalnya setelah pencarian)
  expanded.value = getInitialExpandedState(newData);
}, { deep: true });


const table = useVueTable({
    get data() { return props.data; },
    get columns() { return props.columns; },
    getCoreRowModel: getCoreRowModel(),
    // --- Konfigurasi Tree Data ---
    getSubRows: (row: any) => row._children,
    getExpandedRowModel: getExpandedRowModel(),
    // --- Manajemen State ---
    state: {
        get rowSelection() { return rowSelection.value; },
        get expanded() { return expanded.value; },
    },
    onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
    onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
    // --- Meta untuk event handling ---
    meta: {
        onAddChild,
    },
    enableRowSelection: true,
});

const totalRecords = computed(() => props.meta?.total ?? 0);
const selectedRowCount = computed(() => Object.keys(rowSelection.value).length);
</script>

<template>
    <div class="space-y-4">
        <div class="rounded-md border">
            <Table>
                <TableHeader>
                    <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
                        <TableHead v-for="header in headerGroup.headers" :key="header.id" :style="header.column.columnDef.headerStyle">
                            <FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
                        </TableHead>
                    </TableRow>
                </TableHeader>
                <TableBody>
                    <template v-if="table.getRowModel().rows?.length">
                        <TableRow v-for="row in table.getRowModel().rows" :key="row.id" :data-state="row.getIsSelected() && 'selected'">
                            <TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
                                <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
                            </TableCell>
                        </TableRow>
                    </template>
                    <TableRow v-else>
                        <TableCell :colspan="columns.length" class="h-24 text-center">
                            No results.
                        </TableCell>
                    </TableRow>
                </TableBody>
            </Table>
        </div>
        <!-- Footer Sederhana untuk Tree View -->
        <div class="flex items-center justify-between px-2">
            <div class="flex-1 text-sm text-muted-foreground">
                {{ selectedRowCount }} of {{ totalRecords }} row(s) selected.
            </div>
            <div class="flex items-center space-x-2">
                <p class="text-sm font-medium">
                    Total Records: {{ totalRecords }}
                </p>
            </div>
        </div>
    </div>
</template>

5. Frontend: Index.vue (Halaman Pengguna)

Halaman ini sekarang menggunakan komponen TreeDataTable.vue dan menangani event @addChild.
<script setup>
import { Head, Link, router } from '@inertiajs/vue3';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { Button } from '@/components/ui/button';
import TreeDataTable from './TreeDataTable.vue'; // <-- Menggunakan TreeDataTable
import { columns } from './columns';

defineOptions({ layout: AdminLayout });

const props = defineProps({
    data_object: Object,
    // ... props lainnya
});

// Objek data dari controller tidak lagi dipaginasi
const members = props.data_object.data;
const meta = props.data_object.meta;

// Handler untuk event addChild
const handleAddChild = (parentMember) => {
    console.log('Add child for member:', parentMember.name, parentMember.id);
    // Di sini Anda bisa menavigasi ke halaman 'create' dengan membawa ID parent
    // Contoh menggunakan router Inertia:
    router.get(route('directory.member.create', { parentId: parentMember.id }));
};
</script>

<template>
    <Head title="Member" />
    <div class="p-4 md:p-8">
        <div class="flex items-center justify-between mb-6">
            <h1 class="text-3xl font-bold">Member</h1>
            <Link :href="route('directory.member.create')">
                <Button>New Member</Button>
            </Link>
        </div>

        <div class="space-y-4">
            <TreeDataTable
                :data="members"
                :columns="columns"
                :meta="meta"
                @add-child="handleAddChild"
            />
        </div>
    </div>
</template>
Dengan implementasi ini, aplikasi Anda kini memiliki tabel yang kuat untuk menampilkan dan berinteraksi dengan data hierarkis secara efisien.