Common/Helper for Grouping/Sort on UTable (Nuxt UI)

From my previus blog, How to make UTable (Nuxt UI) grouped rows span all visible columns. so i want to create a common/helper for reuse on another page. here is my recap step

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 calls renderGroupCell.
    - If the cell contains a Link, it triggers RenderLink.
    - 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 by renderCell.

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 the columnId to the drag event in draggedColumn
  • handleDrop: 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 groupedColumns
  • handleRemoveGroup: 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: true to 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 ValueResult
'reorder' (default)Grouped columns are moved to the beginning of the table.
'remove'Grouped columns are hide/remove from the table.
falseGrouped 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)
Result

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.