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,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
|
||||
}
|
||||
Reference in New Issue
Block a user