Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions client/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@
"@tanstack/vue-table": "^9.1.2",
"@unovis/vue": "^1.6.7",
"@vueuse/core": "^14.4.0",
"@vueuse/integrations": "^14.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.13",
"marked": "^18.0.9",
"reka-ui": "^2.10.3",
"sortablejs": "^1.15.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"vue": "^3.5.40",
Expand All @@ -41,6 +43,7 @@
"@biomejs/biome": "^2.5.7",
"@iconify/vue": "^5.0.1",
"@types/node": "^26.2.0",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"tw-animate-css": "^1.4.0",
Expand Down
240 changes: 201 additions & 39 deletions client/src/components/page/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,20 @@ SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts" generic="TData extends RowData">
import type { ColumnDef, RowData } from "@tanstack/vue-table"
import { FlexRender, useTable as useTanStackTable } from "@tanstack/vue-table"
import { Columns3 } from "@lucide/vue"
import type { Cell, ColumnDef, Header, HeaderGroup, Row, RowData } from "@tanstack/vue-table"
import { FlexRender, useTable as useTanStackTable } from "@tanstack/vue-table"
import { useSortable } from "@vueuse/integrations/useSortable"
import { type ComponentPublicInstance, computed, ref } from "vue"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { features, type DataTableFeatures } from "@/lib/data-table"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import { type DataTableFeatures, features } from "@/lib/data-table"

const props = defineProps<{
columns: ColumnDef<DataTableFeatures, TData>[]
Expand All @@ -43,6 +38,97 @@ const table = useTanStackTable({
return props.manualPagination
},
})

const FIXED_COLUMN_IDS = new Set(["__select__", "__actions__"])

type TableHeaderGroup = HeaderGroup<DataTableFeatures, TData>
type TableHeader = Header<DataTableFeatures, TData>
type TableRow = Row<DataTableFeatures, TData>

const getHeader = (headerGroup: TableHeaderGroup, id: string): TableHeader | undefined =>
headerGroup.headers.find((header) => header.column.id === id)

const getDataHeaders = (headerGroup: TableHeaderGroup): TableHeader[] =>
headerGroup.headers.filter((header) => !FIXED_COLUMN_IDS.has(header.column.id))

const getCell = (row: TableRow, id: string): Cell<DataTableFeatures, TData> | undefined =>
row.getVisibleCells().find((cell) => cell.column.id === id)

const previewOrder = ref<string[] | null>(null)
const draggedColumnId = ref<string | null>(null)

const getDataCells = (row: TableRow) => {
const cells = row.getVisibleCells().filter((cell) => !FIXED_COLUMN_IDS.has(cell.column.id))
const cellsById = new Map(cells.map((cell) => [cell.column.id, cell]))
const order = previewOrder.value ?? dataColumnIds.value
return order.map((id) => cellsById.get(id)).filter((cell) => cell != null)
}

const onLayout = (headerGroup: TableHeaderGroup, sizes: number[]) => {
const dataHeaders = getDataHeaders(headerGroup)
table.setColumnSizing((previous) => ({
...previous,
...Object.fromEntries(dataHeaders.map((header, index) => [header.column.id, sizes[index]])),
}))
}

const dataColumnIds = computed<string[]>({
get: () => {
const headerGroup = table.getHeaderGroups()[0]
return headerGroup ? getDataHeaders(headerGroup).map((header) => header.column.id) : []
},
set: (ids) => {
const headerGroup = table.getHeaderGroups()[0]
if (!headerGroup) return

let cursor = 0
table.setColumnOrder(
headerGroup.headers.map((header) =>
FIXED_COLUMN_IDS.has(header.column.id) ? header.column.id : ids[cursor++],
),
)
},
})

let headerRowEl: HTMLElement | null = null

const setHeaderRowEl = (component: ComponentPublicInstance | Element | null) => {
headerRowEl = ((component as ComponentPublicInstance | null)?.$el ??
component) as HTMLElement | null
}

const readDraggedOrder = (): string[] =>
headerRowEl
? Array.from(headerRowEl.querySelectorAll<HTMLElement>("[data-column-header]")).map(
(el) => el.dataset.columnHeader ?? "",
)
: []

useSortable(() => headerRowEl, dataColumnIds, {
draggable: "[data-column-header]",
animation: 150,
onStart: (event) => {
draggedColumnId.value = event.item.dataset.columnHeader ?? null
},
onChange: () => {
previewOrder.value = readDraggedOrder()
},
onEnd: () => {
// Commit whatever order the DOM actually ended up in, rather than replaying
// SortableJS's oldIndex/newIndex math ourselves — that counts every sibling
// (including the interleaved `ResizableHandle`s) and drifted out of sync with
// `dataColumnIds`, producing the wrong final order.
const finalOrder = readDraggedOrder()
if (finalOrder.length === dataColumnIds.value.length) {
dataColumnIds.value = finalOrder
}
previewOrder.value = null
draggedColumnId.value = null
},
// The default `onUpdate` would also try to commit a reorder using the mismatched
// oldIndex/newIndex above; disable it so only the `onEnd` commit above applies.
onUpdate: () => {},
})
</script>

<template>
Expand Down Expand Up @@ -70,36 +156,112 @@ const table = useTanStackTable({
</DropdownMenu>
</div>

<div class="border rounded-md">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id">
<FlexRender v-if="!header.isPlaceholder" :header="header" />
</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'"
<div class="rounded-md border overflow-hidden" role="table">
<div role="rowgroup">
<div
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
role="row"
class="bg-muted/60 flex border-b"
>
<div
v-if="getHeader(headerGroup, '__select__')"
role="columnheader"
class="text-muted-foreground flex h-10 w-10 shrink-0 items-center px-4 text-xs font-semibold tracking-wide [&:has([role=checkbox])]:pr-0"
>
<FlexRender :header="getHeader(headerGroup, '__select__')!" />
</div>

<ResizablePanelGroup
:ref="setHeaderRowEl"
direction="horizontal"
class="h-10 flex-1"
@layout="(sizes) => onLayout(headerGroup, sizes)"
>
<template v-for="(header, index) in getDataHeaders(headerGroup)" :key="header.id">
<ResizableHandle
v-if="index > 0"
with-handle
class="opacity-0 transition-opacity focus-visible:opacity-100 data-[state=hover]:opacity-100 data-[state=drag]:opacity-100"
/>
<ResizablePanel
:default-size="header.getSize()"
:min-size="10"
:data-column-header="header.column.id"
class="min-w-0"
>
<div
role="columnheader"
class="text-muted-foreground flex h-10 min-w-0 cursor-grab items-center truncate px-4 text-xs font-semibold tracking-wide active:cursor-grabbing"
:class="{ 'bg-muted': header.column.id === draggedColumnId }"
>
<FlexRender v-if="!header.isPlaceholder" :header="header" />
</div>
</ResizablePanel>
</template>
</ResizablePanelGroup>

<div
v-if="getHeader(headerGroup, '__actions__')"
role="columnheader"
class="flex h-10 w-14 shrink-0 items-center justify-end px-4"
>
<FlexRender
v-if="!getHeader(headerGroup, '__actions__')!.isPlaceholder"
:header="getHeader(headerGroup, '__actions__')!"
/>
</div>
</div>
</div>

<div role="rowgroup">
<template v-if="table.getRowModel().rows?.length">
<div
v-for="row in table.getRowModel().rows"
:key="row.id"
role="row"
:data-state="row.getIsSelected() && 'selected'"
class="data-[state=selected]:bg-muted flex border-b transition-colors last:border-0 hover:bg-muted/50"
>
<div
v-if="getCell(row, '__select__')"
role="cell"
class="flex w-10 shrink-0 items-center px-4 py-3 [&:has([role=checkbox])]:pr-0"
>
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :cell="getCell(row, '__select__')!" />
</div>

<div class="flex min-w-0 flex-1">
<div
v-for="cell in getDataCells(row)"
:key="cell.id"
role="cell"
class="flex min-w-0 items-center truncate px-4 py-3"
:class="{ 'bg-muted': cell.column.id === draggedColumnId }"
:style="{ width: `${cell.column.getSize()}%` }"
>
<FlexRender :cell="cell" />
</TableCell>
</TableRow>
</template>
<template v-else>
<TableRow>
<TableCell :colspan="columns.length" class="h-24 text-center">
No results.
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</div>

<div
v-if="getCell(row, '__actions__')"
role="cell"
class="flex w-14 shrink-0 items-center justify-end px-4 py-3"
>
<FlexRender :cell="getCell(row, '__actions__')!" />
</div>
</div>
</template>
<template v-else>
<div
role="row"
class="flex h-24 items-center justify-center text-sm text-muted-foreground"
>
No results.
</div>
</template>
</div>
</div>

<div class="text-sm text-muted-foreground">
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/ui/resizable/ResizableHandle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
:class="cn('bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90', props.class)"
>
<template v-if="props.withHandle">
<div class="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<div class="bg-accent z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<slot>
<GripVertical class="size-2.5" />
</slot>
Expand Down
2 changes: 2 additions & 0 deletions client/src/composables/openadmin-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export const useTable = ({

return columnHelper.value.accessor((row) => row[key], {
id: key,
size: 100 / (columnKeys.value.length || 1),
minSize: 10,
header: () =>
h("div", { class: "flex items-center gap-1.5" }, [
column?.icon
Expand Down
4 changes: 4 additions & 0 deletions client/src/lib/data-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import {
columnFilteringFeature,
columnOrderingFeature,
columnSizingFeature,
columnVisibilityFeature,
createExpandedRowModel,
createFilteredRowModel,
Expand All @@ -21,6 +23,8 @@ import {

export const features = tableFeatures({
columnFilteringFeature,
columnOrderingFeature,
columnSizingFeature,
columnVisibilityFeature,
rowExpandingFeature,
rowPaginationFeature,
Expand Down
Loading