From 9b6b682acc5f9849b95ecd44af681c828900aebf Mon Sep 17 00:00:00 2001 From: Knacky Date: Mon, 22 Jun 2026 10:24:36 +0200 Subject: [PATCH 1/3] docs(sprint-13): plan SimulationFormPage 3-tab layout (RT/SOC/C2) --- tasks/todo.md | 356 +++++++++++++++++++++----------------------------- 1 file changed, 152 insertions(+), 204 deletions(-) diff --git a/tasks/todo.md b/tasks/todo.md index 11df455..a4db4e3 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,260 +1,208 @@ -# Sprint 12 — i18n FR complet + EngagementDetailPage tab merge +# Sprint 13 — SimulationFormPage : layout en 3 tabs (Red Team / SOC / Tâche C2) -**Base** : `sprint/11-spectrum-ux` (PR #13 ouverte, pas encore mergée). Sprint 12 enchaîne directement. -**Worktree** : `.claude/worktrees/sprint-12-i18n-fr/` (dédié — conforme à la mémoire worktree-per-sprint). -**Branch** : `sprint/12-i18n-fr`. -**Scope** : frontend uniquement. Backend intouché. +**Base** : `sprint/12-i18n-fr` (PR #14 ouverte, pas encore mergée). Sprint 13 enchaîne directement. +**Worktree** : `.claude/worktrees/sprint-13-sim-tabs/` (dédié — conforme à `feedback_worktree_per_sprint`). +**Branch** : `sprint/13-sim-tabs`. +**Scope** : frontend uniquement. Backend intouché. Aligné sur le pattern Tabs du sprint 11 (EngagementDetailPage 3→2 tabs) appliqué à SimulationFormPage. --- ## Décisions binding (lockées par user) -1. **Approche i18n** : `react-i18next` complet (deps `react-i18next` + `i18next`, JSON FR, hook `useTranslation`). -2. **Acronymes** : RT, SOC, MITRE, ATT&CK, C2, BAS, TLS, MITM, JWT, IP, URL, API, ID — **gardés en anglais** dans les valeurs FR. -3. **Status enum** : valeurs DB (`pending`, `in_progress`, `done`, `planned`, `active`, `closed`) gardées EN — traduction au rendering layer via `i18n/status.ts`. -4. **Format de date** : `fr-FR` locale (`jj/mm/aaaa`, ex. `20/06/2026`). -5. **Ton toasts/alerts** : **bref** (ex. `"Engagement créé"`, pas `"Engagement enregistré avec succès"`). -6. **Schedule → Description tab** : merger Schedule INTO Description. Résultat : 2 tabs (Description + Simulations). +1. **Mode création** : 3 tabs visibles mais SOC + Tâche C2 **désactivés** (`aria-disabled="true"`, non-clickables, opacité réduite). Pas de banner ni d'empty-state — juste tab disabled. +2. **Tab SOC** : désactivé tant que la sim n'est pas en `review_required` (ou plus). Force l'opérateur à passer par le RT pour avancer (Mark for review depuis sticky bar RT). +3. **Sticky bar entièrement contextuelle** par tab : + - **RT tab** : `[Save, Mark for review]` (et `[Delete]` global, voir plus bas) + - **SOC tab** : `[Save SOC, Close]` + - **C2 tab** : aucune sticky bar — actions inline (`Execute via C2`, `Import C2 history`) déjà dans le tab content +4. **Count pill sur tab Tâche C2** : nombre exact de tâches C2 (depuis le hook `useSimulationC2Tasks` existant). Caché si 0 ou en mode création. + +## Décisions secondaires (à appliquer par défaut) + +- **Default active tab** : Red Team (premier opérateur surface). +- **Wiring URL** : `useHashTab('red-team')` (réutilise sprint 11). Hashes : `#red-team`, `#soc`, `#c2`. +- **Delete + Reopen** : pas mentionnés dans la sticky bar contextuelle user. Décision team-lead : + - **Delete** sur le tab **RT** (action destructrice côté RT — c'est l'auteur). + - **Reopen** sur le tab **SOC** (cohérent avec Close, même actor flow). Visible quand status `done`. +- **AlertBanner** (sprint 12) : `Done banner` reste visible au-dessus des tabs (status info, pas tab-spécifique). `SOC-blocked banner` disparaît (remplacé par le mécanisme de tab désactivé). +- **C2TasksPanel** (sprint 8) : déplace son rendering DANS le panel du tab C2. Plus en-dessous de la 2-col. +- **Header** (back link, title, status badge) : reste **au-dessus** des tabs, intouché. ## Contraintes constantes - Primary `#024ad8` Electric Blue intouchée. -- Brutalisme intact : `rounded-none` partout sauf status pills + tab count pills + avatars, zéro `transition-*`, zéro `shadow-*`. -- `tailwind.config.*` inchangé sauf cas exceptionnel justifié. -- DESIGN.md amendments additives uniquement. -- Mono uniquement pour data (IDs MITRE, dates ISO côté backend, commands, etc.). +- Brutalisme intact : `rounded-none` (exceptions documentées), zéro `transition-*`, zéro `shadow-*`. +- DESIGN.md amendments additives uniquement (sub-section "Sub-page tabs with disabled state" si besoin). +- Tous les nouveaux labels passent par `i18n/fr.json` (cf. sprint 12) — pas de string EN hardcodée. +- `pnpm build` MANDATORY avant push (leçon `feedback_css_apply_tokens`). --- -## Task 1 — Merge Schedule into Description (mécanique, commit 1) +## Task 1 — Extend Tabs primitive : support `disabled` per item -**File** : `frontend/src/pages/EngagementDetailPage.tsx` +**File** : `frontend/src/components/Tabs.tsx` -**Current** (3 tabs Schedule / Description / Simulations) : +Current `TabItem` : ```tsx -type TabId = 'schedule' | 'description' | 'simulations'; -const items: TabItem[] = [ - { id: 'schedule', label: 'Schedule' }, - { id: 'description', label: 'Description' }, - { id: 'simulations', label: 'Simulations', count }, -]; +interface TabItem { id: T; label: string; count?: number; } ``` -**Target** (2 tabs) : +Target : ```tsx -type TabId = 'description' | 'simulations'; -const items: TabItem[] = [ - { id: 'description', label: 'Description' }, - { id: 'simulations', label: 'Simulations', count }, -]; +interface TabItem { id: T; label: string; count?: number; disabled?: boolean; } ``` -**Migration du contenu Schedule** : déplacer dans le panel Description, en HEADER (au-dessus du paragraphe description) : -```tsx -
-
-
Start date
{formatDate(eng.start_date)}
-
End date
{eng.end_date ? formatDate(eng.end_date) : '—'}
-
Status
-
Created at
{formatDate(eng.created_at)}
-
- Edit -
-
-

Description

-{eng.description ||

No description provided.

} +Comportement quand `disabled` : +- ` -

{t('engagement.list.title')}

-

{t('engagement.delete.confirm', { name: eng.name })}

+**Sticky bar contextuel** : +- Wrapper `
` avec contenu conditionnel `{activeTab === 'red-team' && }{activeTab === 'soc' && }{activeTab === 'c2' && null}`. +- Ne pas rendre le wrapper du tout si C2 actif (pas de sticky div vide). +- Réutiliser les boutons existants (Save/Mark/Save SOC/Close/Reopen/Delete) — juste les regrouper. + +**Banners** : +- `Done banner` (success) : garde au-dessus des tabs, indépendant du tab actif. +- `SOC-blocked banner` (warn) : **supprime**. Le mécanisme de tab désactivé remplace. + +**C2TasksPanel** : déplacer son rendering directement dans `{activeTab === 'c2' && }`. Conserver toutes les props existantes. Le `[Execute via C2]` et `[Import C2 history]` qui étaient dans la carte Red Team du sprint 8 — décision : **les laisser dans le tab Red Team** (ce sont des actions RT qui déclenchent le C2). Le tab C2 montre juste le panel des tâches. + +**Tests** : `SimulationFormPage.test.tsx` — refactor important : +- Les anciens tests "RT card visible AND SOC card visible simultaneously" → maintenant on test "RT tab content visible, SOC tab content via switch-tab". +- Tests Mark/Close/Reopen : naviguer vers le bon tab avant d'asserter le bouton. +- Tests `data-testid="soc-blocked-banner"` : ce testid disparaît — ajuster ou supprimer le test. +- Add : tab SOC `disabled` quand status pending → test que click ne change pas le tab actif. +- Add : count pill sur tab C2 reflète le nombre de tasks. + +## Task 3 — i18n keys nouvelles + DESIGN.md + +**`fr.json`** ajouter : +```json +"simulation.form.tab.redTeam": "Red Team", +"simulation.form.tab.soc": "SOC", +"simulation.form.tab.c2": "Tâche C2" ``` +(`Red Team` reste idiom, `SOC` acronyme — cohérent avec les conventions sprint 12.) -### Convention de keys — **nested by feature** - -``` -common.* save, cancel, delete, edit, new, loading, retry, back, dismiss -nav.* engagements, templates, users, signOut, brand -auth.* login.title, login.subtitle, login.signIn, login.signingIn, login.invalid, forbidden -engagement.list.* title, subtitle, new, empty.title, empty.desc, col.name, col.status, col.start, col.end, col.createdBy, col.actions, view, edit, delete, deleteConfirm, toast.deleted, error.delete -engagement.detail.* tabs.description, tabs.simulations, schedule.startDate, schedule.endDate, schedule.status, schedule.createdAt, edit, createdBy, noDescription, loading, errorLoad, notFound -engagement.form.* title.new, title.edit, subtitle.new, subtitle.edit, field.name, field.description, field.startDate, field.endDate, field.endDateHint, field.status, validation.nameRequired, validation.startDateRequired, validation.endDateAfterStart, btn.create, btn.save, btn.saving, btn.cancel, toast.created, toast.updated, error.save, error.load -simulation.form.* title.new, field.name, field.mitre, field.description, field.commands, field.commandsHint, field.prerequisites, field.executedAt, field.executionResult, btn.create, btn.creating, btn.save, btn.saving, btn.saveSoc, btn.markReview, btn.close, btn.reopen, btn.delete, btn.executeC2, btn.importC2History, btn.cancel, deleteConfirm.title, deleteConfirm.desc, deleteConfirm.confirm, deleteConfirm.cancel, banner.done, banner.socNotReady, header.redTeam, header.soc, field.logSource, field.logs, field.socComment, field.incidentNumber, validation.nameRequired, toast.created, toast.updated, toast.deleted, toast.markedReview, toast.closed, toast.reopened, toast.socUpdated, error.create, error.update, error.delete, error.soc, error.transition, loading, errorLoad -template.list.* title, subtitle, new, empty.title, empty.desc, col.name, col.description, col.commands, col.createdAt, edit, delete -template.form.* title.new, title.edit, field.name, field.description, field.descriptionPlaceholder, field.commands, field.commandsHint, field.commandsPlaceholder, field.prerequisites, field.prerequisitesPlaceholder, field.mitre, field.mitreSearch, field.mitreOpenMatrix, field.mitreEmpty, btn.save, btn.saving, btn.delete, btn.cancel, validation.nameRequired, toast.created, toast.saved, toast.deleted, deleteConfirm.title, deleteConfirm.desc, deleteConfirm.confirm, deleteConfirm.cancel, loading, errorLoad, errorSave, errorDelete -user.admin.* title, subtitle, new, col.username, col.role, col.createdAt, col.actions, role.admin, role.redteam, role.soc, field.username, field.password, btn.create, btn.creating, btn.resetPassword, btn.delete, btn.cancel, ... -c2.config.* title, disabled, field.url, field.urlHint, field.token, field.tokenHint, field.verifyTls, verifyTlsHint, btn.save, btn.saving, btn.test, btn.testing, btn.delete, toast.saved, toast.tested, toast.deleted, error.save, error.test, error.delete -c2.tasks.* title, empty, col.task, col.command, col.source, col.status, col.completedAt, refreshing -c2.modal.execute.* title, callback, btn.launch, btn.launching, btn.cancel, commandCount_one, commandCount_other, validation.callbackRequired, validation.commandsRequired, toast.launched, error.launch -c2.modal.import.* title, total, page, of, prev, next, btn.import, btn.importing, btn.cancel, selected, empty, toast.imported, toast.partial, error.import -c2.modal.picker.* title, empty, hostnameColon, lastCheckinColon -mitre.matrix.* title, filter, loading, error, applyItem_one, applyItem_other, clearAll, close, retry -mitre.field.* empty, search, openMatrix, savedToast, errorToast -mitre.tag.* remove -state.* loading, error.title, error.desc, empty.default, retry -toast.* dismiss -status.engagement.* planned, active, closed -status.simulation.* pending, in_progress, review_required, done -``` - -### Status enum map (`frontend/src/i18n/status.ts`) -```ts -import i18n from './index'; -import type { EngagementStatus, SimulationStatus } from '@/types'; - -export const engagementStatusLabel = (s: EngagementStatus): string => - i18n.t(`status.engagement.${s}`); -export const simulationStatusLabel = (s: SimulationStatus): string => - i18n.t(`status.simulation.${s}`); -``` - -Consumers : `StatusBadge.tsx`, `SimulationStatusBadge.tsx` si distinct, `EngagementDetailPage` (header dans Description tab), `EngagementFormPage` (STATUS_OPTIONS dérivé du map), `SimulationFormPage` banners (restructurer comme phrases entières dans JSON, pas inline status word). - -### Date formatting (`frontend/src/lib/format.ts`) -```ts -export const formatDate = (iso: string | null | undefined): string => { - if (!iso) return '—'; - return new Date(iso).toLocaleDateString('fr-FR'); // jj/mm/aaaa -}; -export const formatDateTime = (iso: string): string => - new Date(iso).toLocaleString('fr-FR'); -``` - -Replace raw ISO renders dans : `EngagementDetailPage`, `EngagementsListPage`, `TemplatesListPage`, `UsersAdminPage`. Garder ISO dans les commands / execution_result / params (data brut). - -### Acronymes — locked EN dans le JSON FR - -Exemples corrects : -- `"c2.config.title": "Configuration C2"` -- `"mitre.matrix.title": "Matrice MITRE ATT&CK"` -- `"simulation.form.header.redTeam": "Red Team"` (acronyme idiom) -- `"simulation.form.header.soc": "SOC"` (acronyme) -- `"auth.forbidden": "Accès refusé"` (déjà FR dans le code) - -### Strings déjà FR (preserve) - -- `ProtectedRoute.tsx:35` → `'Accès refusé'` (déjà FR). Route via `t('auth.forbidden')`. -- `Toast.test.tsx:28` → assertion `'Session expirée'`. Confirmer cohérence avec la key associée. - -### Composants out-of-scope - -`ForbiddenState.tsx` et `NotFoundState.tsx` n'existent pas — pas de keys i18n à créer. Si tu vois un cas où ils manquent, **flag dans le PR body** pour un sprint follow-up, ne crée rien. +**DESIGN.md** § Navigation > Sub-page tabs : ajouter 1 paragraphe sur la variante `disabled` (visual : opacity-50 + cursor-not-allowed, aria-disabled, skipped par arrow-keys). --- -## Counts (estimation workflow) +## Counts attendus | Métrique | Valeur | |---|---| -| Strings UI uniques à traduire (dedup) | **~140 keys** | -| Test assertions à update | **~123** (sur 193 raw matches, 70 sont data fixture) | -| Fichiers source modifiés | **~30** | -| Fichiers tests modifiés | **23** | -| Nouveaux fichiers | 3 (`i18n/index.ts`, `i18n/fr.json`, `i18n/status.ts`) | +| Fichiers source modifiés | 3 (`Tabs.tsx`, `SimulationFormPage.tsx`, `index.css` recipe) | +| Fichiers source touched (juste i18n keys) | 1 (`fr.json`) | +| Fichiers tests modifiés | 2 (`Tabs.test.tsx`, `SimulationFormPage.test.tsx`) | +| Nouveaux fichiers | 0 | +| DESIGN.md amendments | 1 paragraphe additive | --- -## Commits plan (10 commits) +## Commits plan (~4 commits) -1. `feat(frontend): merge Schedule tab into Description on EngagementDetailPage` -2. `chore(frontend): install react-i18next + i18next deps` -3. `feat(frontend): scaffold i18n init, fr.json skeleton, status map` -4. `feat(frontend): i18n common + nav + auth (Layout, LoginPage, ProtectedRoute)` -5. `feat(frontend): i18n engagement pages (list, detail, form) + status map wiring` -6. `feat(frontend): i18n simulation pages (form + list component)` -7. `feat(frontend): i18n template pages (list + form + picker modal)` -8. `feat(frontend): i18n MITRE components (matrix modal, picker, field, tag)` -9. `feat(frontend): i18n C2 components (config, tasks, modals, picker)` -10. `feat(frontend): i18n shared state components + users admin + fr-FR date formatting` +1. `feat(frontend): Tabs primitive supports per-item disabled state` (Tabs.tsx + recipe + Tabs.test.tsx) +2. `feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / C2 tasks)` (SimulationFormPage.tsx + tests + i18n keys + DESIGN.md amendment) +3. `refactor(frontend): contextual sticky bar per active tab on SimulationFormPage` (extraction des actions par tab, supprimer SOC-blocked banner) +4. `docs(changelog): sprint 13 simulation tabs entry` -**Ordre** : chaque commit = une slice verticale (source + tests mis à jour ensemble) → vitest reste vert à chaque commit. - ---- - -## Tests strategy - -### Baseline -- vitest **236/236** doit tenir à chaque commit. -- Tests rendent avec i18n init déjà chargé (FR loaded), pas de mock setup needed. - -### Pattern de rewrite (3 stratégies) -1. **Direct rewrite** (cas le plus fréquent) : `getByRole('button', { name: /save/i })` → `getByRole('button', { name: /enregistrer/i })`. -2. **Refactor vers `data-testid`** (recommandé pour les boutons avec action semantics) : `data-testid="btn-save"` côté source, `getByTestId('btn-save')` côté test. Survit aux re-wordings futurs. - - Cible prioritaire : `SimulationFormPage.test.tsx` (5 boutons), `UsersAdminPage.test.tsx` (4), `TemplateFormPage.test.tsx` (4), `EngagementFormPage.test.tsx` (2). -3. **Keep domain assertions** (~70 sites) : `getByText('T1078')`, `getByText('alice')`, `getByText('WIN-DC01')`, `getByText('whoami')`. Pas de change. - -### Nouveau test (commit 3) -`frontend/tests/i18n.test.ts` : -- i18n init avec `lng: 'fr'`. -- `t('common.save')` → `'Enregistrer'`. -- Missing key returns key path (debug). -- `simulationStatusLabel('done')` → `'Terminé'`. -- Interpolation : `t('engagement.delete.confirm', { name: 'X' })` contient `« X »`. +Collapse à 2-3 si plus efficace. --- ## DoD -1. EngagementDetailPage a **2 tabs** (Description + Simulations). Schedule content = header block dans le panel Description. Edit button atteignable. -2. `package.json` inclut `react-i18next` et `i18next`. -3. `frontend/src/i18n/index.ts` initialise i18next avec FR comme `lng` ET `fallbackLng`. -4. `frontend/src/i18n/fr.json` contient ~140 keys, organisées en namespaces nested. -5. `frontend/src/i18n/status.ts` expose les 2 labels et est consommé partout où raw status est rendu. -6. **Zéro string UI EN hardcodée** dans `frontend/src/pages/` + `frontend/src/components/`. Exceptions autorisées : acronymes (liste lockée), brand "Mimic", glyphes (`—`, `×`, `▾`, `▸`), API enum passthroughs, MITRE IDs, command/payload text. -7. Dates rendues via `formatDate()` au format `fr-FR` (jj/mm/aaaa). -8. `npm run test` → **236+ / 236+** (+5 nouveaux tests i18n). -9. `npm run build` succès (zéro TS error, PostCSS clean — leçon `feedback_css_apply_tokens` respectée). -10. **Manual smoke obligatoire** : login → engagements → detail → simulation form → templates → users → C2 config — chaque string visible est en FR (sauf acronymes + brand). -11. CHANGELOG.md entrée sprint 12 listant migration i18n + tab merge. - ---- +1. SimulationFormPage en edit mode : 3 tabs visibles (Red Team default, SOC, Tâche C2). Count pill sur C2 = `c2Tasks.length`. +2. Tabs SOC + C2 désactivés en mode création. +3. Tab SOC désactivé tant que status ∉ `{review_required, done}`. +4. Sticky bar contextuelle : RT [Save + Mark for review + Delete], SOC [Save SOC + Close + Reopen], C2 [aucune sticky bar]. +5. `useHashTab` gère le fallback gracieux quand hash pointe vers un tab disabled (retombe sur `'red-team'`). +6. Tabs primitive : ArrowLeft/ArrowRight skippent les tabs disabled. +7. AlertBanner Done garde sa place au-dessus des tabs (status global). SOC-blocked banner supprimé. +8. C2TasksPanel rendu dans le tab C2 panel. +9. `[Execute via C2]` + `[Import C2 history]` restent dans le tab Red Team. +10. Tests : Tabs.test.tsx + SimulationFormPage.test.tsx adaptés, baseline ≥ 245 vitest, +N nouveaux tests sur disabled tabs + count pill + tab gating. +11. `tsc --noEmit` + `eslint --max-warnings=0` + `pnpm build` clean. +12. CHANGELOG.md entrée sprint 13. ## Out of scope (explicite) -- ❌ Backend i18n (pas de translations DB-side) -- ❌ Multi-langue (uniquement FR pour ce sprint, infra prête mais pas d'EN switcher) -- ❌ Language picker UI -- ❌ ForbiddenState / NotFoundState components creation -- ❌ Color palette / brutalism / token changes -- ❌ New features +- ❌ Refactor des sub-forms RT / SOC (juste déplacement dans le tab) +- ❌ Nouveau pattern Tabs ailleurs (uniquement SimulationFormPage cette sprint) +- ❌ Changes backend +- ❌ Color / brutalism / token changes +- ❌ Multi-langue (FR uniquement, infra sprint 12) -- 2.49.1 From 0378792dc417c1d8d93e645e40791505e03d5935 Mon Sep 17 00:00:00 2001 From: Knacky Date: Mon, 22 Jun 2026 10:48:18 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(frontend):=20SimulationFormPage=203-ta?= =?UTF-8?q?b=20layout=20(Red=20Team=20/=20SOC=20/=20T=C3=A2che=20C2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tabs: disabled per-item (aria-disabled, native disabled, tab-underline-disabled, arrow-key skip); aria-controls wired - useHashTab: optional validIds fallback to defaultId - SimulationFormPage: 3-tab refactor; SOC tab gated on review_required|done; C2 tab disabled in create mode; contextual sticky bar hidden on C2 tab; safeTab guard against stale hash; SOC-blocked banner removed - fr.json: simulation.form.tab.{redTeam,soc,c2} keys - DESIGN.md: disabled tab variant + aria-controls documented - Tests: 253 passing (5 new Tabs disabled tests, SimulationFormPage rewritten for tab layout) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 + DESIGN.md | 3 +- frontend/src/components/Tabs.tsx | 22 +- frontend/src/hooks/useHashTab.ts | 14 +- frontend/src/i18n/fr.json | 7 +- frontend/src/pages/SimulationFormPage.tsx | 542 +++++++++++---------- frontend/src/styles/index.css | 4 + frontend/tests/SimulationFormPage.test.tsx | 277 ++++++----- frontend/tests/components/Tabs.test.tsx | 41 ++ 9 files changed, 548 insertions(+), 375 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88fa1ce..5b5dff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Changed — Sprint 13 (SimulationFormPage 3-tab layout) + +**Frontend only** (253 vitest passing) + +- `frontend/src/components/Tabs.tsx` — Added `disabled?: boolean` per-item: `aria-disabled`, `aria-controls`, native `disabled`, `.tab-underline-disabled` CSS, arrow-key skip logic. +- `frontend/src/styles/index.css` — New `.tab-underline-disabled` recipe (`text-graphite opacity-50 cursor-not-allowed`). +- `frontend/src/hooks/useHashTab.ts` — Added optional `validIds?: string[]` for hash fallback to defaultId when hash not in valid set. +- `frontend/src/pages/SimulationFormPage.tsx` — Full 3-tab refactor: Red Team / SOC / Tâche C2. RT fields in RT tab, SOC fields in SOC tab, `C2TasksPanel` in C2 tab. SOC tab disabled when `status ∉ {review_required, done}`. C2 tab disabled in create mode. Contextual sticky bar hides entirely on C2 tab. SOC-blocked `AlertBanner` removed; tab disabled state replaces it. `safeTab` guards against stale hash from previous navigation. +- `frontend/src/i18n/fr.json` — Added `simulation.form.tab.{redTeam,soc,c2}` keys. +- `DESIGN.md` — Documented disabled tab variant and `aria-controls` contract. +- `frontend/tests/SimulationFormPage.test.tsx` — Rewritten for tab-based layout: SOC-blocked-banner tests removed; tab disabled-state tests, SOC-only field tests, C2 tab panel test, count pill test added. +- `frontend/tests/components/Tabs.test.tsx` — Added 5 disabled-state tests. + ### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n) **Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests) diff --git a/DESIGN.md b/DESIGN.md index bb99a46..a6c9fba 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -225,7 +225,8 @@ Used inside pages that need content partitioning without a URL change (e.g. Enga - **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text. - **Count pill** (optional, e.g. simulation count): `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception. - **Active count pill**: `.tab-count-pill-active` — `bg-primary-soft text-primary`. -- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` on each button. +- **Disabled variant**: `.tab-underline-disabled` — `text-graphite opacity-50 cursor-not-allowed`. Applied when `disabled?: boolean` is set on a `TabItem`. Overrides `.tab-underline` hover. ARIA: `aria-disabled="true"` + native `disabled` attribute. Arrow-key navigation skips disabled items. +- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` + `aria-controls="tabpanel-{id}"` on each button. ### Data Tables diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 55d70f8..6cac399 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -4,6 +4,7 @@ interface TabItem { id: string; label: string; count?: number; + disabled?: boolean; } interface TabsProps { @@ -13,13 +14,23 @@ interface TabsProps { } export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element { + function nextEnabled(from: number, direction: 1 | -1): string { + const len = items.length; + let i = (from + direction + len) % len; + while (i !== from) { + if (!items[i].disabled) return items[i].id; + i = (i + direction + len) % len; + } + return items[from].id; + } + function handleKeyDown(e: KeyboardEvent, index: number) { if (e.key === 'ArrowRight') { e.preventDefault(); - onChange(items[(index + 1) % items.length].id); + onChange(nextEnabled(index, 1)); } else if (e.key === 'ArrowLeft') { e.preventDefault(); - onChange(items[(index - 1 + items.length) % items.length].id); + onChange(nextEnabled(index, -1)); } } @@ -30,6 +41,7 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element { > {items.map((item, index) => { const isActive = item.id === activeId; + const isDisabled = item.disabled ?? false; return ( - - {t('simulation.form.btn.cancel')} - -
+ +
+ + + {t('simulation.form.btn.cancel')} + +
); } - const submitting = - updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending; - return (
@@ -297,256 +336,257 @@ export function SimulationFormPage(): JSX.Element {
- {/* Done banner */} + {/* Done banner — above tabs, global status info */} {isDone && ( {t('simulation.form.banner.done')} )} - {/* SOC banner */} - {socBlocked && ( -
- - {t('simulation.form.banner.socNotReady')} - -
- )} + - {/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */} -
- {/* Left column: RT card + C2 tasks panel */} -
- {/* Red Team card */} -
e.preventDefault()} - noValidate - className="card-product flex flex-col gap-md" - > -

{t('simulation.form.header.redTeam')}

+
+ {/* ── Red Team tab ──────────────────────────────────────── */} + {safeTab === 'red-team' && ( + e.preventDefault()} + noValidate + className="card-product flex flex-col gap-md" + > + + setRt({ ...rt, name: e.target.value })} + disabled={rtDisabled} + required + /> + - - setRt({ ...rt, name: e.target.value })} - disabled={rtDisabled} - required - /> - - -
- {t('simulation.form.field.mitre')} - -
- - -