Changes to UI and other stuff
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, h } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
useVueTable,
|
||||
} from "@tanstack/vue-table";
|
||||
import { valueUpdater } from "@/lib/utils";
|
||||
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
||||
import DataTablePagination from "./DataTablePagination.vue";
|
||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||
import DataTableToolbar from "./DataTableToolbar.vue";
|
||||
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
||||
import EmptyState from "../EmptyState.vue";
|
||||
import Pagination from "../Pagination.vue";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
// Column definitions using TanStack Table format or simple format
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
// Data rows
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// Server-side pagination meta (Laravel pagination)
|
||||
meta: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// Current sort state
|
||||
sort: {
|
||||
type: Object,
|
||||
default: () => ({ key: null, direction: null }),
|
||||
},
|
||||
// Search/filter value
|
||||
search: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// Loading state
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Page size for client-side pagination
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
pageSizeOptions: {
|
||||
type: Array,
|
||||
default: () => [10, 25, 50, 100],
|
||||
},
|
||||
// Server-side routing
|
||||
routeName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
routeParams: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pageParamName: {
|
||||
type: String,
|
||||
default: "page",
|
||||
},
|
||||
perPageParamName: {
|
||||
type: String,
|
||||
default: "per_page",
|
||||
},
|
||||
onlyProps: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
preserveState: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
preserveScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Features
|
||||
showPagination: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
filterColumn: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
filterPlaceholder: {
|
||||
type: String,
|
||||
default: "Filter...",
|
||||
},
|
||||
rowKey: {
|
||||
type: [String, Function],
|
||||
default: "id",
|
||||
},
|
||||
enableRowSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
striped: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hoverable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Empty state
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "No results.",
|
||||
},
|
||||
emptyIcon: {
|
||||
type: [String, Object, Array],
|
||||
default: null,
|
||||
},
|
||||
emptyDescription: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"update:search",
|
||||
"update:sort",
|
||||
"update:page",
|
||||
"update:pageSize",
|
||||
"row:click",
|
||||
"row:select",
|
||||
"selection:change",
|
||||
]);
|
||||
|
||||
// Determine if this is server-side mode
|
||||
const isServerSide = computed(() => !!(props.meta && props.routeName));
|
||||
|
||||
// Convert simple column format to TanStack ColumnDef if needed
|
||||
const columnDefinitions = computed(() => {
|
||||
return props.columns.map((col) => {
|
||||
// If already a full ColumnDef, return as is
|
||||
if (col.accessorKey || col.accessorFn) {
|
||||
return col;
|
||||
}
|
||||
|
||||
// Convert simple format to ColumnDef
|
||||
return {
|
||||
accessorKey: col.key,
|
||||
id: col.key,
|
||||
header: ({ column }) => {
|
||||
return h(DataTableColumnHeader, {
|
||||
column,
|
||||
title: col.label,
|
||||
class: col.class,
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue(col.key);
|
||||
return h("div", { class: col.class }, value);
|
||||
},
|
||||
enableSorting: col.sortable !== false,
|
||||
enableHiding: col.hideable !== false,
|
||||
meta: {
|
||||
align: col.align || "left",
|
||||
class: col.class,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Add selection column if enabled
|
||||
const columnsWithSelection = computed(() => {
|
||||
if (!props.enableRowSelection) return columnDefinitions.value;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected(),
|
||||
indeterminate: table.getIsSomePageRowsSelected(),
|
||||
"onUpdate:modelValue": (value) => table.toggleAllPageRowsSelected(!!value),
|
||||
"aria-label": "Select all",
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
"onUpdate:modelValue": (value) => row.toggleSelected(!!value),
|
||||
"aria-label": "Select row",
|
||||
});
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
...columnDefinitions.value,
|
||||
];
|
||||
});
|
||||
|
||||
// Internal state
|
||||
const sorting = ref([]);
|
||||
const columnFilters = ref([]);
|
||||
const columnVisibility = ref({});
|
||||
const rowSelection = ref({});
|
||||
|
||||
// Client-side pagination state
|
||||
const clientPagination = ref({
|
||||
pageIndex: 0,
|
||||
pageSize: props.pageSize,
|
||||
});
|
||||
|
||||
// Initialize sorting from props
|
||||
watch(
|
||||
() => props.sort,
|
||||
(newSort) => {
|
||||
if (newSort?.key && newSort?.direction) {
|
||||
sorting.value = [
|
||||
{
|
||||
id: newSort.key,
|
||||
desc: newSort.direction === "desc",
|
||||
},
|
||||
];
|
||||
} else {
|
||||
sorting.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Initialize filter from props
|
||||
watch(
|
||||
() => props.search,
|
||||
(newSearch) => {
|
||||
if (props.filterColumn && newSearch) {
|
||||
columnFilters.value = [
|
||||
{
|
||||
id: props.filterColumn,
|
||||
value: newSearch,
|
||||
},
|
||||
];
|
||||
} else if (!newSearch) {
|
||||
columnFilters.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Pagination state
|
||||
const pagination = computed(() => {
|
||||
if (isServerSide.value) {
|
||||
// Check URL for custom per-page parameter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const perPageParam = props.perPageParamName || "per_page";
|
||||
const urlPerPage = urlParams.get(perPageParam);
|
||||
const pageSize = urlPerPage
|
||||
? Number(urlPerPage)
|
||||
: (props.meta?.per_page ?? props.pageSize);
|
||||
|
||||
return {
|
||||
pageIndex: (props.meta?.current_page ?? 1) - 1,
|
||||
pageSize: pageSize,
|
||||
};
|
||||
}
|
||||
return clientPagination.value;
|
||||
});
|
||||
|
||||
// Watch for prop changes to update client pagination
|
||||
watch(
|
||||
() => props.pageSize,
|
||||
(newSize) => {
|
||||
if (!isServerSide.value) {
|
||||
clientPagination.value.pageSize = newSize;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create TanStack Table
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.data;
|
||||
},
|
||||
get columns() {
|
||||
return columnsWithSelection.value;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: !isServerSide.value ? getPaginationRowModel() : undefined,
|
||||
getSortedRowModel: !isServerSide.value ? getSortedRowModel() : undefined,
|
||||
getFilteredRowModel: !isServerSide.value ? getFilteredRowModel() : undefined,
|
||||
onSortingChange: (updater) => {
|
||||
valueUpdater(updater, sorting);
|
||||
const newSort = sorting.value[0];
|
||||
if (newSort) {
|
||||
emit("update:sort", {
|
||||
key: newSort.id,
|
||||
direction: newSort.desc ? "desc" : "asc",
|
||||
});
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({
|
||||
sort: newSort.id,
|
||||
direction: newSort.desc ? "desc" : "asc",
|
||||
page: 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
emit("update:sort", { key: null, direction: null });
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({ sort: null, direction: null, page: 1 });
|
||||
}
|
||||
}
|
||||
},
|
||||
onColumnFiltersChange: (updater) => {
|
||||
valueUpdater(updater, columnFilters);
|
||||
const filter = columnFilters.value.find((f) => f.id === props.filterColumn);
|
||||
const searchValue = filter?.value ?? "";
|
||||
emit("update:search", searchValue);
|
||||
if (isServerSide.value) {
|
||||
clearTimeout(searchTimer.value);
|
||||
searchTimer.value = setTimeout(() => {
|
||||
doServerRequest({ search: searchValue, page: 1 });
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
onColumnVisibilityChange: (updater) => valueUpdater(updater, columnVisibility),
|
||||
onRowSelectionChange: (updater) => {
|
||||
valueUpdater(updater, rowSelection);
|
||||
const selectedKeys = Object.keys(rowSelection.value).filter(
|
||||
(key) => rowSelection.value[key]
|
||||
);
|
||||
emit("selection:change", selectedKeys);
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
const currentPagination = pagination.value;
|
||||
const newPagination =
|
||||
typeof updater === "function" ? updater(currentPagination) : updater;
|
||||
|
||||
// Check if page size changed
|
||||
const pageSizeChanged = newPagination.pageSize !== currentPagination.pageSize;
|
||||
|
||||
if (isServerSide.value) {
|
||||
// If page size changed, go back to page 1
|
||||
const targetPage = pageSizeChanged ? 1 : newPagination.pageIndex + 1;
|
||||
doServerRequest({
|
||||
page: targetPage,
|
||||
perPage: newPagination.pageSize,
|
||||
});
|
||||
} else {
|
||||
// Update client-side pagination state
|
||||
clientPagination.value = {
|
||||
pageIndex: newPagination.pageIndex,
|
||||
pageSize: newPagination.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
if (pageSizeChanged) {
|
||||
emit("update:pageSize", newPagination.pageSize);
|
||||
}
|
||||
if (newPagination.pageIndex !== currentPagination.pageIndex) {
|
||||
emit("update:page", newPagination.pageIndex + 1);
|
||||
}
|
||||
},
|
||||
manualSorting: isServerSide.value,
|
||||
manualPagination: isServerSide.value,
|
||||
manualFiltering: isServerSide.value,
|
||||
enableRowSelection: props.enableRowSelection,
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
get columnFilters() {
|
||||
return columnFilters.value;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility.value;
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value;
|
||||
},
|
||||
get pagination() {
|
||||
return pagination.value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const searchTimer = ref(null);
|
||||
|
||||
// Server-side request handler
|
||||
function doServerRequest(overrides = {}) {
|
||||
if (!props.routeName) return;
|
||||
|
||||
const existingParams = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
const perPageParam = props.perPageParamName || "per_page";
|
||||
const pageParam = props.pageParamName || "page";
|
||||
|
||||
const q = {
|
||||
...existingParams,
|
||||
sort: overrides.sort ?? props.sort?.key ?? null,
|
||||
direction: overrides.direction ?? props.sort?.direction ?? null,
|
||||
search: overrides.search ?? props.search ?? "",
|
||||
};
|
||||
|
||||
// Use custom per_page parameter name
|
||||
q[perPageParam] = overrides.perPage ?? props.meta?.per_page ?? props.pageSize;
|
||||
if (perPageParam !== "per_page") {
|
||||
delete q.per_page;
|
||||
}
|
||||
|
||||
// Use custom page parameter name
|
||||
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
|
||||
if (pageParam !== "page") {
|
||||
delete q.page;
|
||||
}
|
||||
|
||||
Object.keys(q).forEach((k) => {
|
||||
if (q[k] === null || q[k] === undefined || q[k] === "") delete q[k];
|
||||
});
|
||||
|
||||
const url = route(props.routeName, props.routeParams || {});
|
||||
router.get(url, q, {
|
||||
preserveScroll: props.preserveScroll,
|
||||
preserveState: props.preserveState,
|
||||
replace: true,
|
||||
only: props.onlyProps.length ? props.onlyProps : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Row key helper
|
||||
function keyOf(row) {
|
||||
if (typeof props.rowKey === "function") return props.rowKey(row);
|
||||
if (typeof props.rowKey === "string" && row && row[props.rowKey] != null)
|
||||
return row[props.rowKey];
|
||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<!-- Toolbar -->
|
||||
<DataTableToolbar
|
||||
v-if="showToolbar"
|
||||
:table="table"
|
||||
:filter-column="filterColumn"
|
||||
:filter-placeholder="filterPlaceholder"
|
||||
:show-per-page-selector="isServerSide"
|
||||
:per-page="pagination.pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
@update:per-page="(value) => table.setPageSize(value)"
|
||||
class="px-4 py-2 border-t"
|
||||
>
|
||||
<template #filters="slotProps">
|
||||
<slot name="toolbar-filters" v-bind="slotProps" />
|
||||
</template>
|
||||
<template #actions="slotProps">
|
||||
<slot name="toolbar-actions" v-bind="slotProps" />
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- Custom toolbar slot for full control -->
|
||||
<slot name="toolbar" :table="table" />
|
||||
|
||||
<!-- Table -->
|
||||
<div class="border-t">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
'py-4',
|
||||
header.column.columnDef.meta?.class,
|
||||
header.column.columnDef.meta?.align === 'right'
|
||||
? 'text-right'
|
||||
: header.column.columnDef.meta?.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left',
|
||||
]"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<TableRow>
|
||||
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
|
||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template v-else-if="table.getRowModel().rows.length === 0">
|
||||
<TableRow>
|
||||
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<!-- Data Rows -->
|
||||
<template v-else>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="keyOf(row.original)"
|
||||
:data-state="row.getIsSelected() && 'selected'"
|
||||
:class="
|
||||
cn(
|
||||
hoverable && 'cursor-pointer hover:bg-muted/50',
|
||||
striped && row.index % 2 === 1 && 'bg-muted/50'
|
||||
)
|
||||
"
|
||||
@click="$emit('row:click', row.original, row.index)"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="[
|
||||
cell.column.columnDef.meta?.class,
|
||||
cell.column.columnDef.meta?.align === 'right'
|
||||
? 'text-right'
|
||||
: cell.column.columnDef.meta?.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left',
|
||||
]"
|
||||
>
|
||||
<!-- Use slot if provided -->
|
||||
<slot
|
||||
:name="`cell-${cell.column.id}`"
|
||||
:row="row.original"
|
||||
:column="cell.column"
|
||||
:value="cell.getValue()"
|
||||
:index="row.index"
|
||||
>
|
||||
<!-- Otherwise use FlexRender -->
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</slot>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="showPagination">
|
||||
<!-- Server-side pagination -->
|
||||
<template v-if="isServerSide && meta?.links">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="meta.from"
|
||||
:to="meta.to"
|
||||
:total="meta.total"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Client-side pagination -->
|
||||
<template v-else>
|
||||
<DataTablePagination :table="table" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user