Files
chaos-api/web/default/src/features/models/components/dialogs/prefill-group-management-dialog.tsx
T
QuentinHsu 2eaa943d9f perf(web): improve dialog sizing and footer layout
- migrate frontend dialogs to the shared footer API so actions stay separated from scrollable body content.
- tune dialog dimensions for model analytics, prefill groups, billing history, channel model sync, and related workflows.
- update channel terminology and dialog action translations across supported locales.
2026-06-06 21:49:33 +08:00

474 lines
17 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 { useEffect, useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Layers3,
Loader2,
Pencil,
Plus,
RefreshCcw,
Trash2,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { deletePrefillGroup, getPrefillGroups } from '../../api'
import { prefillGroupsQueryKeys } from '../../lib'
import type { PrefillGroup } from '../../types'
import {
PREFILL_GROUP_TYPE_META,
parseEndpointKeys,
parseStringItems,
} from '../prefill-group-shared'
type PrefillGroupManagementDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onCreateGroup: () => void
onEditGroup: (group: PrefillGroup) => void
}
export function PrefillGroupManagementDialog({
open,
onOpenChange,
onCreateGroup,
onEditGroup,
}: PrefillGroupManagementDialogProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const isMobile = useIsMobile()
const [deleteState, setDeleteState] = useState<{
open: boolean
group: PrefillGroup | null
}>({ open: false, group: null })
const [isDeleting, setIsDeleting] = useState(false)
const {
data,
isLoading,
isFetching,
error,
refetch: refetchGroups,
} = useQuery({
queryKey: prefillGroupsQueryKeys.list(),
queryFn: () => getPrefillGroups(),
enabled: open,
})
const groups = useMemo(() => data?.data ?? [], [data?.data])
const sortedGroups = useMemo(
() =>
[...groups].sort((a, b) => {
if (a.type === b.type) {
return a.name.localeCompare(b.name)
}
return a.type.localeCompare(b.type)
}),
[groups]
)
const normalizedGroups = useMemo(
() =>
sortedGroups.map((group) => {
const meta = PREFILL_GROUP_TYPE_META[group.type] || {
label: group.type,
badge: 'neutral' as const,
}
const parsedItems =
group.type === 'endpoint'
? parseEndpointKeys(group.items)
: parseStringItems(group.items)
return { group, meta, parsedItems }
}),
[sortedGroups]
)
useEffect(() => {
if (!open) {
setDeleteState({ open: false, group: null })
setIsDeleting(false)
}
}, [open])
const handleDeleteClick = (group: PrefillGroup) => {
setDeleteState({ open: true, group })
}
const handleDeleteConfirm = async () => {
if (!deleteState.group) return
setIsDeleting(true)
try {
const response = await deletePrefillGroup(deleteState.group.id)
if (response.success) {
toast.success(`Deleted "${deleteState.group.name}"`)
queryClient.invalidateQueries({
queryKey: prefillGroupsQueryKeys.lists(),
})
setDeleteState({ open: false, group: null })
} else {
toast.error(response.message || 'Failed to delete group')
}
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Failed to delete group')
} finally {
setIsDeleting(false)
}
}
return (
<>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</>
}
description={t(
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
)}
contentClassName={cn(
'w-[calc(100vw-2rem)] sm:max-w-[52rem]',
isMobile && 'max-w-none rounded-none'
)}
titleClassName='flex flex-wrap items-center gap-2 text-lg'
descriptionClassName='text-sm leading-relaxed'
contentHeight='auto'
bodyClassName={cn(
'space-y-3',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]'
)}
>
<div className='bg-muted/30 flex flex-wrap items-center justify-between gap-3 rounded-md border p-2 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
{t('New Group')}
</Button>
<Button
size='sm'
variant='ghost'
onClick={() => refetchGroups()}
disabled={isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
</div>
<div className='flex flex-col gap-3'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
<AlertDescription>
{(error as Error).message ||
'Please retry or refresh the page.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</div>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed py-10'>
<EmptyMedia variant='icon'>
<Layers3 className='h-6 w-6' />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
<EmptyDescription>
{t(
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
)}
</EmptyDescription>
</EmptyHeader>
<EmptyDescription>
{t(
'Prefill groups help you keep complex configurations in sync.'
)}
</EmptyDescription>
</Empty>
) : isMobile ? (
<div className='space-y-3'>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<Card key={group.id} className='border-border/60'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div className='space-y-2'>
<CardTitle className='flex flex-wrap items-center gap-2'>
{group.name}
<StatusBadge
variant={meta.badge}
size='sm'
copyable={false}
>
{meta.label}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground font-mono'>
#{group.id}
</span>
</StatusBadge>
</CardTitle>
{group.description ? (
<CardDescription className='line-clamp-2'>
{group.description}
</CardDescription>
) : (
<CardDescription className='text-muted-foreground italic'>
No description provided
</CardDescription>
)}
</div>
<div className='flex items-center gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</CardHeader>
<CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span>
<StatusBadge
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
variant='neutral'
size='sm'
copyable={false}
/>
</div>
{parsedItems.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</div>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[680px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[240px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{group.name}</span>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</div>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</Dialog>
<ConfirmDialog
open={deleteState.open}
onOpenChange={(next) => setDeleteState({ open: next, group: null })}
title={t('Delete group')}
desc={
<p>
{t('Are you sure you want to delete')}{' '}
<span className='font-medium'>{deleteState.group?.name}</span>
{t('? This action cannot be undone.')}
</p>
}
destructive
confirmText={isDeleting ? 'Deleting...' : 'Delete'}
isLoading={isDeleting}
handleConfirm={handleDeleteConfirm}
/>
</>
)
}