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).
This commit is contained in:
dongmucat 2026-05-09 09:45:32 +08:00
parent 2f640e42a3
commit 9b64cbedba
11 changed files with 498 additions and 51 deletions

View file

@ -675,9 +675,12 @@ export const namespaceApi = {
})
},
async listMembers(slug: string): Promise<NamespaceMember[]> {
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<PagedResponse<NamespaceMember>> {
const queryPage = params?.page ?? 0
const querySize = params?.size ?? 20
return fetchJson<PagedResponse<NamespaceMember>>(
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members?page=${queryPage}&size=${querySize}`,
)
},
async searchMemberCandidates(slug: string, search: string, size = 10): Promise<NamespaceCandidateUser[]> {
@ -732,6 +735,33 @@ export const namespaceApi = {
headers: await ensureCsrfHeaders(),
})
},
async update(slug: string, request: { displayName?: string; description?: string }): Promise<Namespace> {
const body: Record<string, string> = {}
if (request.displayName !== undefined) {
body.displayName = request.displayName.trim()
}
if (request.description !== undefined) {
body.description = request.description === '' ? '' : request.description.trim()
}
return fetchJson<Namespace>(`/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 = {

View file

@ -183,7 +183,10 @@ export function AddNamespaceMemberDialog({ slug, children }: AddNamespaceMemberD
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('members.searchEmpty')}</p>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{t('members.searchEmpty')}</p>
<p className="text-xs text-muted-foreground">{t('members.searchEmptyMemberHint')}</p>
</div>
)}
</div>
) : null}

View file

@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader className="text-center sm:text-center">
<DialogTitle className="text-center">{t('namespaceEdit.dialogTitle')}</DialogTitle>
<DialogDescription className="text-center">@{namespace.slug}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-display-name">{t('namespaceEdit.displayNameLabel')}</Label>
<Input
id="edit-display-name"
value={displayName}
onChange={(event) => {
setDisplayName(event.target.value)
if (displayNameError) setDisplayNameError(null)
}}
aria-invalid={displayNameError ? 'true' : 'false'}
/>
{displayNameError ? <p className="text-xs text-red-600">{displayNameError}</p> : null}
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">{t('namespaceEdit.descriptionLabel')}</Label>
<Textarea
id="edit-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
/>
</div>
</div>
{updateMutation.error ? (
<p className="text-sm text-red-600">{updateMutation.error.message}</p>
) : null}
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
{t('dialog.cancel')}
</Button>
<Button type="button" onClick={handleSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? t('namespaceEdit.saving') : t('namespaceEdit.saveAction')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -1,16 +1,20 @@
import { useTranslation } from 'react-i18next'
import { Pencil } from 'lucide-react'
import type { Namespace } from '@/api/types'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/utils'
import { EditNamespaceDialog } from './edit-namespace-dialog'
interface NamespaceHeaderProps {
namespace: Namespace
canEdit?: boolean
}
/**
* Header block for namespace pages and namespace-oriented dashboard views.
*/
export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
export function NamespaceHeader({ namespace, canEdit = false }: NamespaceHeaderProps) {
const { t } = useTranslation()
const statusLabel = namespace.status === 'FROZEN'
? t('namespaceStatus.frozen')
@ -59,6 +63,14 @@ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
</div>
) : null}
</div>
{canEdit ? (
<EditNamespaceDialog namespace={namespace}>
<Button type="button" variant="outline" size="sm" aria-label={t('namespaceEdit.editButton')}>
<Pencil className="mr-1 h-4 w-4" />
{t('namespaceEdit.editButton')}
</Button>
</EditNamespaceDialog>
) : null}
</div>
)
}

View file

@ -0,0 +1,147 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Namespace, NamespaceMember } from '@/api/types'
import { useTransferNamespaceOwnership } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/shared/ui/select'
interface TransferOwnershipDialogProps {
namespace: Namespace
members: NamespaceMember[]
children: React.ReactNode
}
export function TransferOwnershipDialog({ namespace, members, children }: TransferOwnershipDialogProps) {
const { t } = useTranslation()
const transferMutation = useTransferNamespaceOwnership()
const [open, setOpen] = useState(false)
const [selectedUserId, setSelectedUserId] = useState('')
const [confirmSlug, setConfirmSlug] = useState('')
const candidates = members.filter((member) => member.role !== 'OWNER')
const resetDialog = () => {
setSelectedUserId('')
setConfirmSlug('')
transferMutation.reset()
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
resetDialog()
}
}
const handleTransfer = async () => {
if (!selectedUserId) return
try {
await transferMutation.mutateAsync({
slug: namespace.slug,
newOwnerUserId: selectedUserId,
})
toast.success(
t('members.transferSuccessTitle'),
t('members.transferSuccessDescription', { userId: selectedUserId }),
)
setOpen(false)
} catch (error) {
toast.error(t('members.transferErrorTitle'), error instanceof Error ? error.message : '')
}
}
const canConfirm = Boolean(selectedUserId) && confirmSlug === namespace.slug
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader className="text-center sm:text-center">
<DialogTitle className="text-center">{t('members.transferDialogTitle')}</DialogTitle>
<DialogDescription className="text-center">
{t('members.transferDialogDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400">
{t('members.transferWarning')}
</div>
<div className="space-y-2">
<Label htmlFor="transfer-new-owner">{t('members.transferNewOwnerLabel')}</Label>
{candidates.length > 0 ? (
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger id="transfer-new-owner">
<SelectValue placeholder={t('members.transferSelectPlaceholder')} />
</SelectTrigger>
<SelectContent>
{candidates.map((candidate) => (
<SelectItem key={candidate.userId} value={candidate.userId}>
{candidate.displayName || candidate.userId} ({candidate.role})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">{t('members.transferNoCandidates')}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="transfer-confirm-slug">{t('members.transferConfirmSlugPrompt')}</Label>
<Input
id="transfer-confirm-slug"
value={confirmSlug}
placeholder={namespace.slug}
onChange={(event) => setConfirmSlug(event.target.value)}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
{t('members.transferConfirmSlugHint', { slug: namespace.slug })}
</p>
</div>
</div>
{transferMutation.error ? (
<p className="text-sm text-red-600">{transferMutation.error.message}</p>
) : null}
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
{t('dialog.cancel')}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleTransfer}
disabled={!canConfirm || transferMutation.isPending}
>
{transferMutation.isPending ? t('members.transferring') : t('members.transferConfirmAction')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -1099,6 +1099,21 @@
"removeSuccessTitle": "Member removed",
"removeSuccessDescription": "User {{userId}} has been removed from this namespace.",
"removeErrorTitle": "Failed to remove member",
"searchEmptyMemberHint": "If the user is already in this namespace, they won't appear here — check the member list to confirm.",
"transferOwnership": "Transfer Ownership",
"transferDialogTitle": "Transfer Namespace Ownership",
"transferDialogDescription": "Transfer ownership to another member. You will become an ADMIN.",
"transferWarning": "This action cannot be undone. After transfer, you will no longer be the owner.",
"transferNewOwnerLabel": "New owner",
"transferSelectPlaceholder": "Select a member",
"transferNoCandidates": "No other members available to transfer ownership to.",
"transferConfirmSlugPrompt": "Type the namespace slug to confirm",
"transferConfirmSlugHint": "Type {{slug}} to confirm",
"transferConfirmAction": "Transfer Ownership",
"transferring": "Transferring...",
"transferSuccessTitle": "Ownership transferred",
"transferSuccessDescription": "{{userId}} is now the owner of this namespace.",
"transferErrorTitle": "Failed to transfer ownership",
"batchImport": "Batch Import",
"batchDialogTitle": "Batch Import Members",
"batchDialogDescription": "Upload a CSV file to add multiple members at once. Each row should contain a user ID and role.",
@ -1131,6 +1146,17 @@
"batchDone": "Done",
"batchBack": "Back"
},
"namespaceEdit": {
"editButton": "Edit",
"dialogTitle": "Edit Namespace",
"displayNameLabel": "Display Name",
"displayNameRequired": "Display name is required",
"descriptionLabel": "Description",
"saveAction": "Save",
"saving": "Saving...",
"saveSuccess": "Namespace updated successfully",
"saveErrorTitle": "Failed to update namespace"
},
"upload": {
"dropHint": "Drop to upload...",
"dragHint": "Drag a ZIP file here, or click to select",

View file

@ -1100,6 +1100,21 @@
"removeSuccessTitle": "成员已移除",
"removeSuccessDescription": "用户 {{userId}} 已从当前命名空间移除。",
"removeErrorTitle": "移除成员失败",
"searchEmptyMemberHint": "如果该用户已在本命名空间,此处不会显示——请到成员列表确认。",
"transferOwnership": "转交 Owner",
"transferDialogTitle": "转交命名空间 Owner",
"transferDialogDescription": "将所有权转交给其他成员。转交后您将变为 ADMIN。",
"transferWarning": "此操作不可撤销。转交后您将不再是 Owner。",
"transferNewOwnerLabel": "新 Owner",
"transferSelectPlaceholder": "选择一位成员",
"transferNoCandidates": "没有其他成员可接手 Owner。",
"transferConfirmSlugPrompt": "输入命名空间 slug 以确认",
"transferConfirmSlugHint": "输入 {{slug}} 以确认",
"transferConfirmAction": "确认转交",
"transferring": "转交中...",
"transferSuccessTitle": "Owner 已转交",
"transferSuccessDescription": "{{userId}} 现在是本命名空间的 Owner。",
"transferErrorTitle": "转交 Owner 失败",
"batchImport": "批量导入",
"batchDialogTitle": "批量导入成员",
"batchDialogDescription": "上传 CSV 文件批量添加成员。每行包含一个用户 ID 和角色。",
@ -1132,6 +1147,17 @@
"batchDone": "完成",
"batchBack": "返回"
},
"namespaceEdit": {
"editButton": "编辑",
"dialogTitle": "编辑命名空间",
"displayNameLabel": "显示名称",
"displayNameRequired": "显示名称不能为空",
"descriptionLabel": "描述",
"saveAction": "保存",
"saving": "保存中...",
"saveSuccess": "命名空间已更新",
"saveErrorTitle": "更新命名空间失败"
},
"upload": {
"dropHint": "放开以上传文件...",
"dragHint": "拖拽 ZIP 文件到此处,或点击选择",

View file

@ -5,8 +5,10 @@ import { formatLocalDateTime } from '@/shared/lib/date-time'
import { AddNamespaceMemberDialog } from '@/features/namespace/add-namespace-member-dialog'
import { BatchImportMembersDialog } from '@/features/namespace/batch-import-members-dialog'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { TransferOwnershipDialog } from '@/features/namespace/transfer-ownership-dialog'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import {
@ -29,6 +31,8 @@ type PendingRemoval = {
userId: string
}
const MEMBER_PAGE_SIZE = 20
/**
* Member management page for a namespace. The route computes mutability from
* both namespace state and the current user's role because the backend model
@ -38,23 +42,30 @@ export function NamespaceMembersPage() {
const { t, i18n } = useTranslation()
const params = useParams({ from: '/dashboard/namespaces/$slug/members' })
const slug = params.slug
const [page, setPage] = useState(0)
const [draftRoles, setDraftRoles] = useState<Record<string, string>>({})
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(null)
const [savingRoleUserId, setSavingRoleUserId] = useState<string | null>(null)
const [removingUserId, setRemovingUserId] = useState<string | null>(null)
const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug)
const { data: members, isLoading: isLoadingMembers, error: membersError } = useNamespaceMembers(slug)
const { data: membersPage, isLoading: isLoadingMembers, error: membersError } = useNamespaceMembers(slug, page, MEMBER_PAGE_SIZE)
const { data: myNamespaces } = useMyNamespaces()
const updateRoleMutation = useUpdateNamespaceMemberRole()
const removeMemberMutation = useRemoveNamespaceMember()
const members = membersPage?.items ?? []
const totalMembers = membersPage?.total ?? 0
const totalPages = Math.max(1, Math.ceil(totalMembers / MEMBER_PAGE_SIZE))
const currentNamespace = myNamespaces?.find((item) => item.slug === slug)
const currentUserRole = currentNamespace?.currentUserRole
const isReadOnly = namespace?.type === 'GLOBAL' || namespace?.status !== 'ACTIVE'
// Membership changes are only allowed in active team namespaces and only for
// elevated roles surfaced through the current user's namespace membership.
const canManageMembers = !isReadOnly && (currentUserRole === 'OWNER' || currentUserRole === 'ADMIN')
const canEditNamespace = canManageMembers
const canTransferOwnership = !isReadOnly && currentUserRole === 'OWNER'
const readOnlyMessage = namespace?.type === 'GLOBAL'
? t('members.globalReadOnly')
@ -144,7 +155,7 @@ export function NamespaceMembersPage() {
title={t('members.title')}
subtitle={namespace ? `@${namespace.slug}` : undefined}
/>
<NamespaceHeader namespace={namespace} />
<NamespaceHeader namespace={namespace} canEdit={canEditNamespace} />
<div className="space-y-6">
{readOnlyMessage ? (
@ -154,6 +165,11 @@ export function NamespaceMembersPage() {
) : null}
<div className="flex items-center justify-end gap-2">
{canTransferOwnership ? (
<TransferOwnershipDialog namespace={namespace} members={members}>
<Button variant="outline">{t('members.transferOwnership')}</Button>
</TransferOwnershipDialog>
) : null}
{canManageMembers ? (
<>
<BatchImportMembersDialog slug={slug}>
@ -178,7 +194,7 @@ export function NamespaceMembersPage() {
<div key={index} className="h-14 animate-shimmer rounded-lg" />
))}
</div>
) : members && members.length > 0 ? (
) : members.length > 0 ? (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
@ -273,6 +289,10 @@ export function NamespaceMembersPage() {
{t('members.empty')}
</Card>
)}
{totalMembers > MEMBER_PAGE_SIZE ? (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
) : null}
</div>
<ConfirmDialog

View file

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole, BatchMemberResponse } from '@/api/types'
import type { Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole, BatchMemberResponse, PagedResponse } from '@/api/types'
import { namespaceApi } from '@/api/client'
import { appendNamespaceMember, replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache'
import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers'
@ -16,8 +16,8 @@ async function getNamespaceDetail(slug: string): Promise<Namespace> {
return namespaceApi.getDetail(slug)
}
async function getNamespaceMembers(slug: string): Promise<NamespaceMember[]> {
return namespaceApi.listMembers(slug)
async function getNamespaceMembers(slug: string, page = 0, size = 20): Promise<PagedResponse<NamespaceMember>> {
return namespaceApi.listMembers(slug, { page, size })
}
async function searchNamespaceMemberCandidates(params: { slug: string; search: string }): Promise<NamespaceCandidateUser[]> {
@ -75,10 +75,10 @@ export function useNamespaceDetail(slug: string) {
})
}
export function useNamespaceMembers(slug: string) {
export function useNamespaceMembers(slug: string, page = 0, size = 20) {
return useQuery({
queryKey: ['namespaces', slug, 'members'],
queryFn: () => getNamespaceMembers(slug),
queryKey: ['namespaces', slug, 'members', { page, size }],
queryFn: () => getNamespaceMembers(slug, page, size),
enabled: !!slug,
})
}
@ -97,9 +97,9 @@ export function useAddNamespaceMember() {
return useMutation({
mutationFn: addNamespaceMember,
onSuccess: (member, variables) => {
queryClient.setQueryData<NamespaceMember[]>(
['namespaces', variables.slug, 'members'],
(currentMembers) => appendNamespaceMember(currentMembers, member),
queryClient.setQueriesData<PagedResponse<NamespaceMember>>(
{ queryKey: ['namespaces', variables.slug, 'members'] },
(currentPage) => appendNamespaceMember(currentPage, member),
)
invalidateNamespaceQueries(queryClient, variables.slug)
},
@ -123,9 +123,9 @@ export function useUpdateNamespaceMemberRole() {
return useMutation({
mutationFn: updateNamespaceMemberRole,
onSuccess: (member, variables) => {
queryClient.setQueryData<NamespaceMember[]>(
['namespaces', variables.slug, 'members'],
(currentMembers) => replaceNamespaceMemberRole(currentMembers, variables.userId, member.role),
queryClient.setQueriesData<PagedResponse<NamespaceMember>>(
{ queryKey: ['namespaces', variables.slug, 'members'] },
(currentPage) => replaceNamespaceMemberRole(currentPage, variables.userId, member.role),
)
invalidateNamespaceQueries(queryClient, variables.slug)
},
@ -186,3 +186,30 @@ export function useRestoreNamespace() {
},
})
}
export function useUpdateNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug, displayName, description }: { slug: string; displayName?: string; description?: string }) =>
namespaceApi.update(slug, { displayName, description }),
onSuccess: (namespace) => {
queryClient.invalidateQueries({ queryKey: ['namespaces', namespace.slug] })
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
},
})
}
export function useTransferNamespaceOwnership() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug, newOwnerUserId }: { slug: string; newOwnerUserId: string }) =>
namespaceApi.transferOwnership(slug, newOwnerUserId),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['namespaces', variables.slug] })
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
queryClient.invalidateQueries({ queryKey: ['namespaces', variables.slug, 'members'] })
},
})
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { appendNamespaceMember, replaceNamespaceMemberRole } from './namespace-member-cache'
import type { NamespaceMember } from '@/api/types'
import type { NamespaceMember, PagedResponse } from '@/api/types'
const baseMember = (overrides: Partial<NamespaceMember>): NamespaceMember => ({
id: 1,
@ -10,32 +10,57 @@ const baseMember = (overrides: Partial<NamespaceMember>): NamespaceMember => ({
...overrides,
})
const basePage = (items: NamespaceMember[]): PagedResponse<NamespaceMember> => ({
items,
total: items.length,
page: 0,
size: 20,
})
describe('appendNamespaceMember', () => {
it('appends a newly added member with the returned role', () => {
const members = [baseMember({})]
const page = basePage([baseMember({})])
const addedMember = baseMember({ id: 2, userId: 'user-2', role: 'ADMIN' })
expect(appendNamespaceMember(members, addedMember)).toEqual([
members[0],
addedMember,
])
const result = appendNamespaceMember(page, addedMember)
expect(result.items).toEqual([page.items[0], addedMember])
expect(result.total).toBe(2)
})
it('replaces the existing member when the same user is returned again', () => {
const members = [baseMember({ role: 'MEMBER' })]
const page = basePage([baseMember({ role: 'MEMBER' })])
const updatedMember = baseMember({ role: 'ADMIN' })
expect(appendNamespaceMember(members, updatedMember)).toEqual([updatedMember])
const result = appendNamespaceMember(page, updatedMember)
expect(result.items).toEqual([updatedMember])
expect(result.total).toBe(1)
})
it('handles undefined page', () => {
const addedMember = baseMember({ id: 2, userId: 'user-2', role: 'ADMIN' })
const result = appendNamespaceMember(undefined, addedMember)
expect(result.items).toEqual([addedMember])
expect(result.total).toBe(1)
})
})
describe('replaceNamespaceMemberRole', () => {
it('updates the member role in the current list', () => {
const members = [baseMember({}), baseMember({ id: 2, userId: 'user-2', role: 'MEMBER' })]
const page = basePage([
baseMember({}),
baseMember({ id: 2, userId: 'user-2', role: 'MEMBER' }),
])
expect(replaceNamespaceMemberRole(members, 'user-2', 'ADMIN')).toEqual([
members[0],
{ ...members[1], role: 'ADMIN' },
const result = replaceNamespaceMemberRole(page, 'user-2', 'ADMIN')
expect(result?.items).toEqual([
page.items[0],
{ ...page.items[1], role: 'ADMIN' },
])
})
it('handles undefined page', () => {
const result = replaceNamespaceMemberRole(undefined, 'user-2', 'ADMIN')
expect(result).toBeUndefined()
})
})

View file

@ -1,30 +1,44 @@
import type { NamespaceMember } from '@/api/types'
import type { NamespaceMember, PagedResponse } from '@/api/types'
export function appendNamespaceMember(
currentMembers: NamespaceMember[] | undefined,
currentPage: PagedResponse<NamespaceMember> | undefined,
nextMember: NamespaceMember,
) {
const members = currentMembers ?? []
const existingIndex = members.findIndex((member) => member.userId === nextMember.userId)
if (existingIndex === -1) {
return [...members, nextMember]
): PagedResponse<NamespaceMember> {
if (!currentPage) {
return { items: [nextMember], total: 1, page: 0, size: 20 }
}
return members.map((member, index) => (index === existingIndex ? nextMember : member))
const existingIndex = currentPage.items.findIndex((member) => member.userId === nextMember.userId)
if (existingIndex === -1) {
return {
...currentPage,
items: [...currentPage.items, nextMember],
total: currentPage.total + 1,
}
}
return {
...currentPage,
items: currentPage.items.map((member, index) => (index === existingIndex ? nextMember : member)),
}
}
export function replaceNamespaceMemberRole(
currentMembers: NamespaceMember[] | undefined,
currentPage: PagedResponse<NamespaceMember> | undefined,
userId: string,
role: string,
) {
return (currentMembers ?? []).map((member) => (
member.userId === userId
? {
...member,
role,
}
: member
))
): PagedResponse<NamespaceMember> | undefined {
if (!currentPage) return currentPage
return {
...currentPage,
items: currentPage.items.map((member) =>
member.userId === userId
? {
...member,
role,
}
: member,
),
}
}