fix(frontend): post-QA sprint 2 — i18n + alignment + textarea + action bar layout

- Translate all remaining French strings to English (toasts, buttons, banner)
- Fix UsersAdminPage create-form grid alignment: items-start + self-end on button wrapper
- Change execution_result from TextInput to TextArea (5 rows, multiline)
- Replace split Save RT / Save SOC footers + workflow div with a single sticky
  action bar (Save Red Team | Save SOC | Mark for review | Close | Delete)
- Update Vitest assertions to use English button labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-26 16:08:46 +02:00
parent b3124ba4dd
commit 2a7d27bf02
4 changed files with 72 additions and 77 deletions

View File

@@ -44,7 +44,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
className="btn-primary" className="btn-primary"
data-testid="new-simulation-btn" data-testid="new-simulation-btn"
> >
Nouvelle simulation New simulation
</Link> </Link>
) : undefined ) : undefined
} }
@@ -62,7 +62,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
className="btn-primary" className="btn-primary"
data-testid="new-simulation-btn" data-testid="new-simulation-btn"
> >
Nouvelle simulation New simulation
</Link> </Link>
) : null} ) : null}
</div> </div>

View File

@@ -137,7 +137,7 @@ export function SimulationFormPage(): JSX.Element {
} }
try { try {
const created = await createMutation.mutateAsync({ name: rt.name.trim() }); const created = await createMutation.mutateAsync({ name: rt.name.trim() });
push('Simulation créée', 'success'); push('Simulation created', 'success');
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`); navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not create simulation')); setSubmitError(extractApiError(err, 'Could not create simulation'));
@@ -164,7 +164,7 @@ export function SimulationFormPage(): JSX.Element {
}; };
try { try {
await updateMutation.mutateAsync(patch); await updateMutation.mutateAsync(patch);
push('Simulation mise à jour', 'success'); push('Simulation updated', 'success');
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not update simulation')); setSubmitError(extractApiError(err, 'Could not update simulation'));
} }
@@ -181,7 +181,7 @@ export function SimulationFormPage(): JSX.Element {
}; };
try { try {
await updateMutation.mutateAsync(patch); await updateMutation.mutateAsync(patch);
push('Rapport SOC mis à jour', 'success'); push('SOC report updated', 'success');
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not update SOC fields')); setSubmitError(extractApiError(err, 'Could not update SOC fields'));
} }
@@ -190,18 +190,18 @@ export function SimulationFormPage(): JSX.Element {
const onMarkReview = async () => { const onMarkReview = async () => {
try { try {
await transitionMutation.mutateAsync('review_required'); await transitionMutation.mutateAsync('review_required');
push('Simulation marquée en revue', 'success'); push('Simulation marked for review', 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Transition impossible'), 'error'); push(extractApiError(err, 'Transition failed'), 'error');
} }
}; };
const onClose = async () => { const onClose = async () => {
try { try {
await transitionMutation.mutateAsync('done'); await transitionMutation.mutateAsync('done');
push('Simulation clôturée', 'success'); push('Simulation closed', 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Transition impossible'), 'error'); push(extractApiError(err, 'Transition failed'), 'error');
} }
}; };
@@ -209,10 +209,10 @@ export function SimulationFormPage(): JSX.Element {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
try { try {
await deleteMutation.mutateAsync(simulationId as number); await deleteMutation.mutateAsync(simulationId as number);
push('Simulation supprimée', 'success'); push('Simulation deleted', 'success');
navigate(`/engagements/${engagementId}`); navigate(`/engagements/${engagementId}`);
} catch (err) { } catch (err) {
push(extractApiError(err, 'Suppression impossible'), 'error'); push(extractApiError(err, 'Could not delete simulation'), 'error');
} }
}; };
@@ -225,7 +225,7 @@ export function SimulationFormPage(): JSX.Element {
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement Back to engagement
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none mt-sm">Nouvelle simulation</h1> <h1 className="text-[44px] font-medium leading-none mt-sm">New simulation</h1>
</header> </header>
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md"> <form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
@@ -290,8 +290,7 @@ export function SimulationFormPage(): JSX.Element {
data-testid="soc-blocked-banner" data-testid="soc-blocked-banner"
className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal" className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
> >
Simulation pas encore en revue la redteam doit la marquer comme &quot;Review required&quot; avant Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section.
que vous puissiez intervenir.
</div> </div>
)} )}
@@ -361,7 +360,6 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField label="Executed at" htmlFor="sim-executed-at"> <FormField label="Executed at" htmlFor="sim-executed-at">
<TextInput <TextInput
id="sim-executed-at" id="sim-executed-at"
@@ -374,23 +372,15 @@ export function SimulationFormPage(): JSX.Element {
</FormField> </FormField>
<FormField label="Execution result" htmlFor="sim-exec-result"> <FormField label="Execution result" htmlFor="sim-exec-result">
<TextInput <TextArea
id="sim-exec-result" id="sim-exec-result"
name="execution_result" name="execution_result"
value={rt.execution_result} value={rt.execution_result}
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })} onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
disabled={rtDisabled} disabled={rtDisabled}
rows={5}
/> />
</FormField> </FormField>
</div>
{canEditRT && (
<div className="flex items-center gap-md pt-sm border-t border-hairline">
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
</button>
</div>
)}
</form> </form>
{/* SOC card */} {/* SOC card */}
@@ -442,13 +432,6 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
{canSaveSoc && (
<div className="flex items-center gap-md pt-sm border-t border-hairline">
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
</button>
</div>
)}
</form> </form>
{submitError ? ( {submitError ? (
@@ -457,8 +440,18 @@ export function SimulationFormPage(): JSX.Element {
</div> </div>
) : null} ) : null}
{/* Workflow + delete footer */} {/* Unified sticky action bar */}
<div className="flex items-center gap-md flex-wrap"> <div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
{canEditRT && (
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
</button>
)}
{canSaveSoc && (
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
</button>
)}
{showMarkReview && ( {showMarkReview && (
<button <button
type="button" type="button"
@@ -466,7 +459,7 @@ export function SimulationFormPage(): JSX.Element {
onClick={onMarkReview} onClick={onMarkReview}
disabled={transitionMutation.isPending} disabled={transitionMutation.isPending}
> >
Marquer en revue Mark for review
</button> </button>
)} )}
{showClose && ( {showClose && (
@@ -476,27 +469,27 @@ export function SimulationFormPage(): JSX.Element {
onClick={onClose} onClick={onClose}
disabled={transitionMutation.isPending} disabled={transitionMutation.isPending}
> >
Clôturer Close
</button> </button>
)} )}
{canEditEngagements && simulationId && ( {canEditEngagements && simulationId && (
<button <button
type="button" type="button"
className="btn-text-link text-bloom-deep" className="btn-text-link text-bloom-deep ml-auto"
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
disabled={submitting} disabled={submitting}
> >
Supprimer Delete
</button> </button>
)} )}
</div> </div>
{showDeleteConfirm && ( {showDeleteConfirm && (
<ConfirmDialog <ConfirmDialog
title="Supprimer la simulation" title="Delete simulation"
description="Cette action est irréversible. La simulation sera définitivement supprimée." description="This action is permanent. The simulation will be deleted forever."
confirmLabel="Supprimer" confirmLabel="Delete"
cancelLabel="Annuler" cancelLabel="Cancel"
destructive destructive
onConfirm={onDelete} onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}

View File

@@ -110,7 +110,7 @@ export function UsersAdminPage(): JSX.Element {
<section className="card-product flex flex-col gap-md"> <section className="card-product flex flex-col gap-md">
<h2 className="text-[20px] font-medium">Create account</h2> <h2 className="text-[20px] font-medium">Create account</h2>
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-end"> <form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-start">
<FormField label="Username" htmlFor="new-username" required> <FormField label="Username" htmlFor="new-username" required>
<TextInput <TextInput
id="new-username" id="new-username"
@@ -137,9 +137,11 @@ export function UsersAdminPage(): JSX.Element {
options={ROLE_OPTIONS} options={ROLE_OPTIONS}
/> />
</FormField> </FormField>
<button type="submit" className="btn-primary" disabled={createMutation.isPending}> <div className="self-end">
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating…' : 'Create'} {createMutation.isPending ? 'Creating…' : 'Create'}
</button> </button>
</div>
</form> </form>
{createError ? ( {createError ? (
<div role="alert" className="text-[14px] text-bloom-deep"> <div role="alert" className="text-[14px] text-bloom-deep">

View File

@@ -95,54 +95,54 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled(); expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled();
}); });
it('shows "Marquer en revue" button when status is pending', async () => { it('shows "Mark for review" button when status is pending', async () => {
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
}); });
}); });
it('does not show "Clôturer" when status is pending', async () => { it('does not show "Close" when status is pending', async () => {
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => screen.getByRole('button', { name: /Marquer en revue/i })); await waitFor(() => screen.getByRole('button', { name: /Mark for review/i }));
expect(screen.queryByRole('button', { name: /Clôturer/i })).toBeNull(); expect(screen.queryByRole('button', { name: /^Close$/i })).toBeNull();
}); });
it('shows "Marquer en revue" for in_progress status', async () => { it('shows "Mark for review" for in_progress status', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' }); mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
}); });
}); });
it('shows "Clôturer" button when status is review_required', async () => { it('shows "Close" button when status is review_required', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' }); mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
}); });
}); });
it('shows "Supprimer" button for redteam', async () => { it('shows "Delete" button for redteam', async () => {
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Supprimer/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Delete$/i })).toBeInTheDocument();
}); });
}); });
}); });
@@ -242,13 +242,13 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull(); expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
}); });
it('shows "Clôturer" for SOC when review_required', async () => { it('shows "Close" for SOC when review_required', async () => {
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
}); });
}); });
}); });