From 284494cee8c4735ca4cc13b26379aa729860aa0b Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:36:20 +0200 Subject: [PATCH] feat(frontend): i18n template pages (list, form, picker modal) Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/TemplatePickerModal.tsx | 35 +++---- frontend/src/pages/TemplateFormPage.tsx | 93 ++++++++++--------- frontend/src/pages/TemplatesListPage.tsx | 66 +++++++------ frontend/tests/TemplateFormPage.test.tsx | 38 ++++---- frontend/tests/TemplatePickerModal.test.tsx | 6 +- frontend/tests/TemplatesListPage.test.tsx | 10 +- 6 files changed, 124 insertions(+), 124 deletions(-) diff --git a/frontend/src/components/TemplatePickerModal.tsx b/frontend/src/components/TemplatePickerModal.tsx index 4ca9452..e8e4cfb 100644 --- a/frontend/src/components/TemplatePickerModal.tsx +++ b/frontend/src/components/TemplatePickerModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import type { SimulationTemplate } from '@/api/types'; import { useTemplates } from '@/hooks/useTemplates'; @@ -23,6 +24,7 @@ export function TemplatePickerModal({ onSelectTemplate, isPending = false, }: TemplatePickerModalProps): JSX.Element { + const { t } = useTranslation(); const { data, isLoading, isError, error, refetch } = useTemplates(); return ( @@ -37,11 +39,11 @@ export function TemplatePickerModal({

- From template… + {t('template.picker.title')}

- {isLoading ? : null} + {isLoading ? : null} {isError ? ( refetch()} /> ) : null} {!isLoading && !isError && data && data.length === 0 ? ( ) : null} @@ -70,22 +71,22 @@ export function TemplatePickerModal({ - - - + + + - {data.map((t) => ( + {data.map((template) => ( !isPending && onSelectTemplate(t)} - data-testid={`template-row-${t.id}`} + onClick={() => !isPending && onSelectTemplate(template)} + data-testid={`template-row-${template.id}`} > - - - + + + ))} @@ -95,7 +96,7 @@ export function TemplatePickerModal({
diff --git a/frontend/src/pages/TemplateFormPage.tsx b/frontend/src/pages/TemplateFormPage.tsx index 566d7b6..02532f7 100644 --- a/frontend/src/pages/TemplateFormPage.tsx +++ b/frontend/src/pages/TemplateFormPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type FormEvent } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { Save, Grid2x2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import type { MitreTechnique, MitreTacticRef } from '@/api/types'; import { useToast } from '@/hooks/useToast'; @@ -25,6 +26,7 @@ interface FormState { const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' }; export function TemplateFormPage(): JSX.Element { + const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const templateId = id ? Number(id) : undefined; const isNew = !templateId; @@ -47,15 +49,15 @@ export function TemplateFormPage(): JSX.Element { useEffect(() => { if (existing.data) { - const t = existing.data; + const tpl = existing.data; setForm({ - name: t.name, - description: t.description ?? '', - commands: t.commands ?? '', - prerequisites: t.prerequisites ?? '', + name: tpl.name, + description: tpl.description ?? '', + commands: tpl.commands ?? '', + prerequisites: tpl.prerequisites ?? '', }); - setTechniques(t.techniques); - setTactics(t.tactics); + setTechniques(tpl.techniques); + setTactics(tpl.tactics); } }, [existing.data]); @@ -65,7 +67,7 @@ export function TemplateFormPage(): JSX.Element { e.preventDefault(); setFormError(null); if (!form.name.trim()) { - setFormError('Name is required'); + setFormError(t('template.form.validation.nameRequired')); return; } const payload = { @@ -73,20 +75,20 @@ export function TemplateFormPage(): JSX.Element { description: form.description.trim() || null, commands: form.commands.trim() || null, prerequisites: form.prerequisites.trim() || null, - technique_ids: techniques.map((t) => t.id), - tactic_ids: tactics.map((t) => t.id), + technique_ids: techniques.map((tech) => tech.id), + tactic_ids: tactics.map((tac) => tac.id), }; try { if (isNew) { const created = await createMutation.mutateAsync(payload); - push('Template created', 'success'); + push(t('template.form.toast.created'), 'success'); navigate(`/admin/templates/${created.id}/edit`, { replace: true }); } else { await updateMutation.mutateAsync(payload); - push('Template saved', 'success'); + push(t('template.form.toast.saved'), 'success'); } } catch (err) { - setFormError(extractApiError(err, 'Could not save template')); + setFormError(extractApiError(err, t('template.form.errorSave'))); } }; @@ -94,10 +96,10 @@ export function TemplateFormPage(): JSX.Element { if (!templateId) return; try { await deleteMutation.mutateAsync(templateId); - push('Template deleted', 'success'); + push(t('template.form.toast.deleted'), 'success'); navigate('/admin/templates', { replace: true }); } catch (err) { - push(extractApiError(err, 'Could not delete template'), 'error'); + push(extractApiError(err, t('template.form.errorDelete')), 'error'); } setShowDeleteConfirm(false); }; @@ -109,16 +111,16 @@ export function TemplateFormPage(): JSX.Element { }; const handlePickerSelect = (technique: MitreTechnique) => { - if (techniques.some((t) => t.id === technique.id)) return; + if (techniques.some((tech) => tech.id === technique.id)) return; setTechniques((prev) => [...prev, technique]); setShowPicker(false); }; - if (!isNew && existing.isLoading) return ; + if (!isNew && existing.isLoading) return ; if (!isNew && existing.isError) { return ( existing.refetch()} /> ); @@ -128,9 +130,9 @@ export function TemplateFormPage(): JSX.Element {
- Back to templates + {t('engagement.detail.backTo')}

- {isNew ? 'New template' : (existing.data?.name ?? 'Edit template')} + {isNew ? t('template.form.title.new') : (existing.data?.name ?? t('template.form.title.edit'))}

{!isNew ? ( @@ -140,13 +142,13 @@ export function TemplateFormPage(): JSX.Element { onClick={() => setShowDeleteConfirm(true)} disabled={deleteMutation.isPending} > - Delete + {t('template.form.btn.delete')} ) : null}
- + - +
NameMITRECreated by{t('template.list.col.name')}{t('simulation.list.col.mitre')}{t('template.list.col.createdBy')}
{t.name}{mitreCount(t)}{t.created_by.username}{template.name}{mitreCount(template)}{template.created_by.username}