refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Data Table Components
|
||||
|
||||
This package keeps a stable public API through `index.ts`; feature code should
|
||||
continue importing from `@/components/data-table`.
|
||||
|
||||
- `core/`: TanStack table rendering primitives, headers, rows, pagination,
|
||||
loading, empty states, and pinned-column behavior.
|
||||
- `layout/`: responsive page-level composition that combines toolbar, desktop
|
||||
table, mobile list, bulk actions, and pagination placement.
|
||||
- `toolbar/`: filter/search/view-option controls and selection action toolbar.
|
||||
- `static/`: lightweight table rendering for local/static arrays that do not
|
||||
need TanStack state.
|
||||
- `hooks/`: table state and filter hooks.
|
||||
|
||||
Keep feature-specific columns, actions, and dialogs inside their feature
|
||||
folders. Shared table code belongs here only when it is reusable across more
|
||||
than one feature.
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type BadgeCellProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function BadgeCell({ className, ...props }: BadgeCellProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot='badge-cell'
|
||||
className={cn(
|
||||
'-ml-1.5 flex max-w-full min-w-0 items-center gap-1 overflow-hidden [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:min-w-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import { StatusBadgeList } from '@/components/status-badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface BadgeListCellProps {
|
||||
items: React.ReactNode[]
|
||||
max?: number
|
||||
tooltipClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Table cell renderer for a list of badges with overflow tooltip.
|
||||
* Displays up to `max` badges inline; remaining items appear in a tooltip.
|
||||
* Applies -ml-1.5 to compensate for badge px-1.5 and align with column header.
|
||||
*/
|
||||
export function BadgeListCell({
|
||||
items,
|
||||
max = 2,
|
||||
tooltipClassName,
|
||||
}: BadgeListCellProps) {
|
||||
if (items.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const showTooltip = items.length > max
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className='-ml-1.5 max-w-full' />}>
|
||||
<StatusBadgeList
|
||||
items={items}
|
||||
max={max}
|
||||
renderItem={(item) => item}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className={
|
||||
tooltipClassName ??
|
||||
'border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
}
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{items}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import {
|
||||
ArrowDown as ArrowDownIcon,
|
||||
ArrowUp as ArrowUpIcon,
|
||||
ChevronsUpDown as CaretSortIcon,
|
||||
EyeOff as EyeNoneIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableColumnHeaderProps<TData, TValue> =
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
column: Column<TData, TValue>
|
||||
title: React.ReactNode
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
const { t } = useTranslation()
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='data-popup-open:bg-accent -ms-3 h-8'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className='ms-2 h-4 w-4' />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className='ms-2 h-4 w-4' />
|
||||
) : (
|
||||
<CaretSortIcon className='ms-2 h-4 w-4' />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Asc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Desc')}
|
||||
</DropdownMenuItem>
|
||||
{column.getCanHide() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Hide')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { DataTableColumnClassName, DataTablePinnedColumn } from './types'
|
||||
|
||||
export function getResolvedColumnClassName(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
): DataTableColumnClassName {
|
||||
return getResolvedColumnClassNameFromMap(
|
||||
getColumnClassName,
|
||||
getPinnedColumnMap(pinnedColumns)
|
||||
)
|
||||
}
|
||||
|
||||
export function getResolvedColumnClassNameFromMap(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumnById?: Map<string, DataTablePinnedColumn>
|
||||
): DataTableColumnClassName {
|
||||
return (columnId, kind) => {
|
||||
const customClassName = getColumnClassName?.(columnId, kind)
|
||||
const pinnedColumn = pinnedColumnById?.get(columnId)
|
||||
|
||||
if (!pinnedColumn) {
|
||||
return customClassName
|
||||
}
|
||||
|
||||
return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind))
|
||||
}
|
||||
}
|
||||
|
||||
export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) {
|
||||
if (!pinnedColumns?.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new Map(pinnedColumns.map((column) => [column.columnId, column]))
|
||||
}
|
||||
|
||||
function getPinnedColumnClassName(
|
||||
pinnedColumn: DataTablePinnedColumn,
|
||||
kind: 'header' | 'cell'
|
||||
) {
|
||||
const edgeClassName =
|
||||
pinnedColumn.side === 'left'
|
||||
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
: 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
|
||||
return cn(
|
||||
'sticky whitespace-nowrap',
|
||||
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
|
||||
edgeClassName,
|
||||
kind === 'header'
|
||||
? '[background-color:var(--table-header-bg,var(--table-header))] group-hover:[background-color:var(--table-header-hover)] z-30'
|
||||
: 'bg-background z-10 group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] group-data-[state=selected]:bg-muted',
|
||||
pinnedColumn.className,
|
||||
kind === 'header'
|
||||
? pinnedColumn.headerClassName
|
||||
: pinnedColumn.cellClassName
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function isContentSizedColumn(columnId: string): boolean {
|
||||
return columnId === 'actions'
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Table as TanstackTable } from '@tanstack/react-table'
|
||||
|
||||
import { isContentSizedColumn } from './content-sized-columns'
|
||||
|
||||
export function DataTableColgroup<TData>({
|
||||
table,
|
||||
}: {
|
||||
table: TanstackTable<TData>
|
||||
}) {
|
||||
const columns = table.getVisibleLeafColumns()
|
||||
const sizedColumns = columns.filter(
|
||||
(column) => !isContentSizedColumn(column.id)
|
||||
)
|
||||
const totalSize = sizedColumns.reduce((sum, col) => sum + col.getSize(), 0)
|
||||
|
||||
return (
|
||||
<colgroup>
|
||||
{columns.map((column) => {
|
||||
const width = getColumnWidth(
|
||||
table,
|
||||
column.id,
|
||||
column.getSize(),
|
||||
totalSize
|
||||
)
|
||||
|
||||
return <col key={column.id} style={{ width }} />
|
||||
})}
|
||||
</colgroup>
|
||||
)
|
||||
}
|
||||
|
||||
function getColumnWidth<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
columnId: string,
|
||||
columnSize: number,
|
||||
totalSize: number
|
||||
) {
|
||||
if (isContentSizedColumn(columnId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (table.options.enableColumnResizing === true) {
|
||||
return `${columnSize}px`
|
||||
}
|
||||
|
||||
if (totalSize <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return `${(columnSize / totalSize) * 100}%`
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
flexRender,
|
||||
type Header,
|
||||
type Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DataTableColumnHeader } from './column-header'
|
||||
import { isContentSizedColumn } from './content-sized-columns'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableHeaderProps<TData> = {
|
||||
table: TanstackTable<TData>
|
||||
applyHeaderSize?: boolean
|
||||
className?: string
|
||||
rowClassName?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
}
|
||||
|
||||
export function DataTableHeader<TData>({
|
||||
table,
|
||||
applyHeaderSize,
|
||||
className,
|
||||
rowClassName,
|
||||
getColumnClassName,
|
||||
}: DataTableHeaderProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<TableHeader className={className}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className={rowClassName}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
data-column-id={header.column.id}
|
||||
className={cn(
|
||||
'relative',
|
||||
getColumnClassName?.(header.column.id, 'header')
|
||||
)}
|
||||
style={getHeaderSizeStyle(header, applyHeaderSize)}
|
||||
>
|
||||
{renderHeaderContent(header)}
|
||||
{shouldRenderColumnResizer(table, header) && (
|
||||
<div
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label={t('Resize column')}
|
||||
data-column-resizer
|
||||
tabIndex={0}
|
||||
onDoubleClick={(event) =>
|
||||
handleColumnAutoSize(event, table, header)
|
||||
}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onKeyDown={(event) =>
|
||||
handleColumnResizeKeyDown(event, table, header)
|
||||
}
|
||||
className={cn(
|
||||
'absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none',
|
||||
'after:bg-border hover:after:bg-primary after:absolute after:top-2 after:right-0 after:h-[calc(100%-1rem)] after:w-px after:transition-colors',
|
||||
header.column.getIsResizing() && 'after:bg-primary'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
function handleColumnResizeKeyDown<TData>(
|
||||
event: KeyboardEvent<HTMLDivElement>,
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>
|
||||
) {
|
||||
const step = event.shiftKey ? 50 : 10
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
resizeColumnByKeyboard(table, header, -step)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
resizeColumnByKeyboard(table, header, step)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
autoSizeColumn(event.currentTarget, table, header)
|
||||
}
|
||||
}
|
||||
|
||||
function resizeColumnByKeyboard<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>,
|
||||
delta: number
|
||||
) {
|
||||
table.setColumnSizing((previous) => ({
|
||||
...previous,
|
||||
[header.column.id]: getClampedColumnSize(
|
||||
header,
|
||||
header.column.getSize() + delta
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function handleColumnAutoSize<TData>(
|
||||
event: MouseEvent<HTMLDivElement>,
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>
|
||||
) {
|
||||
event.preventDefault()
|
||||
autoSizeColumn(event.currentTarget, table, header)
|
||||
}
|
||||
|
||||
function autoSizeColumn<TData>(
|
||||
resizerElement: HTMLElement,
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>
|
||||
) {
|
||||
const measuredSize = measureColumnContentWidth(
|
||||
resizerElement,
|
||||
header.column.id
|
||||
)
|
||||
|
||||
if (measuredSize === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
table.setColumnSizing((previous) => ({
|
||||
...previous,
|
||||
[header.column.id]: getClampedColumnSize(header, measuredSize),
|
||||
}))
|
||||
}
|
||||
|
||||
function getClampedColumnSize<TData>(
|
||||
header: Header<TData, unknown>,
|
||||
nextSize: number
|
||||
) {
|
||||
const { minSize, maxSize } = header.column.columnDef
|
||||
|
||||
if (typeof minSize === 'number' && nextSize < minSize) {
|
||||
return minSize
|
||||
}
|
||||
|
||||
if (typeof maxSize === 'number' && nextSize > maxSize) {
|
||||
return maxSize
|
||||
}
|
||||
|
||||
return nextSize
|
||||
}
|
||||
|
||||
function measureColumnContentWidth(
|
||||
resizerElement: HTMLElement,
|
||||
columnId: string
|
||||
) {
|
||||
const tableElement = resizerElement.closest('table')
|
||||
if (!tableElement) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cells = tableElement.querySelectorAll<HTMLElement>(
|
||||
getColumnElementSelector(columnId)
|
||||
)
|
||||
if (cells.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const measuredWidth = [...cells].reduce(
|
||||
(maxWidth, cell) => Math.max(maxWidth, measureElementWidth(cell)),
|
||||
0
|
||||
)
|
||||
|
||||
return measuredWidth > 0 ? Math.ceil(measuredWidth) : undefined
|
||||
}
|
||||
|
||||
function measureElementWidth(element: HTMLElement) {
|
||||
const clone = element.cloneNode(true) as HTMLElement
|
||||
|
||||
clone.querySelectorAll('[data-column-resizer]').forEach((resizer) => {
|
||||
resizer.remove()
|
||||
})
|
||||
|
||||
clone.style.position = 'absolute'
|
||||
clone.style.visibility = 'hidden'
|
||||
clone.style.pointerEvents = 'none'
|
||||
clone.style.left = '-10000px'
|
||||
clone.style.top = '0'
|
||||
clone.style.width = 'max-content'
|
||||
clone.style.minWidth = '0'
|
||||
clone.style.maxWidth = 'none'
|
||||
clone.style.height = 'auto'
|
||||
clone.style.whiteSpace = 'nowrap'
|
||||
|
||||
document.body.append(clone)
|
||||
const width = clone.scrollWidth
|
||||
clone.remove()
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
function getColumnElementSelector(columnId: string) {
|
||||
const escapedColumnId =
|
||||
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||
? CSS.escape(columnId)
|
||||
: columnId.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
||||
|
||||
return `[data-column-id="${escapedColumnId}"]`
|
||||
}
|
||||
|
||||
function shouldRenderColumnResizer<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>
|
||||
) {
|
||||
return (
|
||||
table.options.enableColumnResizing === true &&
|
||||
!header.isPlaceholder &&
|
||||
header.column.getCanResize() &&
|
||||
!isContentSizedColumn(header.column.id)
|
||||
)
|
||||
}
|
||||
|
||||
function getHeaderSizeStyle<TData>(
|
||||
header: Header<TData, unknown>,
|
||||
applyHeaderSize: boolean | undefined
|
||||
) {
|
||||
if (!applyHeaderSize || isContentSizedColumn(header.column.id)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { width: header.getSize() }
|
||||
}
|
||||
|
||||
function renderHeaderContent<TData>(header: Header<TData, unknown>) {
|
||||
if (header.isPlaceholder) return null
|
||||
const { header: headerDef, meta } = header.column.columnDef
|
||||
// A string header means the user wrote e.g. `header: t('Name')` — auto-render
|
||||
// with DataTableColumnHeader so sorting works without boilerplate.
|
||||
// A function (including TanStack's default accessor-key fallback) is passed
|
||||
// through as-is. meta.label is kept as a fallback for legacy columns.
|
||||
if (typeof headerDef === 'string') {
|
||||
return <DataTableColumnHeader column={header.column} title={headerDef} />
|
||||
}
|
||||
if (meta?.label) {
|
||||
return <DataTableColumnHeader column={header.column} title={meta.label} />
|
||||
}
|
||||
return flexRender(headerDef, header.getContext())
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TruncatedCell } from './truncated-cell'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableRowProps<TData> = {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
cellRenderColumns?: TanstackTable<TData>['options']['columns']
|
||||
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
|
||||
|
||||
type DataTableRowInnerProps<TData> = DataTableRowProps<TData> & {
|
||||
isSelected: boolean
|
||||
/**
|
||||
* Stable signature of currently visible leaf columns for this row.
|
||||
* Captured outside the memo comparator so visibility toggles re-render
|
||||
* even when the TanStack row object reference stays the same.
|
||||
*/
|
||||
visibleColumnIds: string
|
||||
}
|
||||
|
||||
function DataTableRowInner<TData>({
|
||||
row,
|
||||
isSelected,
|
||||
className,
|
||||
getColumnClassName,
|
||||
cellRenderColumns,
|
||||
visibleColumnIds,
|
||||
...rowProps
|
||||
}: DataTableRowInnerProps<TData>) {
|
||||
// Destructured only to keep them out of `rowProps` (not valid DOM attrs)
|
||||
// and to feed the memo comparator below; intentionally unused here.
|
||||
void cellRenderColumns
|
||||
void visibleColumnIds
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className={className}
|
||||
{...rowProps}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const renderedCell = renderCellContent(cell)
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
data-column-id={cell.column.id}
|
||||
className={cn(
|
||||
'max-w-full min-w-0',
|
||||
renderedCell.isPrimitive && 'overflow-hidden',
|
||||
getColumnClassName?.(cell.column.id, 'cell')
|
||||
)}
|
||||
>
|
||||
{renderedCell.content}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
const MemoizedDataTableRow = React.memo(DataTableRowInner, (prev, next) => {
|
||||
// Do not read row.getIsSelected() / row.getVisibleCells() inside the
|
||||
// comparator: TanStack row objects keep a stable reference while selection
|
||||
// and columnVisibility mutate on the table instance. Reading them here would
|
||||
// compare identical live values and miss those updates. Both are lifted to
|
||||
// explicit props, captured per render in DataTableRow.
|
||||
//
|
||||
// Column cell renderers (and getColumnClassName) can close over external
|
||||
// state while the row stays stable, so column definitions and the class
|
||||
// resolver are part of the render identity and must be compared too.
|
||||
return (
|
||||
prev.row === next.row &&
|
||||
prev.className === next.className &&
|
||||
prev.isSelected === next.isSelected &&
|
||||
prev.visibleColumnIds === next.visibleColumnIds &&
|
||||
prev.getColumnClassName === next.getColumnClassName &&
|
||||
prev.cellRenderColumns === next.cellRenderColumns
|
||||
)
|
||||
}) as typeof DataTableRowInner
|
||||
|
||||
export function DataTableRow<TData>(props: DataTableRowProps<TData>) {
|
||||
const visibleColumnIds = props.row
|
||||
.getVisibleCells()
|
||||
.map((cell) => cell.column.id)
|
||||
.join('\0')
|
||||
|
||||
return (
|
||||
<MemoizedDataTableRow
|
||||
{...props}
|
||||
isSelected={props.row.getIsSelected()}
|
||||
visibleColumnIds={visibleColumnIds}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCellContent<TData>(cell: Cell<TData, unknown>) {
|
||||
const content = flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
const textContent = getPrimitiveTextContent(content)
|
||||
|
||||
if (!textContent) {
|
||||
return { content, isPrimitive: false }
|
||||
}
|
||||
|
||||
return {
|
||||
content: (
|
||||
<TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||
),
|
||||
isPrimitive: true,
|
||||
}
|
||||
}
|
||||
|
||||
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||
if (typeof content === 'string' || typeof content === 'number') {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
getPinnedColumnMap,
|
||||
getResolvedColumnClassNameFromMap,
|
||||
} from './column-pinning'
|
||||
import { DataTableColgroup } from './data-table-colgroup'
|
||||
import { DataTableHeader } from './data-table-header'
|
||||
import { DataTableRow } from './data-table-row'
|
||||
import { TableEmpty } from './table-empty'
|
||||
import { getTableSizeStyle } from './table-sizing'
|
||||
import { TableSkeleton } from './table-skeleton'
|
||||
import type {
|
||||
DataTableColumnClassName,
|
||||
DataTablePinnedColumn,
|
||||
DataTableViewProps,
|
||||
} from './types'
|
||||
|
||||
export type {
|
||||
DataTableColumnClassName,
|
||||
DataTablePinnedColumn,
|
||||
DataTableRenderRowHelpers,
|
||||
DataTableViewProps,
|
||||
} from './types'
|
||||
export { DataTableRow } from './data-table-row'
|
||||
export { DataTableRowActionMenu } from './row-action-menu'
|
||||
|
||||
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
|
||||
const rows = props.rows ?? props.table.getRowModel().rows
|
||||
const colSpan = React.useMemo(
|
||||
() => props.table.getVisibleLeafColumns().length,
|
||||
[props.table]
|
||||
)
|
||||
const columnClassName = useResolvedColumnClassName(
|
||||
props.table,
|
||||
props.getColumnClassName,
|
||||
props.pinnedColumns
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border',
|
||||
props.containerClassName
|
||||
)}
|
||||
{...props.containerProps}
|
||||
>
|
||||
{props.splitHeader ? (
|
||||
<SplitHeaderTableView
|
||||
props={props}
|
||||
rows={rows}
|
||||
colSpan={colSpan}
|
||||
getColumnClassName={columnClassName}
|
||||
/>
|
||||
) : (
|
||||
<UnifiedTableView
|
||||
props={props}
|
||||
rows={rows}
|
||||
colSpan={colSpan}
|
||||
getColumnClassName={columnClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnifiedTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const tableSizing = getTableSizing(props)
|
||||
|
||||
return (
|
||||
<div className={props.tableContainerClassName}>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={props.tableHeaderClassName}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SplitHeaderTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const tableSizing = getTableSizing(props)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-0 flex-col',
|
||||
props.tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 flex-1 overflow-auto',
|
||||
'**:data-[slot=table-header]:[--table-header-bg:var(--table-header)]',
|
||||
'**:data-[slot=table-header]:bg-(--table-header-bg)',
|
||||
props.splitHeaderScrollClassName,
|
||||
props.bodyContainerClassName
|
||||
)}
|
||||
>
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn(
|
||||
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
|
||||
props.tableClassName
|
||||
)}
|
||||
style={tableSizing.style}
|
||||
>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={cn('sticky top-0 z-10', props.tableHeaderClassName)}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useResolvedColumnClassName<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
) {
|
||||
const allPinnedColumns = React.useMemo(() => {
|
||||
const metaPinnedColumns = getMetaPinnedColumns(table)
|
||||
return mergePinnedColumns(pinnedColumns, metaPinnedColumns)
|
||||
}, [table, pinnedColumns])
|
||||
|
||||
const pinnedColumnById = React.useMemo(
|
||||
() => getPinnedColumnMap(allPinnedColumns),
|
||||
[allPinnedColumns]
|
||||
)
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
getResolvedColumnClassNameFromMap(getColumnClassName, pinnedColumnById),
|
||||
[getColumnClassName, pinnedColumnById]
|
||||
)
|
||||
}
|
||||
|
||||
function getMetaPinnedColumns<TData>(
|
||||
table: TanstackTable<TData>
|
||||
): DataTablePinnedColumn[] {
|
||||
return table.getAllColumns().flatMap((column) => {
|
||||
const side = column.columnDef.meta?.pinned
|
||||
if (!side) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ columnId: column.id, side }]
|
||||
})
|
||||
}
|
||||
|
||||
function mergePinnedColumns(
|
||||
explicitPinnedColumns: DataTablePinnedColumn[] | undefined,
|
||||
metaPinnedColumns: DataTablePinnedColumn[]
|
||||
): DataTablePinnedColumn[] | undefined {
|
||||
if (!metaPinnedColumns.length) {
|
||||
return explicitPinnedColumns
|
||||
}
|
||||
|
||||
if (!explicitPinnedColumns?.length) {
|
||||
return metaPinnedColumns
|
||||
}
|
||||
|
||||
const explicitColumnIds = new Set(
|
||||
explicitPinnedColumns.map((column) => column.columnId)
|
||||
)
|
||||
|
||||
return [
|
||||
...explicitPinnedColumns,
|
||||
...metaPinnedColumns.filter(
|
||||
(column) => !explicitColumnIds.has(column.columnId)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function getTableSizing<TData>(props: DataTableViewProps<TData>): {
|
||||
colgroup?: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
} {
|
||||
if (props.colgroup) {
|
||||
return { colgroup: props.colgroup }
|
||||
}
|
||||
|
||||
if (!props.splitHeader && !props.applyHeaderSize) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
colgroup: <DataTableColgroup table={props.table} />,
|
||||
style: getTableSizeStyle(props.table),
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableBody<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<TableBody className={props.tableBodyClassName}>
|
||||
{renderTableBodyContent(props, rows, colSpan, getColumnClassName)}
|
||||
</TableBody>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTableBodyContent<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
if (props.isLoading) {
|
||||
return (
|
||||
<TableSkeleton
|
||||
table={props.table}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
rowHeight={props.skeletonRowHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return renderEmptyState(props, colSpan)
|
||||
}
|
||||
|
||||
return rows.map((row) =>
|
||||
props.renderRow
|
||||
? props.renderRow(row, {
|
||||
getCellClassName: (columnId, className) =>
|
||||
cn(getColumnClassName(columnId, 'cell'), className),
|
||||
})
|
||||
: renderDefaultRow(props, row, getColumnClassName)
|
||||
)
|
||||
}
|
||||
|
||||
function renderEmptyState<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
colSpan: number
|
||||
) {
|
||||
if (props.emptyContent) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className={props.emptyCellClassName}>
|
||||
{props.emptyContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableEmpty
|
||||
colSpan={colSpan}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
icon={props.emptyIcon}
|
||||
>
|
||||
{props.emptyAction}
|
||||
</TableEmpty>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultRow<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
row: Row<TData>,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
|
||||
getColumnClassName={getColumnClassName}
|
||||
cellRenderColumns={props.table.options.columns}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
ChevronsLeft as DoubleArrowLeftIcon,
|
||||
ChevronsRight as DoubleArrowRightIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn, getPageNumbers } from '@/lib/utils'
|
||||
|
||||
type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const
|
||||
const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
|
||||
value: `${pageSize}`,
|
||||
label: pageSize,
|
||||
}))
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const pagination = table.getState().pagination
|
||||
const currentPage = pagination.pageIndex + 1
|
||||
const pageSize = pagination.pageSize
|
||||
const totalPages = table.getPageCount()
|
||||
const totalRows = table.getRowCount()
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'@container/pagination flex min-w-0 items-center justify-end overflow-clip'
|
||||
)}
|
||||
style={{ overflowClipMargin: 1 }}
|
||||
>
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-2 @xl/pagination:gap-3'>
|
||||
<div className='flex shrink-0 items-baseline gap-1.5 text-xs font-medium whitespace-nowrap sm:text-sm'>
|
||||
<span className='text-muted-foreground/80'>{t('Total:')}</span>
|
||||
<span className='text-foreground tabular-nums'>
|
||||
{totalRows.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1.5 @lg/pagination:gap-2'>
|
||||
<p className='text-muted-foreground/80 hidden text-sm font-medium whitespace-nowrap @2xl/pagination:block'>
|
||||
{t('Rows per page')}
|
||||
</p>
|
||||
<Select
|
||||
items={PAGE_SIZE_SELECT_ITEMS}
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top' alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{PAGE_SIZE_OPTIONS.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to first page')}</span>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to previous page')}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-8 min-w-8 px-2 tabular-nums',
|
||||
currentPage === pageNumber
|
||||
? 'font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
<span className='sr-only'>
|
||||
{t('Go to page {{page}}', { page: pageNumber })}
|
||||
</span>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to next page')}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to last page')}</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableRowActionMenuProps = {
|
||||
children: React.ReactNode
|
||||
ariaLabel: string
|
||||
contentClassName?: string
|
||||
modal?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function DataTableRowActionMenu(props: DataTableRowActionMenuProps) {
|
||||
return (
|
||||
<DropdownMenu modal={props.modal} onOpenChange={props.onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='data-popup-open:bg-muted'
|
||||
aria-label={props.ariaLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal aria-hidden='true' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className={cn('w-48', props.contentClassName)}
|
||||
>
|
||||
{props.children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { TableRow, TableCell } from '@/components/ui/table'
|
||||
|
||||
interface TableEmptyProps {
|
||||
/**
|
||||
* Number of columns to span
|
||||
*/
|
||||
colSpan: number
|
||||
/**
|
||||
* Custom title for empty state
|
||||
* @default 'No Data'
|
||||
*/
|
||||
title?: string
|
||||
/**
|
||||
* Custom description for empty state
|
||||
* @default 'No records found. Try adjusting your filters.'
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Custom icon component
|
||||
* @default Database icon
|
||||
*/
|
||||
icon?: React.ReactNode
|
||||
/**
|
||||
* Additional content to display (e.g., buttons)
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic table empty state component
|
||||
* Displays a centered empty state message when table has no data
|
||||
*/
|
||||
export function TableEmpty({
|
||||
colSpan,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}: TableEmptyProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedTitle = title ?? t('No Data')
|
||||
const resolvedDescription =
|
||||
description ?? t('No records found. Try adjusting your filters.')
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className='h-[400px] p-0'>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
{icon || <Database className='size-6' />}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{children}
|
||||
</Empty>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Table as TanstackTable } from '@tanstack/react-table'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { isContentSizedColumn } from './content-sized-columns'
|
||||
|
||||
export function getTableSizeStyle<TData>(
|
||||
table: TanstackTable<TData>
|
||||
): React.CSSProperties {
|
||||
const width = table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((column) => !isContentSizedColumn(column.id))
|
||||
.reduce((total, column) => total + column.getSize(), 0)
|
||||
|
||||
return {
|
||||
minWidth: `max(100%, ${width}px)`,
|
||||
tableLayout: 'auto',
|
||||
width: '100%',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TableRow, TableCell } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SKELETON_WIDTHS = [
|
||||
'75%',
|
||||
'60%',
|
||||
'85%',
|
||||
'50%',
|
||||
'70%',
|
||||
'90%',
|
||||
'55%',
|
||||
'80%',
|
||||
'65%',
|
||||
'45%',
|
||||
]
|
||||
|
||||
interface TableSkeletonProps<TData> {
|
||||
table: Table<TData>
|
||||
rowCount?: number
|
||||
rowHeight?: string
|
||||
keyPrefix?: string
|
||||
}
|
||||
|
||||
export function TableSkeleton<TData>({
|
||||
table,
|
||||
rowCount,
|
||||
rowHeight = 'h-[52px]',
|
||||
keyPrefix = 'skeleton',
|
||||
}: TableSkeletonProps<TData>) {
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
const finalRowCount =
|
||||
rowCount ?? Math.min(table.getState().pagination?.pageSize || 20, 20)
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: finalRowCount }, (_, rowIndex) => (
|
||||
<TableRow
|
||||
key={`${keyPrefix}-${rowIndex}`}
|
||||
className={cn(rowHeight, 'border-b')}
|
||||
>
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
const isSelectColumn = column.id === 'select'
|
||||
const widthIndex =
|
||||
(rowIndex * visibleColumns.length + colIndex) %
|
||||
SKELETON_WIDTHS.length
|
||||
|
||||
return (
|
||||
<TableCell key={column.id} className='py-3'>
|
||||
<Skeleton
|
||||
className={cn(
|
||||
'h-4 rounded-sm',
|
||||
isSelectColumn ? 'size-4' : undefined
|
||||
)}
|
||||
style={
|
||||
isSelectColumn
|
||||
? undefined
|
||||
: { width: SKELETON_WIDTHS[widthIndex] }
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type TruncatedCellProps = {
|
||||
children: React.ReactNode
|
||||
cellClassName?: string
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
tooltipClassName?: string
|
||||
tooltipContent?: React.ReactNode
|
||||
}
|
||||
|
||||
export function TruncatedCell({
|
||||
children,
|
||||
cellClassName,
|
||||
className,
|
||||
contentClassName,
|
||||
side = 'top',
|
||||
tooltipClassName,
|
||||
tooltipContent,
|
||||
}: TruncatedCellProps) {
|
||||
const content = tooltipContent ?? getTextContent(children)
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'block max-w-full min-w-0 truncate',
|
||||
cellClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'block max-w-full min-w-0 truncate',
|
||||
cellClassName,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={cn('truncate', contentClassName)}>{children}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
className={cn('max-w-xs break-all', tooltipClassName)}
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function getTextContent(node: React.ReactNode): string {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
||||
if (Array.isArray(node)) return node.map(getTextContent).join('')
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
|
||||
import type * as React from 'react'
|
||||
|
||||
export type DataTableColumnClassName = (
|
||||
columnId: string,
|
||||
kind: 'header' | 'cell'
|
||||
) => string | undefined
|
||||
|
||||
export type DataTablePinnedColumn = {
|
||||
columnId: string
|
||||
side: 'left' | 'right'
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
}
|
||||
|
||||
export type DataTableRenderRowHelpers = {
|
||||
getCellClassName: (columnId: string, className?: string) => string | undefined
|
||||
}
|
||||
|
||||
export type DataTableViewProps<TData> = {
|
||||
table: TanstackTable<TData>
|
||||
isLoading?: boolean
|
||||
rows?: Row<TData>[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
emptyAction?: React.ReactNode
|
||||
emptyContent?: React.ReactNode
|
||||
emptyCellClassName?: string
|
||||
skeletonKeyPrefix?: string
|
||||
skeletonRowHeight?: string
|
||||
renderRow?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableRenderRowHelpers
|
||||
) => React.ReactNode
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
applyHeaderSize?: boolean
|
||||
tableClassName?: string
|
||||
tableHeaderClassName?: string
|
||||
tableHeaderRowClassName?: string
|
||||
tableBodyClassName?: string
|
||||
tableBodyRowClassName?: string
|
||||
splitHeader?: boolean
|
||||
splitHeaderScrollClassName?: string
|
||||
bodyContainerClassName?: string
|
||||
containerClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableContainerClassName?: string
|
||||
colgroup?: React.ReactNode
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
export const DATA_TABLE_VIEW_MODES = {
|
||||
TABLE: 'table',
|
||||
CARD: 'card',
|
||||
} as const
|
||||
|
||||
export type DataTableViewMode =
|
||||
(typeof DATA_TABLE_VIEW_MODES)[keyof typeof DATA_TABLE_VIEW_MODES]
|
||||
|
||||
function isViewMode(value: unknown): value is DataTableViewMode {
|
||||
return (
|
||||
value === DATA_TABLE_VIEW_MODES.TABLE ||
|
||||
value === DATA_TABLE_VIEW_MODES.CARD
|
||||
)
|
||||
}
|
||||
|
||||
function readViewMode(
|
||||
storageKey: string | undefined,
|
||||
fallback: DataTableViewMode
|
||||
): DataTableViewMode {
|
||||
if (!storageKey || typeof window === 'undefined') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
return isViewMode(raw) ? raw : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
type UseDataTableViewModeOptions = {
|
||||
/**
|
||||
* localStorage key for persisting the selected view mode. When omitted the
|
||||
* selection lives only in memory (resets on reload).
|
||||
*/
|
||||
storageKey?: string
|
||||
/** Initial mode used when nothing is persisted. Defaults to `'table'`. */
|
||||
defaultMode?: DataTableViewMode
|
||||
}
|
||||
|
||||
/**
|
||||
* View-mode (table vs. card) state with optional per-table localStorage
|
||||
* persistence. Mirrors the SSR/try-catch guarded approach used for column
|
||||
* visibility persistence in {@link useDataTable}.
|
||||
*/
|
||||
export function useDataTableViewMode(
|
||||
options: UseDataTableViewModeOptions = {}
|
||||
): [DataTableViewMode, (mode: DataTableViewMode) => void] {
|
||||
const defaultMode = options.defaultMode ?? DATA_TABLE_VIEW_MODES.TABLE
|
||||
const storageKey = options.storageKey
|
||||
|
||||
const [viewMode, setViewModeState] = React.useState<DataTableViewMode>(() =>
|
||||
readViewMode(storageKey, defaultMode)
|
||||
)
|
||||
|
||||
// Re-hydrate when the storage key changes (e.g. switching tables).
|
||||
const hydratedStorageKeyRef = React.useRef(storageKey)
|
||||
React.useEffect(() => {
|
||||
if (storageKey === hydratedStorageKeyRef.current) {
|
||||
return
|
||||
}
|
||||
hydratedStorageKeyRef.current = storageKey
|
||||
setViewModeState(readViewMode(storageKey, defaultMode))
|
||||
}, [storageKey, defaultMode])
|
||||
|
||||
const setViewMode = React.useCallback(
|
||||
(mode: DataTableViewMode) => {
|
||||
setViewModeState(mode)
|
||||
if (!storageKey || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, mode)
|
||||
} catch {
|
||||
// Storage can be unavailable in private mode; controls still work.
|
||||
}
|
||||
},
|
||||
[storageKey]
|
||||
)
|
||||
|
||||
return [viewMode, setViewMode]
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ColumnSizingState,
|
||||
type ExpandedState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type TableOptions,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
type DataTableFeatureOptions<TData> = Pick<
|
||||
TableOptions<TData>,
|
||||
| 'enableRowSelection'
|
||||
| 'getRowId'
|
||||
| 'getSubRows'
|
||||
| 'globalFilterFn'
|
||||
| 'autoResetPageIndex'
|
||||
| 'manualFiltering'
|
||||
| 'manualPagination'
|
||||
| 'manualSorting'
|
||||
| 'enableSorting'
|
||||
| 'enableColumnResizing'
|
||||
>
|
||||
|
||||
type DataTableStateOptions = {
|
||||
initialSorting?: SortingState
|
||||
sorting?: SortingState
|
||||
onSortingChange?: OnChangeFn<SortingState>
|
||||
initialColumnVisibility?: VisibilityState
|
||||
columnVisibilityStorageKey?: string | false
|
||||
columnVisibility?: VisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||||
initialColumnSizing?: ColumnSizingState
|
||||
columnSizingStorageKey?: string | false
|
||||
columnSizing?: ColumnSizingState
|
||||
onColumnSizingChange?: OnChangeFn<ColumnSizingState>
|
||||
initialRowSelection?: RowSelectionState
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>
|
||||
initialExpanded?: ExpandedState
|
||||
expanded?: ExpandedState
|
||||
onExpandedChange?: OnChangeFn<ExpandedState>
|
||||
columnFilters?: ColumnFiltersState
|
||||
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
|
||||
globalFilter?: string
|
||||
onGlobalFilterChange?: OnChangeFn<string>
|
||||
initialPagination?: PaginationState
|
||||
pagination?: PaginationState
|
||||
onPaginationChange?: OnChangeFn<PaginationState>
|
||||
}
|
||||
|
||||
type DataTableRowModelOptions = {
|
||||
withFilteredRowModel?: boolean
|
||||
withPaginationRowModel?: boolean
|
||||
withSortedRowModel?: boolean
|
||||
withFacetedRowModel?: boolean
|
||||
withExpandedRowModel?: boolean
|
||||
}
|
||||
|
||||
type UseDataTableOptions<TData> = DataTableFeatureOptions<TData> &
|
||||
DataTableStateOptions &
|
||||
DataTableRowModelOptions & {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
totalCount?: number
|
||||
pageCount?: number
|
||||
ensurePageInRange?: (pageCount: number) => void
|
||||
}
|
||||
|
||||
type ColumnSizingBounds = Record<
|
||||
string,
|
||||
{
|
||||
minSize?: number
|
||||
maxSize?: number
|
||||
}
|
||||
>
|
||||
|
||||
type ColumnWithSizing<TData> = ColumnDef<TData, unknown> & {
|
||||
accessorKey?: string | number
|
||||
columns?: ColumnDef<TData, unknown>[]
|
||||
}
|
||||
|
||||
const COLUMN_SIZING_PERSIST_DELAY_MS = 250
|
||||
|
||||
function resolveUpdater<TValue>(
|
||||
updater: Updater<TValue>,
|
||||
previous: TValue
|
||||
): TValue {
|
||||
return typeof updater === 'function'
|
||||
? (updater as (old: TValue) => TValue)(previous)
|
||||
: updater
|
||||
}
|
||||
|
||||
function useControllableTableState<TValue>(
|
||||
controlledValue: TValue | undefined,
|
||||
defaultValue: TValue,
|
||||
onChange: OnChangeFn<TValue> | undefined
|
||||
): [TValue, OnChangeFn<TValue>] {
|
||||
const [uncontrolledValue, setUncontrolledValue] =
|
||||
React.useState<TValue>(defaultValue)
|
||||
|
||||
const value = controlledValue ?? uncontrolledValue
|
||||
|
||||
const setValue = React.useCallback<OnChangeFn<TValue>>(
|
||||
(updater) => {
|
||||
if (controlledValue === undefined) {
|
||||
setUncontrolledValue((previous) => resolveUpdater(updater, previous))
|
||||
}
|
||||
onChange?.(updater)
|
||||
},
|
||||
[controlledValue, onChange]
|
||||
)
|
||||
|
||||
return [value, setValue]
|
||||
}
|
||||
|
||||
function readColumnVisibility(storageKey: string | undefined): VisibilityState {
|
||||
if (!storageKey || typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return {}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.entries(parsed).reduce<VisibilityState>(
|
||||
(visibility, [key, value]) => {
|
||||
if (typeof value === 'boolean') {
|
||||
visibility[key] = value
|
||||
}
|
||||
return visibility
|
||||
},
|
||||
{}
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function getColumnId<TData>(column: ColumnDef<TData, unknown>) {
|
||||
const columnWithSizing = column as ColumnWithSizing<TData>
|
||||
|
||||
if (typeof columnWithSizing.id === 'string') {
|
||||
return columnWithSizing.id
|
||||
}
|
||||
|
||||
if (typeof columnWithSizing.accessorKey === 'string') {
|
||||
return columnWithSizing.accessorKey.replaceAll('.', '_')
|
||||
}
|
||||
|
||||
if (typeof columnWithSizing.accessorKey === 'number') {
|
||||
return String(columnWithSizing.accessorKey)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildColumnSizingBounds<TData>(
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
): ColumnSizingBounds {
|
||||
return columns.reduce<ColumnSizingBounds>((bounds, column) => {
|
||||
const columnWithSizing = column as ColumnWithSizing<TData>
|
||||
const columnId = getColumnId(column)
|
||||
|
||||
if (columnId) {
|
||||
const minSize =
|
||||
typeof columnWithSizing.minSize === 'number' &&
|
||||
Number.isFinite(columnWithSizing.minSize)
|
||||
? columnWithSizing.minSize
|
||||
: undefined
|
||||
const maxSize =
|
||||
typeof columnWithSizing.maxSize === 'number' &&
|
||||
Number.isFinite(columnWithSizing.maxSize)
|
||||
? columnWithSizing.maxSize
|
||||
: undefined
|
||||
|
||||
if (minSize !== undefined || maxSize !== undefined) {
|
||||
bounds[columnId] = { minSize, maxSize }
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(columnWithSizing.columns)) {
|
||||
Object.assign(bounds, buildColumnSizingBounds(columnWithSizing.columns))
|
||||
}
|
||||
|
||||
return bounds
|
||||
}, {})
|
||||
}
|
||||
|
||||
function getBoundedColumnSize(
|
||||
columnId: string,
|
||||
value: unknown,
|
||||
bounds: ColumnSizingBounds
|
||||
) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const columnBounds = bounds[columnId]
|
||||
let size = value
|
||||
|
||||
if (columnBounds?.minSize !== undefined && size < columnBounds.minSize) {
|
||||
size = columnBounds.minSize
|
||||
}
|
||||
|
||||
if (columnBounds?.maxSize !== undefined && size > columnBounds.maxSize) {
|
||||
size = columnBounds.maxSize
|
||||
}
|
||||
|
||||
return size > 0 ? size : undefined
|
||||
}
|
||||
|
||||
function readColumnSizing(
|
||||
storageKey: string | undefined,
|
||||
bounds: ColumnSizingBounds
|
||||
): ColumnSizingState {
|
||||
if (!storageKey || typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return {}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.entries(parsed).reduce<ColumnSizingState>(
|
||||
(sizing, [key, value]) => {
|
||||
const boundedSize = getBoundedColumnSize(key, value, bounds)
|
||||
|
||||
if (boundedSize !== undefined) {
|
||||
sizing[key] = boundedSize
|
||||
}
|
||||
return sizing
|
||||
},
|
||||
{}
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
totalCount,
|
||||
pageCount: explicitPageCount,
|
||||
ensurePageInRange,
|
||||
manualFiltering,
|
||||
manualPagination,
|
||||
manualSorting,
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
initialColumnSizing = {},
|
||||
initialRowSelection = {},
|
||||
initialExpanded = {},
|
||||
initialPagination = { pageIndex: 0, pageSize: 20 },
|
||||
withFilteredRowModel = !manualFiltering,
|
||||
withPaginationRowModel = !manualPagination,
|
||||
withSortedRowModel = !manualSorting && !manualPagination,
|
||||
withFacetedRowModel = !manualFiltering,
|
||||
withExpandedRowModel = false,
|
||||
} = options
|
||||
|
||||
const columnVisibilityStorageKey =
|
||||
typeof options.columnVisibilityStorageKey === 'string'
|
||||
? options.columnVisibilityStorageKey
|
||||
: undefined
|
||||
const columnSizingStorageKey =
|
||||
typeof options.columnSizingStorageKey === 'string'
|
||||
? options.columnSizingStorageKey
|
||||
: undefined
|
||||
const resolvedInitialColumnVisibility = React.useMemo(
|
||||
() => ({
|
||||
...initialColumnVisibility,
|
||||
...readColumnVisibility(columnVisibilityStorageKey),
|
||||
}),
|
||||
[columnVisibilityStorageKey, initialColumnVisibility]
|
||||
)
|
||||
const columnSizingBounds = React.useMemo(
|
||||
() => buildColumnSizingBounds(columns),
|
||||
[columns]
|
||||
)
|
||||
const resolvedInitialColumnSizing = React.useMemo(
|
||||
() => ({
|
||||
...initialColumnSizing,
|
||||
...readColumnSizing(columnSizingStorageKey, columnSizingBounds),
|
||||
}),
|
||||
[columnSizingBounds, columnSizingStorageKey, initialColumnSizing]
|
||||
)
|
||||
|
||||
const [sorting, onSortingChange] = useControllableTableState(
|
||||
options.sorting,
|
||||
initialSorting,
|
||||
options.onSortingChange
|
||||
)
|
||||
const [columnVisibility, onColumnVisibilityChange] =
|
||||
useControllableTableState(
|
||||
options.columnVisibility,
|
||||
resolvedInitialColumnVisibility,
|
||||
options.onColumnVisibilityChange
|
||||
)
|
||||
const [columnSizing, onColumnSizingChange] = useControllableTableState(
|
||||
options.columnSizing,
|
||||
resolvedInitialColumnSizing,
|
||||
options.onColumnSizingChange
|
||||
)
|
||||
const hydratedColumnVisibilityStorageKeyRef = React.useRef(
|
||||
columnVisibilityStorageKey
|
||||
)
|
||||
const hydratedColumnSizingStorageKeyRef = React.useRef(columnSizingStorageKey)
|
||||
const skipNextColumnVisibilityPersistRef = React.useRef(false)
|
||||
const skipNextColumnSizingPersistRef = React.useRef(false)
|
||||
const columnSizingPersistTimerRef = React.useRef<number | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [rowSelection, onRowSelectionChange] = useControllableTableState(
|
||||
options.rowSelection,
|
||||
initialRowSelection,
|
||||
options.onRowSelectionChange
|
||||
)
|
||||
const [expanded, onExpandedChange] = useControllableTableState(
|
||||
options.expanded,
|
||||
initialExpanded,
|
||||
options.onExpandedChange
|
||||
)
|
||||
const [pagination, onPaginationChange] = useControllableTableState(
|
||||
options.pagination,
|
||||
initialPagination,
|
||||
options.onPaginationChange
|
||||
)
|
||||
|
||||
const resolvedPageCount =
|
||||
explicitPageCount ??
|
||||
(totalCount !== undefined
|
||||
? Math.ceil(totalCount / pagination.pageSize)
|
||||
: undefined)
|
||||
const resolvedEnableSorting =
|
||||
options.enableSorting ??
|
||||
(!manualPagination ||
|
||||
Boolean(options.sorting) ||
|
||||
Boolean(options.onSortingChange))
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
rowCount: totalCount,
|
||||
pageCount: resolvedPageCount,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
rowSelection,
|
||||
expanded,
|
||||
columnFilters: options.columnFilters,
|
||||
globalFilter: options.globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: options.enableRowSelection,
|
||||
enableSorting: resolvedEnableSorting,
|
||||
getRowId: options.getRowId,
|
||||
getSubRows: options.getSubRows,
|
||||
globalFilterFn: options.globalFilterFn,
|
||||
autoResetPageIndex: options.autoResetPageIndex,
|
||||
manualFiltering,
|
||||
manualPagination,
|
||||
manualSorting,
|
||||
enableColumnResizing: options.enableColumnResizing,
|
||||
columnResizeMode: 'onChange',
|
||||
onSortingChange,
|
||||
onColumnVisibilityChange,
|
||||
onColumnSizingChange,
|
||||
onRowSelectionChange,
|
||||
onExpandedChange,
|
||||
onColumnFiltersChange: options.onColumnFiltersChange,
|
||||
onGlobalFilterChange: options.onGlobalFilterChange,
|
||||
onPaginationChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: withFilteredRowModel
|
||||
? getFilteredRowModel()
|
||||
: undefined,
|
||||
getPaginationRowModel: withPaginationRowModel
|
||||
? getPaginationRowModel()
|
||||
: undefined,
|
||||
getSortedRowModel: withSortedRowModel ? getSortedRowModel() : undefined,
|
||||
getFacetedRowModel: withFacetedRowModel ? getFacetedRowModel() : undefined,
|
||||
getFacetedUniqueValues: withFacetedRowModel
|
||||
? getFacetedUniqueValues()
|
||||
: undefined,
|
||||
getExpandedRowModel: withExpandedRowModel
|
||||
? getExpandedRowModel()
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const actualPageCount = table.getPageCount()
|
||||
React.useEffect(() => {
|
||||
ensurePageInRange?.(actualPageCount)
|
||||
}, [actualPageCount, ensurePageInRange])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
options.columnVisibility !== undefined ||
|
||||
columnVisibilityStorageKey ===
|
||||
hydratedColumnVisibilityStorageKeyRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
hydratedColumnVisibilityStorageKeyRef.current = columnVisibilityStorageKey
|
||||
skipNextColumnVisibilityPersistRef.current = true
|
||||
onColumnVisibilityChange(() => resolvedInitialColumnVisibility)
|
||||
}, [
|
||||
columnVisibilityStorageKey,
|
||||
onColumnVisibilityChange,
|
||||
options.columnVisibility,
|
||||
resolvedInitialColumnVisibility,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
options.columnSizing !== undefined ||
|
||||
columnSizingStorageKey === hydratedColumnSizingStorageKeyRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
hydratedColumnSizingStorageKeyRef.current = columnSizingStorageKey
|
||||
skipNextColumnSizingPersistRef.current = true
|
||||
onColumnSizingChange(() => resolvedInitialColumnSizing)
|
||||
}, [
|
||||
columnSizingStorageKey,
|
||||
onColumnSizingChange,
|
||||
options.columnSizing,
|
||||
resolvedInitialColumnSizing,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!columnVisibilityStorageKey || typeof window === 'undefined') return
|
||||
|
||||
if (skipNextColumnVisibilityPersistRef.current) {
|
||||
skipNextColumnVisibilityPersistRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
columnVisibilityStorageKey,
|
||||
JSON.stringify(columnVisibility)
|
||||
)
|
||||
} catch {
|
||||
// Storage can be unavailable in private mode; table controls still work.
|
||||
}
|
||||
}, [columnVisibility, columnVisibilityStorageKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!columnSizingStorageKey || typeof window === 'undefined') return
|
||||
|
||||
if (skipNextColumnSizingPersistRef.current) {
|
||||
skipNextColumnSizingPersistRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (columnSizingPersistTimerRef.current !== undefined) {
|
||||
window.clearTimeout(columnSizingPersistTimerRef.current)
|
||||
}
|
||||
|
||||
columnSizingPersistTimerRef.current = window.setTimeout(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
columnSizingStorageKey,
|
||||
JSON.stringify(columnSizing)
|
||||
)
|
||||
} catch {
|
||||
// Storage can be unavailable in private mode; table controls still work.
|
||||
} finally {
|
||||
columnSizingPersistTimerRef.current = undefined
|
||||
}
|
||||
}, COLUMN_SIZING_PERSIST_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
if (columnSizingPersistTimerRef.current !== undefined) {
|
||||
window.clearTimeout(columnSizingPersistTimerRef.current)
|
||||
columnSizingPersistTimerRef.current = undefined
|
||||
}
|
||||
}
|
||||
}, [columnSizing, columnSizingStorageKey])
|
||||
|
||||
return {
|
||||
table,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ColumnFiltersState, OnChangeFn } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
type UseDebouncedColumnFilterOptions = {
|
||||
columnFilters: ColumnFiltersState
|
||||
columnId: string
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId,
|
||||
onColumnFiltersChange,
|
||||
delay = 500,
|
||||
}: UseDebouncedColumnFilterOptions) {
|
||||
const value =
|
||||
(columnFilters.find((filter) => filter.id === columnId)?.value as
|
||||
| string
|
||||
| undefined) ?? ''
|
||||
const [inputValue, setInputValue] = React.useState(value)
|
||||
const [pendingValue, setPendingValue] = React.useState(value)
|
||||
const isComposingRef = React.useRef(false)
|
||||
const debouncedValue = useDebounce(pendingValue, delay)
|
||||
const onColumnFiltersChangeRef = React.useRef(onColumnFiltersChange)
|
||||
onColumnFiltersChangeRef.current = onColumnFiltersChange
|
||||
|
||||
React.useEffect(() => {
|
||||
// Keep the input aligned when URL state changes outside the local field.
|
||||
if (!isComposingRef.current) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setInputValue(value)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPendingValue(value)
|
||||
}, [value])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (debouncedValue === value) return
|
||||
|
||||
onColumnFiltersChangeRef.current((previous) => {
|
||||
const filters = previous.filter((filter) => filter.id !== columnId)
|
||||
return debouncedValue
|
||||
? [...filters, { id: columnId, value: debouncedValue }]
|
||||
: filters
|
||||
})
|
||||
}, [columnId, debouncedValue, value])
|
||||
|
||||
const updateInputValue = React.useCallback((nextValue: string) => {
|
||||
setInputValue(nextValue)
|
||||
|
||||
if (!isComposingRef.current) {
|
||||
setPendingValue(nextValue)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateInputValue(event.target.value)
|
||||
},
|
||||
[updateInputValue]
|
||||
)
|
||||
|
||||
const handleCompositionStart = React.useCallback(() => {
|
||||
isComposingRef.current = true
|
||||
}, [])
|
||||
|
||||
const handleCompositionEnd = React.useCallback(
|
||||
(event: React.CompositionEvent<HTMLInputElement>) => {
|
||||
isComposingRef.current = false
|
||||
const nextValue = event.currentTarget.value
|
||||
setInputValue(nextValue)
|
||||
setPendingValue(nextValue)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resetInput = React.useCallback(() => {
|
||||
isComposingRef.current = false
|
||||
setInputValue('')
|
||||
setPendingValue('')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
value,
|
||||
inputValue,
|
||||
setInputValue: updateInputValue,
|
||||
onChange: handleChange,
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
resetInput,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export { DataTablePagination } from './core/pagination'
|
||||
export { DataTableColumnHeader } from './core/column-header'
|
||||
export { BadgeCell } from './core/badge-cell'
|
||||
export { BadgeListCell } from './core/badge-list-cell'
|
||||
export { TruncatedCell } from './core/truncated-cell'
|
||||
export { DataTableViewOptions } from './toolbar/view-options'
|
||||
export { DataTableToolbar } from './toolbar/toolbar'
|
||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||
export {
|
||||
StaticDataTable,
|
||||
type StaticDataTableColumn,
|
||||
} from './static/static-data-table'
|
||||
export { StaticRowActions } from './static/static-row-actions'
|
||||
export { staticDataTableClassNames } from './static/static-data-table-classnames'
|
||||
export {
|
||||
DataTableRow,
|
||||
DataTableRowActionMenu,
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from './core/data-table-view'
|
||||
export { MobileCardList } from './layout/mobile-card-list'
|
||||
export {
|
||||
DataTableCardGrid,
|
||||
type DataTableCardGridProps,
|
||||
type DataTableCardHelpers,
|
||||
} from './layout/card-grid'
|
||||
export { CardRowContent } from './layout/card-row-content'
|
||||
export { tableHasCompactMeta } from './layout/card-cell-utils'
|
||||
export {
|
||||
DataTablePage,
|
||||
type DataTablePageProps,
|
||||
} from './layout/data-table-page'
|
||||
export {
|
||||
DataTableViewModeToggle,
|
||||
type DataTableViewModeToggleProps,
|
||||
} from './toolbar/view-mode-toggle'
|
||||
export { useDataTable } from './hooks/use-data-table'
|
||||
export {
|
||||
useDataTableViewMode,
|
||||
DATA_TABLE_VIEW_MODES,
|
||||
type DataTableViewMode,
|
||||
} from './hooks/use-data-table-view-mode'
|
||||
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
|
||||
|
||||
export const DISABLED_ROW_DESKTOP =
|
||||
'[--data-table-card-bg:var(--table-disabled)] hover:[--data-table-card-bg:var(--table-disabled-hover)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] data-[state=selected]:hover:![--data-table-card-bg:var(--table-disabled-hover)] [background-color:var(--table-disabled)] hover:[background-color:var(--table-disabled-hover)] [&>td:first-child]:[border-left-color:var(--table-disabled-border)] [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
|
||||
|
||||
export const DISABLED_ROW_MOBILE =
|
||||
'[--data-table-card-bg:var(--table-disabled)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] [background-color:var(--table-disabled)]'
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { flexRender, type Cell, type Table } from '@tanstack/react-table'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Shared cell helpers for the column-meta-driven card content used by both the
|
||||
* mobile list and the desktop card grid. Kept separate from the card content
|
||||
* component so the module exports only non-component utilities.
|
||||
*/
|
||||
|
||||
export function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null {
|
||||
const { header, meta } = cell.column.columnDef
|
||||
if (typeof header === 'string') {
|
||||
return header
|
||||
}
|
||||
if (meta?.label) {
|
||||
return meta.label
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderCellContent<TData>(
|
||||
cell: Cell<TData, unknown>
|
||||
): ReactNode {
|
||||
const cellRenderer = cell.column.columnDef.cell
|
||||
if (cellRenderer) {
|
||||
return flexRender(cellRenderer, cell.getContext())
|
||||
}
|
||||
return cell.getValue() as ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any visible column declares `mobileTitle`/`mobileBadge` meta. When
|
||||
* true the compact two-tier layout is used; otherwise the condensed
|
||||
* label:value fallback layout is used.
|
||||
*/
|
||||
export function tableHasCompactMeta<TData>(table: Table<TData>): boolean {
|
||||
return table.getVisibleLeafColumns().some((col) => {
|
||||
const meta = col.columnDef.meta
|
||||
return Boolean(meta?.mobileTitle || meta?.mobileBadge)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Row, Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { tableHasCompactMeta } from './card-cell-utils'
|
||||
import { CardRowContent } from './card-row-content'
|
||||
|
||||
/** Helpers passed to a custom {@link DataTableCardGridProps.renderCard}. */
|
||||
export type DataTableCardHelpers = {
|
||||
/**
|
||||
* Whether the table declares compact card meta (`mobileTitle`/`mobileBadge`).
|
||||
* Provided so custom renderers can match the default layout decision.
|
||||
*/
|
||||
compact: boolean
|
||||
/**
|
||||
* Row selection state captured before entering memoized custom card renderers.
|
||||
*/
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
export interface DataTableCardGridProps<TData> {
|
||||
table: Table<TData>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
/**
|
||||
* Custom card renderer. When omitted, cards render generically from the
|
||||
* column definitions via {@link CardRowContent} (driven by column meta).
|
||||
*/
|
||||
renderCard?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableCardHelpers
|
||||
) => React.ReactNode
|
||||
/**
|
||||
* Responsive grid className override. Defaults to a 1/2/3-column grid.
|
||||
*/
|
||||
gridClassName?: string
|
||||
/** Stable key prefix for skeleton cards. */
|
||||
skeletonKeyPrefix?: string
|
||||
}
|
||||
|
||||
const DEFAULT_GRID_CLASSNAME =
|
||||
'grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3'
|
||||
|
||||
function CardGridSkeleton(props: {
|
||||
gridClassName?: string
|
||||
keyPrefix?: string
|
||||
}) {
|
||||
const prefix = props.keyPrefix ?? 'card-skeleton'
|
||||
return (
|
||||
<div className={props.gridClassName ?? DEFAULT_GRID_CLASSNAME}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={`${prefix}-${i}`}
|
||||
className='space-y-3 rounded-lg border bg-(--table-row) p-3'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-5 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-x-3 gap-y-1.5'>
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<div key={j}>
|
||||
<Skeleton className='mb-1 h-2 w-8' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop card view for table data — a responsive grid of bordered cards.
|
||||
*
|
||||
* Renders the same per-row content as {@link MobileCardList} (via
|
||||
* {@link CardRowContent}) unless a custom `renderCard` is supplied. This keeps
|
||||
* the card view reusable across any table with zero per-feature work while
|
||||
* still allowing a bespoke card design when desired.
|
||||
*
|
||||
* The default generic card omits the `select` column. Custom `renderCard`
|
||||
* implementations can use `helpers.isSelected` to keep selection UI in sync.
|
||||
*/
|
||||
export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedEmptyTitle = props.emptyTitle ?? t('No Data')
|
||||
const resolvedEmptyDescription =
|
||||
props.emptyDescription ?? t('No data available')
|
||||
|
||||
const visibleColumns = props.table.getVisibleLeafColumns()
|
||||
const compact = React.useMemo(
|
||||
() => tableHasCompactMeta(props.table),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[visibleColumns]
|
||||
)
|
||||
|
||||
if (props.isLoading) {
|
||||
return (
|
||||
<CardGridSkeleton
|
||||
gridClassName={props.gridClassName}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const rows = props.table.getRowModel().rows
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
{props.emptyIcon ?? <Database className='size-6' />}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={props.gridClassName ?? DEFAULT_GRID_CLASSNAME}>
|
||||
{rows.map((row) => {
|
||||
const key = props.getRowKey ? props.getRowKey(row) : row.id
|
||||
const isSelected = row.getIsSelected()
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-slot='data-table-card'
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className={cn(
|
||||
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3 py-2.5 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
|
||||
props.getRowClassName?.(row)
|
||||
)}
|
||||
>
|
||||
{props.renderCard ? (
|
||||
props.renderCard(row, { compact, isSelected })
|
||||
) : (
|
||||
<CardRowContent row={row} compact={compact} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Cell, Row } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { StatusBadgeTypeContext } from '@/components/status-badge'
|
||||
|
||||
import { getCellLabel, renderCellContent } from './card-cell-utils'
|
||||
|
||||
function orderCardCells<TData>(
|
||||
cells: Cell<TData, unknown>[]
|
||||
): Cell<TData, unknown>[] {
|
||||
return [...cells].sort((a, b) => {
|
||||
const aOrder = a.column.columnDef.meta?.mobileOrder
|
||||
const bOrder = b.column.columnDef.meta?.mobileOrder
|
||||
|
||||
if (aOrder == null && bOrder == null) return 0
|
||||
if (aOrder == null) return 1
|
||||
if (bOrder == null) return -1
|
||||
return aOrder - bOrder
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared, column-meta-driven card content rendering for TanStack rows.
|
||||
*
|
||||
* Both {@link MobileCardList} (mobile) and {@link DataTableCardGrid} (desktop
|
||||
* card view) render the same inner content; only the surrounding container
|
||||
* differs (single bordered list vs. responsive grid of cards). Keeping the
|
||||
* per-row content here guarantees the two stay visually consistent.
|
||||
*
|
||||
* Column meta extensions (see `card-cell-utils.ts`):
|
||||
* - `mobileTitle` — card header (left, larger text)
|
||||
* - `mobileBadge` — inline with title (right, e.g. status badge)
|
||||
* - `mobileHidden` — hidden in card content
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compact content — structured layout with title header + side-by-side fields.
|
||||
* Used when columns define mobileTitle or mobileBadge meta.
|
||||
*
|
||||
* Visual structure:
|
||||
* [Title content] [Badge]
|
||||
* [Field1 label] [Field2 label]
|
||||
* [Field1 value] [Field2 value]
|
||||
* [Actions ⋯]
|
||||
*/
|
||||
function CompactContent<TData>({ row }: { row: Row<TData> }) {
|
||||
const allCells = row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
// Read each cell's meta once, then reuse for all categorisation checks.
|
||||
const cellMetas = React.useMemo(
|
||||
() => allCells.map((c) => c.column.columnDef.meta),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allCells.map((c) => c.id).join(',')]
|
||||
)
|
||||
|
||||
const titleCell = allCells.find((_, i) => cellMetas[i]?.mobileTitle)
|
||||
const badgeCell = allCells.find((_, i) => cellMetas[i]?.mobileBadge)
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const fieldCells = orderCardCells(
|
||||
allCells.filter(
|
||||
(c, i) =>
|
||||
c !== titleCell &&
|
||||
c !== badgeCell &&
|
||||
c !== actionsCell &&
|
||||
!cellMetas[i]?.mobileHidden
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Row 1: Title + Badge */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
{titleCell && (
|
||||
<div className='min-w-0 flex-1 text-sm font-medium [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:whitespace-normal'>
|
||||
{renderCellContent(titleCell)}
|
||||
</div>
|
||||
)}
|
||||
{badgeCell && (
|
||||
<div className='flex-none [&_[data-slot=status-badge]]:max-w-none'>
|
||||
{renderCellContent(badgeCell)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Key fields wrap into compact columns instead of squeezing */}
|
||||
{fieldCells.length > 0 && (
|
||||
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-1.5'>
|
||||
{fieldCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
return (
|
||||
<div key={cell.id} className='min-w-0 flex-1 overflow-hidden'>
|
||||
{label && (
|
||||
<div className='text-muted-foreground mb-0.5 text-[10px] leading-none select-none'>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 overflow-hidden text-xs [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{actionsCell && (
|
||||
<div className='mt-1 -mb-0.5 flex justify-end'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback content — condensed label:value pairs for tables without
|
||||
* mobileTitle/mobileBadge. Still respects mobileHidden.
|
||||
*/
|
||||
function FallbackContent<TData>({ row }: { row: Row<TData> }) {
|
||||
const allCells = row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
const cellMetas = React.useMemo(
|
||||
() => allCells.map((c) => c.column.columnDef.meta),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allCells.map((c) => c.id).join(',')]
|
||||
)
|
||||
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const contentCells = orderCardCells(
|
||||
allCells.filter(
|
||||
(c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className='flex justify-end overflow-hidden [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'
|
||||
>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell)}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className='flex items-start justify-between gap-2 overflow-hidden'
|
||||
>
|
||||
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
|
||||
{label}
|
||||
</span>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{actionsCell && (
|
||||
<div className='-mb-0.5 flex justify-end pt-0.5'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single row's card content, auto-selecting the compact or fallback
|
||||
* layout. Callers compute `compact` once per table (via `tableHasCompactMeta`)
|
||||
* and pass it down to avoid recomputation per row.
|
||||
*/
|
||||
export function CardRowContent<TData>(props: {
|
||||
row: Row<TData>
|
||||
compact: boolean
|
||||
}) {
|
||||
return props.compact ? (
|
||||
<CompactContent row={props.row} />
|
||||
) : (
|
||||
<FallbackContent row={props.row} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type {
|
||||
ColumnDef,
|
||||
Row,
|
||||
Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import { PageFooterPortal } from '@/components/layout/components/page-footer'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from '../core/data-table-view'
|
||||
import { DataTablePagination } from '../core/pagination'
|
||||
import {
|
||||
DATA_TABLE_VIEW_MODES,
|
||||
useDataTableViewMode,
|
||||
type DataTableViewMode,
|
||||
} from '../hooks/use-data-table-view-mode'
|
||||
import { DataTableToolbar } from '../toolbar/toolbar'
|
||||
import { DataTableViewModeToggle } from '../toolbar/view-mode-toggle'
|
||||
import { DataTableCardGrid } from './card-grid'
|
||||
import { MobileCardList } from './mobile-card-list'
|
||||
|
||||
/**
|
||||
* Pass-through configuration for the default {@link DataTableToolbar}.
|
||||
* Pass `toolbar` (ReactNode) instead to fully replace the default toolbar.
|
||||
*/
|
||||
export type DataTablePageToolbarProps<TData> = Omit<
|
||||
React.ComponentProps<typeof DataTableToolbar<TData>>,
|
||||
'table'
|
||||
>
|
||||
|
||||
export type DataTablePageProps<TData> = {
|
||||
/**
|
||||
* TanStack Table instance returned from `useReactTable`.
|
||||
*/
|
||||
table: TanstackTable<TData>
|
||||
|
||||
/**
|
||||
* Column definitions. Used for skeleton column count and empty-state colSpan.
|
||||
*/
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
|
||||
/**
|
||||
* Initial loading state — renders {@link TableSkeleton} or mobile skeleton.
|
||||
*/
|
||||
isLoading?: boolean
|
||||
|
||||
/**
|
||||
* Refetch / background loading — dims the table without removing rows.
|
||||
*/
|
||||
isFetching?: boolean
|
||||
|
||||
/**
|
||||
* Empty-state title (used for both desktop {@link TableEmpty} and mobile fallback).
|
||||
*/
|
||||
emptyTitle?: string
|
||||
|
||||
/**
|
||||
* Empty-state description.
|
||||
*/
|
||||
emptyDescription?: string
|
||||
|
||||
/**
|
||||
* Empty-state icon override (desktop only; mobile uses default Database icon).
|
||||
*/
|
||||
emptyIcon?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Empty-state extra content — e.g. a "Create" button below the message.
|
||||
*/
|
||||
emptyAction?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Custom toolbar node — fully replaces the default {@link DataTableToolbar}.
|
||||
* Useful for layouts like "primary buttons + toolbar" or feature-specific filter cards.
|
||||
* If provided, `toolbarProps` is ignored.
|
||||
*/
|
||||
toolbar?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Pass-through props for the default {@link DataTableToolbar}.
|
||||
* Ignored if `toolbar` is provided. Pass `null` to omit the toolbar entirely.
|
||||
*/
|
||||
toolbarProps?: DataTablePageToolbarProps<TData> | null
|
||||
|
||||
/**
|
||||
* Bulk action bar — typically a wrapped {@link DataTableBulkActions} component.
|
||||
* Rendered only on desktop (mobile selection is uncommon).
|
||||
*/
|
||||
bulkActions?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Custom mobile list node — fully replaces the default {@link MobileCardList}.
|
||||
*/
|
||||
mobile?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Pass-through props for the default {@link MobileCardList}.
|
||||
* Ignored if `mobile` is provided.
|
||||
*/
|
||||
mobileProps?: {
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the mobile-specific layout entirely — always renders desktop table.
|
||||
* Useful for pages where the table is read-only and short.
|
||||
*/
|
||||
hideMobile?: boolean
|
||||
|
||||
/**
|
||||
* Row className resolver — applied to both desktop `TableRow` and mobile card.
|
||||
* Composes with the default `data-state="selected"` styling on desktop.
|
||||
* The `ctx.isMobile` flag is provided so consumers can return the
|
||||
* appropriate variant (e.g. `DISABLED_ROW_DESKTOP` vs `DISABLED_ROW_MOBILE`)
|
||||
* without having to re-call `useMediaQuery` themselves.
|
||||
*/
|
||||
getRowClassName?: (
|
||||
row: Row<TData>,
|
||||
ctx: { isMobile: boolean }
|
||||
) => string | undefined
|
||||
|
||||
/**
|
||||
* Custom desktop row renderer — replaces the default `<TableRow>`/`<TableCell>` mapping.
|
||||
* Use for expanded rows, aggregate rows, click-on-row navigation, etc.
|
||||
*/
|
||||
renderRow?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableRenderRowHelpers
|
||||
) => React.ReactNode
|
||||
|
||||
/**
|
||||
* Desktop column className resolver. Use for semantic alignment/spacing only;
|
||||
* fixed-column behavior should be configured with `pinnedColumns`.
|
||||
*/
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
|
||||
/**
|
||||
* Fixed desktop columns. The shared table component owns sticky position,
|
||||
* layering, shadows, and row-state backgrounds.
|
||||
*/
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
|
||||
/**
|
||||
* Apply explicit column widths from `header.getSize()` to `<TableHead>`.
|
||||
* Enable this when your column definitions include `size` and you want it honored.
|
||||
* Off by default (TanStack Table assigns a default size of 150 to all columns
|
||||
* which would unintentionally constrain layouts that don't define sizes).
|
||||
*/
|
||||
applyHeaderSize?: boolean
|
||||
|
||||
/**
|
||||
* Optional skeleton key prefix for stable React keys across re-renders.
|
||||
*/
|
||||
skeletonKeyPrefix?: string
|
||||
|
||||
/**
|
||||
* Whether to render pagination. Defaults to `true`.
|
||||
*/
|
||||
showPagination?: boolean
|
||||
|
||||
/**
|
||||
* Render pagination via `PageFooterPortal` (sticks to page footer).
|
||||
* Defaults to `true`. Set `false` to render inline below the table.
|
||||
*/
|
||||
paginationInFooter?: boolean
|
||||
|
||||
/**
|
||||
* Extra content rendered between the table/mobile list and the pagination.
|
||||
* E.g. summary stats, helper text.
|
||||
*/
|
||||
afterTable?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Outer wrapper className (applied to the toolbar+table column).
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* Make the desktop table consume the available page height and scroll inside
|
||||
* the table body while keeping the header fixed. Defaults to `true`.
|
||||
*/
|
||||
fixedHeight?: boolean
|
||||
|
||||
/**
|
||||
* Desktop table container className (the bordered scroll wrapper).
|
||||
*/
|
||||
tableClassName?: string
|
||||
|
||||
/**
|
||||
* Desktop `<TableHeader>` className override.
|
||||
* Use for header color/spacing overrides. Fixed-height pages keep the header
|
||||
* outside the scrollable body automatically.
|
||||
*/
|
||||
tableHeaderClassName?: string
|
||||
|
||||
/**
|
||||
* Opt into the table/card view toggle. Defaults to `false`, so existing
|
||||
* pages render the table only and behave exactly as before. When enabled, a
|
||||
* {@link DataTableViewModeToggle} is injected into the default toolbar
|
||||
* (requires `toolbarProps`; ignored when a fully custom `toolbar` is used)
|
||||
* and the view switches between the table and a card grid on desktop and
|
||||
* mobile. Mobile card mode reuses the same card renderer in a single column.
|
||||
*/
|
||||
enableCardView?: boolean
|
||||
|
||||
/**
|
||||
* Controlled view mode. When provided, `onViewModeChange` should update it.
|
||||
* Leave unset to let the page manage view mode internally (optionally
|
||||
* persisted via `viewModeStorageKey`).
|
||||
*/
|
||||
viewMode?: DataTableViewMode
|
||||
|
||||
/**
|
||||
* Change handler for the controlled `viewMode`.
|
||||
*/
|
||||
onViewModeChange?: (mode: DataTableViewMode) => void
|
||||
|
||||
/**
|
||||
* localStorage key for persisting the (uncontrolled) view mode per table.
|
||||
* Ignored when `viewMode` is controlled.
|
||||
*/
|
||||
viewModeStorageKey?: string
|
||||
|
||||
/**
|
||||
* Initial (uncontrolled) view mode. When unset, defaults to `'card'` if
|
||||
* `enableCardView` is `true`, otherwise `'table'`. A persisted selection
|
||||
* (via `viewModeStorageKey`) always takes precedence over this default.
|
||||
*/
|
||||
defaultViewMode?: DataTableViewMode
|
||||
|
||||
/**
|
||||
* Custom card renderer for card view. When omitted, cards are generated
|
||||
* generically from the column definitions (driven by column meta).
|
||||
*/
|
||||
renderCard?: React.ComponentProps<
|
||||
typeof DataTableCardGrid<TData>
|
||||
>['renderCard']
|
||||
|
||||
/**
|
||||
* Responsive grid className override for the card view.
|
||||
*/
|
||||
cardGridClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified table page wrapper. Encapsulates the canonical structure used across
|
||||
* all list pages: toolbar → desktop table / mobile list → pagination, plus
|
||||
* loading/empty states and an opt-in bulk action bar.
|
||||
*
|
||||
* Most pages should be expressible as:
|
||||
* ```tsx
|
||||
* <DataTablePage
|
||||
* table={table}
|
||||
* columns={columns}
|
||||
* isLoading={isLoading}
|
||||
* isFetching={isFetching}
|
||||
* emptyTitle={t('No X Found')}
|
||||
* toolbarProps={{ searchPlaceholder: t('Filter...'), filters }}
|
||||
* bulkActions={<MyBulkActions table={table} />}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* For complex layouts (custom mobile, expanded rows, custom toolbar), use the
|
||||
* `toolbar` / `mobile` / `renderRow` slots instead of the `*Props` variants.
|
||||
*/
|
||||
export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const showMobile = isMobile && !props.hideMobile
|
||||
|
||||
const [internalViewMode, setInternalViewMode] = useDataTableViewMode({
|
||||
storageKey: props.viewModeStorageKey,
|
||||
// When card view is enabled, prefer it as the default unless the consumer
|
||||
// explicitly opts into a different initial mode. A persisted choice (via
|
||||
// `viewModeStorageKey`) still takes precedence over this default.
|
||||
defaultMode:
|
||||
props.defaultViewMode ??
|
||||
(props.enableCardView ? DATA_TABLE_VIEW_MODES.CARD : undefined),
|
||||
})
|
||||
const viewMode = props.viewMode ?? internalViewMode
|
||||
const setViewMode = props.onViewModeChange ?? setInternalViewMode
|
||||
const cardViewActive = !!props.enableCardView
|
||||
|
||||
const viewToggle = cardViewActive ? (
|
||||
<DataTableViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
) : undefined
|
||||
|
||||
const toolbarNode = renderToolbar(props, viewToggle)
|
||||
const mobileNode = renderMobile(props, showMobile, cardViewActive, viewMode)
|
||||
const desktopNode = renderDesktop(props, showMobile, cardViewActive, viewMode)
|
||||
const paginationNode = renderPagination(props)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
props.fixedHeight !== false
|
||||
? 'flex h-full min-h-0 flex-col gap-2.5 sm:gap-3'
|
||||
: 'space-y-2.5 sm:space-y-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{toolbarNode}
|
||||
{mobileNode}
|
||||
{desktopNode}
|
||||
{props.afterTable}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions are typically a fixed-position toolbar; let the consumer
|
||||
handle its own visibility, we just gate it to non-mobile. */}
|
||||
{!showMobile && props.bulkActions}
|
||||
|
||||
{paginationNode}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderToolbar<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
viewToggle: React.ReactNode
|
||||
): React.ReactNode {
|
||||
if (props.toolbar !== undefined) {
|
||||
// Fully custom toolbar: the consumer owns layout, including any toggle.
|
||||
return props.toolbar
|
||||
}
|
||||
if (props.toolbarProps === null) {
|
||||
return null
|
||||
}
|
||||
if (props.toolbarProps) {
|
||||
return (
|
||||
<DataTableToolbar
|
||||
table={props.table}
|
||||
{...props.toolbarProps}
|
||||
viewToggle={props.toolbarProps.viewToggle ?? viewToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function renderPagination<TData>(
|
||||
props: DataTablePageProps<TData>
|
||||
): React.ReactNode {
|
||||
if (props.showPagination === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const pagination = <DataTablePagination table={props.table} />
|
||||
|
||||
return props.paginationInFooter !== false ? (
|
||||
<PageFooterPortal>{pagination}</PageFooterPortal>
|
||||
) : (
|
||||
<div className='pt-2'>{pagination}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMobile<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
showMobile: boolean,
|
||||
cardViewActive: boolean,
|
||||
viewMode: DataTableViewMode
|
||||
): React.ReactNode {
|
||||
if (!showMobile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isFetchingOnly = props.isFetching && !props.isLoading
|
||||
const ownGetRowClassName = props.getRowClassName
|
||||
const mobileGetRowClassName =
|
||||
props.mobileProps?.getRowClassName ??
|
||||
(ownGetRowClassName
|
||||
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
|
||||
: undefined)
|
||||
|
||||
let mobileContent = props.mobile
|
||||
if (mobileContent === undefined) {
|
||||
if (cardViewActive && viewMode === DATA_TABLE_VIEW_MODES.TABLE) {
|
||||
mobileContent = (
|
||||
<DataTableView
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
renderRow={props.renderRow}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
tableHeaderClassName={cn(
|
||||
'[background-color:var(--table-header)]',
|
||||
props.tableHeaderClassName
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
pinnedColumns={props.pinnedColumns}
|
||||
containerClassName={cn(
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60',
|
||||
props.tableClassName
|
||||
)}
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
/>
|
||||
)
|
||||
} else if (cardViewActive) {
|
||||
mobileContent = (
|
||||
<DataTableCardGrid
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
renderCard={props.renderCard}
|
||||
gridClassName={props.cardGridClassName ?? 'grid grid-cols-1 gap-3'}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
getRowKey={props.mobileProps?.getRowKey}
|
||||
getRowClassName={mobileGetRowClassName}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
mobileContent = (
|
||||
<MobileCardList
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
getRowKey={props.mobileProps?.getRowKey}
|
||||
getRowClassName={mobileGetRowClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
|
||||
}
|
||||
|
||||
function renderDesktop<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
showMobile: boolean,
|
||||
cardViewActive: boolean,
|
||||
viewMode: DataTableViewMode
|
||||
): React.ReactNode {
|
||||
if (showMobile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isFetchingOnly = props.isFetching && !props.isLoading
|
||||
const fixedHeight = props.fixedHeight !== false
|
||||
|
||||
if (cardViewActive && viewMode === DATA_TABLE_VIEW_MODES.CARD) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
fixedHeight && 'min-h-0 flex-1 overflow-y-auto',
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<DataTableCardGrid
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
renderCard={props.renderCard}
|
||||
gridClassName={props.cardGridClassName}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTableView
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
renderRow={props.renderRow}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
splitHeader={fixedHeight}
|
||||
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
|
||||
tableHeaderClassName={cn(
|
||||
fixedHeight && '[background-color:var(--table-header)]',
|
||||
props.tableHeaderClassName
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
pinnedColumns={props.pinnedColumns}
|
||||
containerClassName={cn(
|
||||
fixedHeight && 'min-h-0 flex-1',
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60',
|
||||
props.tableClassName
|
||||
)}
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Row, Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { tableHasCompactMeta } from './card-cell-utils'
|
||||
import { CardRowContent } from './card-row-content'
|
||||
|
||||
interface MobileCardListProps<TData> {
|
||||
table: Table<TData>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='px-3 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-5 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='mt-1.5 grid grid-cols-2 gap-2'>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='mb-1 h-2 w-8' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='mb-1 h-2 w-8' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FallbackListSkeleton() {
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='space-y-1.5 px-3 py-2.5'>
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className='flex items-center justify-between'>
|
||||
<Skeleton className='h-2.5 w-16' />
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-optimized list view for table data.
|
||||
*
|
||||
* Renders rows inside a single bordered container with dividers —
|
||||
* a Vercel/Stripe-style list rather than individual cards.
|
||||
*
|
||||
* Per-row content is shared with the desktop card view via
|
||||
* {@link CardRowContent}; see `card-row-content.tsx` for the column-meta
|
||||
* extensions (`mobileTitle`, `mobileBadge`, `mobileHidden`).
|
||||
*/
|
||||
export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
const {
|
||||
table,
|
||||
isLoading = false,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
getRowKey,
|
||||
getRowClassName,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedEmptyTitle = emptyTitle ?? t('No Data')
|
||||
const resolvedEmptyDescription = emptyDescription ?? t('No data available')
|
||||
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
const hasCompactMeta = React.useMemo(
|
||||
() => tableHasCompactMeta(table),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[visibleColumns]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return hasCompactMeta ? <ListSkeleton /> : <FallbackListSkeleton />
|
||||
}
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const key = getRowKey ? getRowKey(row) : row.id
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3 py-2.5',
|
||||
getRowClassName?.(row)
|
||||
)}
|
||||
>
|
||||
<CardRowContent row={row} compact={hasCompactMeta} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export const staticDataTableClassNames = {
|
||||
container: 'overflow-hidden rounded-md border',
|
||||
sectionContainer: 'border-border/60 rounded-lg',
|
||||
embeddedContainer: 'rounded-none border-0',
|
||||
compactTable: 'text-sm',
|
||||
compactHeaderRow: 'hover:bg-transparent',
|
||||
mutedHeaderRow:
|
||||
'[background-color:var(--table-header)] hover:[background-color:var(--table-header-hover)]',
|
||||
compactHeaderCell:
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
|
||||
compactHeaderCellRight:
|
||||
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
|
||||
compactCell: 'py-2.5',
|
||||
compactTopCell: 'py-2.5 align-top',
|
||||
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
|
||||
compactMutedCell: 'text-muted-foreground py-2.5',
|
||||
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
|
||||
compactNumericCell: 'py-2.5 text-right font-mono',
|
||||
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
|
||||
topCell: 'py-2 align-top',
|
||||
topMutedCell: 'text-muted-foreground py-2 align-top',
|
||||
codeCell: 'font-mono text-sm',
|
||||
mutedCell: 'text-muted-foreground text-sm',
|
||||
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
|
||||
topNumericCell: 'py-2 text-right font-mono',
|
||||
mediumCell: 'font-medium',
|
||||
actionHeaderCell: 'w-auto max-w-none text-right',
|
||||
actionCell: 'w-auto max-w-none text-right',
|
||||
} as const
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TruncatedCell } from '../core/truncated-cell'
|
||||
import { staticDataTableClassNames } from './static-data-table-classnames'
|
||||
|
||||
type StaticDataTableBaseProps = {
|
||||
className?: string
|
||||
tableClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableProps?: Omit<
|
||||
React.ComponentProps<typeof Table>,
|
||||
'className' | 'children'
|
||||
>
|
||||
}
|
||||
|
||||
type StaticDataTableDataProps<TData = unknown> = StaticDataTableBaseProps & {
|
||||
columns: StaticDataTableColumn<TData>[]
|
||||
data: TData[]
|
||||
getRowKey?: (row: TData, index: number) => React.Key
|
||||
getRowClassName?: (row: TData, index: number) => string | undefined
|
||||
renderRow?: (row: TData, index: number) => React.ReactNode
|
||||
empty?: boolean
|
||||
emptyContent?: React.ReactNode
|
||||
emptyClassName?: string
|
||||
headerRowClassName?: string
|
||||
}
|
||||
|
||||
type StaticDataTableChildrenProps = StaticDataTableBaseProps & {
|
||||
children: React.ReactNode
|
||||
columns?: never
|
||||
data?: never
|
||||
}
|
||||
|
||||
type StaticDataTableProps<TData = unknown> =
|
||||
| StaticDataTableDataProps<TData>
|
||||
| StaticDataTableChildrenProps
|
||||
|
||||
export type StaticDataTableColumn<TData = unknown> = {
|
||||
id: string
|
||||
header: React.ReactNode
|
||||
className?: string
|
||||
cellClassName?: string | ((row: TData, index: number) => string | undefined)
|
||||
cell?: (row: TData, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
export function StaticDataTable<TData = unknown>(
|
||||
props: StaticDataTableProps<TData>
|
||||
) {
|
||||
const { className, tableClassName, containerProps, tableProps } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(staticDataTableClassNames.container, className)}
|
||||
{...containerProps}
|
||||
>
|
||||
<Table className={tableClassName} {...tableProps}>
|
||||
{props.columns !== undefined ? (
|
||||
<StaticDataTableWithColumns {...props} />
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDataTableWithColumns<TData>({
|
||||
columns,
|
||||
data,
|
||||
getRowKey,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
empty,
|
||||
emptyContent,
|
||||
emptyClassName,
|
||||
headerRowClassName,
|
||||
}: StaticDataTableDataProps<TData>) {
|
||||
const isEmpty = empty ?? (data !== undefined && data.length === 0)
|
||||
const bodyRows = data.map((row, index) => (
|
||||
<StaticDataTableRow
|
||||
key={getRowKey?.(row, index) ?? index}
|
||||
row={row}
|
||||
index={index}
|
||||
columns={columns}
|
||||
getRowClassName={getRowClassName}
|
||||
renderRow={renderRow}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeader>
|
||||
<TableRow className={headerRowClassName}>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.id} className={column.className}>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isEmpty ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={columns.length}
|
||||
className={emptyClassName}
|
||||
>
|
||||
{emptyContent}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
bodyRows
|
||||
)}
|
||||
</TableBody>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type StaticDataTableRowProps<TData> = Required<
|
||||
Pick<StaticDataTableDataProps<TData>, 'columns'>
|
||||
> &
|
||||
Pick<StaticDataTableDataProps<TData>, 'getRowClassName' | 'renderRow'> & {
|
||||
row: TData
|
||||
index: number
|
||||
}
|
||||
|
||||
function StaticDataTableRow<TData>({
|
||||
row,
|
||||
index,
|
||||
columns,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
}: StaticDataTableRowProps<TData>) {
|
||||
if (renderRow) {
|
||||
return <>{renderRow(row, index)}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow className={getRowClassName?.(row, index)}>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={cn(
|
||||
'max-w-full min-w-0 overflow-hidden',
|
||||
getStaticCellClassName(column, row, index)
|
||||
)}
|
||||
>
|
||||
{renderStaticCellContent(column, row, index)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStaticCellContent<TData>(
|
||||
column: StaticDataTableColumn<TData>,
|
||||
row: TData,
|
||||
index: number
|
||||
) {
|
||||
const content = column.cell?.(row, index)
|
||||
const textContent = getPrimitiveTextContent(content)
|
||||
|
||||
if (!textContent) return content
|
||||
|
||||
return <TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||
}
|
||||
|
||||
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||
if (typeof content === 'string' || typeof content === 'number') {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
if (
|
||||
React.isValidElement<{ children?: React.ReactNode }>(content) &&
|
||||
(typeof content.props.children === 'string' ||
|
||||
typeof content.props.children === 'number')
|
||||
) {
|
||||
return String(content.props.children)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getStaticCellClassName<TData>(
|
||||
column: StaticDataTableColumn<TData>,
|
||||
row: TData,
|
||||
index: number
|
||||
) {
|
||||
return typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row, index)
|
||||
: column.cellClassName
|
||||
}
|
||||
|
||||
type StaticDataTableEmptyRowProps = {
|
||||
colSpan: number
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function StaticDataTableEmptyRow({
|
||||
colSpan,
|
||||
children,
|
||||
className,
|
||||
}: StaticDataTableEmptyRowProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={colSpan}
|
||||
className={cn('h-24 text-center', className)}
|
||||
>
|
||||
{children}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
import { DataTableRowActionMenu } from '../core/row-action-menu'
|
||||
|
||||
type StaticRowActionsProps = {
|
||||
editLabel: string
|
||||
deleteLabel: string
|
||||
menuLabel: string
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
editDisabled?: boolean
|
||||
deleteDisabled?: boolean
|
||||
}
|
||||
|
||||
export function StaticRowActions(props: StaticRowActionsProps) {
|
||||
return (
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={props.onEdit}
|
||||
disabled={props.editDisabled}
|
||||
aria-label={props.editLabel}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<DataTableRowActionMenu ariaLabel={props.menuLabel}>
|
||||
<DropdownMenuItem
|
||||
onClick={props.onDelete}
|
||||
disabled={props.deleteDisabled}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{props.deleteLabel}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DataTableRowActionMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { X } from 'lucide-react'
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableBulkActionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
entityName: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A modular toolbar for displaying bulk actions when table rows are selected.
|
||||
*
|
||||
* @template TData The type of data in the table.
|
||||
* @param {object} props The component props.
|
||||
* @param {Table<TData>} props.table The react-table instance.
|
||||
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
|
||||
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
|
||||
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
|
||||
*/
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
entityName,
|
||||
children,
|
||||
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
|
||||
const { t } = useTranslation()
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedCount = selectedRows.length
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const buttonsRef = useRef<NodeListOf<HTMLButtonElement> | null>(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
useLayoutEffect(() => {
|
||||
buttonsRef.current = toolbarRef.current?.querySelectorAll('button') ?? null
|
||||
})
|
||||
|
||||
// Announce selection changes to screen readers
|
||||
useEffect(() => {
|
||||
if (selectedCount > 0) {
|
||||
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAnnouncement(message)
|
||||
|
||||
// Clear announcement after a delay
|
||||
const timer = setTimeout(() => setAnnouncement(''), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [selectedCount, entityName])
|
||||
|
||||
const handleClearSelection = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const buttons = buttonsRef.current
|
||||
if (!buttons) return
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
(button) => button === document.activeElement
|
||||
)
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault()
|
||||
const nextIndex = (currentIndex + 1) % buttons.length
|
||||
buttons[nextIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault()
|
||||
const prevIndex =
|
||||
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
|
||||
buttons[prevIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
buttons[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
buttons[buttons.length - 1]?.focus()
|
||||
break
|
||||
case 'Escape': {
|
||||
// Check if the Escape key came from a dropdown trigger or content
|
||||
// We can't check dropdown state because the menu closes before our handler runs.
|
||||
const target = event.target as HTMLElement
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
|
||||
// Check if the event target or currently focused element is a dropdown trigger
|
||||
const isFromDropdownTrigger =
|
||||
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
|
||||
activeElement?.getAttribute('data-slot') ===
|
||||
'dropdown-menu-trigger' ||
|
||||
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
|
||||
|
||||
// Check if the focused element is inside dropdown content (which is portaled)
|
||||
const isFromDropdownContent =
|
||||
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
|
||||
target?.closest('[data-slot="dropdown-menu-content"]')
|
||||
|
||||
if (isFromDropdownTrigger || isFromDropdownContent) {
|
||||
// Escape was meant for the dropdown - don't clear selection
|
||||
return
|
||||
}
|
||||
|
||||
// Escape was meant for the toolbar - clear selection
|
||||
event.preventDefault()
|
||||
handleClearSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Live region for screen reader announcements */}
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-atomic='true'
|
||||
className='sr-only'
|
||||
role='status'
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role='toolbar'
|
||||
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
|
||||
aria-describedby='bulk-actions-description'
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 shadow-xl',
|
||||
'rounded-xl border',
|
||||
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleClearSelection}
|
||||
className='size-6'
|
||||
aria-label={t('Clear selection')}
|
||||
title={t('Clear selection (Escape)')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X />
|
||||
<span className='sr-only'>{t('Clear selection')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Clear selection (Escape)')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
<div
|
||||
className='flex items-center gap-x-1 text-sm'
|
||||
id='bulk-actions-description'
|
||||
>
|
||||
<Badge
|
||||
variant='default'
|
||||
className='min-w-8 rounded-lg'
|
||||
aria-label={`${selectedCount} selected`}
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
<span className='hidden sm:inline'>
|
||||
{entityName}
|
||||
{selectedCount > 1 ? 's' : ''}
|
||||
</span>{' '}
|
||||
{t('selected')}
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { Check as CheckIcon, PlusCircle as PlusCircledIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableFacetedFilterProps<TData, TValue> = {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconNode?: React.ReactNode
|
||||
count?: number
|
||||
}[]
|
||||
/** Enable single select mode (only one option can be selected at a time) */
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
function DataTableFacetedFilterInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
singleSelect = false,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const { t } = useTranslation()
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const filterValue = column?.getFilterValue() as string[] | undefined
|
||||
const selectedValues = new Set(filterValue)
|
||||
|
||||
const handleOptionSelect = (optionValue: string) => {
|
||||
const nextSelectedValues = getNextSelectedValues(
|
||||
selectedValues,
|
||||
optionValue,
|
||||
singleSelect
|
||||
)
|
||||
|
||||
column?.setFilterValue(
|
||||
nextSelectedValues.length ? nextSelectedValues : undefined
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant='outline' size='sm' className='h-8 border-dashed' />
|
||||
}
|
||||
>
|
||||
<PlusCircledIcon className='size-4' />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation='vertical' className='mx-2 h-4' />
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal lg:hidden'
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className='hidden space-x-1 lg:flex'>
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{selectedValues.size} {t('selected')}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
key={option.value}
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{t(option.label)}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='max-w-[360px] min-w-[200px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('No results found.')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => handleOptionSelect(option.value)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'border-primary flex size-4 items-center justify-center rounded-sm border',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn('text-background h-4 w-4')} />
|
||||
</div>
|
||||
{option.iconNode ? (
|
||||
<span className='text-muted-foreground flex size-4 items-center justify-center'>
|
||||
{option.iconNode}
|
||||
</span>
|
||||
) : option.icon ? (
|
||||
<option.icon className='text-muted-foreground size-4' />
|
||||
) : null}
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate'
|
||||
title={t(option.label)}
|
||||
>
|
||||
{t(option.label)}
|
||||
</span>
|
||||
{typeof option.count === 'number' ? (
|
||||
<span className='text-muted-foreground ms-auto flex h-4 min-w-4 items-center justify-center font-mono text-xs'>
|
||||
{option.count}
|
||||
</span>
|
||||
) : facets?.get(option.value) ? (
|
||||
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className='justify-center text-center'
|
||||
>
|
||||
{t('Clear filters')}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export const DataTableFacetedFilter = React.memo(
|
||||
DataTableFacetedFilterInner
|
||||
) as typeof DataTableFacetedFilterInner
|
||||
|
||||
function getNextSelectedValues(
|
||||
selectedValues: Set<string>,
|
||||
optionValue: string,
|
||||
singleSelect: boolean
|
||||
): string[] {
|
||||
if (singleSelect) {
|
||||
return selectedValues.has(optionValue) ? [] : [optionValue]
|
||||
}
|
||||
|
||||
const nextSelectedValues = new Set(selectedValues)
|
||||
if (nextSelectedValues.has(optionValue)) {
|
||||
nextSelectedValues.delete(optionValue)
|
||||
} else {
|
||||
nextSelectedValues.add(optionValue)
|
||||
}
|
||||
|
||||
return Array.from(nextSelectedValues)
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DataTableFacetedFilter } from './faceted-filter'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
|
||||
type FilterDef = {
|
||||
columnId: string
|
||||
title: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconNode?: React.ReactNode
|
||||
count?: number
|
||||
}[]
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
type SearchDraft = {
|
||||
baseValue: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
/**
|
||||
* Placeholder for the default search input. Defaults to `t('Filter...')`.
|
||||
*/
|
||||
searchPlaceholder?: string
|
||||
/**
|
||||
* Delay committing the default search input. Defaults to immediate updates.
|
||||
*/
|
||||
searchDebounceMs?: number
|
||||
/**
|
||||
* Column id to filter on. When provided, the search input filters
|
||||
* a specific column. When omitted, the search input updates the
|
||||
* table's `globalFilter`.
|
||||
*/
|
||||
searchKey?: string
|
||||
/**
|
||||
* Column-level filter chips (faceted multi-select / single-select).
|
||||
*/
|
||||
filters?: FilterDef[]
|
||||
/**
|
||||
* Replaces the default search input entirely. Use when the primary
|
||||
* "search" is something custom — e.g. a date-time range picker.
|
||||
*/
|
||||
customSearch?: ReactNode
|
||||
/**
|
||||
* Extra inputs/selects displayed in the primary row alongside the
|
||||
* search input and filter chips.
|
||||
*/
|
||||
additionalSearch?: ReactNode
|
||||
/**
|
||||
* Whether non-table filters (e.g. `additionalSearch` or `expandable`
|
||||
* inputs) are currently active. Controls Reset button visibility
|
||||
* when no column filters are set.
|
||||
*/
|
||||
hasAdditionalFilters?: boolean
|
||||
/**
|
||||
* Callback invoked when the user clicks Reset.
|
||||
*/
|
||||
onReset?: () => void
|
||||
/**
|
||||
* Additional filter inputs hidden behind an Expand/Collapse toggle.
|
||||
* Inputs flow inline with the primary row when expanded.
|
||||
*/
|
||||
expandable?: ReactNode
|
||||
/**
|
||||
* When `expandable` is collapsed, highlights the toggle if any of
|
||||
* the expandable inputs currently hold a value.
|
||||
*/
|
||||
hasExpandedActiveFilters?: boolean
|
||||
/**
|
||||
* Custom action buttons rendered BEFORE the built-in
|
||||
* Reset / Search / View buttons.
|
||||
*/
|
||||
preActions?: ReactNode
|
||||
/**
|
||||
* Explicit "Search" / "Apply" callback. When provided the toolbar
|
||||
* shows a primary Search button. Filters are committed only on click
|
||||
* (form-mode workflow).
|
||||
*/
|
||||
onSearch?: () => void
|
||||
/**
|
||||
* Loading state for the explicit Search button.
|
||||
*/
|
||||
searchLoading?: boolean
|
||||
/**
|
||||
* Hide the View Options (column visibility) dropdown.
|
||||
*/
|
||||
hideViewOptions?: boolean
|
||||
/**
|
||||
* Optional view-mode toggle (e.g. table vs. card) rendered in the right
|
||||
* action cluster, before the View Options dropdown. Typically a
|
||||
* {@link DataTableViewModeToggle}. Omitted by default.
|
||||
*/
|
||||
viewToggle?: ReactNode
|
||||
/**
|
||||
* Content rendered on the LEFT side of the secondary action row. When
|
||||
* provided the toolbar splits into two visual rows:
|
||||
* Row 1: search inputs / filter chips …… Expand
|
||||
* Row 2: expanded filters
|
||||
* Row 3: leftActions …… Reset / Search / ViewOptions
|
||||
*/
|
||||
leftActions?: ReactNode
|
||||
/**
|
||||
* Outer wrapper className override.
|
||||
*/
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified data-table filter panel — Ant Design Pro inspired.
|
||||
*
|
||||
* Layout (single flex-wrap row):
|
||||
* - Filters (search input + additional inputs + filter chips + expandable
|
||||
* inputs) flow horizontally and wrap as needed.
|
||||
* - The action cluster (Reset / Search / View / Expand) hugs the right
|
||||
* edge via `ms-auto`. When filters fill a row, the cluster naturally
|
||||
* wraps to the next line — still right-aligned — matching the
|
||||
* collapsed/expanded states from the user's reference design.
|
||||
*
|
||||
* No background panel, no row separators — relies on whitespace and the
|
||||
* adjacent table border for visual hierarchy.
|
||||
*/
|
||||
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isSearchComposing, setIsSearchComposing] = useState(false)
|
||||
|
||||
const filters = props.filters ?? []
|
||||
const hasExpandable = props.expandable != null
|
||||
const hasSearch = props.onSearch != null
|
||||
|
||||
const isFiltered =
|
||||
props.table.getState().columnFilters.length > 0 ||
|
||||
!!props.table.getState().globalFilter ||
|
||||
!!props.hasAdditionalFilters
|
||||
|
||||
const placeholder = props.searchPlaceholder ?? t('Filter...')
|
||||
const currentSearchValue = props.searchKey
|
||||
? ((props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
|
||||
'')
|
||||
: ((props.table.getState().globalFilter as string | undefined) ?? '')
|
||||
|
||||
const [searchDraft, setSearchDraft] = useState<SearchDraft | null>(null)
|
||||
const activeSearchDraft =
|
||||
searchDraft &&
|
||||
(isSearchComposing || searchDraft.baseValue === currentSearchValue)
|
||||
? searchDraft
|
||||
: null
|
||||
const searchValue = activeSearchDraft?.value ?? currentSearchValue
|
||||
const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0)
|
||||
const debouncedSearchValue = useDebounce(searchValue, searchDebounceMs)
|
||||
|
||||
const commitSearchValue = React.useCallback(
|
||||
(value: string) => {
|
||||
if (value === currentSearchValue) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.searchKey) {
|
||||
props.table.getColumn(props.searchKey)?.setFilterValue(value)
|
||||
return
|
||||
}
|
||||
|
||||
props.table.setGlobalFilter(value)
|
||||
},
|
||||
[currentSearchValue, props.searchKey, props.table]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
searchDebounceMs <= 0 ||
|
||||
isSearchComposing ||
|
||||
debouncedSearchValue !== searchValue
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
commitSearchValue(debouncedSearchValue)
|
||||
}, [
|
||||
commitSearchValue,
|
||||
debouncedSearchValue,
|
||||
isSearchComposing,
|
||||
searchDebounceMs,
|
||||
searchValue,
|
||||
])
|
||||
|
||||
const queueSearchValue = (value: string) => {
|
||||
if (searchDebounceMs <= 0) {
|
||||
commitSearchValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setSearchDraft({ baseValue: currentSearchValue, value })
|
||||
|
||||
if (!isSearchComposing) {
|
||||
queueSearchValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchCompositionStart = () => {
|
||||
setIsSearchComposing(true)
|
||||
}
|
||||
|
||||
const handleSearchCompositionEnd = (
|
||||
event: React.CompositionEvent<HTMLInputElement>
|
||||
) => {
|
||||
setIsSearchComposing(false)
|
||||
const value = event.currentTarget.value
|
||||
setSearchDraft({ baseValue: currentSearchValue, value })
|
||||
queueSearchValue(value)
|
||||
}
|
||||
|
||||
const searchInput = (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onCompositionStart={handleSearchCompositionStart}
|
||||
onCompositionEnd={handleSearchCompositionEnd}
|
||||
className='w-full sm:w-[200px] lg:w-[240px]'
|
||||
/>
|
||||
)
|
||||
|
||||
const filterChips = React.useMemo(
|
||||
() =>
|
||||
filters.map((filter) => {
|
||||
const column = props.table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
key={filter.columnId}
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
singleSelect={filter.singleSelect}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[props.filters, props.table]
|
||||
)
|
||||
|
||||
const handleReset = () => {
|
||||
setIsSearchComposing(false)
|
||||
setSearchDraft(null)
|
||||
props.table.resetColumnFilters()
|
||||
props.table.setGlobalFilter('')
|
||||
props.onReset?.()
|
||||
}
|
||||
|
||||
// Reset: outline text-only for form mode (always visible, disabled when
|
||||
// nothing to reset); ghost text + X for filter-as-you-type mode (only
|
||||
// visible when active filters exist).
|
||||
let resetButton: ReactNode = null
|
||||
if (hasSearch) {
|
||||
resetButton = (
|
||||
<Button variant='outline' onClick={handleReset} disabled={!isFiltered}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)
|
||||
} else if (isFiltered) {
|
||||
resetButton = (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleReset}
|
||||
className='text-muted-foreground hover:text-foreground gap-1 px-2'
|
||||
>
|
||||
{t('Reset')}
|
||||
<Cross2Icon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const searchButton = hasSearch ? (
|
||||
<Button onClick={props.onSearch} disabled={props.searchLoading}>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const viewOptionsNode = !props.hideViewOptions ? (
|
||||
<DataTableViewOptions table={props.table} />
|
||||
) : null
|
||||
|
||||
const viewToggleNode = props.viewToggle ?? null
|
||||
|
||||
const expandToggle = hasExpandable ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
props.hasExpandedActiveFilters &&
|
||||
!expanded &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{expanded ? t('Collapse') : t('Expand')}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const hasLeftActions = props.leftActions != null
|
||||
|
||||
if (hasLeftActions) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', props.className)}>
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.customSearch !== undefined ? props.customSearch : searchInput}
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{expandToggle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasExpandable && (
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.expandable}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.leftActions}
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-2 sm:gap-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.customSearch !== undefined ? props.customSearch : searchInput}
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
{expanded && hasExpandable && props.expandable}
|
||||
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
{expandToggle}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Grid2X2, Table2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
DATA_TABLE_VIEW_MODES,
|
||||
type DataTableViewMode,
|
||||
} from '../hooks/use-data-table-view-mode'
|
||||
|
||||
export type DataTableViewModeToggleProps = {
|
||||
value: DataTableViewMode
|
||||
onChange: (mode: DataTableViewMode) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
type Segment = {
|
||||
value: DataTableViewMode
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable icon segmented control for switching a data table between table and
|
||||
* card views. Shared, accessible version of the local control used by the
|
||||
* model square (`pricing-toolbar.tsx`).
|
||||
*/
|
||||
export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const segments: Segment[] = [
|
||||
{
|
||||
value: DATA_TABLE_VIEW_MODES.CARD,
|
||||
icon: Grid2X2,
|
||||
tooltip: t('Card view'),
|
||||
},
|
||||
{
|
||||
value: DATA_TABLE_VIEW_MODES.TABLE,
|
||||
icon: Table2,
|
||||
tooltip: t('Table view'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
role='group'
|
||||
aria-label={t('View mode')}
|
||||
className={cn(
|
||||
'bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{segments.map((segment) => {
|
||||
const Icon = segment.icon
|
||||
const isActive = segment.value === props.value
|
||||
return (
|
||||
<Tooltip key={segment.value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => props.onChange(segment.value)}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'inline-flex h-full w-7 items-center justify-center rounded-md text-xs font-medium transition-all',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className='size-3.5' />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side='bottom' className='text-xs'>
|
||||
{segment.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const hideableColumns = React.useMemo(
|
||||
() =>
|
||||
table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
),
|
||||
[table]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
className='shrink-0'
|
||||
aria-label={t('View')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('View')}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
|
||||
{hideableColumns.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{typeof column.columnDef.header === 'string'
|
||||
? column.columnDef.header
|
||||
: (column.columnDef.meta?.label ?? column.id)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user