Folder Structure
project/ ├── app/ │ ├── composables/ │ │ └── useTableGrouping.ts │ ├── utils/ │ │ └── tableHelpers.ts │ ├── pages/ │ │ └── issues/index.vue ← Your UI │ └── app.vue ├── server/ │ └── utils/ ← server side ├── shared/ │ └── utils/ ← client + server side └── nuxt.config.ts
Common Class
📌 tableHelper.ts - Helper for Render Component
import { h } from 'vue'
import { UButton, UIcon } from '#components'
import type { Row } from '@tanstack/vue-table'
/**
* Shortcut for rendering a draggable & group header, commonly used for group display columns in grouped tables.
* @param title The title of the header.
* @param columnId The ID of the column.
* @param onDragStart The function to call when dragging starts.
* @returns A VNode representing the header.
*/
export const renderGroupOnlyHeader = (title: string, columnId: string, onDragStart: any) =>
renderSortableHeader(title, undefined, {
draggable: true,
columnId,
onDragStart
})
/**
* Shortcut for rendering a sortable header, with optional drag-and-drop capabilities.
* If a column object is provided, it will render with sorting functionality.
* If not, it will render as plain text (useful for non-sortable group display columns).
* @param title The title of the header.
* @param column The column object from the table.
* @param options Additional options for rendering the header.
* @returns A VNode representing the header.
*/
export function renderSortableHeader(
title: string,
column?: any,
options?: {
draggable?: boolean,
columnId?: string,
onDragStart?: (e: DragEvent, columnId: string) => void
}
) {
// Create the inner content (either sortable or plain text)
let headerContent
if (column) {
// Add sorting base if column is provided
const isSorted = column.getIsSorted()
const icon = isSorted === 'asc'
? 'i-heroicons-arrow-up'
: isSorted === 'desc'
? 'i-heroicons-arrow-down'
: 'i-heroicons-arrows-up-down'
headerContent = h(UButton, {
color: 'neutral',
variant: 'ghost',
label: title,
icon: icon,
class: '-ml-3',
onClick: () => column.toggleSorting(isSorted === 'asc')
})
} else {
// If no column is provided -> display as plain text
headerContent = h('span', title)
}
// If draggable is required, wrap with a div for dragging
if (options?.draggable && options.onDragStart && options.columnId) {
return h('div', {
draggable: true,
onDragstart: (e: DragEvent) => options.onDragStart!(e, options.columnId!),
class: 'cursor-grab active:cursor-grabbing flex items-center gap-1 hover:text-primary-500 transition-colors',
}, [
headerContent,
h(UIcon, { name: 'i-heroicons-bars-2', class: 'w-4 h-4 text-gray-400' + (column ? ' -ml-2' : '') }),
])
}
return headerContent
}
// Group Row Cell
export function renderGroupCell<T>(
row: Row<T>,
columnLabels: Record<string, string>
) {
if (!row.getIsGrouped()) return null
const columnId = row.groupingColumnId
if (!columnId) return null
const label = columnLabels[columnId] || columnId
const value = row.getValue(columnId)
return h('div', {
class: 'flex items-center gap-2',
style: { paddingLeft: `${row.depth * 1.5}rem` },
}, [
h(UButton, {
color: 'neutral',
variant: 'ghost',
icon: row.getIsExpanded() ? 'i-heroicons-minus' : 'i-heroicons-plus',
size: 'xs',
class: '-ml-2',
onClick: (e: Event) => {
e.stopPropagation()
row.toggleExpanded()
},
}),
h('span', { class: 'font-bold text-sm text-nowrap' },
`${label}: ${value} (${row.subRows.length})`
),
])
}
/**
* Shortcut for rendering a regular cell, with optional link and formatting capabilities.
* @param row The row object from the table.
* @param columnLabels A record of column labels.
* @param columnId The ID of the column.
* @param options Additional options for rendering the cell.
* @returns A VNode representing the cell.
*/
export function renderCell<T>(
row: Row<T>,
columnLabels: Record<string, string>,
columnId: string,
options?: {
isGroupDisplayColumn?: boolean,
urlBuilder?: (id: string) => string | undefined,
formatValue?: (val: any) => any,
className?: string
}
) {
// If row is grouped, only render in the designated display column
if (row.getIsGrouped()) {
return options?.isGroupDisplayColumn ? renderGroupCell(row, columnLabels) : null
}
const value = row.getValue(columnId)
const displayValue = options?.formatValue ? options.formatValue(value) : value
// If we have a URL builder, render as a link
if (options?.urlBuilder) {
const url = options.urlBuilder(value as string)
if (url) {
return h('a', {
href: url,
target: '_blank',
class: 'text-primary hover:underline ' + (options.className || ''),
}, displayValue as string)
}
}
return h('span', { class: options?.className }, displayValue as string)
}
renderDraggableHeader: Renders a draggable column header, allowing users to drag and drop it into a grouping area. Includes a 'Veggie Burger' icon to indicate draggable functionality.renderCell: Manages the rendering logic for individual cells:
- If the row is a Group, it callsrenderGroupCell.
- If the cell contains a Link, it triggersRenderLink.
- Otherwise, it renders the standard cell data.renderGroupCell: Renders the group row, providing+/-buttons for drill-down functionality. It displays the grouping field name and the total number of items within the group. This component is invoked byrenderCell.
To ensure stability, all components such as UButton, UIcon are imported directly within the helper, as framework-level injection was avoided to prevent unexpected rendering behavior
📌 useTableGrouping.ts - Nuxt Composable that use for handle event and State , I spilit tableHelpers.ts / useTableGrouping.ts because i want to seperate role of these class
export function useTableGrouping(allowedColumns: string[]) {
const groupedColumns = ref<string[]>([])
const isDragOver = ref(false)
const draggedColumn = ref<string | null>(null)
const handleDragStart = (event: DragEvent, columnId: string) => {
draggedColumn.value = columnId
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', columnId)
}
}
const handleDrop = (event: DragEvent) => {
isDragOver.value = false
const columnId = event.dataTransfer?.getData('text/plain')
if (columnId && allowedColumns.includes(columnId)) {
if (!groupedColumns.value.includes(columnId)) {
groupedColumns.value = [...groupedColumns.value, columnId]
}
}
}
const handleRemoveGroup= (columnId: string) => {
groupedColumns.value = groupedColumns.value.filter(id => id !== columnId)
}
return {
groupedColumns,
isDragOver,
handleDragStart,
handleDrop,
removeGroup,
}
}
handleDragStart: Triggers when user begins to drag a column. It utilizes event.dataTransfer to attach thecolumnIdto the drag event in draggedColumnhandleDrop: Triggers when the user drops the dragged item (Colume Name) onto the drop zone.
- Change State isDragOver to false
- Get Select Column from event.dataTransfer
- Check Allow Column in Not Duplcate in groupedColumnshandleRemoveGroup: Triggers when user ungroup or remove a column from the grouped list (groupedColumns)
Note: draggedColumn / isDragOver / groupedColumns are state that export for UI to see current state
Using - New Screen
📌 Prepare Data
- Variable for Store Data
items = ref([])
const { groupedColumns, isDragOver, handleDragStart, handleDrop, handleRemoveGroup } = useTableGrouping(['creator_name', 'ageDays'])
const allowColumnLabels: Record<string, string> = {
creator_name: 'Creator',
ageDays: 'Age (Days)'
}
- create state from
useTableGrouping- param with ColumnID (nameof you field in json such as creator_name / ageDays) - create a record for map ColumnID with Display Name
📌 Define Columns - with renderCell from tableHelpers.ts
- On first column renderCell with option
isGroupDisplayColumn: trueto show +/- when group - On Column that can grouping set header section with renderDraggableHeader
const columns: TableColumn<GitLabBranch>[] = [
{
accessorKey: 'name',
header: 'Branch Name',
cell: ({ row }) => {
const groupResult = renderCell(row, allowColumnLabels, 'name', { isGroupDisplayColumn: true })
if (groupResult) return groupResult
const branch = row.original
return h('div', {
class: 'flex items-center gap-2',
style: { paddingLeft: `${row.depth * 1.5}rem` }
}, [
branch.default ? h(UIcon, { name: 'i-mdi-star', class: 'text-yellow-500' }) : null,
h('span', { class: 'font-medium' }, branch.name),
branch.merged ? h(UBadge, { color: 'success', variant: 'soft', size: 'xs' }, () => 'Merged') : null,
branch.protected ? h(UBadge, { color: 'error', variant: 'soft', size: 'xs' }, () => 'Protected') : null
])
}
},
{
accessorKey: 'creator_name',
header: ({ column }) => renderDraggableHeader('Creator', 'creator_name', handleDragStart),
cell: ({ row }) => renderCell(row, allowColumnLabels, 'creator_name'),
meta: {
class: {
td: 'w-16 whitespace-normal',
},
},
},
{
accessorKey: 'created_at',
header: 'Created At (Age)',
cell: ({ row }) => {
if (row.getIsGrouped()) return null
const branch = row.original
return h('div', { class: 'flex flex-col' }, [
h('span', formatDate(branch.created_at)),
h('div', { class: 'flex items-center gap-1 mt-0.5' }, [
h('span', { class: 'text-xs text-gray-500 font-medium' }, getRelativeAge(branch.created_at)),
h(UBadge, {
color: branch.is_direct ? 'success' : 'neutral',
variant: 'soft',
size: 'xs',
class: 'px-1 py-0 text-[10px]'
}, () => branch.is_direct ? 'Direct' : 'Indirect')
])
])
}
},
{
id: 'ageDays',
accessorFn: (branch) => getAgeDays(branch.created_at),
header: ({ column }) => {
const isSorted = column.getIsSorted()
return h(UButton, {
color: 'neutral',
variant: 'ghost',
label: 'Age (Days)',
icon: isSorted === 'asc'
? 'i-heroicons-arrow-up'
: isSorted === 'desc'
? 'i-heroicons-arrow-down'
: 'i-heroicons-arrows-up-down',
class: '-ml-3',
onClick: () => column.toggleSorting(isSorted === 'asc')
})
},
enableSorting: true,
cell: ({ row }) => renderCell(row, allowColumnLabels, 'ageDays', { className: 'font-medium' })
},
{
accessorKey: 'commit.title',
header: 'Latest Commit',
cell: ({ row }) => {
if (row.getIsGrouped()) return null
const branch = row.original
return h('div', { class: 'flex flex-col max-w-md' }, [
h('span', { class: 'text-sm truncate' }, branch.commit.title),
h('span', { class: 'text-xs text-gray-500' }, `${branch.commit.short_id} by ${branch.commit.author_name}`)
])
}
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
if (row.getIsGrouped()) return null
const branch = row.original
return h(UButton, {
to: branch.web_url,
target: '_blank',
icon: 'i-mdi-open-in-new',
variant: 'ghost',
color: 'neutral',
size: 'xs'
})
}
}
]
📌 Create Drop Zone that use can drag column to trigger group that call handleDrop / handleRemoveGroup
<!-- Group Panel Drop Zone -->
<div
class="w-full min-h-10 border-2 border-dashed rounded-lg flex items-center px-4 transition-colors"
:class="isDragOver ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/10' : 'border-gray-300 dark:border-gray-700'"
@dragover.prevent="isDragOver = true"
@dragleave="isDragOver = false"
@drop="handleDrop"
>
<div v-if="groupedColumns.length > 0" class="flex items-center gap-2 flex-wrap py-1">
<span class="text-sm text-gray-500 mr-2">Grouped by:</span>
<UBadge
v-for="col in groupedColumns"
:key="col"
:label="allowColumnLabels[col] || col"
variant="subtle"
size="lg"
>
{{ allowColumnLabels[col] || col }}
<template #trailing>
<UIcon name="i-heroicons-x-mark" class="cursor-pointer" @click="handleRemoveGroup(col)" />
</template>
</UBadge>
</div>
<div v-else class="text-gray-400 text-sm flex items-center gap-2">
<UIcon name="i-heroicons-arrow-down-tray" />
<span>Drag a column header here to group</span>
</div>
</div>
📌 At last of UTable set
- v-model:grouping - use groupedColumns from
useTableGrouping.ts
จากนั้มีส่วน :grouping-options
- groupedColumnMode อันบอกว่าเมื่อ Group แล้วต้องทำอะไร
| Possible Value | Result |
|---|---|
'reorder' (default) | Grouped columns are moved to the beginning of the table. |
'remove' | Grouped columns are hide/remove from the table. |
false | Grouped columns remain in their original position. |
- getGroupedRowModel: getGroupedRowModel() - บอกรูปแบบการ Group
- getExpandedRowModel: getExpandedRowModel(), - ให้ Defauld Expand Row หลัง Group
<UTable
:key="refreshKey"
ref="table"
:data="filteredBranches"
:columns="columns"
:loading="loading"
:ui="{ td: 'empty:p-0' }"
class="w-full"
v-model:sorting="sorting"
:column-filters="columnFilters"
v-model:pagination="pagination"
:pagination-options="{ getPaginationRowModel: getPaginationRowModel() }"
v-model:grouping="groupedColumns"
:grouping-options="{
groupedColumnMode: false,
getGroupedRowModel: getGroupedRowModel()
}"
/>
- Other Case: Example Apply from tanStack
// set row before group const preGrouped = table.getPreGroupedRowModel().rows // set row after group const grouped = table.getGroupedRowModel().rows // -------------------------------------------------- // for export csv without group rows const flatRows = table.getPreGroupedRowModel().flatRows // -------------------------------------------------- // Calculate some aggregation such as sum const allRows = table.getPreGroupedRowModel().flatRows const total = allRows.reduce((sum, row) => sum + row.original.salary, 0)

this is my first versions and i will improve it. Here is my commit for reference 30a9081 / 0b6d6ca
Reference
Discover more from naiwaen@DebuggingSoft
Subscribe to get the latest posts sent to your email.



