feat(cloudflare): add domain move and batch operations in group detail page

This commit is contained in:
ryan
2026-09-26 15:26:37 +08:00
parent 4d52a5b097
commit 11c8e5c7f3
7 changed files with 713 additions and 17 deletions
@@ -0,0 +1,127 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {
CloudflareGroup,
CloudflareMember,
} from '@/lib/services/openflare';
export interface MemberMoveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
members: CloudflareMember[];
groups: CloudflareGroup[];
currentGroupId: number;
pending: boolean;
onSubmit: (targetGroupId: number) => void;
}
export function MemberMoveDialog({
open,
onOpenChange,
members,
groups,
currentGroupId,
pending,
onSubmit,
}: MemberMoveDialogProps) {
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const [targetGroupId, setTargetGroupId] = useState('');
const availableGroups = useMemo(
() => groups.filter((g) => g.id !== currentGroupId),
[groups, currentGroupId],
);
useEffect(() => {
if (!open) return;
setTargetGroupId('');
}, [open]);
const title =
members.length === 1
? t('moveDialog.titleSingle', { domain: members[0]?.domain ?? '' })
: t('moveDialog.titleBatch', { count: members.length });
const description =
members.length > 0
? members.map((m) => m.domain).join(', ')
: t('moveDialog.targetGroupLabel');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className='line-clamp-2'>
{description}
</DialogDescription>
</DialogHeader>
<FieldGroup>
{availableGroups.length === 0 ? (
<p className='text-sm text-muted-foreground'>
{t('moveDialog.noOtherGroups')}
</p>
) : (
<Field>
<FieldLabel htmlFor='cf-move-target-group'>
{t('moveDialog.targetGroupLabel')}
</FieldLabel>
<Select value={targetGroupId} onValueChange={setTargetGroupId}>
<SelectTrigger id='cf-move-target-group' className='w-full'>
<SelectValue
placeholder={t('moveDialog.targetGroupPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableGroups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.name}
{group.primary_node?.ip
? ` · ${group.primary_node.ip}`
: ''}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
)}
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{tCommon('cancel')}
</Button>
<Button
disabled={pending || !targetGroupId || availableGroups.length === 0}
onClick={() => onSubmit(Number(targetGroupId))}
>
{pending ? t('moveDialog.moving') : t('moveDialog.submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -3,6 +3,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
ArrowRightLeft,
Cloud,
Loader2,
Plus,
@@ -16,6 +17,16 @@ import { usePathname } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -25,6 +36,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import {
Table,
@@ -41,10 +53,12 @@ import {
cloudflareQueryKey,
NodeService,
type CloudflareGroupPayload,
type CloudflareMember,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../../../websites/components/website-utils';
import { GroupDialog } from '../../components/group-dialog';
import { MemberAddDialog } from '../../components/member-add-dialog';
import { MemberMoveDialog } from '../../components/member-move-dialog';
function getGroupIdFromPathname(pathname: string | null): number {
const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/);
@@ -53,6 +67,7 @@ function getGroupIdFromPathname(pathname: string | null): number {
export function CloudflareGroupDetailPageClient() {
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
@@ -63,6 +78,13 @@ export function CloudflareGroupDetailPageClient() {
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [selectedMemberIDs, setSelectedMemberIDs] = useState<Set<number>>(
() => new Set(),
);
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
const [movingMembers, setMovingMembers] = useState<CloudflareMember[]>([]);
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false);
const detailQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups', groupID],
queryFn: () => CloudflareService.getGroup(groupID),
@@ -77,6 +99,10 @@ export function CloudflareGroupDetailPageClient() {
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const groupsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups'],
queryFn: () => CloudflareService.listGroups(),
});
const invalidate = async () =>
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
@@ -150,12 +176,80 @@ export function CloudflareGroupDetailPageClient() {
const removeMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.removeMember(groupID, memberID),
onSuccess: async () => {
onSuccess: async (_, memberID) => {
toast.success(t('memberDeleted'));
setSelectedMemberIDs((prev) => {
const next = new Set(prev);
next.delete(memberID);
return next;
});
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const moveMutation = useMutation({
mutationFn: async ({
targetGroupId,
membersToMove,
}: {
targetGroupId: number;
membersToMove: CloudflareMember[];
}) => {
if (membersToMove.length === 1) {
return CloudflareService.moveMember(
groupID,
membersToMove[0].id,
targetGroupId,
);
}
return CloudflareService.batchMoveMembers(
groupID,
membersToMove.map((m) => m.id),
targetGroupId,
);
},
onSuccess: async (_, variables) => {
if (variables.membersToMove.length === 1) {
toast.success(t('memberMoved'));
} else {
toast.success(
t('batchMoved', { count: variables.membersToMove.length }),
);
}
setMoveDialogOpen(false);
setSelectedMemberIDs((prev) => {
const next = new Set(prev);
for (const m of variables.membersToMove) {
next.delete(m.id);
}
return next;
});
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const batchRemoveMutation = useMutation({
mutationFn: (memberIds: number[]) =>
CloudflareService.batchRemoveMembers(groupID, memberIds),
onSuccess: async (_, memberIds) => {
toast.success(t('batchRemoved', { count: memberIds.length }));
setBatchRemoveOpen(false);
setSelectedMemberIDs(new Set());
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleSingleMove = (member: CloudflareMember) => {
setMovingMembers([member]);
setMoveDialogOpen(true);
};
const handleBatchMove = (selectedList: CloudflareMember[]) => {
if (selectedList.length === 0) return;
setMovingMembers(selectedList);
setMoveDialogOpen(true);
};
if (!mounted || detailQuery.isLoading)
return (
@@ -174,6 +268,12 @@ export function CloudflareGroupDetailPageClient() {
);
const { group, members } = detailQuery.data;
const selectedMembers = members.filter((m) => selectedMemberIDs.has(m.id));
const allSelected =
members.length > 0 && selectedMembers.length === members.length;
const isIndeterminate =
selectedMembers.length > 0 && selectedMembers.length < members.length;
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex flex-col gap-4'>
@@ -246,9 +346,41 @@ export function CloudflareGroupDetailPageClient() {
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>{t('members')}</CardTitle>
<CardDescription>{t('membersDesc')}</CardDescription>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div>
<CardTitle className='text-base'>{t('members')}</CardTitle>
<CardDescription>{t('membersDesc')}</CardDescription>
</div>
{selectedMembers.length > 0 && (
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='secondary' className='text-xs'>
{t('selectedCount', { count: selectedMembers.length })}
</Badge>
<Button
variant='outline'
size='sm'
onClick={() => handleBatchMove(selectedMembers)}
>
<ArrowRightLeft data-icon='inline-start' />
{t('batchMove')}
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => setBatchRemoveOpen(true)}
>
<Trash2 data-icon='inline-start' />
{t('batchRemove')}
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setSelectedMemberIDs(new Set())}
>
{t('clearSelection')}
</Button>
</div>
)}
</CardHeader>
<CardContent>
{members.length === 0 ? (
@@ -259,6 +391,27 @@ export function CloudflareGroupDetailPageClient() {
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-12'>
<Checkbox
checked={
allSelected
? true
: isIndeterminate
? 'indeterminate'
: false
}
onCheckedChange={(checked) => {
if (checked) {
setSelectedMemberIDs(
new Set(members.map((m) => m.id)),
);
} else {
setSelectedMemberIDs(new Set());
}
}}
aria-label='Select all'
/>
</TableHead>
<TableHead>{t('columns.domain')}</TableHead>
<TableHead>{t('columns.desiredIp')}</TableHead>
<TableHead>{t('columns.status')}</TableHead>
@@ -270,7 +423,29 @@ export function CloudflareGroupDetailPageClient() {
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableRow
key={member.id}
data-state={
selectedMemberIDs.has(member.id) ? 'selected' : undefined
}
>
<TableCell className='w-12'>
<Checkbox
checked={selectedMemberIDs.has(member.id)}
onCheckedChange={(checked) => {
setSelectedMemberIDs((prev) => {
const next = new Set(prev);
if (checked) {
next.add(member.id);
} else {
next.delete(member.id);
}
return next;
});
}}
aria-label={`Select ${member.domain}`}
/>
</TableCell>
<TableCell>
<div className='font-medium'>{member.domain}</div>
{member.last_error ? (
@@ -308,6 +483,14 @@ export function CloudflareGroupDetailPageClient() {
</TableCell>
<TableCell>
<div className='flex justify-end gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handleSingleMove(member)}
>
<ArrowRightLeft data-icon='inline-start' />
{t('move')}
</Button>
<Button
variant='outline'
size='sm'
@@ -352,6 +535,43 @@ export function CloudflareGroupDetailPageClient() {
addMutation.mutate({ domainIDs, proxied })
}
/>
<MemberMoveDialog
open={moveDialogOpen}
onOpenChange={setMoveDialogOpen}
members={movingMembers}
groups={groupsQuery.data ?? []}
currentGroupId={groupID}
pending={moveMutation.isPending}
onSubmit={(targetGroupId) =>
moveMutation.mutate({
targetGroupId,
membersToMove: movingMembers,
})
}
/>
<AlertDialog open={batchRemoveOpen} onOpenChange={setBatchRemoveOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('batchRemoveDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('batchRemoveDialog.desc', { count: selectedMembers.length })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
<AlertDialogAction
disabled={batchRemoveMutation.isPending}
onClick={() =>
batchRemoveMutation.mutate(selectedMembers.map((m) => m.id))
}
>
{batchRemoveMutation.isPending
? t('batchRemoveDialog.removing')
: t('batchRemoveDialog.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -102,4 +102,35 @@ export class CloudflareService extends OpenFlareBaseService {
`/groups/${groupId}/members/${memberId}/sync`,
);
}
static moveMember(
groupId: number,
memberId: number,
targetGroupId: number,
): Promise<CloudflareMember> {
return this.post<CloudflareMember>(
`/groups/${groupId}/members/${memberId}/move`,
{ target_group_id: targetGroupId },
);
}
static batchMoveMembers(
groupId: number,
memberIds: number[],
targetGroupId: number,
): Promise<void> {
return this.post<void>(`/groups/${groupId}/members/batch-move`, {
member_ids: memberIds,
target_group_id: targetGroupId,
});
}
static batchRemoveMembers(
groupId: number,
memberIds: number[],
): Promise<void> {
return this.post<void>(`/groups/${groupId}/members/batch-remove`, {
member_ids: memberIds,
});
}
}
@@ -0,0 +1,27 @@
{
"cloudflare": {
"move": "Move",
"batchMove": "Batch Move",
"batchRemove": "Batch Delete",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection",
"moveDialog": {
"titleSingle": "Move domain \"{domain}\"",
"titleBatch": "Batch move domains ({count} items)",
"targetGroupLabel": "Target pointing group",
"targetGroupPlaceholder": "Select target group",
"noOtherGroups": "No other pointing groups available. Please create one first.",
"submit": "Confirm move",
"moving": "Moving..."
},
"batchRemoveDialog": {
"title": "Confirm batch domain deletion",
"desc": "Are you sure you want to remove {count} selected domains from the current group? This will also clean up DNS records on Cloudflare.",
"confirm": "Confirm delete",
"removing": "Deleting..."
},
"memberMoved": "Domain moved successfully",
"batchMoved": "Successfully moved {count} domains",
"batchRemoved": "Successfully deleted {count} domains"
}
}
@@ -0,0 +1,27 @@
{
"cloudflare": {
"move": "移动",
"batchMove": "批量移动",
"batchRemove": "批量删除",
"selectedCount": "已选择 {count} 项",
"clearSelection": "取消选择",
"moveDialog": {
"titleSingle": "移动域名「{domain}」",
"titleBatch": "批量移动域名(共 {count} 项)",
"targetGroupLabel": "目标指向分组",
"targetGroupPlaceholder": "请选择目标分组",
"noOtherGroups": "没有其他可用的指向分组,请先创建分组",
"submit": "确认移动",
"moving": "移动中..."
},
"batchRemoveDialog": {
"title": "确认批量删除域名",
"desc": "确认从当前分组移出已选中的 {count} 个域名吗?这将同时清理 Cloudflare 上的解析记录。",
"confirm": "确认删除",
"removing": "删除中..."
},
"memberMoved": "域名移动成功",
"batchMoved": "成功移动 {count} 个域名",
"batchRemoved": "成功删除 {count} 个域名"
}
}
@@ -39,17 +39,74 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
CloudflareService: {
...actual.CloudflareService,
getGroup: vi.fn(),
listGroups: vi.fn(),
listAvailableDomains: vi.fn(),
updateGroup: vi.fn(),
createMember: vi.fn(),
updateMember: vi.fn(),
syncMember: vi.fn(),
removeMember: vi.fn(),
moveMember: vi.fn(),
batchMoveMembers: vi.fn(),
batchRemoveMembers: vi.fn(),
},
NodeService: { ...actual.NodeService, listNodes: vi.fn() },
};
});
const mockGroup = {
id: 7,
name: '生产节点',
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
backup_node: null,
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
default_proxied: true,
enabled: true,
member_count: 2,
created_at: '',
updated_at: '',
};
const mockTargetGroup = {
id: 8,
name: '备用分组',
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
backup_node: null,
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
default_proxied: false,
enabled: true,
member_count: 0,
created_at: '',
updated_at: '',
};
const mockMembers = [
{
id: 101,
group_id: 7,
zone_domain_id: 1,
domain: 'a.example.com',
zone_id: 10,
proxied: true,
desired_ip: '192.0.2.1',
sync_status: 'ok' as const,
last_error: '',
synced_at: null,
},
{
id: 102,
group_id: 7,
zone_domain_id: 2,
domain: 'b.example.com',
zone_id: 10,
proxied: false,
desired_ip: '192.0.2.1',
sync_status: 'ok' as const,
last_error: '',
synced_at: null,
},
];
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
@@ -72,25 +129,36 @@ describe('Cloudflare group detail refresh', () => {
mockGroupId = '7';
mockParamId = '7';
vi.mocked(CloudflareService.getGroup).mockReset();
vi.mocked(CloudflareService.listGroups).mockReset();
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
vi.mocked(CloudflareService.moveMember).mockReset();
vi.mocked(CloudflareService.batchMoveMembers).mockReset();
vi.mocked(CloudflareService.batchRemoveMembers).mockReset();
vi.mocked(NodeService.listNodes).mockReset();
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
window.HTMLElement.prototype.setPointerCapture = vi.fn();
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
window.HTMLElement.prototype.scrollIntoView = vi.fn();
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
group: {
id: 7,
name: '生产节点',
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
backup_node: null,
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
default_proxied: true,
enabled: true,
member_count: 0,
created_at: '',
updated_at: '',
},
group: mockGroup,
members: [],
});
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
mockGroup,
mockTargetGroup,
]);
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
vi.mocked(CloudflareService.moveMember).mockResolvedValue({
...mockMembers[0],
group_id: 8,
});
vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined);
vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue(
undefined,
);
});
afterEach(() => {
@@ -141,3 +209,174 @@ describe('Cloudflare group detail refresh', () => {
});
});
});
describe('Cloudflare group detail move and batch operations', () => {
beforeEach(() => {
mockGroupId = '7';
mockParamId = '7';
vi.mocked(CloudflareService.getGroup).mockReset();
vi.mocked(CloudflareService.listGroups).mockReset();
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
vi.mocked(CloudflareService.moveMember).mockReset();
vi.mocked(CloudflareService.batchMoveMembers).mockReset();
vi.mocked(CloudflareService.batchRemoveMembers).mockReset();
vi.mocked(NodeService.listNodes).mockReset();
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
window.HTMLElement.prototype.setPointerCapture = vi.fn();
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
window.HTMLElement.prototype.scrollIntoView = vi.fn();
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
group: mockGroup,
members: mockMembers,
});
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
mockGroup,
mockTargetGroup,
]);
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
vi.mocked(CloudflareService.moveMember).mockResolvedValue({
...mockMembers[0],
group_id: 8,
});
vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined);
vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue(
undefined,
);
});
it('supports selecting all members and toggling selection', async () => {
renderPage();
expect(await screen.findByText('a.example.com')).toBeVisible();
expect(screen.getByText('b.example.com')).toBeVisible();
// Initially batch toolbar is not visible
expect(screen.queryByText(/已选择/)).not.toBeInTheDocument();
// Select all via header checkbox
const selectAllCheckbox = screen.getByRole('checkbox', {
name: 'Select all',
});
fireEvent.click(selectAllCheckbox);
// Batch toolbar should appear with count 2
expect(await screen.findByText('已选择 2 项')).toBeVisible();
expect(screen.getByRole('button', { name: '批量移动' })).toBeVisible();
expect(screen.getByRole('button', { name: '批量删除' })).toBeVisible();
// Clear selection
fireEvent.click(screen.getByRole('button', { name: '取消选择' }));
expect(screen.queryByText(/已选择/)).not.toBeInTheDocument();
});
it('opens single move dialog and triggers moveMember', async () => {
renderPage();
expect(await screen.findByText('a.example.com')).toBeVisible();
// Click single move button on first row
const moveButtons = screen.getAllByRole('button', { name: '移动' });
fireEvent.click(moveButtons[0]);
// Move dialog should show single member title
expect(
await screen.findByRole('heading', {
name: '移动域名「a.example.com」',
}),
).toBeVisible();
// Open target group select
const selectTrigger = screen.getByRole('combobox');
fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' });
// Select target group
const targetOption = await screen.findByRole('option', {
name: /备用分组/,
});
fireEvent.click(targetOption);
// Submit move
fireEvent.click(screen.getByRole('button', { name: '确认移动' }));
await waitFor(() => {
expect(CloudflareService.moveMember).toHaveBeenCalledWith(7, 101, 8);
});
});
it('opens batch move dialog and triggers batchMoveMembers', async () => {
renderPage();
expect(await screen.findByText('a.example.com')).toBeVisible();
// Select all members
fireEvent.click(screen.getByRole('checkbox', { name: 'Select all' }));
expect(await screen.findByText('已选择 2 项')).toBeVisible();
// Click batch move button
fireEvent.click(screen.getByRole('button', { name: '批量移动' }));
// Dialog title should show batch title
expect(
await screen.findByRole('heading', {
name: '批量移动域名(共 2 项)',
}),
).toBeVisible();
// Open target group select
const selectTrigger = screen.getByRole('combobox');
fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' });
// Select target group
const targetOption = await screen.findByRole('option', {
name: /备用分组/,
});
fireEvent.click(targetOption);
// Submit move
fireEvent.click(screen.getByRole('button', { name: '确认移动' }));
await waitFor(() => {
expect(CloudflareService.batchMoveMembers).toHaveBeenCalledWith(
7,
[101, 102],
8,
);
});
});
it('opens batch remove dialog and triggers batchRemoveMembers', async () => {
renderPage();
expect(await screen.findByText('a.example.com')).toBeVisible();
// Select first member only
fireEvent.click(
screen.getByRole('checkbox', { name: 'Select a.example.com' }),
);
expect(await screen.findByText('已选择 1 项')).toBeVisible();
// Click batch remove
fireEvent.click(screen.getByRole('button', { name: '批量删除' }));
// Confirmation dialog appears
expect(
await screen.findByRole('heading', { name: '确认批量删除域名' }),
).toBeVisible();
expect(
screen.getByText(/确认从当前分组移出已选中的 1 个域名吗?/),
).toBeVisible();
// Confirm deletion
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => {
expect(CloudflareService.batchRemoveMembers).toHaveBeenCalledWith(
7,
[101],
);
});
});
});
@@ -55,4 +55,29 @@ describe('CloudflareService', () => {
undefined,
);
});
it('calls move and batch member endpoints with correct paths and payloads', async () => {
vi.mocked(apiClient.post).mockResolvedValue(response({}));
await CloudflareService.moveMember(7, 9, 8);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/cloudflare/groups/7/members/9/move',
{ target_group_id: 8 },
undefined,
);
await CloudflareService.batchMoveMembers(7, [9, 10], 8);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/cloudflare/groups/7/members/batch-move',
{ member_ids: [9, 10], target_group_id: 8 },
undefined,
);
await CloudflareService.batchRemoveMembers(7, [9, 10]);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/cloudflare/groups/7/members/batch-remove',
{ member_ids: [9, 10] },
undefined,
);
});
});