feat(data-table): add reusable card/table view toggle
Add an opt-in card view to the shared data-table stack (DataTablePage), toggled via a segmented control in the toolbar with per-table localStorage persistence. Cards render generically from column meta by default, with an optional renderCard slot. Defaults to table-only so existing pages are unchanged. Wire it into the channels page with a bespoke ChannelCard that reuses every column's cell renderer, preserving all table information and interactions (selection, inline priority/weight, balance refresh, status, actions, tag expand).
This commit is contained in:
@@ -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]
|
||||
}
|
||||
+16
@@ -37,11 +37,27 @@ export {
|
||||
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 =
|
||||
|
||||
@@ -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,175 @@
|
||||
/*
|
||||
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 type { Row, Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
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
|
||||
}
|
||||
|
||||
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='bg-card space-y-3 rounded-lg border 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.
|
||||
*
|
||||
* Selection (the `select` column) is intentionally not rendered in card mode;
|
||||
* bulk selection remains a table-mode capability.
|
||||
*/
|
||||
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
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'bg-card rounded-lg border px-3 py-2.5',
|
||||
props.getRowClassName?.(row)
|
||||
)}
|
||||
>
|
||||
{props.renderCard ? (
|
||||
props.renderCard(row, { compact })
|
||||
) : (
|
||||
<CardRowContent row={row} compact={compact} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
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 type { Row } from '@tanstack/react-table'
|
||||
import { StatusBadgeTypeContext } from '@/components/status-badge'
|
||||
import { getCellLabel, renderCellContent } from './card-cell-utils'
|
||||
|
||||
/**
|
||||
* 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 = 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 [&_[data-slot=provider-badge]]:ml-0 [&_[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 = 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 [&_[data-slot=provider-badge]]:ml-0 [&_[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 [&_[data-slot=provider-badge]]:ml-0 [&_[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} />
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,14 @@ import {
|
||||
} from '../core/data-table-view'
|
||||
import { DataTablePagination } from '../core/pagination'
|
||||
import { DataTableToolbar } from '../toolbar/toolbar'
|
||||
import { DataTableViewModeToggle } from '../toolbar/view-mode-toggle'
|
||||
import {
|
||||
DATA_TABLE_VIEW_MODES,
|
||||
useDataTableViewMode,
|
||||
type DataTableViewMode,
|
||||
} from '../hooks/use-data-table-view-mode'
|
||||
import { MobileCardList } from './mobile-card-list'
|
||||
import { DataTableCardGrid } from './card-grid'
|
||||
|
||||
/**
|
||||
* Pass-through configuration for the default {@link DataTableToolbar}.
|
||||
@@ -209,6 +216,53 @@ export type DataTablePageProps<TData> = {
|
||||
* 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 desktop view switches between the table and a card grid.
|
||||
*
|
||||
* The mobile layout is unaffected — it always renders the mobile list.
|
||||
*/
|
||||
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. Defaults to `'table'`.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,9 +290,21 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const showMobile = isMobile && !props.hideMobile
|
||||
|
||||
const toolbarNode = renderToolbar(props)
|
||||
const [internalViewMode, setInternalViewMode] = useDataTableViewMode({
|
||||
storageKey: props.viewModeStorageKey,
|
||||
defaultMode: props.defaultViewMode,
|
||||
})
|
||||
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)
|
||||
const desktopNode = renderDesktop(props, showMobile)
|
||||
const desktopNode = renderDesktop(props, showMobile, cardViewActive, viewMode)
|
||||
const paginationNode = renderPagination(props)
|
||||
|
||||
return (
|
||||
@@ -267,16 +333,24 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
}
|
||||
|
||||
function renderToolbar<TData>(
|
||||
props: DataTablePageProps<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} />
|
||||
return (
|
||||
<DataTableToolbar
|
||||
table={props.table}
|
||||
{...props.toolbarProps}
|
||||
viewToggle={props.toolbarProps.viewToggle ?? viewToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -323,13 +397,41 @@ function renderMobile<TData>(
|
||||
|
||||
function renderDesktop<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
showMobile: boolean
|
||||
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}
|
||||
|
||||
@@ -17,12 +17,7 @@ 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 {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Row,
|
||||
type Table,
|
||||
} from '@tanstack/react-table'
|
||||
import type { Row, Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -34,7 +29,8 @@ import {
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusBadgeTypeContext } from '@/components/status-badge'
|
||||
import { tableHasCompactMeta } from './card-cell-utils'
|
||||
import { CardRowContent } from './card-row-content'
|
||||
|
||||
interface MobileCardListProps<TData> {
|
||||
table: Table<TData>
|
||||
@@ -45,21 +41,6 @@ interface MobileCardListProps<TData> {
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function renderCellContent<TData>(cell: Cell<TData, unknown>): React.ReactNode {
|
||||
const cellRenderer = cell.column.columnDef.cell
|
||||
if (cellRenderer) {
|
||||
return flexRender(cellRenderer, cell.getContext())
|
||||
}
|
||||
return cell.getValue() as React.ReactNode
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
@@ -102,165 +83,15 @@ function FallbackListSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact list row — structured layout with title header + side-by-side fields.
|
||||
* Used when columns define mobileTitle or mobileBadge meta.
|
||||
*
|
||||
* Visual structure per row:
|
||||
* [Title content] [Badge]
|
||||
* [Field1 label] [Field2 label]
|
||||
* [Field1 value] [Field2 value]
|
||||
* [Actions ⋯]
|
||||
*/
|
||||
function CompactRow<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 = 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 [&_[data-slot=provider-badge]]:ml-0 [&_[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 list row — condensed label:value pairs for tables without
|
||||
* mobileTitle/mobileBadge. Still respects mobileHidden.
|
||||
*/
|
||||
function FallbackRow<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 = 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 [&_[data-slot=provider-badge]]:ml-0 [&_[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 [&_[data-slot=provider-badge]]:ml-0 [&_[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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Column meta extensions:
|
||||
* - `mobileTitle` — card header (left, larger text)
|
||||
* - `mobileBadge` — inline with title (right, e.g. status badge)
|
||||
* - `mobileHidden` — hidden on mobile
|
||||
*
|
||||
* When mobileTitle or mobileBadge is set on any column, uses a structured
|
||||
* two-tier layout: title+badge header, then 2 key fields side-by-side.
|
||||
* Otherwise falls back to a condensed single-column label:value list.
|
||||
* 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 {
|
||||
@@ -278,11 +109,8 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
const hasCompactMeta = React.useMemo(
|
||||
() =>
|
||||
visibleColumns.some((col) => {
|
||||
const meta = col.columnDef.meta
|
||||
return meta?.mobileTitle || meta?.mobileBadge
|
||||
}),
|
||||
() => tableHasCompactMeta(table),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[visibleColumns]
|
||||
)
|
||||
|
||||
@@ -308,8 +136,6 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
)
|
||||
}
|
||||
|
||||
const RowComponent = hasCompactMeta ? CompactRow : FallbackRow
|
||||
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
@@ -319,7 +145,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
key={key}
|
||||
className={cn('bg-card px-3 py-2.5', getRowClassName?.(row))}
|
||||
>
|
||||
<RowComponent row={row} />
|
||||
<CardRowContent row={row} compact={hasCompactMeta} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -115,6 +115,12 @@ export type DataTableToolbarProps<TData> = {
|
||||
* 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:
|
||||
@@ -302,6 +308,8 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
<DataTableViewOptions table={props.table} />
|
||||
) : null
|
||||
|
||||
const viewToggleNode = props.viewToggle ?? null
|
||||
|
||||
const expandToggle = hasExpandable ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -350,6 +358,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,6 +382,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
{expandToggle}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
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 { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
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.TABLE,
|
||||
icon: Table2,
|
||||
tooltip: t('Table view'),
|
||||
},
|
||||
{
|
||||
value: DATA_TABLE_VIEW_MODES.CARD,
|
||||
icon: Grid2X2,
|
||||
tooltip: t('Card 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,122 @@
|
||||
/*
|
||||
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 Row } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isTagAggregateRow } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
/**
|
||||
* Field columns rendered in the card body (in display order). The header
|
||||
* columns (`select`, `name`, `status`, `actions`) are laid out separately.
|
||||
* `models` spans the full width because it can hold many badges.
|
||||
*/
|
||||
const FIELD_COLUMN_IDS = [
|
||||
'type',
|
||||
'id',
|
||||
'group',
|
||||
'balance',
|
||||
'priority',
|
||||
'weight',
|
||||
'response_time',
|
||||
'test_time',
|
||||
'tag',
|
||||
'models',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Bespoke channel card for the card view. Reuses every column's existing cell
|
||||
* renderer via `flexRender`, so all table information and interactions are
|
||||
* preserved: row selection, name/remark + warning icons, status (with tooltips),
|
||||
* provider/multi-key/IO.NET badges, groups, models, tag, inline priority/weight
|
||||
* spinners, balance refresh, response/test times, tag expand-collapse, and the
|
||||
* per-row (or per-tag) actions menu.
|
||||
*/
|
||||
export function ChannelCard({ row }: { row: Row<Channel> }) {
|
||||
const { t } = useTranslation()
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const cells = row.getAllCells()
|
||||
|
||||
const renderCell = (id: string) => {
|
||||
const cell = cells.find((c) => c.column.id === id)
|
||||
if (!cell || !cell.column.columnDef.cell) {
|
||||
return null
|
||||
}
|
||||
return flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
type: t('Type'),
|
||||
id: t('ID'),
|
||||
group: t('Groups'),
|
||||
balance: t('Used / Remaining'),
|
||||
priority: t('Priority'),
|
||||
weight: t('Weight'),
|
||||
response_time: t('Response'),
|
||||
test_time: t('Last Tested'),
|
||||
tag: t('Tag'),
|
||||
models: t('Models'),
|
||||
}
|
||||
|
||||
const selectCell = renderCell('select')
|
||||
const nameCell = renderCell('name')
|
||||
const statusCell = renderCell('status')
|
||||
const actionsCell = renderCell('actions')
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
{/* Header: selection + name/remark, with status badge + actions menu */}
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-start gap-2'>
|
||||
{!isTagRow && selectCell && (
|
||||
<div className='pt-0.5'>{selectCell}</div>
|
||||
)}
|
||||
<div className='min-w-0 flex-1'>{nameCell}</div>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-1.5'>
|
||||
{statusCell}
|
||||
{actionsCell}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: labelled fields for every remaining column */}
|
||||
<div className='grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3'>
|
||||
{FIELD_COLUMN_IDS.map((id) => {
|
||||
const content = renderCell(id)
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
id === 'models' && 'col-span-2 sm:col-span-3'
|
||||
)}
|
||||
>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] font-medium tracking-wide uppercase select-none'>
|
||||
{fieldLabels[id]}
|
||||
</div>
|
||||
<div className='min-w-0 overflow-hidden text-sm'>
|
||||
{content ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,12 +51,14 @@ import {
|
||||
} from '../lib'
|
||||
import type { Channel, ChannelSortBy } from '../types'
|
||||
import { useChannelsColumns } from './channels-columns'
|
||||
import { ChannelCard } from './channel-card'
|
||||
import { useChannels } from './channels-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
|
||||
const route = getRouteApi('/_authenticated/channels/')
|
||||
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY =
|
||||
'channels:column-visibility'
|
||||
const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode'
|
||||
|
||||
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
|
||||
'id',
|
||||
@@ -355,6 +357,10 @@ export function ChannelsTable() {
|
||||
'No channels available. Create your first channel to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='channel-skeleton'
|
||||
enableCardView
|
||||
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
|
||||
renderCard={(row) => <ChannelCard row={row} />}
|
||||
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2 2xl:grid-cols-3'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||
|
||||
Reference in New Issue
Block a user