feat(admin): convert storage form dialog to right-side drawer (#276)

Replace the centered Dialog modal with a Sheet (right-side drawer) for the
storage create/edit form. The drawer slides in from the right with a scrollable
form body and pinned footer. Also fixes import ordering in the storages route.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-12 00:32:29 -04:00
committed by GitHub
parent 1353badf9c
commit 903f12be4f
5 changed files with 357 additions and 231 deletions
@@ -1,227 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod'
import type { Storage } from '@shared/types'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Eye, EyeOff } from 'lucide-react'
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { createStorage, updateStorage } from '@/lib/api'
import { formatSize } from '@/lib/format'
const UNITS = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const
type Unit = keyof typeof UNITS
function bytesToDisplay(bytes: number): { value: number; unit: Unit } {
if (bytes === 0) return { value: 0, unit: 'GB' }
if (bytes >= UNITS.TB && bytes % UNITS.TB === 0) return { value: bytes / UNITS.TB, unit: 'TB' }
if (bytes >= UNITS.GB && bytes % UNITS.GB === 0) return { value: bytes / UNITS.GB, unit: 'GB' }
return { value: bytes / UNITS.MB, unit: 'MB' }
}
const storageFormSchema = z.object({
title: z.string().min(1),
mode: z.enum(['private', 'public']),
bucket: z.string().min(1),
endpoint: z.string().url(),
region: z.string().min(1),
accessKey: z.string().min(1),
secretKey: z.string().min(1),
filePath: z.string().min(1),
customHost: z.string().optional(),
capacityValue: z.coerce.number().min(0),
capacityUnit: z.enum(['MB', 'GB', 'TB']),
})
type StorageFormValues = z.infer<typeof storageFormSchema>
const DEFAULT_VALUES: StorageFormValues = {
title: '',
mode: 'private',
bucket: '',
endpoint: '',
region: 'auto',
accessKey: '',
secretKey: '',
filePath: '$UID/$RAW_NAME',
customHost: '',
capacityValue: 0,
capacityUnit: 'GB',
}
interface StorageFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
storage: Storage | null
}
export function StorageFormDialog({ open, onOpenChange, storage }: StorageFormDialogProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showSecret, setShowSecret] = useState(false)
const isEditing = storage !== null
const form = useForm<StorageFormValues>({
resolver: zodResolver(storageFormSchema),
defaultValues: DEFAULT_VALUES,
})
useEffect(() => {
if (!open) return
if (storage) {
const { value, unit } = bytesToDisplay(storage.capacity ?? 0)
form.reset({
title: storage.title,
mode: storage.mode,
bucket: storage.bucket,
endpoint: storage.endpoint,
region: storage.region,
accessKey: storage.accessKey,
secretKey: storage.secretKey,
filePath: storage.filePath,
customHost: storage.customHost || '',
capacityValue: value,
capacityUnit: unit,
})
} else {
form.reset(DEFAULT_VALUES)
}
setShowSecret(false)
}, [open, storage, form])
const mutation = useMutation({
mutationFn: ({ capacityValue, capacityUnit, ...rest }: StorageFormValues) => {
const capacity = capacityValue * UNITS[capacityUnit]
return isEditing ? updateStorage(storage.id, { ...rest, capacity }) : createStorage({ ...rest, capacity })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
onOpenChange(false)
toast.success(isEditing ? t('admin.storages.updated') : t('admin.storages.created'))
},
onError: (err) => {
toast.error(err.message)
},
})
function onSubmit(values: StorageFormValues) {
mutation.mutate(values)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isEditing ? t('admin.storages.editTitle') : t('admin.storages.addTitle')}</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4">
<FormField label={t('admin.storages.fieldTitle')} error={form.formState.errors.title?.message}>
<Input {...form.register('title')} />
</FormField>
<FormField label={t('admin.storages.fieldMode')} error={form.formState.errors.mode?.message}>
<select
{...form.register('mode')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="private">{t('admin.storages.modePrivate')}</option>
<option value="public">{t('admin.storages.modePublic')}</option>
</select>
</FormField>
<FormField label={t('admin.storages.fieldBucket')} error={form.formState.errors.bucket?.message}>
<Input {...form.register('bucket')} />
</FormField>
<FormField label={t('admin.storages.fieldEndpoint')} error={form.formState.errors.endpoint?.message}>
<Input {...form.register('endpoint')} placeholder="https://s3.amazonaws.com" />
</FormField>
<FormField label={t('admin.storages.fieldRegion')} error={form.formState.errors.region?.message}>
<Input {...form.register('region')} placeholder="auto" />
</FormField>
<FormField label={t('admin.storages.fieldAccessKey')} error={form.formState.errors.accessKey?.message}>
<Input {...form.register('accessKey')} />
</FormField>
<FormField label={t('admin.storages.fieldSecretKey')} error={form.formState.errors.secretKey?.message}>
<div className="relative">
<Input {...form.register('secretKey')} type={showSecret ? 'text' : 'password'} className="pr-10" />
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={() => setShowSecret((v) => !v)}
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</FormField>
<FormField label={t('admin.storages.fieldFilePath')} error={form.formState.errors.filePath?.message}>
<Input {...form.register('filePath')} placeholder="$UID/$RAW_NAME" />
</FormField>
<FormField label={t('admin.storages.fieldCustomHost')} error={form.formState.errors.customHost?.message}>
<Input {...form.register('customHost')} placeholder={t('admin.storages.customHostPlaceholder')} />
</FormField>
<FormField label={t('admin.storages.fieldCapacity')} error={form.formState.errors.capacityValue?.message}>
<div className="flex items-center gap-2">
<Input type="number" min={0} step={1} className="w-32" {...form.register('capacityValue')} />
<Select
value={form.watch('capacityUnit')}
onValueChange={(v) => form.setValue('capacityUnit', v as Unit)}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MB">MB</SelectItem>
<SelectItem value="GB">GB</SelectItem>
<SelectItem value="TB">TB</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{form.watch('capacityValue') > 0
? `= ${formatSize(form.watch('capacityValue') * UNITS[form.watch('capacityUnit')])}`
: t('admin.storages.capacityUnlimited')}
</span>
</div>
<p className="text-xs text-muted-foreground">{t('admin.storages.capacityHint')}</p>
</FormField>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t('common.loading') : t('common.save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function FormField({ label, error, children }: { label: string; error?: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
)
}
@@ -0,0 +1,229 @@
import { zodResolver } from '@hookform/resolvers/zod'
import type { Storage } from '@shared/types'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Eye, EyeOff } from 'lucide-react'
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { createStorage, updateStorage } from '@/lib/api'
import { formatSize } from '@/lib/format'
const UNITS = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const
type Unit = keyof typeof UNITS
function bytesToDisplay(bytes: number): { value: number; unit: Unit } {
if (bytes === 0) return { value: 0, unit: 'GB' }
if (bytes >= UNITS.TB && bytes % UNITS.TB === 0) return { value: bytes / UNITS.TB, unit: 'TB' }
if (bytes >= UNITS.GB && bytes % UNITS.GB === 0) return { value: bytes / UNITS.GB, unit: 'GB' }
return { value: bytes / UNITS.MB, unit: 'MB' }
}
const storageFormSchema = z.object({
title: z.string().min(1),
mode: z.enum(['private', 'public']),
bucket: z.string().min(1),
endpoint: z.string().url(),
region: z.string().min(1),
accessKey: z.string().min(1),
secretKey: z.string().min(1),
filePath: z.string().min(1),
customHost: z.string().optional(),
capacityValue: z.coerce.number().min(0),
capacityUnit: z.enum(['MB', 'GB', 'TB']),
})
type StorageFormValues = z.infer<typeof storageFormSchema>
const DEFAULT_VALUES: StorageFormValues = {
title: '',
mode: 'private',
bucket: '',
endpoint: '',
region: 'auto',
accessKey: '',
secretKey: '',
filePath: '$UID/$RAW_NAME',
customHost: '',
capacityValue: 0,
capacityUnit: 'GB',
}
interface StorageFormDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
storage: Storage | null
}
export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDrawerProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showSecret, setShowSecret] = useState(false)
const isEditing = storage !== null
const form = useForm<StorageFormValues>({
resolver: zodResolver(storageFormSchema),
defaultValues: DEFAULT_VALUES,
})
useEffect(() => {
if (!open) return
if (storage) {
const { value, unit } = bytesToDisplay(storage.capacity ?? 0)
form.reset({
title: storage.title,
mode: storage.mode,
bucket: storage.bucket,
endpoint: storage.endpoint,
region: storage.region,
accessKey: storage.accessKey,
secretKey: storage.secretKey,
filePath: storage.filePath,
customHost: storage.customHost || '',
capacityValue: value,
capacityUnit: unit,
})
} else {
form.reset(DEFAULT_VALUES)
}
setShowSecret(false)
}, [open, storage, form])
const mutation = useMutation({
mutationFn: ({ capacityValue, capacityUnit, ...rest }: StorageFormValues) => {
const capacity = capacityValue * UNITS[capacityUnit]
return isEditing ? updateStorage(storage.id, { ...rest, capacity }) : createStorage({ ...rest, capacity })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
onOpenChange(false)
toast.success(isEditing ? t('admin.storages.updated') : t('admin.storages.created'))
},
onError: (err) => {
toast.error(err.message)
},
})
function onSubmit(values: StorageFormValues) {
mutation.mutate(values)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="sm:max-w-lg">
<SheetHeader>
<SheetTitle>{isEditing ? t('admin.storages.editTitle') : t('admin.storages.addTitle')}</SheetTitle>
</SheetHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-4">
<div className="grid gap-4">
<FormField label={t('admin.storages.fieldTitle')} error={form.formState.errors.title?.message}>
<Input {...form.register('title')} />
</FormField>
<FormField label={t('admin.storages.fieldMode')} error={form.formState.errors.mode?.message}>
<select
{...form.register('mode')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="private">{t('admin.storages.modePrivate')}</option>
<option value="public">{t('admin.storages.modePublic')}</option>
</select>
</FormField>
<FormField label={t('admin.storages.fieldBucket')} error={form.formState.errors.bucket?.message}>
<Input {...form.register('bucket')} />
</FormField>
<FormField label={t('admin.storages.fieldEndpoint')} error={form.formState.errors.endpoint?.message}>
<Input {...form.register('endpoint')} placeholder="https://s3.amazonaws.com" />
</FormField>
<FormField label={t('admin.storages.fieldRegion')} error={form.formState.errors.region?.message}>
<Input {...form.register('region')} placeholder="auto" />
</FormField>
<FormField label={t('admin.storages.fieldAccessKey')} error={form.formState.errors.accessKey?.message}>
<Input {...form.register('accessKey')} />
</FormField>
<FormField label={t('admin.storages.fieldSecretKey')} error={form.formState.errors.secretKey?.message}>
<div className="relative">
<Input {...form.register('secretKey')} type={showSecret ? 'text' : 'password'} className="pr-10" />
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={() => setShowSecret((v) => !v)}
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</FormField>
<FormField label={t('admin.storages.fieldFilePath')} error={form.formState.errors.filePath?.message}>
<Input {...form.register('filePath')} placeholder="$UID/$RAW_NAME" />
</FormField>
<FormField label={t('admin.storages.fieldCustomHost')} error={form.formState.errors.customHost?.message}>
<Input {...form.register('customHost')} placeholder={t('admin.storages.customHostPlaceholder')} />
</FormField>
<FormField label={t('admin.storages.fieldCapacity')} error={form.formState.errors.capacityValue?.message}>
<div className="flex items-center gap-2">
<Input type="number" min={0} step={1} className="w-32" {...form.register('capacityValue')} />
<Select
value={form.watch('capacityUnit')}
onValueChange={(v) => form.setValue('capacityUnit', v as Unit)}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MB">MB</SelectItem>
<SelectItem value="GB">GB</SelectItem>
<SelectItem value="TB">TB</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{form.watch('capacityValue') > 0
? `= ${formatSize(form.watch('capacityValue') * UNITS[form.watch('capacityUnit')])}`
: t('admin.storages.capacityUnlimited')}
</span>
</div>
<p className="text-xs text-muted-foreground">{t('admin.storages.capacityHint')}</p>
</FormField>
</div>
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t('common.loading') : t('common.save')}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
function FormField({ label, error, children }: { label: string; error?: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
)
}
+51
View File
@@ -38,6 +38,9 @@ const ADMIN_STORAGES_KEYS = [
'admin.storages.fieldFilePath',
'admin.storages.fieldCustomHost',
'admin.storages.customHostPlaceholder',
'admin.storages.fieldCapacity',
'admin.storages.capacityUnlimited',
'admin.storages.capacityHint',
]
const ADMIN_NAV_KEYS = ['admin.nav.management', 'admin.nav.storages', 'admin.nav.users']
@@ -173,6 +176,18 @@ describe('admin.storages locale keys — English values contract', () => {
it('admin.nav.users is "Users"', () => {
expect(enLocale['admin.nav.users']).toBe('Users')
})
it('admin.storages.fieldCapacity is "Capacity"', () => {
expect(enLocale['admin.storages.fieldCapacity']).toBe('Capacity')
})
it('admin.storages.capacityUnlimited is "Unlimited"', () => {
expect(enLocale['admin.storages.capacityUnlimited']).toBe('Unlimited')
})
it('admin.storages.capacityHint is "Maximum storage space. 0 means unlimited."', () => {
expect(enLocale['admin.storages.capacityHint']).toBe('Maximum storage space. 0 means unlimited.')
})
})
describe('admin.storages locale keys — i18n runtime translation', () => {
@@ -328,4 +343,40 @@ describe('admin.storages locale keys — i18n runtime translation', () => {
await i18n.changeLanguage('zh')
expect(i18n.t('admin.nav.management')).toBe('管理')
})
it('translates admin.storages.fieldCapacity to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.storages.fieldCapacity')).toBe('Capacity')
})
it('translates admin.storages.fieldCapacity to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.storages.fieldCapacity')).toBe('可用空间')
})
it('translates admin.storages.capacityUnlimited to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.storages.capacityUnlimited')).toBe('Unlimited')
})
it('translates admin.storages.capacityUnlimited to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.storages.capacityUnlimited')).toBe('不限制')
})
it('translates admin.storages.capacityHint to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.storages.capacityHint')).toBe('Maximum storage space. 0 means unlimited.')
})
it('translates admin.storages.capacityHint to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.storages.capacityHint')).toBe('最大存储空间,0 表示不限制。')
})
})
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { formatDate, formatSize } from './format'
describe('formatSize', () => {
it('returns "0 B" for 0 bytes', () => {
expect(formatSize(0)).toBe('0 B')
})
it('returns bytes with no decimal for values under 1 KB', () => {
expect(formatSize(512)).toBe('512 B')
})
it('returns KB with one decimal for kilobyte values', () => {
expect(formatSize(1024)).toBe('1.0 KB')
})
it('returns MB with one decimal for megabyte values', () => {
expect(formatSize(1024 * 1024)).toBe('1.0 MB')
})
it('returns GB with one decimal for gigabyte values', () => {
expect(formatSize(1024 * 1024 * 1024)).toBe('1.0 GB')
})
it('returns TB with one decimal for terabyte values', () => {
expect(formatSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TB')
})
it('returns fractional KB for values between 1 KB and 1 MB', () => {
expect(formatSize(1536)).toBe('1.5 KB')
})
it('returns fractional GB for non-round gigabyte values', () => {
expect(formatSize(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB')
})
it('returns single byte without decimal', () => {
expect(formatSize(1)).toBe('1 B')
})
it('returns 10 GB correctly', () => {
expect(formatSize(10 * 1024 * 1024 * 1024)).toBe('10.0 GB')
})
it('returns 100 MB correctly', () => {
expect(formatSize(100 * 1024 * 1024)).toBe('100.0 MB')
})
})
describe('formatDate', () => {
it('returns a locale date string for a valid ISO timestamp', () => {
const result = formatDate('2024-01-15T00:00:00.000Z')
expect(result).toBeTruthy()
expect(result).not.toBe('—')
})
it('returns "—" for an empty string', () => {
expect(formatDate('')).toBe('—')
})
it('returns "—" for a non-date string', () => {
expect(formatDate('not-a-date')).toBe('—')
})
it('returns "—" for a random invalid string', () => {
expect(formatDate('foobar')).toBe('—')
})
it('returns a non-empty string for a valid Unix epoch string', () => {
const result = formatDate('2000-06-15')
expect(result).toBeTruthy()
expect(result).not.toBe('—')
})
})
@@ -1,3 +1,4 @@
import { StorageStatus } from '@shared/constants'
import type { Storage } from '@shared/types'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
@@ -5,7 +6,7 @@ import { Database, Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DeleteStorageDialog } from '@/components/admin/delete-storage-dialog'
import { StorageFormDialog } from '@/components/admin/storage-form-dialog'
import { StorageFormDrawer } from '@/components/admin/storage-form-drawer'
import { Button } from '@/components/ui/button'
import { listStorages } from '@/lib/api'
@@ -13,8 +14,6 @@ export const Route = createFileRoute('/_authenticated/admin/storages/')({
component: StoragesPage,
})
import { StorageStatus } from '@shared/constants'
function StoragesPage() {
const { t } = useTranslation()
const [formOpen, setFormOpen] = useState(false)
@@ -96,7 +95,7 @@ function StoragesPage() {
</table>
</div>
<StorageFormDialog open={formOpen} onOpenChange={handleFormOpenChange} storage={editingStorage} />
<StorageFormDrawer open={formOpen} onOpenChange={handleFormOpenChange} storage={editingStorage} />
<DeleteStorageDialog
open={deleteTarget !== null}