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:
+17
-12
@@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
+7
-9
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user