feat(frontend): i18n engagement pages (list, detail, form) + fr-FR date formatting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-06-21 23:26:22 +02:00
parent fe597e9be3
commit ea870af324
4 changed files with 83 additions and 76 deletions

View File

@@ -1,4 +1,5 @@
import { useParams, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client';
import { useAuth } from '@/hooks/useAuth';
import { useEngagement } from '@/hooks/useEngagements';
@@ -11,10 +12,12 @@ import { SimulationList } from '@/components/SimulationList';
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
import { BackLink } from '@/components/BackLink';
import { Tabs } from '@/components/Tabs';
import { formatDateTime } from '@/lib/format';
type TabId = 'description' | 'simulations';
export function EngagementDetailPage(): JSX.Element {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const numericId = id ? Number(id) : undefined;
const { canEditEngagements } = useAuth();
@@ -25,35 +28,36 @@ export function EngagementDetailPage(): JSX.Element {
const [activeTabRaw, setActiveTab] = useHashTab('description');
const activeTab = activeTabRaw as TabId;
if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
if (detail.isLoading) return <LoadingState label={t('engagement.detail.loading')} />;
if (detail.isError) {
return (
<ErrorState
message={extractApiError(detail.error, 'Could not load engagement')}
message={extractApiError(detail.error, t('engagement.detail.errorLoad'))}
onRetry={() => detail.refetch()}
/>
);
}
if (!detail.data) return <ErrorState message="Engagement not found" />;
if (!detail.data) return <ErrorState message={t('engagement.detail.notFound')} />;
const eng = detail.data;
const simCount = simsQuery.data?.length;
const tabs = [
{ id: 'description', label: 'Description' },
{ id: 'simulations', label: 'Simulations', count: simCount },
{ id: 'description', label: t('engagement.detail.tabs.description') },
{ id: 'simulations', label: t('engagement.detail.tabs.simulations'), count: simCount },
];
return (
<div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm">
<BackLink to="/engagements">Back to engagements</BackLink>
<BackLink to="/engagements">{t('engagement.detail.backTo')}</BackLink>
<h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
<div className="flex items-center gap-md">
<StatusBadge status={eng.status} />
<span className="text-[14px] text-graphite">
Created by <span className="text-ink">{eng.created_by.username}</span>
{t('engagement.detail.createdBy')}{' '}
<span className="text-ink">{eng.created_by.username}</span>
</span>
</div>
</div>
@@ -73,25 +77,25 @@ export function EngagementDetailPage(): JSX.Element {
<div className="card-product flex flex-col gap-md">
<header className="flex items-start justify-between gap-md">
<dl className="grid grid-cols-2 gap-x-lg gap-y-xxs text-caption-md">
<dt className="text-graphite">Start date</dt>
<dt className="text-graphite">{t('engagement.detail.schedule.startDate')}</dt>
<dd className="font-mono">{eng.start_date}</dd>
<dt className="text-graphite">End date</dt>
<dt className="text-graphite">{t('engagement.detail.schedule.endDate')}</dt>
<dd className="font-mono">{eng.end_date ?? '—'}</dd>
<dt className="text-graphite">Status</dt>
<dt className="text-graphite">{t('engagement.detail.schedule.status')}</dt>
<dd><StatusBadge status={eng.status} /></dd>
<dt className="text-graphite">Created at</dt>
<dd className="font-mono">{eng.created_at}</dd>
<dt className="text-graphite">{t('engagement.detail.schedule.createdAt')}</dt>
<dd className="font-mono">{formatDateTime(eng.created_at)}</dd>
</dl>
{canEditEngagements ? (
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline shrink-0">
Edit
{t('engagement.detail.edit')}
</Link>
) : null}
</header>
<hr className="border-hairline" />
<h2 className="text-display-sm">Description</h2>
<h2 className="text-display-sm">{t('engagement.detail.tabs.description')}</h2>
<p className="text-[16px] text-charcoal whitespace-pre-line">
{eng.description?.trim() ? eng.description : 'No description provided.'}
{eng.description?.trim() ? eng.description : t('engagement.detail.noDescription')}
</p>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client';
import type { EngagementInput, EngagementStatus } from '@/api/types';
import {
@@ -13,12 +14,7 @@ import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { C2ConfigCard } from '@/components/C2ConfigCard';
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
{ value: 'planned', label: 'Planned' },
{ value: 'active', label: 'Active' },
{ value: 'closed', label: 'Closed' },
];
import { engagementStatusLabel } from '@/i18n/status';
interface FormState {
name: string;
@@ -36,17 +32,8 @@ const EMPTY: FormState = {
status: 'planned',
};
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
const errors: Partial<Record<keyof FormState, string>> = {};
if (!state.name.trim()) errors.name = 'Name is required';
if (!state.start_date) errors.start_date = 'Start date is required';
if (state.end_date && state.start_date && state.end_date < state.start_date) {
errors.end_date = 'End date must be on or after start date';
}
return errors;
}
export function EngagementFormPage(): JSX.Element {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const editing = Boolean(id);
const numericId = id ? Number(id) : undefined;
@@ -62,6 +49,22 @@ export function EngagementFormPage(): JSX.Element {
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
{ value: 'planned', label: engagementStatusLabel('planned') },
{ value: 'active', label: engagementStatusLabel('active') },
{ value: 'closed', label: engagementStatusLabel('closed') },
];
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
const errs: Partial<Record<keyof FormState, string>> = {};
if (!state.name.trim()) errs.name = t('engagement.form.validation.nameRequired');
if (!state.start_date) errs.start_date = t('engagement.form.validation.startDateRequired');
if (state.end_date && state.start_date && state.end_date < state.start_date) {
errs.end_date = t('engagement.form.validation.endDateAfterStart');
}
return errs;
}
// Hydrate edit form when data arrives.
useEffect(() => {
if (editing && detail.data) {
@@ -75,11 +78,11 @@ export function EngagementFormPage(): JSX.Element {
}
}, [editing, detail.data]);
if (editing && detail.isLoading) return <LoadingState label="Loading engagement…" />;
if (editing && detail.isLoading) return <LoadingState label={t('engagement.form.loading')} />;
if (editing && detail.isError) {
return (
<ErrorState
message={extractApiError(detail.error, 'Could not load engagement')}
message={extractApiError(detail.error, t('engagement.form.error.load'))}
onRetry={() => detail.refetch()}
/>
);
@@ -109,15 +112,15 @@ export function EngagementFormPage(): JSX.Element {
try {
if (editing && numericId) {
await patchMutation.mutateAsync(payload);
push('Engagement updated', 'success');
push(t('engagement.form.toast.updated'), 'success');
navigate(`/engagements/${numericId}`);
} else {
const created = await createMutation.mutateAsync(payload);
push('Engagement created', 'success');
push(t('engagement.form.toast.created'), 'success');
navigate(`/engagements/${created.id}`);
}
} catch (err) {
setSubmitError(extractApiError(err, 'Could not save engagement'));
setSubmitError(extractApiError(err, t('engagement.form.error.save')));
}
};
@@ -127,12 +130,10 @@ export function EngagementFormPage(): JSX.Element {
<div className="flex flex-col gap-xl">
<header>
<h1 className="text-[32px] font-medium leading-none">
{editing ? 'Edit engagement' : 'New engagement'}
{editing ? t('engagement.form.title.edit') : t('engagement.form.title.new')}
</h1>
<p className="text-charcoal text-[16px] mt-sm">
{editing
? 'Update the engagement metadata.'
: 'Create a new red team mission to host simulations.'}
{editing ? t('engagement.form.subtitle.edit') : t('engagement.form.subtitle.new')}
</p>
</header>
@@ -144,7 +145,7 @@ export function EngagementFormPage(): JSX.Element {
}
>
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
<FormField label={t('engagement.form.field.name')} htmlFor="eng-name" required error={errors.name}>
<TextInput
id="eng-name"
name="name"
@@ -154,7 +155,7 @@ export function EngagementFormPage(): JSX.Element {
/>
</FormField>
<FormField label="Description" htmlFor="eng-description">
<FormField label={t('engagement.form.field.description')} htmlFor="eng-description">
<TextArea
id="eng-description"
name="description"
@@ -165,7 +166,7 @@ export function EngagementFormPage(): JSX.Element {
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField
label="Start date"
label={t('engagement.form.field.startDate')}
htmlFor="eng-start"
required
error={errors.start_date}
@@ -181,9 +182,9 @@ export function EngagementFormPage(): JSX.Element {
</FormField>
<FormField
label="End date"
label={t('engagement.form.field.endDate')}
htmlFor="eng-end"
hint="Leave empty to clear / leave open-ended"
hint={t('engagement.form.field.endDateHint')}
error={errors.end_date}
>
<TextInput
@@ -196,7 +197,7 @@ export function EngagementFormPage(): JSX.Element {
</FormField>
</div>
<FormField label="Status" htmlFor="eng-status" required>
<FormField label={t('engagement.form.field.status')} htmlFor="eng-status" required>
<Select
id="eng-status"
name="status"
@@ -213,14 +214,18 @@ export function EngagementFormPage(): JSX.Element {
) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
<button type="submit" className="btn-primary" disabled={submitting} data-testid="btn-submit">
{submitting
? t('engagement.form.btn.saving')
: editing
? t('engagement.form.btn.save')
: t('engagement.form.btn.create')}
</button>
<Link
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
className="btn-outline-ink"
>
Cancel
{t('engagement.form.btn.cancel')}
</Link>
</div>
</form>

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom';
import { Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client';
import type { Engagement } from '@/api/types';
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
@@ -9,25 +10,22 @@ import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { EmptyState } from '@/components/EmptyState';
import { StatusBadge } from '@/components/StatusBadge';
function formatDate(value: string | null): string {
if (!value) return '—';
return value;
}
import { formatDate } from '@/lib/format';
export function EngagementsListPage(): JSX.Element {
const { t } = useTranslation();
const { data, isLoading, isError, error, refetch } = useEngagementsList();
const { canEditEngagements } = useAuth();
const { push } = useToast();
const deleteMutation = useDeleteEngagement();
const onDelete = async (eng: Engagement) => {
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
if (!window.confirm(t('engagement.list.deleteConfirm', { name: eng.name }))) return;
try {
await deleteMutation.mutateAsync(eng.id);
push('Engagement deleted', 'success');
push(t('engagement.list.toast.deleted'), 'success');
} catch (err) {
push(extractApiError(err, 'Could not delete engagement'), 'error');
push(extractApiError(err, t('engagement.list.error.delete')), 'error');
}
};
@@ -35,32 +33,32 @@ export function EngagementsListPage(): JSX.Element {
<div className="flex flex-col gap-xl">
<header className="flex items-end justify-between gap-md">
<div>
<h1 className="text-[32px] font-medium leading-none">Engagements</h1>
<h1 className="text-[32px] font-medium leading-none">{t('engagement.list.title')}</h1>
<p className="text-charcoal text-[16px] mt-sm">
Red team missions and their lifecycle status.
{t('engagement.list.subtitle')}
</p>
</div>
{canEditEngagements ? (
<Link to="/engagements/new" className="btn-primary">
<Plus size={14} aria-hidden /> New
<Plus size={14} aria-hidden /> {t('engagement.list.new')}
</Link>
) : null}
</header>
{isLoading ? <LoadingState label="Loading engagements…" /> : null}
{isLoading ? <LoadingState label={t('common.loading')} /> : null}
{isError ? (
<ErrorState message={extractApiError(error, 'Could not load engagements')} onRetry={() => refetch()} />
<ErrorState message={extractApiError(error, t('engagement.list.error.load'))} onRetry={() => refetch()} />
) : null}
{!isLoading && !isError && data && data.length === 0 ? (
<EmptyState
title="No engagements yet"
description="Create your first engagement to start tracking red team missions."
title={t('engagement.list.empty.title')}
description={t('engagement.list.empty.desc')}
action={
canEditEngagements ? (
<Link to="/engagements/new" className="btn-primary">
<Plus size={14} aria-hidden /> New
<Plus size={14} aria-hidden /> {t('engagement.list.new')}
</Link>
) : undefined
}
@@ -72,12 +70,12 @@ export function EngagementsListPage(): JSX.Element {
<table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline">
<tr>
<th>Name</th>
<th>Status</th>
<th>Start</th>
<th>End</th>
<th>Created by</th>
<th className="text-right">Actions</th>
<th>{t('engagement.list.col.name')}</th>
<th>{t('engagement.list.col.status')}</th>
<th>{t('engagement.list.col.start')}</th>
<th>{t('engagement.list.col.end')}</th>
<th>{t('engagement.list.col.createdBy')}</th>
<th className="text-right">{t('engagement.list.col.actions')}</th>
</tr>
</thead>
<tbody>
@@ -97,12 +95,12 @@ export function EngagementsListPage(): JSX.Element {
<td className="text-right">
<div className="inline-flex gap-sm">
<Link to={`/engagements/${eng.id}`} className="btn-text-link">
View
{t('engagement.list.view')}
</Link>
{canEditEngagements ? (
<>
<Link to={`/engagements/${eng.id}/edit`} className="btn-text-link">
Edit
{t('engagement.list.edit')}
</Link>
<button
type="button"
@@ -110,7 +108,7 @@ export function EngagementsListPage(): JSX.Element {
onClick={() => onDelete(eng)}
disabled={deleteMutation.isPending}
>
Delete
{t('engagement.list.delete')}
</button>
</>
) : null}

View File

@@ -90,7 +90,7 @@ describe('EngagementFormPage — C2 config card visibility', () => {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByTestId('btn-submit')).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-config-card')).toBeNull();
});
@@ -101,7 +101,7 @@ describe('EngagementFormPage — C2 config card visibility', () => {
routerProps: { initialEntries: ['/engagements/new'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /create engagement/i })).toBeInTheDocument();
expect(screen.getByTestId('btn-submit')).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-config-card')).toBeNull();
});