Files
new-api/web/default/src/features/channels/components/data-table-row-actions.tsx
T
QuentinHsu 9ba251ce5f perf(web): streamline table actions and destructive dialogs (#5645)
* perf(data-table): autosize action columns

- exclude actions columns from shared table width calculations so action cells size to their content.
- remove fixed size and w-* width overrides from feature action columns to preserve content-based layout.

* perf(data-table): streamline row action controls

- expose common edit and status actions directly while moving secondary actions into overflow menus.
- add shared row action menu helpers so static and table rows use consistent action controls.
- let action columns size to their content instead of relying on fixed widths.

* fix(web): localize destructive dialog copy

- route delete, reset, and batch update confirmation text through i18n.
- add locale entries for affected channel, model, system settings, and user dialogs.

* perf(web): unify destructive dialog actions

- align delete and cleanup confirmation buttons with the shared destructive variant.
- replace custom destructive color overrides with semantic button variants.
- clean up lint errors in touched dialog files before committing.

* fix(web): add user action success translations

- add localized success messages for user delete, status, and role changes.
- keep user management toast copy available across all frontend locales.

* fix(data-table): prevent mobile badge clipping

- expose badge cell slots so mobile card styles can target nested badge wrappers.
- reset badge margins in card rows to keep provider icons fully visible on small screens.
2026-06-25 13:13:41 +08:00

370 lines
10 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useContext, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { Row } from '@tanstack/react-table'
import {
MoreHorizontal,
Boxes,
Pencil,
PlugZap,
Gauge,
DollarSign,
Download,
Copy,
Power,
PowerOff,
Key,
Trash2,
RefreshCw,
Loader2,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { MODEL_FETCHABLE_TYPES } from '../constants'
import {
channelsQueryKeys,
handleDeleteChannel,
handleTestChannel,
handleToggleChannelStatus,
isChannelEnabled,
isMultiKeyChannel,
} from '../lib'
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
interface DataTableRowActionsProps {
row: Row<Channel>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const layout = useContext(ChannelRowActionsLayoutContext)
const channel = row.original
const { setOpen, setCurrentRow, upstream } = useChannels()
const queryClient = useQueryClient()
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const isEnabled = isChannelEnabled(channel)
const isMultiKey = isMultiKeyChannel(channel)
const handleEdit = () => {
setCurrentRow(channel)
setOpen('update-channel')
}
const handleTest = () => {
setCurrentRow(channel)
setOpen('test-channel')
}
const handleDirectTest = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
setIsTesting(true)
try {
await handleTestChannel(channel.id, { channelName: channel.name }, () => {
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
})
} finally {
setIsTesting(false)
}
}
const handleQueryBalance = () => {
setCurrentRow(channel)
setOpen('balance-query')
}
const handleFetchModels = () => {
setCurrentRow(channel)
setOpen('fetch-models')
}
const handleManageOllamaModels = () => {
setCurrentRow(channel)
setOpen('ollama-models')
}
const handleCopy = () => {
setCurrentRow(channel)
setOpen('copy-channel')
}
const handleManageKeys = () => {
setCurrentRow(channel)
setOpen('multi-key-manage')
}
const handleToggleStatus = async (
e?: React.MouseEvent<HTMLButtonElement>
) => {
e?.stopPropagation()
setIsTogglingStatus(true)
try {
await handleToggleChannelStatus(channel.id, channel.status, queryClient)
} finally {
setIsTogglingStatus(false)
}
}
let statusIcon = <Power className='size-4' />
if (isTogglingStatus) {
statusIcon = <Loader2 className='size-4 animate-spin' />
} else if (isEnabled) {
statusIcon = <PowerOff className='size-4' />
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleEdit()
}}
aria-label={t('Edit')}
/>
}
>
<Pencil className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
/>
}
>
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
{layout === 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleTest()
}}
aria-label={t('Test Channel Connection')}
/>
}
>
<PlugZap className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
/>
}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Test Connection */}
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
<DropdownMenuShortcut>
<PlugZap size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
{t('Query Balance')}
<DropdownMenuShortcut>
<DollarSign size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Fetch Models */}
<DropdownMenuItem onClick={handleFetchModels}>
{t('Fetch Models')}
<DropdownMenuShortcut>
<Download size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Detect Upstream Updates (only for fetchable channel types) */}
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
<DropdownMenuItem
onClick={() => {
const meta = parseUpstreamUpdateMeta(channel.settings)
if (
meta.pendingAddModels.length > 0 ||
meta.pendingRemoveModels.length > 0
) {
upstream.openModal(
channel,
meta.pendingAddModels,
meta.pendingRemoveModels,
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
)
} else {
upstream.detectChannelUpdates(channel)
}
}}
>
{t('Upstream Updates')}
<DropdownMenuShortcut>
<RefreshCw size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Ollama Models (only for Ollama channels) */}
{channel.type === 4 && (
<DropdownMenuItem onClick={handleManageOllamaModels}>
{t('Manage Ollama Models')}
<DropdownMenuShortcut>
<Boxes size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Copy Channel */}
<DropdownMenuItem onClick={handleCopy}>
{t('Copy Channel')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Manage Keys (only for multi-key channels) */}
{isMultiKey && (
<DropdownMenuItem onClick={handleManageKeys}>
{t('Manage Keys')}
<DropdownMenuShortcut>
<Key size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Channel')}
desc={t(
'Are you sure you want to delete channel "{{name}}"? This action cannot be undone.',
{ name: channel.name }
)}
confirmText={t('Delete')}
destructive
handleConfirm={() => {
handleDeleteChannel(channel.id, queryClient)
setDeleteConfirmOpen(false)
}}
/>
</div>
)
}