From bdda02b07ac2ed871902c110ca557e3ad73654e0 Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:41:32 +0200 Subject: [PATCH] feat(frontend): i18n MITRE components (matrix modal, picker, field, tag) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/MitreMatrixModal.tsx | 18 ++++---- .../src/components/MitreTechniquePicker.tsx | 12 +++-- frontend/src/components/MitreTechniqueTag.tsx | 7 ++- .../src/components/MitreTechniquesField.tsx | 46 ++++++++++--------- frontend/tests/MitreMatrixModal.test.tsx | 14 +++--- frontend/tests/MitreTechniquePicker.test.tsx | 2 +- frontend/tests/MitreTechniqueTag.test.tsx | 12 ++--- frontend/tests/MitreTechniquesField.test.tsx | 18 ++++---- 8 files changed, 69 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/MitreMatrixModal.tsx b/frontend/src/components/MitreMatrixModal.tsx index e37a49e..0b59f61 100644 --- a/frontend/src/components/MitreMatrixModal.tsx +++ b/frontend/src/components/MitreMatrixModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { LoadingState } from './LoadingState'; import { ErrorState } from './ErrorState'; import { extractApiError } from '@/api/client'; @@ -41,6 +42,7 @@ export function MitreMatrixModal({ onApply, onCancel, }: MitreMatrixModalProps): JSX.Element | null { + const { t } = useTranslation(); const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen); const [selectedTechMap, setSelectedTechMap] = useState>( @@ -177,24 +179,24 @@ export function MitreMatrixModal({ {/* Header */}

- MITRE ATT&CK Matrix + {t('mitre.matrix.title')}

setSearch(e.target.value)} className="text-input w-56 h-9 text-[14px]" - aria-label="Filter techniques" + aria-label={t('mitre.matrix.filter')} />
{/* Body — overflow-y-auto, NO overflow-x */}
- {isLoading && } + {isLoading && } {isError && ( - + )} {!isLoading && !isError && matrix && (
diff --git a/frontend/src/components/MitreTechniquePicker.tsx b/frontend/src/components/MitreTechniquePicker.tsx index f20162d..4ee9ecd 100644 --- a/frontend/src/components/MitreTechniquePicker.tsx +++ b/frontend/src/components/MitreTechniquePicker.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import type { MitreTechnique } from '@/api/types'; import { useMitreSearch } from '@/hooks/useMitre'; @@ -14,6 +15,7 @@ export function MitreTechniquePicker({ onSelect, disabled = false, }: MitreTechniquePickerProps): JSX.Element { + const { t } = useTranslation(); const [inputValue, setInputValue] = useState(''); const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); @@ -95,31 +97,31 @@ export function MitreTechniquePicker({ aria-expanded={open} aria-controls={listboxId} aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined} - aria-label="Search MITRE technique" + aria-label={t('mitre.field.search')} className="text-input" value={inputValue} onChange={(e) => handleInputChange(e.target.value)} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} disabled={disabled} - placeholder="Search by ID or name (e.g. T1059)" + placeholder={t('mitre.field.search')} autoComplete="off" /> {open && (
{isFetching && ( -
Searching…
+
{t('state.loading')}
)} {isError && !isFetching && (
- {extractApiError(error, 'MITRE search unavailable')} + {extractApiError(error, t('mitre.matrix.error'))}
)} {!isFetching && !isError && items.length === 0 && query.trim().length > 0 && ( -
No results
+
{t('state.empty.default')}
)} {!isFetching && items.length > 0 && ( diff --git a/frontend/src/components/MitreTechniqueTag.tsx b/frontend/src/components/MitreTechniqueTag.tsx index 481e1b3..08ffc43 100644 --- a/frontend/src/components/MitreTechniqueTag.tsx +++ b/frontend/src/components/MitreTechniqueTag.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import type { MitreTechnique, MitreTacticRef } from '@/api/types'; interface TechniqueTagProps { @@ -18,6 +19,7 @@ export function MitreTechniqueTag({ onRemove, disabled = false, }: TechniqueTagProps): JSX.Element { + const { t } = useTranslation(); return ( @@ -45,6 +47,7 @@ export function MitreTacticTag({ onRemove, disabled = false, }: TacticTagProps): JSX.Element { + const { t } = useTranslation(); return ( diff --git a/frontend/src/components/MitreTechniquesField.tsx b/frontend/src/components/MitreTechniquesField.tsx index 6314bd8..5bfc88b 100644 --- a/frontend/src/components/MitreTechniquesField.tsx +++ b/frontend/src/components/MitreTechniquesField.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Grid2x2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import type { MitreTechnique, MitreTacticRef } from '@/api/types'; import { useUpdateSimulation } from '@/hooks/useSimulations'; @@ -24,6 +25,7 @@ export function MitreTechniquesField({ engagementId, disabled = false, }: MitreTechniquesFieldProps): JSX.Element { + const { t } = useTranslation(); const [showMatrix, setShowMatrix] = useState(false); const [showPicker, setShowPicker] = useState(false); @@ -33,34 +35,34 @@ export function MitreTechniquesField({ const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => { try { await updateMutation.mutateAsync({ - technique_ids: techniques.map((t) => t.id), - tactic_ids: nextTactics.map((t) => t.id), + technique_ids: techniques.map((tech) => tech.id), + tactic_ids: nextTactics.map((tac) => tac.id), }); - push('Techniques updated', 'success'); + push(t('mitre.field.savedToast'), 'success'); } catch (err) { - push(extractApiError(err, 'Could not update techniques'), 'error'); + push(extractApiError(err, t('mitre.field.errorToast')), 'error'); } }; const handleRemoveTechnique = (id: string) => { - void save(value.filter((t) => t.id !== id), tactics); + void save(value.filter((tech) => tech.id !== id), tactics); }; const handleRemoveTactic = (id: string) => { - void save(value, tactics.filter((t) => t.id !== id)); + void save(value, tactics.filter((tac) => tac.id !== id)); }; const handleSelect = (technique: MitreTechnique) => { - if (value.some((t) => t.id === technique.id)) return; + if (value.some((tech) => tech.id === technique.id)) return; void save([...value, technique], tactics); setShowPicker(false); }; const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => { setShowMatrix(false); - const merged = techniques.map((s) => { - const existing = value.find((v) => v.id === s.id); - return existing ?? s; + const merged = techniques.map((sel) => { + const existing = value.find((v) => v.id === sel.id); + return existing ?? sel; }); void save(merged, newTactics); }; @@ -72,22 +74,22 @@ export function MitreTechniquesField({
{/* Chips area */} {isEmpty ? ( -

No techniques selected

+

{t('mitre.field.empty')}

) : (
- {tactics.map((t) => ( + {tactics.map((tac) => ( handleRemoveTactic(t.id)} + key={tac.id} + tactic={tac} + onRemove={() => handleRemoveTactic(tac.id)} disabled={disabled || isPending} /> ))} - {value.map((t) => ( + {value.map((tech) => ( handleRemoveTechnique(t.id)} + key={tech.id} + technique={tech} + onRemove={() => handleRemoveTechnique(tech.id)} disabled={disabled || isPending} /> ))} @@ -107,20 +109,20 @@ export function MitreTechniquesField({ onClick={() => setShowPicker(true)} disabled={isPending} > - Search technique (e.g. T1059)… + {t('mitre.field.search')} )}
- {isPending && Saving…} + {isPending && {t('common.saving')}}
)} diff --git a/frontend/tests/MitreMatrixModal.test.tsx b/frontend/tests/MitreMatrixModal.test.tsx index 6d8a851..47f1b36 100644 --- a/frontend/tests/MitreMatrixModal.test.tsx +++ b/frontend/tests/MitreMatrixModal.test.tsx @@ -124,7 +124,7 @@ describe('MitreMatrixModal', () => { ); await user.click(t1078Btn!); - await user.click(screen.getByRole('button', { name: /Apply/i })); + await user.click(screen.getByRole('button', { name: /Appliquer/i })); expect(onApply).toHaveBeenCalledWith( expect.objectContaining({ @@ -148,7 +148,7 @@ describe('MitreMatrixModal', () => { />, ); - await user.click(screen.getByRole('button', { name: /Cancel/i })); + await user.click(screen.getByRole('button', { name: /Fermer/i })); expect(onCancel).toHaveBeenCalled(); expect(onApply).not.toHaveBeenCalled(); @@ -202,7 +202,7 @@ describe('MitreMatrixModal', () => { await waitFor(() => screen.getByText('T1078')); - const searchInput = screen.getByPlaceholderText(/Filter techniques/i); + const searchInput = screen.getByPlaceholderText(/Filtrer/i); await user.type(searchInput, 'T1059'); expect(screen.queryByText('T1078')).toBeNull(); @@ -242,7 +242,7 @@ describe('MitreMatrixModal', () => { ); await waitFor(() => { - expect(screen.getByRole('button', { name: /Apply 1 item/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Appliquer \(1 élément\)/i })).toBeInTheDocument(); }); }); @@ -259,11 +259,11 @@ describe('MitreMatrixModal', () => { await waitFor(() => screen.getByText('T1078')); - const applyBtn = screen.getByRole('button', { name: /Clear all/i }); + const applyBtn = screen.getByRole('button', { name: /Tout effacer/i }); expect(applyBtn).toBeDisabled(); }); - it('Apply button shows "Clear all" and is enabled when initial selection is deselected', async () => { + it('Apply button shows "Tout effacer" and is enabled when initial selection is deselected', async () => { const onApply = vi.fn(); const user = userEvent.setup(); @@ -284,7 +284,7 @@ describe('MitreMatrixModal', () => { ); await user.click(t1078Btn!); - const applyBtn = screen.getByRole('button', { name: /Clear all/i }); + const applyBtn = screen.getByRole('button', { name: /Tout effacer/i }); expect(applyBtn).not.toBeDisabled(); await user.click(applyBtn); expect(onApply).toHaveBeenCalledWith( diff --git a/frontend/tests/MitreTechniquePicker.test.tsx b/frontend/tests/MitreTechniquePicker.test.tsx index ed75574..248c004 100644 --- a/frontend/tests/MitreTechniquePicker.test.tsx +++ b/frontend/tests/MitreTechniquePicker.test.tsx @@ -30,7 +30,7 @@ describe('MitreTechniquePicker', () => { vi.useRealTimers(); renderWithProviders(); expect(screen.getByRole('combobox')).toBeInTheDocument(); - expect(screen.getByPlaceholderText(/Search by ID or name/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Rechercher une technique/i)).toBeInTheDocument(); }); it('is disabled when disabled prop is true', () => { diff --git a/frontend/tests/MitreTechniqueTag.test.tsx b/frontend/tests/MitreTechniqueTag.test.tsx index c2b2f99..5cf0926 100644 --- a/frontend/tests/MitreTechniqueTag.test.tsx +++ b/frontend/tests/MitreTechniqueTag.test.tsx @@ -23,7 +23,7 @@ describe('MitreTechniqueTag', () => { renderWithProviders( , ); - expect(screen.getByRole('button', { name: /Remove T1059/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Retirer T1059/i })).toBeInTheDocument(); }); it('clicking × calls onRemove', async () => { @@ -32,7 +32,7 @@ describe('MitreTechniqueTag', () => { renderWithProviders( , ); - await user.click(screen.getByRole('button', { name: /Remove T1059/i })); + await user.click(screen.getByRole('button', { name: /Retirer T1059/i })); expect(onRemove).toHaveBeenCalledOnce(); }); @@ -40,7 +40,7 @@ describe('MitreTechniqueTag', () => { renderWithProviders( , ); - expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Retirer/i })).toBeNull(); }); }); @@ -57,7 +57,7 @@ describe('MitreTacticTag', () => { renderWithProviders( , ); - expect(screen.getByRole('button', { name: /Remove TA0007/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Retirer TA0007/i })).toBeInTheDocument(); }); it('clicking × calls onRemove', async () => { @@ -66,7 +66,7 @@ describe('MitreTacticTag', () => { renderWithProviders( , ); - await user.click(screen.getByRole('button', { name: /Remove TA0007/i })); + await user.click(screen.getByRole('button', { name: /Retirer TA0007/i })); expect(onRemove).toHaveBeenCalledOnce(); }); @@ -74,6 +74,6 @@ describe('MitreTacticTag', () => { renderWithProviders( , ); - expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Retirer/i })).toBeNull(); }); }); diff --git a/frontend/tests/MitreTechniquesField.test.tsx b/frontend/tests/MitreTechniquesField.test.tsx index b8d5b5d..363b6c2 100644 --- a/frontend/tests/MitreTechniquesField.test.tsx +++ b/frontend/tests/MitreTechniquesField.test.tsx @@ -48,7 +48,7 @@ describe('MitreTechniquesField', () => { renderWithProviders( , ); - expect(screen.getByText(/No techniques selected/i)).toBeInTheDocument(); + expect(screen.getByText(/Aucune technique associée/i)).toBeInTheDocument(); }); it('renders technique tags for each technique', () => { @@ -72,16 +72,16 @@ describe('MitreTechniquesField', () => { renderWithProviders( , ); - expect(screen.getByRole('button', { name: /Open MITRE matrix/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Ouvrir la matrice/i })).toBeInTheDocument(); // The search placeholder button - expect(screen.getByRole('button', { name: /Search technique/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Rechercher une technique/i })).toBeInTheDocument(); }); it('hides input row when disabled', () => { renderWithProviders( , ); - expect(screen.queryByRole('button', { name: /Open MITRE matrix/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Ouvrir la matrice/i })).toBeNull(); }); it('× button on technique tag calls PATCH with technique removed', async () => { @@ -94,7 +94,7 @@ describe('MitreTechniquesField', () => { , ); - const removeBtn = screen.getByRole('button', { name: /Remove T1059/i }); + const removeBtn = screen.getByRole('button', { name: /Retirer T1059/i }); await user.click(removeBtn); await waitFor(() => { @@ -115,7 +115,7 @@ describe('MitreTechniquesField', () => { , ); - const removeBtn = screen.getByRole('button', { name: /Remove TA0007/i }); + const removeBtn = screen.getByRole('button', { name: /Retirer TA0007/i }); await user.click(removeBtn); await waitFor(() => { @@ -131,7 +131,7 @@ describe('MitreTechniquesField', () => { renderWithProviders( , ); - await user.click(screen.getByRole('button', { name: /Search technique/i })); + await user.click(screen.getByRole('button', { name: /Rechercher une technique/i })); expect(screen.getByRole('combobox')).toBeInTheDocument(); }); @@ -142,7 +142,7 @@ describe('MitreTechniquesField', () => { , ); - await user.click(screen.getByRole('button', { name: /Search technique/i })); + await user.click(screen.getByRole('button', { name: /Rechercher une technique/i })); const combobox = screen.getByRole('combobox'); await user.type(combobox, 'T1059'); @@ -161,7 +161,7 @@ describe('MitreTechniquesField', () => { renderWithProviders( , ); - await user.click(screen.getByRole('button', { name: /Open MITRE matrix/i })); + await user.click(screen.getByRole('button', { name: /Ouvrir la matrice/i })); expect(screen.getByRole('dialog')).toBeInTheDocument(); }); });