From 9b64cbedba5413ea0821c552de2ace7fc1f44128 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Sat, 9 May 2026 09:45:32 +0800 Subject: [PATCH] feat(namespace): add edit/transfer UI with pagination support Add namespace edit dialog, transfer ownership dialog, and member list pagination. Update API layer to support pagination (PagedResponse), add useUpdateNamespace and useTransferNamespaceOwnership hooks with pagination-aware optimistic updates. Integrate edit button in NamespaceHeader and transfer button on members page. Add i18n keys for all new features (en + zh). --- web/src/api/client.ts | 36 ++++- .../namespace/add-namespace-member-dialog.tsx | 5 +- .../namespace/edit-namespace-dialog.tsx | 117 ++++++++++++++ .../features/namespace/namespace-header.tsx | 14 +- .../namespace/transfer-ownership-dialog.tsx | 147 ++++++++++++++++++ web/src/i18n/locales/en.json | 26 ++++ web/src/i18n/locales/zh.json | 26 ++++ web/src/pages/dashboard/namespace-members.tsx | 26 +++- web/src/shared/hooks/use-namespace-queries.ts | 51 ++++-- .../shared/lib/namespace-member-cache.test.ts | 49 ++++-- web/src/shared/lib/namespace-member-cache.ts | 52 ++++--- 11 files changed, 498 insertions(+), 51 deletions(-) create mode 100644 web/src/features/namespace/edit-namespace-dialog.tsx create mode 100644 web/src/features/namespace/transfer-ownership-dialog.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0573fa18..aeb44250 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -675,9 +675,12 @@ export const namespaceApi = { }) }, - async listMembers(slug: string): Promise { - const page = await fetchJson<{ items: NamespaceMember[] }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`) - return page.items + async listMembers(slug: string, params?: { page?: number; size?: number }): Promise> { + const queryPage = params?.page ?? 0 + const querySize = params?.size ?? 20 + return fetchJson>( + `${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members?page=${queryPage}&size=${querySize}`, + ) }, async searchMemberCandidates(slug: string, search: string, size = 10): Promise { @@ -732,6 +735,33 @@ export const namespaceApi = { headers: await ensureCsrfHeaders(), }) }, + + async update(slug: string, request: { displayName?: string; description?: string }): Promise { + const body: Record = {} + if (request.displayName !== undefined) { + body.displayName = request.displayName.trim() + } + if (request.description !== undefined) { + body.description = request.description === '' ? '' : request.description.trim() + } + return fetchJson(`/api/v1/namespaces/${normalizeNamespaceSlug(slug)}`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(body), + }) + }, + + async transferOwnership(slug: string, newOwnerUserId: string): Promise<{ message: string }> { + return fetchJson<{ message: string }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/transfer-ownership`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ newOwnerId: newOwnerUserId.trim() }), + }) + }, } export const tokenApi = { diff --git a/web/src/features/namespace/add-namespace-member-dialog.tsx b/web/src/features/namespace/add-namespace-member-dialog.tsx index d027ff7e..b7013cdc 100644 --- a/web/src/features/namespace/add-namespace-member-dialog.tsx +++ b/web/src/features/namespace/add-namespace-member-dialog.tsx @@ -183,7 +183,10 @@ export function AddNamespaceMemberDialog({ slug, children }: AddNamespaceMemberD ))} ) : ( -

{t('members.searchEmpty')}

+
+

{t('members.searchEmpty')}

+

{t('members.searchEmptyMemberHint')}

+
)} ) : null} diff --git a/web/src/features/namespace/edit-namespace-dialog.tsx b/web/src/features/namespace/edit-namespace-dialog.tsx new file mode 100644 index 00000000..66949fcc --- /dev/null +++ b/web/src/features/namespace/edit-namespace-dialog.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { Namespace } from '@/api/types' +import { useUpdateNamespace } from '@/shared/hooks/use-namespace-queries' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Textarea } from '@/shared/ui/textarea' + +interface EditNamespaceDialogProps { + namespace: Namespace + children: React.ReactNode +} + +export function EditNamespaceDialog({ namespace, children }: EditNamespaceDialogProps) { + const { t } = useTranslation() + const updateMutation = useUpdateNamespace() + const [open, setOpen] = useState(false) + const [displayName, setDisplayName] = useState(namespace.displayName) + const [description, setDescription] = useState(namespace.description ?? '') + const [displayNameError, setDisplayNameError] = useState(null) + + const resetDialog = () => { + setDisplayName(namespace.displayName) + setDescription(namespace.description ?? '') + setDisplayNameError(null) + updateMutation.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + resetDialog() + } + } + + const handleSave = async () => { + const trimmedDisplayName = displayName.trim() + if (!trimmedDisplayName) { + setDisplayNameError(t('namespaceEdit.displayNameRequired')) + return + } + + try { + await updateMutation.mutateAsync({ + slug: namespace.slug, + displayName: trimmedDisplayName, + description: description.trim(), + }) + toast.success(t('namespaceEdit.saveSuccess')) + setOpen(false) + } catch (error) { + toast.error(t('namespaceEdit.saveErrorTitle'), error instanceof Error ? error.message : '') + } + } + + return ( + + {children} + + + {t('namespaceEdit.dialogTitle')} + @{namespace.slug} + + +
+
+ + { + setDisplayName(event.target.value) + if (displayNameError) setDisplayNameError(null) + }} + aria-invalid={displayNameError ? 'true' : 'false'} + /> + {displayNameError ?

{displayNameError}

: null} +
+ +
+ +