feat(namespace): implement frontend delete functionality with reason input

Add complete frontend implementation for namespace deletion:
- API layer: add delete() method to namespaceApi
- Hooks layer: add useDeleteNamespace() mutation hook
- UI layer: add DeleteNamespaceDialog component with reason input
- Add delete button in my-namespaces page (only shown when canDelete is true)
- Add i18n keys for delete confirmation, reason input, and success/error messages
- Update ManagedNamespace type to include canDelete permission flag
- Fix test mocks to include canDelete property

The delete dialog requires users to provide a deletion reason before confirming,
following the backend requirement. After successful deletion, users are redirected
to the my-namespaces page.
This commit is contained in:
dongmucat 2026-05-06 14:21:18 +08:00
parent f36cd43689
commit 5cc41c4e92
8 changed files with 213 additions and 1 deletions

View file

@ -732,6 +732,16 @@ export const namespaceApi = {
headers: await ensureCsrfHeaders(),
})
},
async delete(slug: string, reason: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ reason: reason.trim() }),
})
},
}
export const tokenApi = {

View file

@ -121,6 +121,7 @@ export interface ManagedNamespace extends Namespace {
canUnfreeze: boolean
canArchive: boolean
canRestore: boolean
canDelete: boolean
}
export interface NamespaceMember {

View file

@ -0,0 +1,97 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Textarea } from '@/shared/ui/textarea'
import { Label } from '@/shared/ui/label'
interface DeleteNamespaceDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
namespaceName: string
onConfirm: (reason: string) => void | Promise<void>
}
export function DeleteNamespaceDialog({
open,
onOpenChange,
namespaceName,
onConfirm,
}: DeleteNamespaceDialogProps) {
const { t } = useTranslation()
const [reason, setReason] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleConfirm = async () => {
if (!reason.trim()) {
return
}
setIsSubmitting(true)
try {
await onConfirm(reason)
setReason('')
onOpenChange(false)
} finally {
setIsSubmitting(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (!isSubmitting) {
if (!newOpen) {
setReason('')
}
onOpenChange(newOpen)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('namespace.delete.confirm.title')}</DialogTitle>
<DialogDescription>
{t('namespace.delete.confirm.description', { name: namespaceName })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="delete-reason">{t('namespace.delete.reason.label')}</Label>
<Textarea
id="delete-reason"
placeholder={t('namespace.delete.reason.placeholder')}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={isSubmitting}
rows={3}
/>
</div>
</div>
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
{t('dialog.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={!reason.trim() || isSubmitting}
>
{t('namespace.delete.button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -45,6 +45,7 @@ describe('review-paths', () => {
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
currentUserRole: 'ADMIN',
createdAt: '',
},
@ -59,6 +60,7 @@ describe('review-paths', () => {
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
currentUserRole: 'OWNER',
createdAt: '',
},
@ -78,6 +80,7 @@ describe('review-paths', () => {
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
currentUserRole: 'MEMBER',
createdAt: '',
},
@ -98,6 +101,7 @@ describe('review-paths', () => {
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
currentUserRole: 'ADMIN',
createdAt: '',
},

View file

@ -428,6 +428,24 @@
"unfreeze": "Unfreeze",
"archive": "Archive",
"restore": "Restore",
"delete": {
"button": "Delete",
"confirm": {
"title": "Delete namespace",
"description": "\"{{name}}\" will be permanently deleted. This action cannot be undone. Please provide a reason for deletion."
},
"reason": {
"label": "Deletion reason",
"placeholder": "Please explain why this namespace needs to be deleted..."
},
"success": {
"title": "Namespace deleted",
"description": "\"{{name}}\" has been permanently deleted."
},
"error": {
"title": "Failed to delete namespace"
}
},
"activeHint": "This namespace is fully active and can continue managing members, reviews, and skill publishing.",
"frozenHint": "This namespace is frozen. Members can still view it, but it is read-only and cannot publish or change membership.",
"archivedHint": "This namespace is archived. Public entry points are hidden, but you can still view and restore it from the dashboard.",

View file

@ -428,6 +428,24 @@
"unfreeze": "解冻",
"archive": "归档",
"restore": "恢复",
"delete": {
"button": "删除",
"confirm": {
"title": "删除命名空间",
"description": "\"{{name}}\"将被永久删除,此操作无法撤销。请提供删除原因。"
},
"reason": {
"label": "删除原因",
"placeholder": "请说明为什么需要删除此命名空间..."
},
"success": {
"title": "命名空间已删除",
"description": "\"{{name}}\"已被永久删除。"
},
"error": {
"title": "删除命名空间失败"
}
},
"activeHint": "命名空间运行正常,可继续管理成员、处理审核和发布技能。",
"frozenHint": "命名空间已冻结。成员仍可查看,但当前处于只读状态,不能继续发布或变更成员。",
"archivedHint": "命名空间已归档。公开入口已隐藏,但你仍可在管理台查看并恢复。",

View file

@ -9,8 +9,9 @@ import { EmptyState } from '@/shared/components/empty-state'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog'
import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-namespace-queries'
import { useArchiveNamespace, useDeleteNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-namespace-queries'
import { toast } from '@/shared/lib/toast'
import { DeleteNamespaceDialog } from '@/features/namespace/delete-namespace-dialog'
type PendingNamespaceAction =
| { action: 'freeze'; slug: string; name: string }
@ -18,6 +19,11 @@ type PendingNamespaceAction =
| { action: 'archive'; slug: string; name: string }
| { action: 'restore'; slug: string; name: string }
type PendingDeleteAction = {
slug: string
name: string
}
/**
* Dashboard page for namespaces the current user can manage or review. It owns
* namespace lifecycle actions because each action combines permissions, copy,
@ -29,11 +35,13 @@ export function MyNamespacesPage() {
const { hasRole } = useAuth()
const canCreateNamespace = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const [pendingAction, setPendingAction] = useState<PendingNamespaceAction | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDeleteAction | null>(null)
const { data: namespaces, isLoading } = useMyNamespaces()
const freezeMutation = useFreezeNamespace()
const unfreezeMutation = useUnfreezeNamespace()
const archiveMutation = useArchiveNamespace()
const restoreMutation = useRestoreNamespace()
const deleteMutation = useDeleteNamespace()
const handleNamespaceClick = (slug: string) => {
navigate({ to: `/space/${encodeURIComponent(slug)}` })
@ -157,6 +165,28 @@ export function MyNamespacesPage() {
}
}
const handleDeleteNamespace = async (reason: string) => {
if (!pendingDelete) {
return
}
try {
await deleteMutation.mutateAsync({ slug: pendingDelete.slug, reason })
toast.success(
t('namespace.delete.success.title'),
t('namespace.delete.success.description', { name: pendingDelete.name }),
)
setPendingDelete(null)
navigate({ to: '/dashboard/namespaces' })
} catch (error) {
toast.error(
t('namespace.delete.error.title'),
error instanceof Error ? error.message : '',
)
throw error
}
}
if (isLoading) {
return (
<div className="space-y-4 animate-fade-up">
@ -281,6 +311,18 @@ export function MyNamespacesPage() {
{t('myNamespaces.restore')}
</Button>
)}
{namespace.canDelete && (
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingDelete({ slug: namespace.slug, name: namespace.displayName })
}}
>
{t('namespace.delete.button')}
</Button>
)}
</div>
</div>
</Card>
@ -311,6 +353,17 @@ export function MyNamespacesPage() {
variant={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).variant : 'default'}
onConfirm={handleNamespaceAction}
/>
<DeleteNamespaceDialog
open={!!pendingDelete}
onOpenChange={(open) => {
if (!open) {
setPendingDelete(null)
}
}}
namespaceName={pendingDelete?.name ?? ''}
onConfirm={handleDeleteNamespace}
/>
</div>
)
}

View file

@ -186,3 +186,14 @@ export function useRestoreNamespace() {
},
})
}
export function useDeleteNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug, reason }: { slug: string; reason: string }) => namespaceApi.delete(slug, reason),
onSuccess: (_data, variables) => {
invalidateNamespaceQueries(queryClient, variables.slug)
},
})
}