From 104ab12fcceb03eb9a8fa5814be0b079861ef3d9 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 11 Jun 2026 10:38:08 +0800 Subject: [PATCH 01/12] perf(table): use percentage-based column widths - compute each column's width as a percentage of total column size instead of a fixed pixel value, letting the colgroup scale fluidly with the table container. --- .../data-table/core/data-table-colgroup.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/default/src/components/data-table/core/data-table-colgroup.tsx b/web/default/src/components/data-table/core/data-table-colgroup.tsx index 26c57ba30c9c..03dc9644ccc5 100644 --- a/web/default/src/components/data-table/core/data-table-colgroup.tsx +++ b/web/default/src/components/data-table/core/data-table-colgroup.tsx @@ -23,10 +23,21 @@ export function DataTableColgroup({ }: { table: TanstackTable }) { + const columns = table.getVisibleLeafColumns() + const totalSize = columns.reduce((sum, col) => sum + col.getSize(), 0) + return ( - {table.getVisibleLeafColumns().map((column) => ( - + {columns.map((column) => ( + 0 + ? `${(column.getSize() / totalSize) * 100}%` + : undefined, + }} + /> ))} ) From 257c169265c654f6ec14ec5b3431d478a47c3796 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 11 Jun 2026 11:10:09 +0800 Subject: [PATCH 02/12] perf(data-table): reduce unnecessary re-renders across table components - stabilize commitSearchValue in toolbar by reading table/searchKey via refs, eliminating recreation on every parent render - store onColumnFiltersChange in a ref so debounce effect is not reset when the caller passes a new function reference each render - wrap DataTableRow in React.memo with a custom comparator that ignores getColumnClassName reference churn - memoize selectedValues Set in DataTableFacetedFilter and wrap with React.memo to prevent rerenders on unrelated state changes - cache cell meta reads in CompactRow and FallbackRow to a single pass per row; memoize hasCompactMeta in MobileCardList - memoize hideable columns list in DataTableViewOptions and colSpan in DataTableView - remove tableClassName and colgroup from scroll-sync effect deps; cache toolbar button NodeList via useLayoutEffect to avoid per-keydown DOM queries --- .../data-table/core/data-table-row.tsx | 15 ++++++- .../data-table/core/data-table-view.tsx | 7 ++- .../hooks/use-debounced-column-filter.ts | 6 ++- .../data-table/layout/mobile-card-list.tsx | 38 +++++++++++----- .../data-table/toolbar/bulk-actions.tsx | 9 +++- .../data-table/toolbar/faceted-filter.tsx | 12 +++++- .../components/data-table/toolbar/toolbar.tsx | 43 +++++++++++-------- .../data-table/toolbar/view-options.tsx | 41 ++++++++++-------- 8 files changed, 117 insertions(+), 54 deletions(-) diff --git a/web/default/src/components/data-table/core/data-table-row.tsx b/web/default/src/components/data-table/core/data-table-row.tsx index 8ad703aef193..b6d56bb10281 100644 --- a/web/default/src/components/data-table/core/data-table-row.tsx +++ b/web/default/src/components/data-table/core/data-table-row.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import type * as React from 'react' +import * as React from 'react' import { flexRender, type Row } from '@tanstack/react-table' import { TableCell, TableRow } from '@/components/ui/table' import type { DataTableColumnClassName } from './types' @@ -27,7 +27,7 @@ type DataTableRowProps = { getColumnClassName?: DataTableColumnClassName } & Omit, 'children'> -export function DataTableRow({ +function DataTableRowInner({ row, className, getColumnClassName, @@ -50,3 +50,14 @@ export function DataTableRow({ ) } + +export const DataTableRow = React.memo(DataTableRowInner, (prev, next) => { + // Skip re-render when only the getColumnClassName reference changed but the + // row identity and selection state are the same — callers rarely stabilize + // this callback, so excluding it from comparison avoids unnecessary renders. + return ( + prev.row === next.row && + prev.className === next.className && + prev.row.getIsSelected() === next.row.getIsSelected() + ) +}) as typeof DataTableRowInner diff --git a/web/default/src/components/data-table/core/data-table-view.tsx b/web/default/src/components/data-table/core/data-table-view.tsx index 978dabb5fa45..c46c111dc666 100644 --- a/web/default/src/components/data-table/core/data-table-view.tsx +++ b/web/default/src/components/data-table/core/data-table-view.tsx @@ -46,7 +46,10 @@ export { DataTableRow } from './data-table-row' export function DataTableView(props: DataTableViewProps) { const rows = props.rows ?? props.table.getRowModel().rows - const colSpan = props.table.getVisibleLeafColumns().length + const colSpan = React.useMemo( + () => props.table.getVisibleLeafColumns().length, + [props.table] + ) const columnClassName = useResolvedColumnClassName( props.getColumnClassName, props.pinnedColumns @@ -144,7 +147,7 @@ function SplitHeaderTableView({ return () => { bodyScroller.removeEventListener('scroll', syncHeaderScroll) } - }, [rows.length, props.tableClassName, props.colgroup]) + }, [rows.length]) // tableClassName / colgroup are styling — don't need to re-attach listener return (
{ // Keep the input aligned when URL state changes outside the local field. @@ -55,13 +57,13 @@ export function useDebouncedColumnFilter({ React.useEffect(() => { if (debouncedValue === value) return - onColumnFiltersChange((previous) => { + onColumnFiltersChangeRef.current((previous) => { const filters = previous.filter((filter) => filter.id !== columnId) return debouncedValue ? [...filters, { id: columnId, value: debouncedValue }] : filters }) - }, [columnId, debouncedValue, onColumnFiltersChange, value]) + }, [columnId, debouncedValue, value]) const updateInputValue = React.useCallback((nextValue: string) => { setInputValue(nextValue) diff --git a/web/default/src/components/data-table/layout/mobile-card-list.tsx b/web/default/src/components/data-table/layout/mobile-card-list.tsx index 0ca3336d1f3c..bde648991e0b 100644 --- a/web/default/src/components/data-table/layout/mobile-card-list.tsx +++ b/web/default/src/components/data-table/layout/mobile-card-list.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import * as React from 'react' import { flexRender, type Cell, @@ -128,16 +129,22 @@ function CompactRow({ row }: { row: Row }) { .getVisibleCells() .filter((cell) => cell.column.id !== 'select') - const titleCell = allCells.find((c) => getCellMeta(c)?.mobileTitle) - const badgeCell = allCells.find((c) => getCellMeta(c)?.mobileBadge) - const actionsCell = allCells.find((c) => c.column.id === 'actions') + // Read each cell's meta once, then reuse for all categorisation checks. + const cellMetas = React.useMemo( + () => allCells.map(getCellMeta), + // 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) => + (c, i) => c !== titleCell && c !== badgeCell && c !== actionsCell && - !getCellMeta(c)?.mobileHidden + !cellMetas[i]?.mobileHidden ) return ( @@ -194,9 +201,15 @@ function FallbackRow({ row }: { row: Row }) { .getVisibleCells() .filter((cell) => cell.column.id !== 'select') + const cellMetas = React.useMemo( + () => allCells.map(getCellMeta), + // 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) => c.column.id !== 'actions' && !getCellMeta(c)?.mobileHidden + (c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden ) return ( @@ -265,10 +278,15 @@ export function MobileCardList(props: MobileCardListProps) { const resolvedEmptyTitle = emptyTitle ?? t('No Data') const resolvedEmptyDescription = emptyDescription ?? t('No data available') - const hasCompactMeta = table.getVisibleLeafColumns().some((col) => { - const meta = col.columnDef.meta as MobileColumnMeta | undefined - return meta?.mobileTitle || meta?.mobileBadge - }) + const visibleColumns = table.getVisibleLeafColumns() + const hasCompactMeta = React.useMemo( + () => + visibleColumns.some((col) => { + const meta = col.columnDef.meta as MobileColumnMeta | undefined + return meta?.mobileTitle || meta?.mobileBadge + }), + [visibleColumns] + ) if (isLoading) { return hasCompactMeta ? : diff --git a/web/default/src/components/data-table/toolbar/bulk-actions.tsx b/web/default/src/components/data-table/toolbar/bulk-actions.tsx index 08a7406595ff..69be45b23424 100644 --- a/web/default/src/components/data-table/toolbar/bulk-actions.tsx +++ b/web/default/src/components/data-table/toolbar/bulk-actions.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useLayoutEffect, useRef } from 'react' import { type Table } from '@tanstack/react-table' import { X } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -55,8 +55,13 @@ export function DataTableBulkActions({ const selectedRows = table.getFilteredSelectedRowModel().rows const selectedCount = selectedRows.length const toolbarRef = useRef(null) + const buttonsRef = useRef | null>(null) const [announcement, setAnnouncement] = useState('') + useLayoutEffect(() => { + buttonsRef.current = toolbarRef.current?.querySelectorAll('button') ?? null + }) + // Announce selection changes to screen readers useEffect(() => { if (selectedCount > 0) { @@ -75,7 +80,7 @@ export function DataTableBulkActions({ } const handleKeyDown = (event: React.KeyboardEvent) => { - const buttons = toolbarRef.current?.querySelectorAll('button') + const buttons = buttonsRef.current if (!buttons) return const currentIndex = Array.from(buttons).findIndex( diff --git a/web/default/src/components/data-table/toolbar/faceted-filter.tsx b/web/default/src/components/data-table/toolbar/faceted-filter.tsx index 9198e7e0801d..33a1c1812ad5 100644 --- a/web/default/src/components/data-table/toolbar/faceted-filter.tsx +++ b/web/default/src/components/data-table/toolbar/faceted-filter.tsx @@ -53,7 +53,7 @@ type DataTableFacetedFilterProps = { singleSelect?: boolean } -export function DataTableFacetedFilter({ +function DataTableFacetedFilterInner({ column, title, options, @@ -62,7 +62,11 @@ export function DataTableFacetedFilter({ const { t } = useTranslation() const facets = column?.getFacetedUniqueValues() const filterValue = column?.getFilterValue() as string[] | undefined - const selectedValues = new Set(filterValue) + const selectedValues = React.useMemo( + () => new Set(filterValue), + // eslint-disable-next-line react-hooks/exhaustive-deps + [filterValue?.join(',')] + ) return ( @@ -197,3 +201,7 @@ export function DataTableFacetedFilter({ ) } + +export const DataTableFacetedFilter = React.memo( + DataTableFacetedFilterInner +) as typeof DataTableFacetedFilterInner diff --git a/web/default/src/components/data-table/toolbar/toolbar.tsx b/web/default/src/components/data-table/toolbar/toolbar.tsx index 1859e60254f1..44903a4c8bd9 100644 --- a/web/default/src/components/data-table/toolbar/toolbar.tsx +++ b/web/default/src/components/data-table/toolbar/toolbar.tsx @@ -143,6 +143,10 @@ export function DataTableToolbar(props: DataTableToolbarProps) { const [expanded, setExpanded] = useState(false) const isSearchComposingRef = React.useRef(false) const lastCommittedSearchValueRef = React.useRef('') + const tableRef = React.useRef(props.table) + tableRef.current = props.table + const searchKeyRef = React.useRef(props.searchKey) + searchKeyRef.current = props.searchKey const filters = props.filters ?? [] const hasExpandable = props.expandable != null @@ -184,14 +188,14 @@ export function DataTableToolbar(props: DataTableToolbarProps) { lastCommittedSearchValueRef.current = value - if (props.searchKey) { - props.table.getColumn(props.searchKey)?.setFilterValue(value) + if (searchKeyRef.current) { + tableRef.current.getColumn(searchKeyRef.current)?.setFilterValue(value) return } - props.table.setGlobalFilter(value) + tableRef.current.setGlobalFilter(value) }, - [props.searchKey, props.table] + [] // stable — reads props via refs at call time ) React.useEffect(() => { @@ -261,19 +265,24 @@ export function DataTableToolbar(props: DataTableToolbarProps) { /> ) - const filterChips = filters.map((filter) => { - const column = props.table.getColumn(filter.columnId) - if (!column) return null - return ( - - ) - }) + const filterChips = React.useMemo( + () => + filters.map((filter) => { + const column = props.table.getColumn(filter.columnId) + if (!column) return null + return ( + + ) + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.filters, props.table] + ) const handleReset = () => { isSearchComposingRef.current = false diff --git a/web/default/src/components/data-table/toolbar/view-options.tsx b/web/default/src/components/data-table/toolbar/view-options.tsx index 08e03172ffce..04fd12034e5e 100644 --- a/web/default/src/components/data-table/toolbar/view-options.tsx +++ b/web/default/src/components/data-table/toolbar/view-options.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import * as React from 'react' import { type Table } from '@tanstack/react-table' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' @@ -36,6 +37,18 @@ export function DataTableViewOptions({ table, }: DataTableViewOptionsProps) { const { t } = useTranslation() + + const hideableColumns = React.useMemo( + () => + table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== 'undefined' && column.getCanHide() + ), + [table] + ) + return ( ({ {t('Toggle columns')} - {table - .getAllColumns() - .filter( - (column) => - typeof column.accessorFn !== 'undefined' && column.getCanHide() + {hideableColumns.map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.columnDef.meta?.label ?? column.id} + ) - .map((column) => { - return ( - column.toggleVisibility(!!value)} - > - {column.columnDef.meta?.label ?? column.id} - - ) - })} + })} From ad62b191053df26dc935efb24298a074da6178a0 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 11 Jun 2026 17:28:29 +0800 Subject: [PATCH 03/12] perf(data-table): replace scroll-sync split header with CSS sticky - remove JS scroll-sync effect and event listener between split header and body containers. - merge separate header/body tables into a single scrollable table element, reducing DOM complexity. - apply CSS sticky positioning to the header for a simpler, hardware-accelerated freeze effect. --- .../data-table/core/data-table-view.tsx | 69 +++++-------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/web/default/src/components/data-table/core/data-table-view.tsx b/web/default/src/components/data-table/core/data-table-view.tsx index c46c111dc666..67799d73f3a9 100644 --- a/web/default/src/components/data-table/core/data-table-view.tsx +++ b/web/default/src/components/data-table/core/data-table-view.tsx @@ -123,32 +123,8 @@ function SplitHeaderTableView({ colSpan: number getColumnClassName: DataTableColumnClassName }) { - const headerHostRef = React.useRef(null) - const bodyHostRef = React.useRef(null) const tableSizing = getTableSizing(props) - React.useEffect(() => { - const headerScroller = headerHostRef.current?.querySelector( - '[data-slot=table-container]' - ) - const bodyScroller = bodyHostRef.current?.querySelector( - '[data-slot=table-container]' - ) - - if (!headerScroller || !bodyScroller) return - - const syncHeaderScroll = () => { - headerScroller.scrollLeft = bodyScroller.scrollLeft - } - - syncHeaderScroll() - bodyScroller.addEventListener('scroll', syncHeaderScroll, { passive: true }) - - return () => { - bodyScroller.removeEventListener('scroll', syncHeaderScroll) - } - }, [rows.length]) // tableClassName / colgroup are styling — don't need to re-attach listener - return (
({ >
-
- - {tableSizing.colgroup} - -
-
-
- - {tableSizing.colgroup} - {renderTableBody(props, rows, colSpan, getColumnClassName)} -
-
+ {tableSizing.colgroup} + + {renderTableBody(props, rows, colSpan, getColumnClassName)} +
) From 73c576ceb983405a451c4bcb9bb092688cbcff1a Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 11 Jun 2026 18:00:09 +0800 Subject: [PATCH 04/12] fix(data-table): replace opacity muted colors with color-mix - switch from bg-muted/50 and bg-muted/30 to color-mix(in oklch) to produce opaque blended backgrounds that prevent scroll content from showing through pinned cells. - expose --table-header-bg CSS variable so pinned header cells inherit the exact same computed color as the thead background. - add group class to TableRow to enable group-hover selectors on pinned cell styles. --- web/default/src/components/data-table/core/column-pinning.ts | 4 ++-- .../src/components/data-table/core/data-table-view.tsx | 3 ++- .../src/components/data-table/layout/data-table-page.tsx | 2 +- .../data-table/static/static-data-table-classnames.ts | 2 +- web/default/src/components/ui/table.tsx | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/default/src/components/data-table/core/column-pinning.ts b/web/default/src/components/data-table/core/column-pinning.ts index ed86ea140b12..fb43dfe960e3 100644 --- a/web/default/src/components/data-table/core/column-pinning.ts +++ b/web/default/src/components/data-table/core/column-pinning.ts @@ -63,8 +63,8 @@ function getPinnedColumnClassName( pinnedColumn.side === 'left' ? 'left-0' : 'right-0', edgeClassName, kind === 'header' - ? 'bg-background z-30' - : 'bg-background z-10 group-hover:bg-muted group-data-[state=selected]:bg-muted', + ? '[background-color:var(--table-header-bg,var(--background))] group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] 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 diff --git a/web/default/src/components/data-table/core/data-table-view.tsx b/web/default/src/components/data-table/core/data-table-view.tsx index 67799d73f3a9..0b9d57d48453 100644 --- a/web/default/src/components/data-table/core/data-table-view.tsx +++ b/web/default/src/components/data-table/core/data-table-view.tsx @@ -135,7 +135,8 @@ function SplitHeaderTableView({
( splitHeader={fixedHeight} tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined} tableHeaderClassName={cn( - fixedHeight && 'bg-muted/30', + fixedHeight && '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]', props.tableHeaderClassName )} getColumnClassName={props.getColumnClassName} diff --git a/web/default/src/components/data-table/static/static-data-table-classnames.ts b/web/default/src/components/data-table/static/static-data-table-classnames.ts index 5780cfe2a325..ca057af87b23 100644 --- a/web/default/src/components/data-table/static/static-data-table-classnames.ts +++ b/web/default/src/components/data-table/static/static-data-table-classnames.ts @@ -22,7 +22,7 @@ export const staticDataTableClassNames = { embeddedContainer: 'rounded-none border-0', compactTable: 'text-sm', compactHeaderRow: 'hover:bg-transparent', - mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30', + mutedHeaderRow: '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))] hover:[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]', compactHeaderCell: 'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase', compactHeaderCellRight: diff --git a/web/default/src/components/ui/table.tsx b/web/default/src/components/ui/table.tsx index 0490d8141da0..d5a523c5468e 100644 --- a/web/default/src/components/ui/table.tsx +++ b/web/default/src/components/ui/table.tsx @@ -77,7 +77,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { Date: Thu, 11 Jun 2026 18:25:56 +0800 Subject: [PATCH 05/12] feat(data-table): support column pinning via meta.pinned - add pinned?: 'left' | 'right' to ColumnMeta so pinning is declared once in the column definition and applies to both header and body automatically - DataTableView derives pinnedColumns from meta.pinned at runtime, merged with any explicit pinnedColumns prop; explicit entries take precedence - add header and meta.pinned: 'right' to all actions columns across channels, users, api-keys, redemption-codes, models, deployments, and subscriptions tables --- .../data-table/core/data-table-view.tsx | 22 ++++++++++++++++--- .../channels/components/channels-columns.tsx | 2 ++ .../keys/components/api-keys-columns.tsx | 3 ++- .../models/components/deployments-columns.tsx | 2 ++ .../models/components/models-columns.tsx | 2 ++ .../components/redemptions-columns.tsx | 2 ++ .../components/subscriptions-columns.tsx | 2 ++ .../users/components/users-columns.tsx | 3 ++- web/default/src/tanstack-table.d.ts | 2 ++ 9 files changed, 35 insertions(+), 5 deletions(-) diff --git a/web/default/src/components/data-table/core/data-table-view.tsx b/web/default/src/components/data-table/core/data-table-view.tsx index 0b9d57d48453..805819093873 100644 --- a/web/default/src/components/data-table/core/data-table-view.tsx +++ b/web/default/src/components/data-table/core/data-table-view.tsx @@ -51,6 +51,7 @@ export function DataTableView(props: DataTableViewProps) { [props.table] ) const columnClassName = useResolvedColumnClassName( + props.table, props.getColumnClassName, props.pinnedColumns ) @@ -164,13 +165,28 @@ function SplitHeaderTableView({ ) } -function useResolvedColumnClassName( +function useResolvedColumnClassName( + table: import('@tanstack/react-table').Table, getColumnClassName?: DataTableColumnClassName, pinnedColumns?: DataTablePinnedColumn[] ) { + const allPinnedColumns = React.useMemo(() => { + const fromMeta: DataTablePinnedColumn[] = table + .getAllColumns() + .filter((col) => col.columnDef.meta?.pinned) + .map((col) => ({ + columnId: col.id, + side: col.columnDef.meta!.pinned!, + })) + if (!fromMeta.length) return pinnedColumns + if (!pinnedColumns?.length) return fromMeta + const explicitIds = new Set(pinnedColumns.map((p) => p.columnId)) + return [...pinnedColumns, ...fromMeta.filter((p) => !explicitIds.has(p.columnId))] + }, [table, pinnedColumns]) + const pinnedColumnById = React.useMemo( - () => getPinnedColumnMap(pinnedColumns), - [pinnedColumns] + () => getPinnedColumnMap(allPinnedColumns), + [allPinnedColumns] ) return React.useMemo( diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 331f1867c539..e6959c5aaf0e 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -1037,6 +1037,7 @@ export function useChannelsColumns(): ColumnDef[] { // Actions column { id: 'actions', + header: () => t('Actions'), cell: ({ row }) => { // Check if this is a tag row (has children) const isTagRow = isTagAggregateRow(row.original) @@ -1055,6 +1056,7 @@ export function useChannelsColumns(): ColumnDef[] { size: 132, enableSorting: false, enableHiding: false, + meta: { pinned: 'right' as const }, }, ] } diff --git a/web/default/src/features/keys/components/api-keys-columns.tsx b/web/default/src/features/keys/components/api-keys-columns.tsx index c21dabe79cc8..6c68e34198a7 100644 --- a/web/default/src/features/keys/components/api-keys-columns.tsx +++ b/web/default/src/features/keys/components/api-keys-columns.tsx @@ -325,8 +325,9 @@ export function useApiKeysColumns(): ColumnDef[] { }, { id: 'actions', + header: () => t('Actions'), cell: ({ row }) => , - meta: { label: t('Actions') }, + meta: { label: t('Actions'), pinned: 'right' as const }, size: 88, }, ] diff --git a/web/default/src/features/models/components/deployments-columns.tsx b/web/default/src/features/models/components/deployments-columns.tsx index 32d5eff0ce3d..ae9d435dbe38 100644 --- a/web/default/src/features/models/components/deployments-columns.tsx +++ b/web/default/src/features/models/components/deployments-columns.tsx @@ -241,6 +241,7 @@ export function useDeploymentsColumns(opts: { }, { id: 'actions', + header: () => t('Actions'), enableHiding: false, enableSorting: false, cell: ({ row }) => { @@ -305,6 +306,7 @@ export function useDeploymentsColumns(opts: { ) }, size: 180, + meta: { pinned: 'right' as const }, }, ] } diff --git a/web/default/src/features/models/components/models-columns.tsx b/web/default/src/features/models/components/models-columns.tsx index 247f507524d7..7ad7a6e970f8 100644 --- a/web/default/src/features/models/components/models-columns.tsx +++ b/web/default/src/features/models/components/models-columns.tsx @@ -582,12 +582,14 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { // Actions column { id: 'actions', + header: () => t('Actions'), cell: ({ row }) => { return }, size: 100, enableSorting: false, enableHiding: false, + meta: { pinned: 'right' as const }, }, ] } diff --git a/web/default/src/features/redemption-codes/components/redemptions-columns.tsx b/web/default/src/features/redemption-codes/components/redemptions-columns.tsx index 2370d44db0d6..53487d3baa77 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-columns.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-columns.tsx @@ -272,7 +272,9 @@ export function useRedemptionsColumns(): ColumnDef[] { }, { id: 'actions', + header: () => t('Actions'), cell: ({ row }) => , + meta: { pinned: 'right' as const }, size: 88, }, ] diff --git a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx index a0e0c8a72557..0b986a9cbd27 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx @@ -209,7 +209,9 @@ export function useSubscriptionsColumns(): ColumnDef[] { }, { id: 'actions', + header: () => t('Actions'), cell: ({ row }) => , + meta: { pinned: 'right' as const }, size: 80, }, ], diff --git a/web/default/src/features/users/components/users-columns.tsx b/web/default/src/features/users/components/users-columns.tsx index aed7bd2e8dc0..c054a1792d58 100644 --- a/web/default/src/features/users/components/users-columns.tsx +++ b/web/default/src/features/users/components/users-columns.tsx @@ -383,8 +383,9 @@ export function useUsersColumns(): ColumnDef[] { }, { id: 'actions', + header: () => t('Actions'), cell: ({ row }) => , - meta: { label: t('Actions') }, + meta: { label: t('Actions'), pinned: 'right' as const }, }, ] } diff --git a/web/default/src/tanstack-table.d.ts b/web/default/src/tanstack-table.d.ts index ba7d6c2a22bd..fb3c9d782b47 100644 --- a/web/default/src/tanstack-table.d.ts +++ b/web/default/src/tanstack-table.d.ts @@ -29,5 +29,7 @@ declare module '@tanstack/react-table' { sortable?: boolean // Custom CSS classes to apply to the column cells className?: string + // Pin this column to 'left' or 'right' — applies to both header and body + pinned?: 'left' | 'right' } } From 5bcc69a25070b94339a25dedfc28810ff1194007 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 11 Jun 2026 19:11:13 +0800 Subject: [PATCH 06/12] style(row-actions): align action buttons to leading edge of column --- .../src/features/channels/components/data-table-row-actions.tsx | 2 +- .../src/features/keys/components/data-table-row-actions.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/default/src/features/channels/components/data-table-row-actions.tsx b/web/default/src/features/channels/components/data-table-row-actions.tsx index 92394245d76d..60cb6b23e8ac 100644 --- a/web/default/src/features/channels/components/data-table-row-actions.tsx +++ b/web/default/src/features/channels/components/data-table-row-actions.tsx @@ -140,7 +140,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { } return ( -
+
({ } return ( -
+
Date: Thu, 11 Jun 2026 19:53:33 +0800 Subject: [PATCH 07/12] refactor(data-table): extract BadgeListCell and centralize badge alignment - add BadgeListCell component to data-table for badge lists with overflow tooltip, replacing duplicated renderLimitedItems helpers in channels, models, and pricing columns - move StatusBadge -ml-1.5 alignment into the component itself via a table-cell context selector, so callers no longer need manual offset wrappers - remove the table-cell-level -ml-1.5 selector from TableCell now that alignment is handled by StatusBadge directly --- .../data-table/core/badge-list-cell.tsx | 74 +++++++ .../src/components/data-table/index.ts | 1 + web/default/src/components/status-badge.tsx | 2 +- web/default/src/components/ui/table.tsx | 2 +- .../channels/components/channels-columns.tsx | 88 ++------ .../models/components/models-columns.tsx | 207 ++++-------------- .../pricing/components/pricing-columns.tsx | 120 +++------- .../users/components/users-columns.tsx | 2 +- 8 files changed, 175 insertions(+), 321 deletions(-) create mode 100644 web/default/src/components/data-table/core/badge-list-cell.tsx diff --git a/web/default/src/components/data-table/core/badge-list-cell.tsx b/web/default/src/components/data-table/core/badge-list-cell.tsx new file mode 100644 index 000000000000..74128ce98588 --- /dev/null +++ b/web/default/src/components/data-table/core/badge-list-cell.tsx @@ -0,0 +1,74 @@ +/* +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 . + +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 - + } + + const showTooltip = items.length > max + + return ( + + + }> + item} + /> + + {showTooltip && ( + +
{items}
+
+ )} +
+
+ ) +} diff --git a/web/default/src/components/data-table/index.ts b/web/default/src/components/data-table/index.ts index 7cf6c81f9844..ded3be53246c 100644 --- a/web/default/src/components/data-table/index.ts +++ b/web/default/src/components/data-table/index.ts @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ export { DataTablePagination } from './core/pagination' export { DataTableColumnHeader } from './core/column-header' +export { BadgeListCell } from './core/badge-list-cell' export { DataTableViewOptions } from './toolbar/view-options' export { DataTableToolbar } from './toolbar/toolbar' export { DataTableBulkActions } from './toolbar/bulk-actions' diff --git a/web/default/src/components/status-badge.tsx b/web/default/src/components/status-badge.tsx index 809903b8b929..0af763986ed5 100644 --- a/web/default/src/components/status-badge.tsx +++ b/web/default/src/components/status-badge.tsx @@ -132,7 +132,7 @@ export function StatusBadge({ &]:-ml-1.5', sizeMap[size ?? 'sm'], textColorMap[computedVariant], pulse && 'animate-pulse', diff --git a/web/default/src/components/ui/table.tsx b/web/default/src/components/ui/table.tsx index d5a523c5468e..eaa2d4226786 100644 --- a/web/default/src/components/ui/table.tsx +++ b/web/default/src/components/ui/table.tsx @@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) { *:has(>[data-slot=status-badge]:first-child):first-child]:-ml-1.5 [&>[data-slot=status-badge]:first-child]:-ml-1.5', + 'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', className )} {...props} diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index e6959c5aaf0e..0aba00d6fcc0 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -45,10 +45,10 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { ConfirmDialog } from '@/components/confirm-dialog' -import { DataTableColumnHeader } from '@/components/data-table' +import { DataTableColumnHeader, BadgeListCell } from '@/components/data-table' import { GroupBadge } from '@/components/group-badge' import { ProviderBadge } from '@/components/provider-badge' -import { StatusBadge, StatusBadgeList } from '@/components/status-badge' +import { StatusBadge } from '@/components/status-badge' import { TableId } from '@/components/table-id' import { TruncatedText } from '@/components/truncated-text' import { getCodexUsage } from '../api' @@ -98,22 +98,6 @@ function parseIonetMeta(otherInfo: string | null | undefined): null | { return null } -/** - * Render limited items with "and X more" indicator - */ -function renderLimitedItems( - items: React.ReactNode[], - maxDisplay: number = 2 -): React.ReactNode { - return ( - item} - /> - ) -} - /** * Upstream update tags (+N / -N) shown on channel name for model-fetchable channels */ @@ -349,7 +333,7 @@ function BalanceCell({ channel }: { channel: Channel }) { return ( -
+
[] { cell: ({ row }) => { const models = row.getValue('models') as string const modelArray = parseModelsList(models) - - if (modelArray.length === 0) { - return - - } - - const modelBadges = modelArray.map((model, idx) => ( - - )) - return ( - - - }> - {renderLimitedItems(modelBadges, 2)} - - {modelArray.length > 2 && ( - -
{modelBadges}
-
- )} -
-
+ ( + + ))} + /> ) }, size: 200, @@ -890,27 +855,12 @@ export function useChannelsColumns(): ColumnDef[] { cell: ({ row }) => { const group = row.getValue('group') as string const groupArray = parseGroupsList(group) - - const groupBadges = groupArray.map((g) => ( - - )) - return ( - - - }> - {renderLimitedItems(groupBadges, 2)} - - {groupArray.length > 2 && ( - -
{groupBadges}
-
- )} -
-
+ ( + + ))} + /> ) }, filterFn: (row, id, value) => { diff --git a/web/default/src/features/models/components/models-columns.tsx b/web/default/src/features/models/components/models-columns.tsx index 7ad7a6e970f8..82f91e78c2e3 100644 --- a/web/default/src/features/models/components/models-columns.tsx +++ b/web/default/src/features/models/components/models-columns.tsx @@ -27,10 +27,10 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' -import { DataTableColumnHeader } from '@/components/data-table' +import { DataTableColumnHeader, BadgeListCell } from '@/components/data-table' import { GroupBadge } from '@/components/group-badge' import { ProviderBadge } from '@/components/provider-badge' -import { StatusBadge, StatusBadgeList } from '@/components/status-badge' +import { StatusBadge } from '@/components/status-badge' import { TableId } from '@/components/table-id' import { getModelStatusConfig, @@ -48,22 +48,6 @@ function getCompactModelIcon(iconKey: string) { return getLobeIcon(`${baseIconKey}.Avatar.type={'platform'}`, 20) } -/** - * Render limited items with "and X more" indicator - */ -function renderLimitedItems( - items: React.ReactNode[], - maxDisplay: number = 2 -): React.ReactNode { - return ( - item} - /> - ) -} - /** * Generate models columns configuration */ @@ -209,7 +193,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { return ( - }>{badge} + }>{badge} [] { cell: ({ row }) => { const tags = row.getValue('tags') as string const tagArray = parseModelTags(tags) - - if (tagArray.length === 0) { - return - - } - - const tagBadges = tagArray.map((tag, idx) => ( - - )) - return ( - - - }> - {renderLimitedItems(tagBadges, 2)} - - {tagArray.length > 2 && ( - -
{tagBadges}
-
- )} -
-
+ ( + + ))} + /> ) }, size: 150, @@ -344,31 +309,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { cell: ({ row }) => { const endpoints = row.getValue('endpoints') as string const endpointArray = formatEndpointsDisplay(endpoints) - - if (endpointArray.length === 0) { - return - - } - - const endpointBadges = endpointArray.map((ep, idx) => ( - - )) - return ( - - - }> - {renderLimitedItems(endpointBadges, 2)} - - {endpointArray.length > 2 && ( - -
{endpointBadges}
-
- )} -
-
+ ( + + ))} + /> ) }, size: 150, @@ -387,36 +333,17 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { type?: number status?: number }> - - if (!channels || channels.length === 0) { - return - - } - - const channelBadges = channels.map((c, idx) => ( - - )) - return ( - - - }> - {renderLimitedItems(channelBadges, 2)} - - {channels.length > 2 && ( - -
{channelBadges}
-
- )} -
-
+ ( + + ))} + /> ) }, size: 150, @@ -432,31 +359,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { ), cell: ({ row }) => { const groups = row.getValue('enable_groups') as string[] - - if (!groups || groups.length === 0) { - return - - } - - const groupBadges = groups.map((g) => ( - - )) - return ( - - - }> - {renderLimitedItems(groupBadges, 2)} - - {groups.length > 2 && ( - -
{groupBadges}
-
- )} -
-
+ ( + + ))} + /> ) }, size: 150, @@ -470,46 +378,27 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { header: t('Quota Types'), cell: ({ row }) => { const quotaTypes = row.getValue('quota_types') as number[] - - if (!quotaTypes || quotaTypes.length === 0) { - return - - } - - const quotaBadges = quotaTypes.map((qt, idx) => { - const config = QUOTA_TYPE_CONFIG[qt] - return ( - - ) - }) - return ( - - - }> - {renderLimitedItems(quotaBadges, 2)} - - {quotaTypes.length > 2 && ( - -
{quotaBadges}
-
- )} -
-
+ { + const config = QUOTA_TYPE_CONFIG[qt] + return ( + + ) + })} + /> ) }, size: 150, diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index 2f1bc3d29a53..561117d5e3a7 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -19,15 +19,9 @@ For commercial licensing, please contact support@quantumnous.com import { type ColumnDef } from '@tanstack/react-table' import { useTranslation } from 'react-i18next' import { getLobeIcon } from '@/lib/lobe-icon' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' -import { DataTableColumnHeader } from '@/components/data-table' +import { DataTableColumnHeader, BadgeListCell } from '@/components/data-table' import { GroupBadge } from '@/components/group-badge' -import { StatusBadge, StatusBadgeList } from '@/components/status-badge' +import { StatusBadge } from '@/components/status-badge' import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants' import { getDynamicDisplayGroupRatio, @@ -53,36 +47,6 @@ export interface PricingColumnsOptions { showRechargePrice?: boolean } -function renderLimitedTags( - items: string[], - maxDisplay: number = 3 -): React.ReactNode { - return ( - item} - renderItem={(item) => ( - - )} - /> - ) -} - -function renderLimitedGroupBadges( - groups: string[], - maxDisplay: number = 2 -): React.ReactNode { - return ( - group} - renderItem={(group) => } - /> - ) -} - export function usePricingColumns( options: PricingColumnsOptions = {} ): ColumnDef[] { @@ -377,23 +341,18 @@ export function usePricingColumns( header: t('Tags'), cell: ({ row }) => { const tags = parseTags(row.original.tags) - if (tags.length === 0) { - return - } - return ( - - - }> - {renderLimitedTags(tags, 2)} - - {tags.length > 2 && ( - - {tags.join(', ')} - - )} - - + ( + + ))} + /> ) }, size: 140, @@ -407,23 +366,18 @@ export function usePricingColumns( header: t('Endpoints'), cell: ({ row }) => { const endpoints = row.original.supported_endpoint_types || [] - if (endpoints.length === 0) { - return - } - return ( - - - }> - {renderLimitedTags(endpoints, 2)} - - {endpoints.length > 2 && ( - - {endpoints.join(', ')} - - )} - - + ( + + ))} + /> ) }, size: 130, @@ -437,27 +391,13 @@ export function usePricingColumns( header: t('Groups'), cell: ({ row }) => { const groups = row.original.enable_groups || [] - if (groups.length === 0) { - return - } - return ( - - - }> - {renderLimitedGroupBadges(groups, 2)} - - {groups.length > 2 && ( - -
- {groups.map((group) => ( - - ))} -
-
- )} -
-
+ ( + + ))} + tooltipClassName='max-w-[280px] p-2' + /> ) }, size: 130, diff --git a/web/default/src/features/users/components/users-columns.tsx b/web/default/src/features/users/components/users-columns.tsx index c054a1792d58..2ae23ea6321f 100644 --- a/web/default/src/features/users/components/users-columns.tsx +++ b/web/default/src/features/users/components/users-columns.tsx @@ -142,7 +142,7 @@ export function useUsersColumns(): ColumnDef[] { return ( - }> + }> Date: Thu, 11 Jun 2026 19:59:46 +0800 Subject: [PATCH 08/12] style(row-actions): offset action buttons to align with column header text --- .../features/channels/components/data-table-row-actions.tsx | 2 +- .../src/features/keys/components/data-table-row-actions.tsx | 2 +- .../src/features/models/components/data-table-row-actions.tsx | 4 +++- .../src/features/models/components/deployments-columns.tsx | 2 +- .../redemption-codes/components/data-table-row-actions.tsx | 4 +++- .../subscriptions/components/data-table-row-actions.tsx | 4 +++- .../src/features/users/components/data-table-row-actions.tsx | 4 ++-- 7 files changed, 14 insertions(+), 8 deletions(-) diff --git a/web/default/src/features/channels/components/data-table-row-actions.tsx b/web/default/src/features/channels/components/data-table-row-actions.tsx index 60cb6b23e8ac..0d93bf11a0e7 100644 --- a/web/default/src/features/channels/components/data-table-row-actions.tsx +++ b/web/default/src/features/channels/components/data-table-row-actions.tsx @@ -140,7 +140,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { } return ( -
+
({ } return ( -
+
+
+ +
) } diff --git a/web/default/src/features/models/components/deployments-columns.tsx b/web/default/src/features/models/components/deployments-columns.tsx index ae9d435dbe38..fb8a22ee4ebb 100644 --- a/web/default/src/features/models/components/deployments-columns.tsx +++ b/web/default/src/features/models/components/deployments-columns.tsx @@ -253,7 +253,7 @@ export function useDeploymentsColumns(opts: { '' return ( -
+
) }, - meta: { label: t('User') }, } ) } columns.push({ accessorKey: 'token_name', - header: ({ column }) => ( - - ), + header: t('Token'), cell: function TokenNameCell({ row }) { const { sensitiveVisible } = useUsageLogsContext() const log = row.original @@ -520,16 +508,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
) }, - meta: { label: t('Token') }, size: 160, }) - columns.push( { accessorKey: 'model_name', - header: ({ column }) => ( - - ), + header: t('Model'), cell: function ModelCell({ row }) { const log = row.original if (!isDisplayableLogType(log.type)) return null @@ -545,14 +529,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
) }, - meta: { label: t('Model'), mobileTitle: true }, + meta: { mobileTitle: true }, }, - { accessorKey: 'use_time', - header: ({ column }) => ( - - ), + header: t('Timing'), cell: ({ row }) => { const log = row.original if (!isTimingLogType(log.type)) return null @@ -656,14 +637,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
) }, - meta: { label: t('Timing') }, }, { accessorKey: 'prompt_tokens', - header: ({ column }) => ( - - ), + header: 'Tokens', cell: ({ row }) => { const log = row.original if (!isDisplayableLogType(log.type)) return null @@ -707,14 +685,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
) }, - meta: { label: 'Tokens' }, }, { accessorKey: 'quota', - header: ({ column }) => ( - - ), + header: t('Cost'), cell: ({ row }) => { const log = row.original if (!isDisplayableLogType(log.type)) return null @@ -762,7 +737,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
) }, - meta: { label: t('Cost') }, }, { @@ -820,7 +794,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] { ) }, - meta: { label: t('Details') }, size: 180, maxSize: 200, } diff --git a/web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx index 41b46aa6dd75..e424de6bbd08 100644 --- a/web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx +++ b/web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx @@ -38,7 +38,6 @@ import { } from 'lucide-react' import { useTranslation } from 'react-i18next' import { formatTimestampToDate } from '@/lib/format' -import { DataTableColumnHeader } from '@/components/data-table' import { StatusBadge } from '@/components/status-badge' import { MJ_TASK_TYPES } from '../../constants' import { @@ -87,9 +86,7 @@ export function useDrawingLogsColumns( const columns: ColumnDef[] = [ { accessorKey: 'submit_time', - header: ({ column }) => ( - - ), + header: t('Submit Time'), cell: ({ row }) => { const log = row.original const submitTime = row.getValue('submit_time') as number @@ -109,7 +106,6 @@ export function useDrawingLogsColumns( ) }, size: 180, - meta: { label: t('Submit Time') }, }, ] @@ -121,9 +117,7 @@ export function useDrawingLogsColumns( columns.push({ accessorKey: 'action', - header: ({ column }) => ( - - ), + header: t('Type'), cell: ({ row }) => { const action = row.getValue('action') as string return ( @@ -136,14 +130,11 @@ export function useDrawingLogsColumns( /> ) }, - meta: { label: t('Type') }, }) columns.push({ accessorKey: 'mj_id', - header: ({ column }) => ( - - ), + header: t('Task ID'), cell: ({ row }) => { const mjId = row.getValue('mj_id') as string @@ -162,7 +153,7 @@ export function useDrawingLogsColumns(
) }, - meta: { label: t('Task ID'), mobileTitle: true }, + meta: { mobileTitle: true }, }) columns.push( @@ -176,9 +167,7 @@ export function useDrawingLogsColumns( if (isAdmin) { columns.push({ accessorKey: 'code', - header: ({ column }) => ( - - ), + header: t('Submit Result'), cell: ({ row }) => { const code = row.getValue('code') as number @@ -191,7 +180,6 @@ export function useDrawingLogsColumns( /> ) }, - meta: { label: t('Submit Result') }, }) } @@ -199,9 +187,7 @@ export function useDrawingLogsColumns( createProgressColumn({ headerLabel: t('Progress') }), { accessorKey: 'image_url', - header: ({ column }) => ( - - ), + header: t('Image'), cell: function ImageCell({ row }) { const log = row.original const imageUrl = row.getValue('image_url') as string @@ -232,13 +218,10 @@ export function useDrawingLogsColumns( ) }, - meta: { label: t('Image') }, }, { accessorKey: 'prompt', - header: ({ column }) => ( - - ), + header: t('Prompt'), cell: function PromptCell({ row }) { const log = row.original const prompt = row.getValue('prompt') as string @@ -269,7 +252,6 @@ export function useDrawingLogsColumns( ) }, - meta: { label: t('Prompt') }, size: 200, maxSize: 220, }, diff --git a/web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx index 61825161d186..932ca2b2fba7 100644 --- a/web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx +++ b/web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx @@ -25,7 +25,6 @@ import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar' import { formatTimestampToDate } from '@/lib/format' import { cn } from '@/lib/utils' import { Avatar, AvatarFallback } from '@/components/ui/avatar' -import { DataTableColumnHeader } from '@/components/data-table' import { StatusBadge } from '@/components/status-badge' import { TASK_ACTIONS, TASK_STATUS } from '../../constants' import { taskActionMapper, taskStatusMapper } from '../../lib/mappers' @@ -94,9 +93,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { const columns: ColumnDef[] = [ { accessorKey: 'submit_time', - header: ({ column }) => ( - - ), + header: t('Submit Time'), cell: ({ row }) => { const log = row.original const submitTime = row.getValue('submit_time') as number @@ -117,17 +114,14 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { ) }, size: 180, - meta: { label: t('Submit Time') }, }, ] if (isAdmin) { columns.push(createChannelColumn({ headerLabel: t('Channel') }), { id: 'user', + header: t('User'), accessorFn: (row) => row.username || row.user_id, - header: ({ column }) => ( - - ), cell: function UserCell({ row }) { const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } = useUsageLogsContext() @@ -163,16 +157,13 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { ) }, - meta: { label: t('User') }, }) } columns.push( { accessorKey: 'task_id', - header: ({ column }) => ( - - ), + header: t('Task ID'), cell: ({ row }) => { const log = row.original const taskId = row.getValue('task_id') as string @@ -193,7 +184,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] {
) }, - meta: { label: t('Task ID'), mobileTitle: true }, + meta: { mobileTitle: true }, }, createDurationColumn({ submitTimeKey: 'submit_time', @@ -204,9 +195,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { }), { accessorKey: 'status', - header: ({ column }) => ( - - ), + header: t('Status'), cell: ({ row }) => { const status = row.getValue('status') as string return ( @@ -218,14 +207,11 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { /> ) }, - meta: { label: t('Status') }, }, createProgressColumn({ headerLabel: t('Progress') }), { accessorKey: 'fail_reason', - header: ({ column }) => ( - - ), + header: t('Details'), cell: function DetailsCell({ row }) { const log = row.original const failReason = row.getValue('fail_reason') as string @@ -295,7 +281,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { ) }, - meta: { label: t('Details') }, size: 200, maxSize: 220, } diff --git a/web/default/src/features/users/components/users-columns.tsx b/web/default/src/features/users/components/users-columns.tsx index 2ae23ea6321f..6c5e5ee54b4e 100644 --- a/web/default/src/features/users/components/users-columns.tsx +++ b/web/default/src/features/users/components/users-columns.tsx @@ -27,7 +27,6 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' -import { DataTableColumnHeader } from '@/components/data-table' import { GroupBadge } from '@/components/group-badge' import { LongText } from '@/components/long-text' import { StatusBadge } from '@/components/status-badge' @@ -67,26 +66,21 @@ export function useUsersColumns(): ColumnDef[] { enableSorting: false, enableHiding: false, size: 40, - meta: { label: t('Select') }, }, { accessorKey: 'id', - header: ({ column }) => ( - - ), + header: t('ID'), cell: ({ row }) => { return ( ) }, size: 80, - meta: { label: t('ID'), mobileHidden: true }, + meta: { mobileHidden: true }, }, { accessorKey: 'username', - header: ({ column }) => ( - - ), + header: t('Username'), cell: ({ row }) => { const username = row.getValue('username') as string const displayName = row.original.display_name @@ -121,13 +115,11 @@ export function useUsersColumns(): ColumnDef[] { }, enableHiding: false, size: 220, - meta: { label: t('Username'), mobileTitle: true }, + meta: { mobileTitle: true }, }, { accessorKey: 'status', - header: ({ column }) => ( - - ), + header: t('Status'), cell: ({ row }) => { const user = row.original const requestCount = user.request_count @@ -162,14 +154,12 @@ export function useUsersColumns(): ColumnDef[] { }, enableSorting: false, size: 120, - meta: { label: t('Status'), mobileBadge: true }, + meta: { mobileBadge: true }, }, { id: 'quota', accessorKey: 'quota', - header: ({ column }) => ( - - ), + header: t('Quota'), cell: ({ row }) => { const user = row.original const used = user.used_quota @@ -225,13 +215,10 @@ export function useUsersColumns(): ColumnDef[] { ) }, size: 170, - meta: { label: t('Quota') }, }, { accessorKey: 'group', - header: ({ column }) => ( - - ), + header: t('Group'), cell: ({ row }) => { const group = row.getValue('group') as string return @@ -242,13 +229,10 @@ export function useUsersColumns(): ColumnDef[] { return group.includes(searchValue) }, size: 140, - meta: { label: t('Group') }, }, { accessorKey: 'role', - header: ({ column }) => ( - - ), + header: t('Role'), cell: ({ row }) => { const roleValue = row.getValue('role') as number const roleConfig = USER_ROLES[roleValue as keyof typeof USER_ROLES] @@ -271,13 +255,10 @@ export function useUsersColumns(): ColumnDef[] { }, enableSorting: false, size: 120, - meta: { label: t('Role') }, }, { id: 'invite_info', - header: ({ column }) => ( - - ), + header: t('Invite Info'), cell: ({ row }) => { const user = row.original const affCount = user.aff_count || 0 @@ -347,13 +328,11 @@ export function useUsersColumns(): ColumnDef[] { }, size: 240, enableSorting: false, - meta: { label: t('Invite Info'), mobileHidden: true }, + meta: { mobileHidden: true }, }, { accessorKey: 'created_at', - header: ({ column }) => ( - - ), + header: t('Created At'), cell: ({ row }) => { const ts = row.getValue('created_at') as number | undefined return ( @@ -363,13 +342,11 @@ export function useUsersColumns(): ColumnDef[] { ) }, size: 180, - meta: { label: t('Created At'), mobileHidden: true }, + meta: { mobileHidden: true }, }, { accessorKey: 'last_login_at', - header: ({ column }) => ( - - ), + header: t('Last Login'), cell: ({ row }) => { const ts = row.getValue('last_login_at') as number | undefined return ( @@ -379,13 +356,13 @@ export function useUsersColumns(): ColumnDef[] { ) }, size: 180, - meta: { label: t('Last Login'), mobileHidden: true }, + meta: { mobileHidden: true }, }, { id: 'actions', header: () => t('Actions'), cell: ({ row }) => , - meta: { label: t('Actions'), pinned: 'right' as const }, + meta: { pinned: 'right' as const }, }, ] } From 37ce863691217931049eddf5115bbef8a61f6d23 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Fri, 12 Jun 2026 22:04:34 +0800 Subject: [PATCH 11/12] feat(status-badge): add text and underline display types - introduce StatusBadgeType ('badge' | 'text' | 'underline') and StatusBadgeTypeContext so ancestors can override rendering without touching call sites - mobile card field rows now use the text type via context, showing badges as plain colored text instead of pills - ProviderBadge gains data-slot='provider-badge' to enable targeted CSS resets in compact layouts - replace the implicit [[data-slot=table-cell]>&]:-ml-1.5 rule with explicit -ml-1.5 at each column call site --- .../data-table/layout/mobile-card-list.tsx | 26 +++++++++----- web/default/src/components/provider-badge.tsx | 10 ++++-- web/default/src/components/status-badge.tsx | 36 ++++++++++++++++--- .../channels/components/channels-columns.tsx | 8 ++++- .../keys/components/api-keys-columns.tsx | 3 ++ .../models/components/deployments-columns.tsx | 4 ++- .../models/components/models-columns.tsx | 5 ++- .../pricing/components/pricing-columns.tsx | 1 + .../components/redemptions-columns.tsx | 4 +++ .../components/subscriptions-columns.tsx | 2 ++ .../models/model-ratio-table-columns.tsx | 2 +- .../columns/drawing-logs-columns.tsx | 2 ++ .../components/columns/task-logs-columns.tsx | 1 + .../users/components/users-columns.tsx | 1 + 14 files changed, 86 insertions(+), 19 deletions(-) diff --git a/web/default/src/components/data-table/layout/mobile-card-list.tsx b/web/default/src/components/data-table/layout/mobile-card-list.tsx index d89f8e87d1d6..8eb319052bfe 100644 --- a/web/default/src/components/data-table/layout/mobile-card-list.tsx +++ b/web/default/src/components/data-table/layout/mobile-card-list.tsx @@ -25,6 +25,7 @@ import { } from '@tanstack/react-table' import { Database } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { StatusBadgeTypeContext } from '@/components/status-badge' import { cn } from '@/lib/utils' import { Empty, @@ -139,12 +140,14 @@ function CompactRow({ row }: { row: Row }) { {/* Row 1: Title + Badge */}
{titleCell && ( -
+
{renderCellContent(titleCell)}
)} {badgeCell && ( -
{renderCellContent(badgeCell)}
+
+ {renderCellContent(badgeCell)} +
)}
@@ -160,8 +163,10 @@ function CompactRow({ row }: { row: Row }) { {label}
)} -
- {renderCellContent(cell) ?? '-'} +
+ + {renderCellContent(cell) ?? '-'} +
) @@ -203,12 +208,13 @@ function FallbackRow({ row }: { row: Row }) { <> {contentCells.map((cell) => { const label = getCellLabel(cell) - const content = renderCellContent(cell) if (!label) { return ( -
- {content} +
+ + {renderCellContent(cell)} +
) } @@ -221,8 +227,10 @@ function FallbackRow({ row }: { row: Row }) { {label} -
- {content ?? '-'} +
+ + {renderCellContent(cell) ?? '-'} +
) diff --git a/web/default/src/components/provider-badge.tsx b/web/default/src/components/provider-badge.tsx index c2eb401d781f..40891fe43a76 100644 --- a/web/default/src/components/provider-badge.tsx +++ b/web/default/src/components/provider-badge.tsx @@ -36,9 +36,15 @@ export function ProviderBadge({ const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null return ( -
+
{icon} - +
) } diff --git a/web/default/src/components/status-badge.tsx b/web/default/src/components/status-badge.tsx index 0af763986ed5..24e04aaa024f 100644 --- a/web/default/src/components/status-badge.tsx +++ b/web/default/src/components/status-badge.tsx @@ -22,7 +22,6 @@ import { type LucideIcon } from 'lucide-react' import { stringToColor } from '@/lib/colors' import { cn } from '@/lib/utils' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' - export const dotColorMap = { success: 'bg-success', warning: 'bg-warning', @@ -73,12 +72,29 @@ export const textColorMap = { export type StatusVariant = keyof typeof dotColorMap +/** Controls the visual style of the badge. + * - `badge` — default pill with background and padding (default) + * - `text` — plain text, no background or padding, only color + * - `underline`— plain text with a bottom border underline + */ +export type StatusBadgeType = 'badge' | 'text' | 'underline' + +/** Context that lets ancestor components (e.g. MobileCardList field area) + * override the badge type without modifying every call site. */ +export const StatusBadgeTypeContext = React.createContext('badge') + const sizeMap = { sm: 'h-5 gap-1 px-1.5 text-xs leading-none', md: 'h-5 gap-1 px-1.5 text-xs leading-none', lg: 'h-6 gap-1.5 px-2 text-xs leading-none', } as const +const textSizeMap = { + sm: 'gap-1 text-xs leading-none', + md: 'gap-1 text-xs leading-none', + lg: 'gap-1.5 text-xs leading-none', +} as const + export interface StatusBadgeProps extends Omit< React.HTMLAttributes, 'children' @@ -94,6 +110,8 @@ export interface StatusBadgeProps extends Omit< copyable?: boolean copyText?: string autoColor?: string + /** Visual style. Defaults to 'badge'. Can be overridden via StatusBadgeTypeContext. */ + type?: StatusBadgeType } export function StatusBadge({ @@ -107,11 +125,14 @@ export function StatusBadge({ copyable = true, copyText, autoColor, + type: typeProp, className, onClick, ...props }: StatusBadgeProps) { const { copyToClipboard } = useCopyToClipboard() + const contextType = React.useContext(StatusBadgeTypeContext) + const type = typeProp ?? contextType const computedVariant: StatusVariant = autoColor ? (stringToColor(autoColor) as StatusVariant) @@ -126,14 +147,21 @@ export function StatusBadge({ } const content = - children ?? (label ? {label} : null) + children ?? + (label ? ( + {label} + ) : null) + + const isBadge = type === 'badge' return ( &]:-ml-1.5', - sizeMap[size ?? 'sm'], + 'inline-flex w-fit max-w-full shrink-0 items-center font-medium tracking-normal whitespace-nowrap transition-colors', + isBadge + ? cn('rounded-4xl', sizeMap[size ?? 'sm']) + : cn(textSizeMap[size ?? 'sm'], type === 'underline' && 'border-b border-current pb-px'), textColorMap[computedVariant], pulse && 'animate-pulse', copyable && diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index f177ee2742d3..cf8d3cefe4ed 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -298,6 +298,7 @@ function BalanceCell({ channel }: { channel: Channel }) { size='sm' copyable={false} showDot={false} + className='-ml-1.5' /> ) } @@ -594,6 +595,7 @@ export function useChannelsColumns(): ColumnDef[] { variant='blue' size='sm' copyable={false} + className='-ml-1.5' /> ) } @@ -716,6 +718,7 @@ export function useChannelsColumns(): ColumnDef[] { variant='success' size='sm' copyable={false} + className='-ml-1.5' /> ) } else { @@ -725,6 +728,7 @@ export function useChannelsColumns(): ColumnDef[] { variant='neutral' size='sm' copyable={false} + className='-ml-1.5' /> ) } @@ -802,6 +806,7 @@ export function useChannelsColumns(): ColumnDef[] { variant={config.variant} size='sm' copyable={false} + className='-ml-1.5' /> ) }, @@ -878,7 +883,7 @@ export function useChannelsColumns(): ColumnDef[] { if (!tag) return - - return + return }, size: 120, enableSorting: false, @@ -926,6 +931,7 @@ export function useChannelsColumns(): ColumnDef[] { variant={config.variant} size='sm' copyable={false} + className='-ml-1.5' /> ) }, diff --git a/web/default/src/features/keys/components/api-keys-columns.tsx b/web/default/src/features/keys/components/api-keys-columns.tsx index eb20e4b43196..9b8c896f638f 100644 --- a/web/default/src/features/keys/components/api-keys-columns.tsx +++ b/web/default/src/features/keys/components/api-keys-columns.tsx @@ -115,6 +115,7 @@ export function useApiKeysColumns(): ColumnDef[] { label={t(statusConfig.label)} variant={statusConfig.variant} copyable={false} + className='-ml-1.5' /> ) }, @@ -142,6 +143,7 @@ export function useApiKeysColumns(): ColumnDef[] { label={t('Unlimited')} variant='neutral' copyable={false} + className='-ml-1.5' /> ) } @@ -283,6 +285,7 @@ export function useApiKeysColumns(): ColumnDef[] { label={t('Never')} variant='neutral' copyable={false} + className='-ml-1.5' /> ) } diff --git a/web/default/src/features/models/components/deployments-columns.tsx b/web/default/src/features/models/components/deployments-columns.tsx index c16c745f9817..541fefb56402 100644 --- a/web/default/src/features/models/components/deployments-columns.tsx +++ b/web/default/src/features/models/components/deployments-columns.tsx @@ -66,7 +66,7 @@ export function useDeploymentsColumns(opts: { variant='neutral' copyText={name} size='sm' - className='font-mono' + className='-ml-1.5 font-mono' /> ) }, @@ -90,6 +90,7 @@ export function useDeploymentsColumns(opts: { variant={config.variant} size='sm' copyable={false} + className='-ml-1.5' /> ) }, @@ -120,6 +121,7 @@ export function useDeploymentsColumns(opts: { autoColor={String(provider)} size='sm' copyable={false} + className='-ml-1.5' /> ) }, diff --git a/web/default/src/features/models/components/models-columns.tsx b/web/default/src/features/models/components/models-columns.tsx index c2b2f0e83607..a5d29528fbaa 100644 --- a/web/default/src/features/models/components/models-columns.tsx +++ b/web/default/src/features/models/components/models-columns.tsx @@ -137,7 +137,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { variant='neutral' copyText={name} size='sm' - className='font-mono' + className='-ml-1.5 font-mono' /> ) }, @@ -170,6 +170,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { | 'info' } size='sm' + className='-ml-1.5' /> ) @@ -220,6 +221,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { variant={config.variant} size='sm' copyable={false} + className='-ml-1.5' /> ) }, @@ -408,6 +410,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef[] { variant={syncOfficial === 1 ? 'success' : 'warning'} size='sm' copyable={false} + className='-ml-1.5' /> ) }, diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index 162f6c9b02a6..d64d3bccd7ce 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -96,6 +96,7 @@ export function usePricingColumns( label={isTokenBased ? t('Token') : t('Request')} variant={isTokenBased ? 'info' : 'neutral'} copyable={false} + className='-ml-1.5' /> ) }, diff --git a/web/default/src/features/redemption-codes/components/redemptions-columns.tsx b/web/default/src/features/redemption-codes/components/redemptions-columns.tsx index 15084bb6d8b3..6512fb73b22c 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-columns.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-columns.tsx @@ -98,6 +98,7 @@ export function useRedemptionsColumns(): ColumnDef[] { label={t('Expired')} variant='warning' copyable={false} + className='-ml-1.5' /> ) } @@ -113,6 +114,7 @@ export function useRedemptionsColumns(): ColumnDef[] { label={t(statusConfig.labelKey)} variant={statusConfig.variant} copyable={false} + className='-ml-1.5' /> ) }, @@ -164,6 +166,7 @@ export function useRedemptionsColumns(): ColumnDef[] { label={formatQuota(quota)} variant='neutral' copyable={false} + className='-ml-1.5' /> ) }, @@ -194,6 +197,7 @@ export function useRedemptionsColumns(): ColumnDef[] { label={t('Never')} variant='neutral' copyable={false} + className='-ml-1.5' /> ) } diff --git a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx index 044e68863370..4c4d64979fc5 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx @@ -115,12 +115,14 @@ export function useSubscriptionsColumns(): ColumnDef[] { label={t('Enable')} variant='success' copyable={false} + className='-ml-1.5' /> ) : ( ), size: 80, diff --git a/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx b/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx index edb2423d8bad..7e89f22324c0 100644 --- a/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-table-columns.tsx @@ -112,7 +112,7 @@ export function buildModelRatioColumns({ variant={getModeVariant(row.original.billingMode)} copyable={false} showDot={false} - className='px-0' + className='-ml-1.5 px-0' /> ), filterFn: (row, id, value) => diff --git a/web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx index e424de6bbd08..83fb73ed1c94 100644 --- a/web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx +++ b/web/default/src/features/usage-logs/components/columns/drawing-logs-columns.tsx @@ -127,6 +127,7 @@ export function useDrawingLogsColumns( icon={getDrawingTypeIcon(action)} size='sm' copyable={false} + className='-ml-1.5' /> ) }, @@ -177,6 +178,7 @@ export function useDrawingLogsColumns( variant={mjSubmitResultMapper.getVariant(String(code))} size='sm' copyable={false} + className='-ml-1.5' /> ) }, diff --git a/web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx index 932ca2b2fba7..9f37b6ee856b 100644 --- a/web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx +++ b/web/default/src/features/usage-logs/components/columns/task-logs-columns.tsx @@ -204,6 +204,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { variant={taskStatusMapper.getVariant(status)} size='sm' copyable={false} + className='-ml-1.5' /> ) }, diff --git a/web/default/src/features/users/components/users-columns.tsx b/web/default/src/features/users/components/users-columns.tsx index 6c5e5ee54b4e..1cc3953dcb3b 100644 --- a/web/default/src/features/users/components/users-columns.tsx +++ b/web/default/src/features/users/components/users-columns.tsx @@ -173,6 +173,7 @@ export function useUsersColumns(): ColumnDef[] { label={t('No Quota')} variant='neutral' copyable={false} + className='-ml-1.5' /> ) } From 2d5e08762fe5c58bd9c54eb60c1dafd0995115fd Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Fri, 12 Jun 2026 22:54:35 +0800 Subject: [PATCH 12/12] refactor(data-table): simplify table filtering internals - derive toolbar search state from the active table filter to avoid render-time ref writes. - extract faceted filter selection updates into a pure helper for clearer single and multi-select behavior. - split pinned column resolution into focused helpers so explicit and meta pins merge predictably. --- .../data-table/core/data-table-view.tsx | 52 +++++++++--- .../data-table/toolbar/faceted-filter.tsx | 61 +++++++------ .../components/data-table/toolbar/toolbar.tsx | 85 +++++++------------ 3 files changed, 104 insertions(+), 94 deletions(-) diff --git a/web/default/src/components/data-table/core/data-table-view.tsx b/web/default/src/components/data-table/core/data-table-view.tsx index 805819093873..9bc1d1e414bb 100644 --- a/web/default/src/components/data-table/core/data-table-view.tsx +++ b/web/default/src/components/data-table/core/data-table-view.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import * as React from 'react' -import { type Row } from '@tanstack/react-table' +import { type Row, type Table as TanstackTable } from '@tanstack/react-table' import { cn } from '@/lib/utils' import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table' import { @@ -166,22 +166,13 @@ function SplitHeaderTableView({ } function useResolvedColumnClassName( - table: import('@tanstack/react-table').Table, + table: TanstackTable, getColumnClassName?: DataTableColumnClassName, pinnedColumns?: DataTablePinnedColumn[] ) { const allPinnedColumns = React.useMemo(() => { - const fromMeta: DataTablePinnedColumn[] = table - .getAllColumns() - .filter((col) => col.columnDef.meta?.pinned) - .map((col) => ({ - columnId: col.id, - side: col.columnDef.meta!.pinned!, - })) - if (!fromMeta.length) return pinnedColumns - if (!pinnedColumns?.length) return fromMeta - const explicitIds = new Set(pinnedColumns.map((p) => p.columnId)) - return [...pinnedColumns, ...fromMeta.filter((p) => !explicitIds.has(p.columnId))] + const metaPinnedColumns = getMetaPinnedColumns(table) + return mergePinnedColumns(pinnedColumns, metaPinnedColumns) }, [table, pinnedColumns]) const pinnedColumnById = React.useMemo( @@ -196,6 +187,41 @@ function useResolvedColumnClassName( ) } +function getMetaPinnedColumns( + table: TanstackTable +): 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(props: DataTableViewProps): { colgroup?: React.ReactNode style?: React.CSSProperties diff --git a/web/default/src/components/data-table/toolbar/faceted-filter.tsx b/web/default/src/components/data-table/toolbar/faceted-filter.tsx index 33a1c1812ad5..5fb800159f51 100644 --- a/web/default/src/components/data-table/toolbar/faceted-filter.tsx +++ b/web/default/src/components/data-table/toolbar/faceted-filter.tsx @@ -62,11 +62,19 @@ function DataTableFacetedFilterInner({ const { t } = useTranslation() const facets = column?.getFacetedUniqueValues() const filterValue = column?.getFilterValue() as string[] | undefined - const selectedValues = React.useMemo( - () => new Set(filterValue), - // eslint-disable-next-line react-hooks/exhaustive-deps - [filterValue?.join(',')] - ) + const selectedValues = new Set(filterValue) + + const handleOptionSelect = (optionValue: string) => { + const nextSelectedValues = getNextSelectedValues( + selectedValues, + optionValue, + singleSelect + ) + + column?.setFilterValue( + nextSelectedValues.length ? nextSelectedValues : undefined + ) + } return ( @@ -122,29 +130,7 @@ function DataTableFacetedFilterInner({ return ( { - if (singleSelect) { - // Single select mode: toggle or switch selection - if (isSelected) { - // Deselect if clicking the same option - column?.setFilterValue(undefined) - } else { - // Select only this option - column?.setFilterValue([option.value]) - } - } else { - // Multi-select mode: original behavior - if (isSelected) { - selectedValues.delete(option.value) - } else { - selectedValues.add(option.value) - } - const filterValues = Array.from(selectedValues) - column?.setFilterValue( - filterValues.length ? filterValues : undefined - ) - } - }} + onSelect={() => handleOptionSelect(option.value)} >
({ export const DataTableFacetedFilter = React.memo( DataTableFacetedFilterInner ) as typeof DataTableFacetedFilterInner + +function getNextSelectedValues( + selectedValues: Set, + optionValue: string, + singleSelect: boolean +): string[] { + if (singleSelect) { + return selectedValues.has(optionValue) ? [] : [optionValue] + } + + const nextSelectedValues = new Set(selectedValues) + if (nextSelectedValues.has(optionValue)) { + nextSelectedValues.delete(optionValue) + } else { + nextSelectedValues.add(optionValue) + } + + return Array.from(nextSelectedValues) +} diff --git a/web/default/src/components/data-table/toolbar/toolbar.tsx b/web/default/src/components/data-table/toolbar/toolbar.tsx index 44903a4c8bd9..543e6146ec5d 100644 --- a/web/default/src/components/data-table/toolbar/toolbar.tsx +++ b/web/default/src/components/data-table/toolbar/toolbar.tsx @@ -19,9 +19,9 @@ For commercial licensing, please contact support@quantumnous.com import * as React from 'react' import { useState, type ReactNode } from 'react' import { type Table } from '@tanstack/react-table' +import { useDebounce } from '@/hooks' import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { useDebounce } from '@/hooks' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -41,6 +41,11 @@ type FilterDef = { singleSelect?: boolean } +type SearchDraft = { + baseValue: string + value: string +} + export type DataTableToolbarProps = { table: Table /** @@ -141,12 +146,7 @@ export type DataTableToolbarProps = { export function DataTableToolbar(props: DataTableToolbarProps) { const { t } = useTranslation() const [expanded, setExpanded] = useState(false) - const isSearchComposingRef = React.useRef(false) - const lastCommittedSearchValueRef = React.useRef('') - const tableRef = React.useRef(props.table) - tableRef.current = props.table - const searchKeyRef = React.useRef(props.searchKey) - searchKeyRef.current = props.searchKey + const [isSearchComposing, setIsSearchComposing] = useState(false) const filters = props.filters ?? [] const hasExpandable = props.expandable != null @@ -163,46 +163,37 @@ export function DataTableToolbar(props: DataTableToolbarProps) { '') : ((props.table.getState().globalFilter as string | undefined) ?? '') - const [searchValue, setSearchValue] = useState(currentSearchValue) - const [pendingSearchValue, setPendingSearchValue] = - useState(currentSearchValue) + const [searchDraft, setSearchDraft] = useState(null) + const activeSearchDraft = + searchDraft && + (isSearchComposing || searchDraft.baseValue === currentSearchValue) + ? searchDraft + : null + const searchValue = activeSearchDraft?.value ?? currentSearchValue const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0) - const debouncedSearchValue = useDebounce( - pendingSearchValue, - searchDebounceMs - ) - - React.useEffect(() => { - lastCommittedSearchValueRef.current = currentSearchValue - if (!isSearchComposingRef.current) { - setSearchValue(currentSearchValue) - } - setPendingSearchValue(currentSearchValue) - }, [currentSearchValue]) + const debouncedSearchValue = useDebounce(searchValue, searchDebounceMs) const commitSearchValue = React.useCallback( (value: string) => { - if (value === lastCommittedSearchValueRef.current) { + if (value === currentSearchValue) { return } - lastCommittedSearchValueRef.current = value - - if (searchKeyRef.current) { - tableRef.current.getColumn(searchKeyRef.current)?.setFilterValue(value) + if (props.searchKey) { + props.table.getColumn(props.searchKey)?.setFilterValue(value) return } - tableRef.current.setGlobalFilter(value) + props.table.setGlobalFilter(value) }, - [] // stable — reads props via refs at call time + [currentSearchValue, props.searchKey, props.table] ) React.useEffect(() => { if ( searchDebounceMs <= 0 || - isSearchComposingRef.current || - debouncedSearchValue !== pendingSearchValue + isSearchComposing || + debouncedSearchValue !== searchValue ) { return } @@ -211,13 +202,12 @@ export function DataTableToolbar(props: DataTableToolbarProps) { }, [ commitSearchValue, debouncedSearchValue, - pendingSearchValue, + isSearchComposing, searchDebounceMs, + searchValue, ]) const queueSearchValue = (value: string) => { - setPendingSearchValue(value) - if (searchDebounceMs <= 0) { commitSearchValue(value) } @@ -225,36 +215,27 @@ export function DataTableToolbar(props: DataTableToolbarProps) { const handleSearchChange = (event: React.ChangeEvent) => { const value = event.target.value - setSearchValue(value) + setSearchDraft({ baseValue: currentSearchValue, value }) - if (!isSearchComposingRef.current) { + if (!isSearchComposing) { queueSearchValue(value) } } const handleSearchCompositionStart = () => { - isSearchComposingRef.current = true + setIsSearchComposing(true) } const handleSearchCompositionEnd = ( event: React.CompositionEvent ) => { - isSearchComposingRef.current = false + setIsSearchComposing(false) const value = event.currentTarget.value - setSearchValue(value) + setSearchDraft({ baseValue: currentSearchValue, value }) queueSearchValue(value) } - const searchInput = props.searchKey ? ( - - ) : ( + const searchInput = ( (props: DataTableToolbarProps) { ) const handleReset = () => { - isSearchComposingRef.current = false - setSearchValue('') - setPendingSearchValue('') - lastCommittedSearchValueRef.current = '' + setIsSearchComposing(false) + setSearchDraft(null) props.table.resetColumnFilters() props.table.setGlobalFilter('') props.onReset?.()