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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user