perf(data-table): improve data table layout and badge display (#5460)

* 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.

* 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

* 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.

* 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.

* 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

* style(row-actions): align action buttons to leading edge of column

* 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

* style(row-actions): offset action buttons to align with column header text

* refactor(data-table): consolidate mobile meta into ColumnMeta declaration

- move mobileTitle, mobileBadge, mobileHidden into the global ColumnMeta augmentation so the type is shared across the project
- remove the local MobileColumnMeta interface and getCellMeta helper from mobile-card-list.tsx
- direct col.columnDef.meta access is now type-safe without explicit casting

* refactor(data-table): simplify column header and meta config

- auto-render string `header` values via DataTableColumnHeader so sortable/non-sortable columns work without boilerplate function wrappers
- promote mobile layout hints (mobileTitle, mobileBadge, mobileHidden) into the global ColumnMeta type, removing the local MobileColumnMeta cast in mobile-card-list
- migrate all column files from `meta: { label }` to top-level `header: t('...')`, cutting ~180 lines of repetitive template code
- ViewOptions and MobileCardList label resolution now reads string header first, then meta.label as fallback

* 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

* 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.

Co-authored-by: t0ng7u <dev@aiass.cc>
This commit is contained in:
QuentinHsu
2026-06-12 23:18:22 +08:00
committed by GitHub
co-authored by t0ng7u
parent 6f415428d3
commit 27b2b2c4b9
37 changed files with 694 additions and 877 deletions
@@ -45,10 +45,10 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTableColumnHeader } from '@/components/data-table'
import { 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 (
<StatusBadgeList
items={items}
max={maxDisplay}
renderItem={(item) => item}
/>
)
}
/**
* Upstream update tags (+N / -N) shown on channel name for model-fetchable channels
*/
@@ -314,6 +298,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
size='sm'
copyable={false}
showDot={false}
className='-ml-1.5'
/>
)
}
@@ -349,7 +334,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
return (
<TooltipProvider>
<div className='flex items-center gap-1'>
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
@@ -476,10 +461,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// ID column
{
accessorKey: 'id',
meta: { label: t('ID'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title='ID' />
),
header: t('ID'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const id = row.getValue('id') as number
return <TableId value={id} />
@@ -490,10 +473,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Name column
{
accessorKey: 'name',
meta: { label: t('Name'), mobileTitle: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Name')} />
),
header: t('Name'),
meta: { mobileTitle: true },
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
const name = row.getValue('name') as string
@@ -603,7 +584,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Type column
{
accessorKey: 'type',
meta: { label: t('Type') },
header: t('Type'),
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
@@ -615,6 +595,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
variant='blue'
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
}
@@ -718,8 +699,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Status column
{
accessorKey: 'status',
meta: { label: t('Status'), mobileBadge: true },
header: t('Status'),
meta: { mobileBadge: true },
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
const status = row.getValue('status') as number
@@ -737,6 +718,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
variant='success'
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
} else {
@@ -746,6 +728,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
variant='neutral'
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
}
@@ -823,6 +806,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
variant={config.variant}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
@@ -840,42 +824,23 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Models column
{
accessorKey: 'models',
meta: { label: t('Models'), mobileHidden: true },
header: t('Models'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const models = row.getValue('models') as string
const modelArray = parseModelsList(models)
if (modelArray.length === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const modelBadges = modelArray.map((model, idx) => (
<StatusBadge
key={idx}
label={model}
autoColor={model}
size='sm'
className='font-mono'
/>
))
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<div />}>
{renderLimitedItems(modelBadges, 2)}
</TooltipTrigger>
{modelArray.length > 2 && (
<TooltipContent
side='top'
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
>
<div className='flex flex-wrap gap-1'>{modelBadges}</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<BadgeListCell
items={modelArray.map((model, idx) => (
<StatusBadge
key={idx}
label={model}
autoColor={model}
size='sm'
className='font-mono'
/>
))}
/>
)
},
size: 200,
@@ -885,32 +850,17 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Group column
{
accessorKey: 'group',
meta: { label: t('Groups'), mobileHidden: true },
header: t('Groups'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const group = row.getValue('group') as string
const groupArray = parseGroupsList(group)
const groupBadges = groupArray.map((g) => (
<GroupBadge key={g} group={g} size='sm' />
))
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<div />}>
{renderLimitedItems(groupBadges, 2)}
</TooltipTrigger>
{groupArray.length > 2 && (
<TooltipContent
side='top'
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
>
<div className='flex flex-wrap gap-1'>{groupBadges}</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<BadgeListCell
items={groupArray.map((g) => (
<GroupBadge key={g} group={g} size='sm' />
))}
/>
)
},
filterFn: (row, id, value) => {
@@ -926,14 +876,14 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Tag column
{
accessorKey: 'tag',
meta: { label: t('Tag'), mobileHidden: true },
header: t('Tag'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const tag = row.getValue('tag') as string | null
if (!tag)
return <span className='text-muted-foreground text-xs'>-</span>
return <StatusBadge label={tag} autoColor={tag} size='sm' />
return <StatusBadge label={tag} autoColor={tag} size='sm' className='-ml-1.5' />
},
size: 120,
enableSorting: false,
@@ -942,10 +892,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Priority column
{
accessorKey: 'priority',
meta: { label: t('Priority'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Priority')} />
),
header: t('Priority'),
meta: { mobileHidden: true },
cell: ({ row }) => <PriorityCell channel={row.original} />,
size: 100,
},
@@ -953,8 +901,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Weight column
{
accessorKey: 'weight',
meta: { label: t('Weight'), mobileHidden: true },
header: t('Weight'),
meta: { mobileHidden: true },
cell: ({ row }) => <WeightCell channel={row.original} />,
size: 90,
enableSorting: false,
@@ -963,10 +911,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Balance column (Used/Remaining)
{
accessorKey: 'balance',
meta: { label: t('Used / Remaining') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Used / Remaining')} />
),
header: t('Used / Remaining'),
cell: ({ row }) => <BalanceCell channel={row.original} />,
size: 180,
},
@@ -974,10 +919,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Response Time column
{
accessorKey: 'response_time',
meta: { label: t('Response'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Response')} />
),
header: t('Response'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const responseTime = row.getValue('response_time') as number
const config = getResponseTimeConfig(responseTime)
@@ -988,6 +931,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
variant={config.variant}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
@@ -997,10 +941,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Test Time column
{
accessorKey: 'test_time',
meta: { label: t('Last Tested'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Last Tested')} />
),
header: t('Last Tested'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const testTime = row.getValue('test_time') as number
@@ -1037,6 +979,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// 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 +998,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
size: 132,
enableSorting: false,
enableHiding: false,
meta: { pinned: 'right' as const },
},
]
}