Adds frontend for cms page

This commit is contained in:
2026-06-30 23:23:50 +02:00
parent c156107cb1
commit 488ab821a7
38 changed files with 2047 additions and 39 deletions
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { resetMockCmsInstances } from '@/mocks/cms/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
resetMockCmsInstances();
});
async function openDialog() {
mockAuthenticated();
renderApp('/cms');
await screen.findByTestId('cms-add-button', {}, { timeout: 5000 });
await userEvent.click(screen.getByTestId('cms-add-button'));
expect(screen.getByTestId('add-cms-name')).toBeInTheDocument();
}
describe('AddCmsInstanceDialog', () => {
it('shows name, url and apiKey fields on open', async () => {
await openDialog();
expect(screen.getByTestId('add-cms-name')).toBeInTheDocument();
expect(screen.getByTestId('add-cms-url')).toBeInTheDocument();
expect(screen.getByTestId('add-cms-apikey-input')).toBeInTheDocument();
expect(screen.getByTestId('add-cms-submit')).toBeInTheDocument();
});
it('shows field error when name is empty', async () => {
await openDialog();
await userEvent.click(screen.getByTestId('add-cms-url'));
await userEvent.click(screen.getByTestId('add-cms-name'));
await userEvent.tab();
expect(await screen.findByTestId('add-cms-name-error')).toBeInTheDocument();
});
it('shows field error when URL has no protocol', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('add-cms-url'), 'cms.example.com');
await userEvent.tab();
expect(await screen.findByTestId('add-cms-url-error')).toBeInTheDocument();
});
it('shows FormErrorBanner on HTTP 400', async () => {
server.use(
http.post(`${API_BASE}/api/v1/CmsInstances`, () =>
HttpResponse.json({ title: 'Bad Request' }, { status: 400 }),
),
);
await openDialog();
await userEvent.type(screen.getByTestId('add-cms-name'), 'CMS');
await userEvent.type(screen.getByTestId('add-cms-url'), 'http://example.com');
await userEvent.type(screen.getByTestId('add-cms-apikey-input'), 'key');
await userEvent.click(screen.getByTestId('add-cms-submit'));
expect(await screen.findByTestId('form-error-banner')).toBeInTheDocument();
expect(screen.getByTestId('add-cms-name')).toBeInTheDocument();
});
it('resets form when dialog is closed and reopened', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('add-cms-name'), 'Typed value');
await userEvent.keyboard('{Escape}');
await userEvent.click(screen.getByTestId('cms-add-button'));
expect((screen.getByTestId('add-cms-name') as HTMLInputElement).value).toBe('');
});
});
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordField } from '@/components/ui/PasswordField';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useAddCmsInstance } from '@/api/useAddCmsInstance';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
import { addCmsInstanceSchema, type AddCmsInstanceFormData } from '@/lib/schemas/cms';
interface AddCmsInstanceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddCmsInstanceDialog({ open, onOpenChange }: AddCmsInstanceDialogProps) {
const { t } = useTranslation();
const [serverError, setServerError] = useState<string | null>(null);
const addInstance = useAddCmsInstance({
onError: (err) => {
if (err instanceof ProblemDetailsError && err.status === 409) {
setServerError(t('cms.add.errors.conflict'));
} else if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('errors.generic'));
}
},
});
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<AddCmsInstanceFormData>({
resolver: zodResolver(addCmsInstanceSchema),
mode: 'onTouched',
});
useEffect(() => {
if (!open) {
setServerError(null);
addInstance.reset();
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
try {
await addInstance.mutateAsync(values);
toast.success(t('cms.add.successToast'));
onOpenChange(false);
} catch {
// error shown via serverError state
}
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('cms.add.title')}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} noValidate className="space-y-4">
<FormErrorBanner
error={serverError !== null ? { message: serverError } : null}
onDismiss={() => setServerError(null)}
/>
<div className="space-y-2">
<Label htmlFor="add-cms-name">{t('cms.add.nameLabel')}</Label>
<Input
id="add-cms-name"
type="text"
data-testid="add-cms-name"
aria-invalid={errors.name !== undefined}
{...register('name')}
/>
{errors.name && (
<FieldError message={errors.name.message} testId="add-cms-name-error" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="add-cms-url">{t('cms.add.urlLabel')}</Label>
<Input
id="add-cms-url"
type="text"
placeholder="http://cms.example.com"
data-testid="add-cms-url"
aria-invalid={errors.url !== undefined}
{...register('url')}
/>
{errors.url && (
<FieldError message={errors.url.message} testId="add-cms-url-error" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="add-cms-apikey">{t('cms.add.apiKeyLabel')}</Label>
<PasswordField
id="add-cms-apikey"
{...register('apiKey')}
/>
{errors.apiKey && (
<FieldError message={errors.apiKey.message} testId="add-cms-apikey-error" />
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || addInstance.isPending}
data-testid="add-cms-submit"
>
{isSubmitting || addInstance.isPending ? '…' : t('cms.add.submitButton')}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,97 @@
import { useTranslation } from 'react-i18next';
import { MoreHorizontal } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
function statusBadgeVariant(status: CmsInstanceStatus) {
if (status === 'Available') return 'secondary';
if (status === 'NotAvailable') return 'destructive';
return 'outline';
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
interface CmsInstanceListProps {
instances: CmsInstance[];
onSetStatus: (instance: CmsInstance) => void;
}
export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps) {
const { t } = useTranslation();
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('cms.table.name')}</TableHead>
<TableHead>{t('cms.table.url')}</TableHead>
<TableHead>{t('cms.table.status')}</TableHead>
<TableHead>{t('cms.table.lastContact')}</TableHead>
<TableHead>{t('cms.table.disableMessage')}</TableHead>
<TableHead>{t('cms.table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{instances.map((instance) => (
<TableRow
key={instance.id}
data-testid="cms-instance-row"
className={instance.status === 'Inactive' ? 'opacity-50' : ''}
>
<TableCell>{instance.name}</TableCell>
<TableCell>{instance.url}</TableCell>
<TableCell>
<Badge
variant={statusBadgeVariant(instance.status)}
data-testid="cms-instance-status-badge"
>
{t(`cms.status.${instance.status}`)}
</Badge>
</TableCell>
<TableCell>{formatDate(instance.lastContactedAt)}</TableCell>
<TableCell>{instance.disableMessage ?? '—'}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
data-testid="cms-instance-actions-trigger"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="cms-instance-set-status"
onClick={() => onSetStatus(instance)}
>
{t('cms.actions.setStatus')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { resetMockCmsInstances } from '@/mocks/cms/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
resetMockCmsInstances();
});
async function openSetStatusDialog() {
mockAuthenticated();
renderApp('/cms');
const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 5000 });
await userEvent.click(triggers[0]);
await userEvent.click(await screen.findByTestId('cms-instance-set-status'));
expect(screen.getByTestId('set-status-select')).toBeInTheDocument();
}
describe('SetStatusDialog', () => {
it('opens with status select and submit button', async () => {
await openSetStatusDialog();
expect(screen.getByTestId('set-status-select')).toBeInTheDocument();
expect(screen.getByTestId('set-status-submit')).toBeInTheDocument();
});
it('does not show DisableMessage field when status is Available', async () => {
await openSetStatusDialog();
expect(screen.queryByTestId('set-status-disable-message')).not.toBeInTheDocument();
});
it('shows DisableMessage field when NotAvailable is selected', async () => {
await openSetStatusDialog();
await userEvent.click(screen.getByTestId('set-status-select'));
await userEvent.click(await screen.findByRole('option', { name: 'Unavailable' }));
expect(await screen.findByTestId('set-status-disable-message')).toBeInTheDocument();
});
it('blocks submit when NotAvailable is selected but DisableMessage is empty', async () => {
await openSetStatusDialog();
await userEvent.click(screen.getByTestId('set-status-select'));
await userEvent.click(await screen.findByRole('option', { name: 'Unavailable' }));
await screen.findByTestId('set-status-disable-message');
await userEvent.click(screen.getByTestId('set-status-submit'));
expect(await screen.findByTestId('set-status-disable-message-error')).toBeInTheDocument();
});
});
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useUpdateCmsInstanceStatus } from '@/api/useUpdateCmsInstanceStatus';
import { NetworkError } from '@/lib/api-client';
import { setStatusSchema, type SetStatusFormData } from '@/lib/schemas/cms';
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
interface SetStatusDialogProps {
instance: CmsInstance | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const statusOptions: CmsInstanceStatus[] = ['Available', 'NotAvailable', 'Inactive'];
export function SetStatusDialog({ instance, open, onOpenChange }: SetStatusDialogProps) {
const { t } = useTranslation();
const [serverError, setServerError] = useState<string | null>(null);
const updateStatus = useUpdateCmsInstanceStatus({
onSuccess: (result) => {
if (result.slaveContactSuccess) {
toast.success(t('cms.setStatus.successContactedToast'));
} else {
toast.success(t('cms.setStatus.successUnreachableToast'));
}
onOpenChange(false);
},
onError: (err) => {
if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('errors.generic'));
}
},
});
const {
register,
handleSubmit,
control,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<SetStatusFormData>({
resolver: zodResolver(setStatusSchema),
mode: 'onTouched',
defaultValues: { status: 'Available', disableMessage: '' },
});
const selectedStatus = watch('status');
useEffect(() => {
if (!open || !instance) {
setServerError(null);
updateStatus.reset();
reset({ status: 'Available', disableMessage: '' });
} else {
reset({
status: instance.status,
disableMessage: instance.disableMessage ?? '',
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, instance]);
const onSubmit = handleSubmit(async (values) => {
if (!instance) return;
setServerError(null);
try {
await updateStatus.mutateAsync({
id: instance.id,
status: values.status,
disableMessage: values.status === 'NotAvailable' ? (values.disableMessage ?? null) : null,
});
} catch {
// error shown via serverError state
}
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('cms.setStatus.title')}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} noValidate className="space-y-4">
<FormErrorBanner
error={serverError !== null ? { message: serverError } : null}
onDismiss={() => setServerError(null)}
/>
<div className="space-y-2">
<Label htmlFor="set-status-status">{t('cms.setStatus.statusLabel')}</Label>
<Controller
name="status"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as CmsInstanceStatus)}
>
<SelectTrigger
id="set-status-status"
data-testid="set-status-select"
aria-invalid={errors.status !== undefined}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((s) => (
<SelectItem key={s} value={s}>
{t(`cms.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.status && (
<FieldError message={errors.status.message} testId="set-status-status-error" />
)}
</div>
{selectedStatus === 'NotAvailable' && (
<div className="space-y-2">
<Label htmlFor="set-status-disable-message">
{t('cms.setStatus.disableMessageLabel')}
</Label>
<Input
id="set-status-disable-message"
type="text"
placeholder={t('cms.setStatus.disableMessagePlaceholder')}
data-testid="set-status-disable-message"
aria-invalid={errors.disableMessage !== undefined}
{...register('disableMessage')}
/>
{errors.disableMessage && (
<FieldError
message={errors.disableMessage.message}
testId="set-status-disable-message-error"
/>
)}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isSubmitting || updateStatus.isPending}
data-testid="set-status-submit"
>
{isSubmitting || updateStatus.isPending ? '…' : t('cms.setStatus.submitButton')}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
@@ -23,12 +22,8 @@ import {
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useInviteUser } from '@/api/useUsers';
const inviteSchema = z.object({
email: z.string().email(),
role: z.enum(['Administrator', 'User']),
});
type InviteFormData = z.infer<typeof inviteSchema>;
import { inviteUserSchema, type InviteUserFormData } from '@/lib/schemas/users';
import { NetworkError } from '@/lib/api-client';
interface InviteUserDialogProps {
open: boolean;
@@ -39,12 +34,22 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps)
const { t } = useTranslation();
const [step, setStep] = useState<1 | 2>(1);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const inviteUser = useInviteUser();
const [serverError, setServerError] = useState<string | null>(null);
const inviteUser = useInviteUser({
onError: (err) => {
if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('errors.generic'));
}
},
});
useEffect(() => {
if (!open) {
setStep(1);
setInviteLink(null);
setServerError(null);
inviteUser.reset();
reset();
}
@@ -57,17 +62,18 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps)
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
} = useForm<InviteUserFormData>({
resolver: zodResolver(inviteUserSchema),
});
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
try {
const response = await inviteUser.mutateAsync(values);
setInviteLink(response.inviteLink);
setStep(2);
} catch {
// error shown via inviteUser.error
// error shown via serverError state
}
});
@@ -89,8 +95,8 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps)
{step === 1 && (
<form onSubmit={onSubmit} noValidate className="space-y-4">
<FormErrorBanner
error={inviteUser.error ? { message: inviteUser.error.message } : null}
onDismiss={() => inviteUser.reset()}
error={serverError !== null ? { message: serverError } : null}
onDismiss={() => setServerError(null)}
/>
<div className="space-y-2">