feat(cli-endpoints): add create, update, and delete functionality for CLI endpoints

- Implemented `useCreateCliEndpoint`, `useUpdateCliEndpoint`, and `useDeleteCliEndpoint` hooks for managing CLI endpoints.
- Added `CliEndpointFormDialog` component for creating and editing CLI endpoints with validation.
- Updated translations for CLI hooks and manager to include new fields and messages.
- Refactored `CcwToolsMcpCard` to simplify enabling and disabling tools.
- Adjusted `SkillCreateDialog` to display paths based on CLI type.
This commit is contained in:
catlog22
2026-02-07 21:56:08 +08:00
parent 678be8d41f
commit 6073627ff2
12 changed files with 1252 additions and 422 deletions

View File

@@ -0,0 +1,131 @@
// ========================================
// Endpoints Page Tests
// ========================================
// Tests for the CLI endpoints page with i18n
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { EndpointsPage } from './EndpointsPage';
import type { CliEndpoint } from '@/lib/api';
const mockEndpoints: CliEndpoint[] = [
{
id: 'ep-1',
name: 'Endpoint 1',
type: 'custom',
enabled: true,
config: { foo: 'bar' },
},
];
const createEndpointMock = vi.fn();
const updateEndpointMock = vi.fn();
const deleteEndpointMock = vi.fn();
const toggleEndpointMock = vi.fn();
vi.mock('@/hooks', () => ({
useCliEndpoints: () => ({
endpoints: mockEndpoints,
litellmEndpoints: [],
customEndpoints: mockEndpoints,
wrapperEndpoints: [],
totalCount: mockEndpoints.length,
enabledCount: mockEndpoints.length,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
invalidate: vi.fn(),
}),
useToggleCliEndpoint: () => ({
toggleEndpoint: toggleEndpointMock,
isToggling: false,
error: null,
}),
useCreateCliEndpoint: () => ({
createEndpoint: createEndpointMock,
isCreating: false,
error: null,
}),
useUpdateCliEndpoint: () => ({
updateEndpoint: updateEndpointMock,
isUpdating: false,
error: null,
}),
useDeleteCliEndpoint: () => ({
deleteEndpoint: deleteEndpointMock,
isDeleting: false,
error: null,
}),
}));
vi.mock('@/hooks/useNotifications', () => ({
useNotifications: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}));
describe('EndpointsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
// confirm() used for delete
// @ts-expect-error - test override
global.confirm = vi.fn(() => true);
});
it('should render page title', () => {
render(<EndpointsPage />, { locale: 'en' });
expect(screen.getByText(/CLI Endpoints/i)).toBeInTheDocument();
});
it('should open create dialog and call createEndpoint', async () => {
const user = userEvent.setup();
createEndpointMock.mockResolvedValueOnce({ id: 'ep-2' });
render(<EndpointsPage />, { locale: 'en' });
await user.click(screen.getByRole('button', { name: /Add Endpoint/i }));
expect(screen.getByText(/Add Endpoint/i)).toBeInTheDocument();
await user.type(screen.getByLabelText(/^Name/i), 'New Endpoint');
await user.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(createEndpointMock).toHaveBeenCalledWith({
name: 'New Endpoint',
type: 'custom',
enabled: true,
config: {},
});
});
});
it('should open edit dialog when edit clicked', async () => {
const user = userEvent.setup();
updateEndpointMock.mockResolvedValueOnce({});
render(<EndpointsPage />, { locale: 'en' });
await user.click(screen.getByRole('button', { name: /Edit Endpoint/i }));
expect(screen.getByText(/Edit Endpoint/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^ID$/i)).toHaveValue('ep-1');
});
it('should call deleteEndpoint when delete confirmed', async () => {
const user = userEvent.setup();
deleteEndpointMock.mockResolvedValueOnce(undefined);
render(<EndpointsPage />, { locale: 'en' });
await user.click(screen.getByRole('button', { name: /Delete Endpoint/i }));
await waitFor(() => {
expect(deleteEndpointMock).toHaveBeenCalledWith('ep-1');
});
});
});

View File

@@ -25,9 +25,11 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { useCliEndpoints, useToggleCliEndpoint } from '@/hooks';
import { useCliEndpoints, useCreateCliEndpoint, useDeleteCliEndpoint, useToggleCliEndpoint, useUpdateCliEndpoint } from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import type { CliEndpoint } from '@/lib/api';
import { cn } from '@/lib/utils';
import { CliEndpointFormDialog, type CliEndpointFormMode, type CliEndpointSavePayload } from '@/components/cli-endpoints/CliEndpointFormDialog';
// ========== Endpoint Card Component ==========
@@ -37,7 +39,7 @@ interface EndpointCardProps {
onToggleExpand: () => void;
onToggle: (endpointId: string, enabled: boolean) => void;
onEdit: (endpoint: CliEndpoint) => void;
onDelete: (endpointId: string) => void;
onDelete: (endpointId: string) => void | Promise<void>;
}
function EndpointCard({ endpoint, isExpanded, onToggleExpand, onToggle, onEdit, onDelete }: EndpointCardProps) {
@@ -94,6 +96,7 @@ function EndpointCard({ endpoint, isExpanded, onToggleExpand, onToggle, onEdit,
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label={formatMessage({ id: 'cliEndpoints.actions.toggle' })}
onClick={(e) => {
e.stopPropagation();
onToggle(endpoint.id, !endpoint.enabled);
@@ -105,6 +108,7 @@ function EndpointCard({ endpoint, isExpanded, onToggleExpand, onToggle, onEdit,
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label={formatMessage({ id: 'cliEndpoints.actions.edit' })}
onClick={(e) => {
e.stopPropagation();
onEdit(endpoint);
@@ -116,6 +120,7 @@ function EndpointCard({ endpoint, isExpanded, onToggleExpand, onToggle, onEdit,
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label={formatMessage({ id: 'cliEndpoints.actions.delete' })}
onClick={(e) => {
e.stopPropagation();
onDelete(endpoint.id);
@@ -152,9 +157,13 @@ function EndpointCard({ endpoint, isExpanded, onToggleExpand, onToggle, onEdit,
export function EndpointsPage() {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<'all' | 'litellm' | 'custom' | 'wrapper'>('all');
const [expandedEndpoints, setExpandedEndpoints] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<CliEndpointFormMode>('create');
const [editingEndpoint, setEditingEndpoint] = useState<CliEndpoint | undefined>(undefined);
const {
endpoints,
@@ -168,6 +177,9 @@ export function EndpointsPage() {
} = useCliEndpoints();
const { toggleEndpoint } = useToggleCliEndpoint();
const { createEndpoint, isCreating } = useCreateCliEndpoint();
const { updateEndpoint, isUpdating } = useUpdateCliEndpoint();
const { deleteEndpoint, isDeleting } = useDeleteCliEndpoint();
const toggleExpand = (endpointId: string) => {
setExpandedEndpoints((prev) => {
@@ -185,16 +197,45 @@ export function EndpointsPage() {
toggleEndpoint(endpointId, enabled);
};
const handleDelete = (endpointId: string) => {
if (confirm(formatMessage({ id: 'cliEndpoints.deleteConfirm' }, { id: endpointId }))) {
// TODO: Implement delete functionality
console.log('Delete endpoint:', endpointId);
}
const handleAdd = () => {
setDialogMode('create');
setEditingEndpoint(undefined);
setDialogOpen(true);
};
const handleEdit = (endpoint: CliEndpoint) => {
// TODO: Implement edit dialog
console.log('Edit endpoint:', endpoint);
setDialogMode('edit');
setEditingEndpoint(endpoint);
setDialogOpen(true);
};
const handleDelete = async (endpointId: string) => {
if (!confirm(formatMessage({ id: 'cliEndpoints.deleteConfirm' }, { id: endpointId }))) return;
try {
await deleteEndpoint(endpointId);
success(formatMessage({ id: 'cliEndpoints.messages.deleted' }));
} catch (err) {
console.error('Failed to delete CLI endpoint:', err);
showError(formatMessage({ id: 'cliEndpoints.messages.deleteFailed' }));
}
};
const handleDialogSave = async (payload: CliEndpointSavePayload) => {
try {
if (dialogMode === 'edit' && editingEndpoint) {
await updateEndpoint(editingEndpoint.id, payload);
success(formatMessage({ id: 'cliEndpoints.messages.updated' }));
return;
}
await createEndpoint(payload);
success(formatMessage({ id: 'cliEndpoints.messages.created' }));
} catch (err) {
console.error('Failed to save CLI endpoint:', err);
showError(formatMessage({ id: 'cliEndpoints.messages.saveFailed' }));
throw err;
}
};
// Filter endpoints by search query and type
@@ -234,7 +275,7 @@ export function EndpointsPage() {
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button>
<Button onClick={handleAdd} disabled={isCreating || isUpdating || isDeleting}>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'cliEndpoints.actions.add' })}
</Button>
@@ -327,6 +368,14 @@ export function EndpointsPage() {
))}
</div>
)}
<CliEndpointFormDialog
mode={dialogMode}
endpoint={editingEndpoint}
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onSave={handleDialogSave}
/>
</div>
);
}