fix(web): prevent list cell text and badge overflow (#5510)

* fix(ui): prevent table cell text overflow

- add default truncation with hover details for text cells in shared and static data tables to prevent content from spilling into adjacent columns.
- adjust API key group, model, and IP restriction columns to fix badge overlap and left alignment drift.
- reuse a shared truncated cell component and add width constraints for composite badge cells.

* fix(table): prevent badge content from overflowing columns

- make table text and badge cells shrink within constrained columns so long values truncate instead of bleeding into adjacent cells.
- add a shared BadgeCell wrapper to keep badge alignment consistent across API keys and other list pages.
- update affected list views to use constrained wrappers for group, provider, pricing, OAuth, and API info values.
This commit is contained in:
QuentinHsu
2026-06-15 14:52:57 +08:00
committed by GitHub
parent 1ac0f5807a
commit 3c1bb0a74f
20 changed files with 355 additions and 140 deletions
+10 -3
View File
@@ -32,6 +32,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { BadgeCell } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { type ApiKey } from '../types'
import { useApiKeys } from './api-keys-provider'
@@ -157,7 +158,12 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
return (
<StatusBadge label={t('Unlimited')} variant='neutral' copyable={false} />
<StatusBadge
label={t('Unlimited')}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
)
}
@@ -165,7 +171,7 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
return (
<Tooltip>
<TooltipTrigger render={<span />}>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} model(s)', { count: models.length })}
variant='neutral'
@@ -195,6 +201,7 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
label={t('No restriction')}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
)
}
@@ -206,7 +213,7 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
return (
<Tooltip>
<TooltipTrigger render={<span />}>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} IP(s)', { count: ips.length })}
variant='neutral'
@@ -29,6 +29,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { BadgeCell, TruncatedCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { API_KEY_STATUSES } from '../constants'
@@ -97,9 +98,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
accessorKey: 'name',
header: t('Name'),
cell: ({ row }) => (
<div className='max-w-[200px] truncate font-medium'>
{row.getValue('name')}
</div>
<span className='font-medium'>{row.getValue('name')}</span>
),
size: 180,
meta: { mobileTitle: true },
@@ -200,9 +199,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
return (
<Tooltip>
<TooltipTrigger
render={
<span className='inline-flex items-center gap-1.5 text-xs' />
}
render={<BadgeCell className='gap-1.5 text-xs' />}
>
<GroupBadge group='auto' />
{apiKey.cross_group_retry && (
@@ -223,7 +220,15 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</Tooltip>
)
}
return <GroupBadge group={group} ratio={ratio} />
return (
<TruncatedCell
className='-ml-1.5'
tooltipContent={group || '-'}
tooltipClassName='break-all'
>
<GroupBadge group={group} ratio={ratio} />
</TruncatedCell>
)
},
size: 160,
meta: { mobileHidden: true },
@@ -197,7 +197,7 @@ export function useDeploymentsColumns(opts: {
if (!hardware)
return <span className='text-muted-foreground text-xs'>-</span>
return (
<div className='flex flex-wrap items-center gap-2'>
<div className='flex max-w-full min-w-0 flex-nowrap items-center gap-2 overflow-hidden'>
<StatusBadge
label={String(hardware)}
variant='neutral'
@@ -27,7 +27,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { BadgeListCell } from '@/components/data-table'
import { BadgeCell, BadgeListCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge } from '@/components/status-badge'
@@ -187,7 +187,9 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<div className='-ml-1.5' />}>{badge}</TooltipTrigger>
<TooltipTrigger render={<div className='-ml-1.5' />}>
{badge}
</TooltipTrigger>
<TooltipContent
side='top'
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
@@ -248,7 +250,11 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
return <span className='text-muted-foreground text-xs'>-</span>
}
return <ProviderBadge iconKey={vendor.icon} label={vendor.name} />
return (
<BadgeCell>
<ProviderBadge iconKey={vendor.icon} label={vendor.name} />
</BadgeCell>
)
},
filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true
@@ -19,7 +19,11 @@ 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 { DataTableColumnHeader, BadgeListCell } from '@/components/data-table'
import {
BadgeCell,
BadgeListCell,
DataTableColumnHeader,
} from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
@@ -74,7 +78,7 @@ export function usePricingColumns(
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
return (
<div className='flex min-w-[200px] items-center gap-2'>
<div className='flex max-w-full min-w-0 items-center gap-2'>
{modelIcon}
<span className='truncate font-mono text-sm font-medium'>
{model.model_name}
@@ -124,7 +128,7 @@ export function usePricingColumns(
if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) {
return (
<div className='max-w-[320px] min-w-[200px]'>
<div className='max-w-full min-w-0'>
<div className='text-xs font-medium text-amber-700 dark:text-amber-300'>
{t('Special billing expression')}
</div>
@@ -148,7 +152,7 @@ export function usePricingColumns(
}
return (
<div className='min-w-[180px]'>
<div className='max-w-full min-w-0'>
<span className='font-mono text-sm tabular-nums'>
{primaryEntries.map((entry, index) => (
<span key={entry.key}>
@@ -195,7 +199,7 @@ export function usePricingColumns(
)
return (
<div className='min-w-[160px]'>
<div className='max-w-full min-w-0'>
<span className='font-mono text-sm tabular-nums'>
{inputPrice}
<span className='text-muted-foreground/40 mx-1'>/</span>
@@ -218,7 +222,7 @@ export function usePricingColumns(
)
return (
<div className='min-w-[100px]'>
<div className='max-w-full min-w-0'>
<span className='font-mono text-sm tabular-nums'>{price}</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {t('request')}
@@ -261,7 +265,7 @@ export function usePricingColumns(
}
return (
<div className='min-w-[80px]'>
<div className='max-w-full min-w-0'>
<span className='font-mono text-sm tabular-nums'>
{stripTrailingZeros(cacheEntry.formatted)}
</span>
@@ -290,7 +294,7 @@ export function usePricingColumns(
)
return (
<div className='min-w-[80px]'>
<div className='max-w-full min-w-0'>
<span className='font-mono text-sm tabular-nums'>
{cachedPrice}
</span>
@@ -317,7 +321,7 @@ export function usePricingColumns(
? getLobeIcon(model.vendor_icon, 12)
: null
return (
<span className='flex items-center gap-1.5'>
<BadgeCell className='gap-1.5'>
{vendorIcon}
<StatusBadge
label={model.vendor_name}
@@ -325,7 +329,7 @@ export function usePricingColumns(
size='sm'
copyable={false}
/>
</span>
</BadgeCell>
)
},
size: 130,
@@ -74,13 +74,9 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
accessorKey: 'name',
header: t('Name'),
meta: { mobileTitle: true },
cell: ({ row }) => {
return (
<div className='max-w-[150px] truncate font-medium'>
{row.getValue('name')}
</div>
)
},
cell: ({ row }) => (
<span className='font-medium'>{row.getValue('name')}</span>
),
size: 180,
},
{
@@ -20,6 +20,7 @@ import { useMemo } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format'
import { BadgeCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
@@ -48,7 +49,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
cell: ({ row }) => {
const plan = row.original.plan
return (
<div className='max-w-[200px]'>
<div className='max-w-full min-w-0'>
<div className='truncate font-medium'>{plan.title}</div>
{plan.subtitle && (
<div className='text-muted-foreground truncate text-xs'>
@@ -134,7 +135,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
cell: ({ row }) => {
const plan = row.original.plan
return (
<div className='flex gap-1'>
<BadgeCell>
{plan.stripe_price_id && (
<StatusBadge
label='Stripe'
@@ -152,7 +153,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
copyable={false}
/>
)}
</div>
</BadgeCell>
)
},
size: 140,
@@ -182,7 +183,11 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
<span className='text-muted-foreground'>{t('No Upgrade')}</span>
)
}
return <GroupBadge group={group} />
return (
<BadgeCell>
<GroupBadge group={group} />
</BadgeCell>
)
},
size: 120,
},
@@ -21,7 +21,7 @@ import { Pencil, Trash2, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { BadgeCell, StaticDataTable } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
import type { CustomOAuthProvider } from '../types'
@@ -83,28 +83,33 @@ export function ProviderTable(props: ProviderTableProps) {
id: 'slug',
header: t('Slug'),
cell: (provider) => (
<StatusBadge
label={provider.slug}
variant='neutral'
copyable={false}
/>
<BadgeCell>
<StatusBadge
label={provider.slug}
variant='neutral'
copyable={false}
/>
</BadgeCell>
),
},
{
id: 'status',
header: t('Status'),
cell: (provider) => (
<StatusBadge
label={provider.enabled ? t('Enabled') : t('Disabled')}
variant={provider.enabled ? 'success' : 'neutral'}
copyable={false}
/>
<BadgeCell>
<StatusBadge
label={provider.enabled ? t('Enabled') : t('Disabled')}
variant={provider.enabled ? 'success' : 'neutral'}
copyable={false}
/>
</BadgeCell>
),
},
{
id: 'client-id',
header: t('Client ID'),
cellClassName: 'text-muted-foreground max-w-[120px] truncate font-mono',
cellClassName:
'text-muted-foreground max-w-[120px] truncate font-mono',
cell: (provider) => provider.client_id,
},
{
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -54,7 +54,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { StaticDataTable } from '@/components/data-table'
import { BadgeCell, StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
@@ -103,18 +103,37 @@ const colorOptions = [
{ value: 'slate', label: 'Slate' },
]
function parseApiInfoList(data: string): ApiInfo[] {
try {
const parsed = JSON.parse(data || '[]')
if (!Array.isArray(parsed)) return []
return parsed.map((item, idx) => ({
...item,
id: item.id || idx + 1,
}))
} catch {
return []
}
}
export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const apiInfoSchema = createApiInfoSchema(t)
const [apiInfoList, setApiInfoList] = useState<ApiInfo[]>([])
const [isEnabled, setIsEnabled] = useState(enabled)
const [hasChanges, setHasChanges] = useState(false)
const parsedApiInfoList = useMemo(() => parseApiInfoList(data), [data])
const [draftApiInfoList, setDraftApiInfoList] = useState<ApiInfo[] | null>(
null
)
const [isEnabledDraft, setIsEnabledDraft] = useState<boolean | null>(null)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [showDialog, setShowDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [editingApiInfo, setEditingApiInfo] = useState<ApiInfo | null>(null)
const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single')
const apiInfoList = draftApiInfoList ?? parsedApiInfoList
const isEnabled = isEnabledDraft ?? enabled
const hasChanges = draftApiInfoList !== null
const form = useForm<ApiInfoFormValues>({
resolver: zodResolver(apiInfoSchema),
@@ -126,33 +145,13 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
},
})
useEffect(() => {
try {
const parsed = JSON.parse(data || '[]')
if (Array.isArray(parsed)) {
setApiInfoList(
parsed.map((item, idx) => ({
...item,
id: item.id || idx + 1,
}))
)
}
} catch {
setApiInfoList([])
}
}, [data])
useEffect(() => {
setIsEnabled(enabled)
}, [enabled])
const handleToggleEnabled = async (checked: boolean) => {
try {
await updateOption.mutateAsync({
key: 'console_setting.api_info_enabled',
value: checked,
})
setIsEnabled(checked)
setIsEnabledDraft(checked)
toast.success(t('Setting saved'))
} catch {
toast.error(t('Failed to update setting'))
@@ -198,17 +197,15 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
const confirmDelete = () => {
if (deleteTarget === 'single' && editingApiInfo) {
setApiInfoList((prev) =>
prev.filter((item) => item.id !== editingApiInfo.id)
setDraftApiInfoList(
apiInfoList.filter((item) => item.id !== editingApiInfo.id)
)
setHasChanges(true)
toast.success(t('API info deleted. Click "Save Settings" to apply.'))
} else if (deleteTarget === 'batch') {
setApiInfoList((prev) =>
prev.filter((item) => !selectedIds.includes(item.id))
setDraftApiInfoList(
apiInfoList.filter((item) => !selectedIds.includes(item.id))
)
setSelectedIds([])
setHasChanges(true)
toast.success(
t('{{count}} API entries deleted. Click "Save Settings" to apply.', {
count: selectedIds.length,
@@ -221,18 +218,17 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
const handleSubmitForm = (values: ApiInfoFormValues) => {
if (editingApiInfo) {
setApiInfoList((prev) =>
prev.map((item) =>
setDraftApiInfoList(
apiInfoList.map((item) =>
item.id === editingApiInfo.id ? { ...item, ...values } : item
)
)
toast.success(t('API info updated. Click "Save Settings" to apply.'))
} else {
const newId = Math.max(...apiInfoList.map((item) => item.id), 0) + 1
setApiInfoList((prev) => [...prev, { id: newId, ...values }])
setDraftApiInfoList([...apiInfoList, { id: newId, ...values }])
toast.success(t('API info added. Click "Save Settings" to apply.'))
}
setHasChanges(true)
setShowDialog(false)
}
@@ -243,7 +239,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
value: JSON.stringify(apiInfoList),
})
if (result.success) {
setHasChanges(false)
setDraftApiInfoList(null)
}
} catch {
toast.error(t('Failed to save API info'))
@@ -330,22 +326,26 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
header: t('URL'),
cellClassName: 'max-w-xs truncate font-mono text-sm',
cell: (apiInfo) => (
<StatusBadge
label={apiInfo.url}
variant='neutral'
copyable={false}
/>
<BadgeCell>
<StatusBadge
label={apiInfo.url}
variant='neutral'
copyable={false}
/>
</BadgeCell>
),
},
{
id: 'route',
header: t('Route'),
cell: (apiInfo) => (
<StatusBadge
label={apiInfo.route}
variant='neutral'
copyable={false}
/>
<BadgeCell>
<StatusBadge
label={apiInfo.route}
variant='neutral'
copyable={false}
/>
</BadgeCell>
),
},
{
@@ -27,6 +27,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { BadgeCell } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import type { RatioType } from '../types'
import {
@@ -63,8 +64,8 @@ export function useUpstreamRatioSyncColumns(
cell: ({ row }) => {
const model = row.original.model
return (
<div className='flex min-w-[180px] items-center gap-2'>
<span className='font-medium'>{model}</span>
<div className='flex max-w-full min-w-0 items-center gap-2'>
<span className='truncate font-medium'>{model}</span>
{row.original.billingConflict && (
<TooltipProvider>
<Tooltip>
@@ -94,14 +95,11 @@ export function useUpstreamRatioSyncColumns(
ratioTypeFilter
)
return (
<div className='flex min-w-[260px] flex-col gap-2'>
<div className='flex max-w-full min-w-0 flex-col gap-2'>
{fields.map((ratioType) => {
const current = row.original.ratioTypes[ratioType]?.current
return (
<div
key={ratioType}
className='flex min-w-0 flex-wrap items-center gap-2'
>
<BadgeCell key={ratioType} className='ml-0 flex-wrap gap-2'>
<StatusBadge
label={getSyncFieldLabel(ratioType, t)}
autoColor={ratioType}
@@ -136,7 +134,7 @@ export function useUpstreamRatioSyncColumns(
</Tooltip>
</TooltipProvider>
)}
</div>
</BadgeCell>
)
})}
</div>
@@ -215,7 +213,7 @@ export function useUpstreamRatioSyncColumns(
)
return (
<div className='flex min-w-[280px] flex-col gap-2'>
<div className='flex max-w-full min-w-0 flex-col gap-2'>
{fields.map((ratioType) => {
const diff = row.original.ratioTypes[ratioType]
const upstreamVal = diff?.upstreams?.[upstreamName]
@@ -27,6 +27,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { BadgeCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text'
import { StatusBadge } from '@/components/status-badge'
@@ -227,7 +228,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
header: t('Group'),
cell: ({ row }) => {
const group = row.getValue('group') as string
return <GroupBadge group={group} />
return (
<BadgeCell>
<GroupBadge group={group} />
</BadgeCell>
)
},
filterFn: (row, id, value) => {
const group = String(row.getValue(id) || t('User Group')).toLowerCase()
@@ -272,7 +277,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
const inviterId = user.inviter_id || 0
return (
<div className='flex min-w-[220px] flex-wrap items-center gap-1'>
<div className='flex max-w-full min-w-0 flex-wrap items-center gap-1 overflow-hidden'>
<Tooltip>
<TooltipTrigger
render={