10 KiB
Sprint 13 — SimulationFormPage : layout en 3 tabs (Red Team / SOC / Tâche C2)
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)
- 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. - 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). - 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
- RT tab :
- Count pill sur tab Tâche C2 : nombre exact de tâches C2 (depuis le hook
useSimulationC2Tasksexistant). 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 bannerreste visible au-dessus des tabs (status info, pas tab-spécifique).SOC-blocked bannerdisparaî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
#024ad8Electric Blue intouchée. - Brutalisme intact :
rounded-none(exceptions documentées), zérotransition-*, zéroshadow-*. - 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 buildMANDATORY avant push (leçonfeedback_css_apply_tokens).
Task 1 — Extend Tabs primitive : support disabled per item
File : frontend/src/components/Tabs.tsx
Current TabItem :
interface TabItem<T extends string> { id: T; label: string; count?: number; }
Target :
interface TabItem<T extends string> { id: T; label: string; count?: number; disabled?: boolean; }
Comportement quand disabled :
<button>reçoitdisabled={true}etaria-disabled="true"- Class CSS
.tab-underline-disabledajoutée (nouvelle recipe ci-dessous) - Le hover ne change pas la couleur (
hover:text-graphiteou rien) - ArrowLeft/ArrowRight nav skippent les tabs disabled (cycle vers le tab actif suivant non-disabled)
- Clicks ignorés (
onClick={() => !disabled && onChange(item.id)}) aria-selectedrestefalsequand disabled
New recipe dans frontend/src/styles/index.css :
.tab-underline-disabled {
@apply text-graphite opacity-50 cursor-not-allowed;
}
(Override le hover du .tab-underline parent ; pas de border-primary sur l'active state quand disabled — n'arrivera jamais puisque aria-selected=false.)
Tests : extend Tabs.test.tsx :
- Tab avec
disabled: truene fire pasonChangeau click - ArrowRight/ArrowLeft skippent les tabs disabled
aria-disabled="true"présent- Classe
.tab-underline-disabledappliquée
DESIGN.md : ajouter 1 ligne dans la sub-section Sub-page tabs documentant la variante disabled.
Task 2 — SimulationFormPage : refactor en 3 tabs
File : frontend/src/pages/SimulationFormPage.tsx
Layout cible (edit mode) :
[Header : ← Retour à l'engagement | titre sim | status badge]
[AlertBanner Done si done (au-dessus des tabs, status info global)]
[Tabs : Red Team | SOC (disabled si !=review_required+) | Tâche C2 (count pill)]
[Panel du tab actif :
- Red Team : tous les fields RT (Name, MITRE Techniques, Description, Commands, Prerequisites, Executed at, Execution result) + actions inline (Execute via C2, Import C2 history déjà existants en sprint 8/12)
- SOC : tous les fields SOC (Log source, Logs, SOC comment, Incident number)
- Tâche C2 : C2TasksPanel (sprint 8) directement, sans wrapper
]
[Sticky bar bottom (contextuel selon tab actif) :
- RT : [Save] [Mark for review (si pending/in_progress)] ... [Delete (toujours)]
- SOC : [Save SOC] [Close (si review_required)] [Reopen (si done)]
- C2 : aucune sticky bar (display:none ou pas de render)
]
Layout en mode création (new) :
[Header : ← Retour à l'engagement | "Nouvelle simulation"]
[Tabs : Red Team (active) | SOC (disabled) | Tâche C2 (disabled, pas de count pill)]
[Panel Red Team : champs simplifiés (juste Name + MITRE Techniques minimum)]
[Sticky bar bottom : [Create simulation] [Cancel]]
Wiring :
useHashTab<TabId>('red-team')oùTabId = 'red-team' | 'soc' | 'c2'.- Items du Tabs :
const items: TabItem<TabId>[] = [ { id: 'red-team', label: t('simulation.form.tab.redTeam') }, { id: 'soc', label: t('simulation.form.tab.soc'), disabled: !editing || sim.status === 'pending' || sim.status === 'in_progress', }, { id: 'c2', label: t('simulation.form.tab.c2'), count: editing ? c2Tasks?.length : undefined, disabled: !editing, }, ]; - Le hook
useHashTabdoit fallback proprement : si#socmais tab disabled, retomber sur'red-team'. Vérifie le comportement actuel (sprint 11 a peut-être déjà ça, sinon ajout dans la même PR).
Sticky bar contextuel :
- Wrapper
<div className="sticky bottom-0 …">avec contenu conditionnel{activeTab === 'red-team' && <RtActions />}{activeTab === 'soc' && <SocActions />}{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' && <C2TasksPanel … />}. 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
disabledquand 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 :
"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.)
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 attendus
| Métrique | Valeur |
|---|---|
| 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 (~4 commits)
feat(frontend): Tabs primitive supports per-item disabled state(Tabs.tsx + recipe + Tabs.test.tsx)feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / C2 tasks)(SimulationFormPage.tsx + tests + i18n keys + DESIGN.md amendment)refactor(frontend): contextual sticky bar per active tab on SimulationFormPage(extraction des actions par tab, supprimer SOC-blocked banner)docs(changelog): sprint 13 simulation tabs entry
Collapse à 2-3 si plus efficace.
DoD
- SimulationFormPage en edit mode : 3 tabs visibles (Red Team default, SOC, Tâche C2). Count pill sur C2 =
c2Tasks.length. - Tabs SOC + C2 désactivés en mode création.
- Tab SOC désactivé tant que status ∉
{review_required, done}. - Sticky bar contextuelle : RT [Save + Mark for review + Delete], SOC [Save SOC + Close + Reopen], C2 [aucune sticky bar].
useHashTabgère le fallback gracieux quand hash pointe vers un tab disabled (retombe sur'red-team').- Tabs primitive : ArrowLeft/ArrowRight skippent les tabs disabled.
- AlertBanner Done garde sa place au-dessus des tabs (status global). SOC-blocked banner supprimé.
- C2TasksPanel rendu dans le tab C2 panel.
[Execute via C2]+[Import C2 history]restent dans le tab Red Team.- Tests : Tabs.test.tsx + SimulationFormPage.test.tsx adaptés, baseline ≥ 245 vitest, +N nouveaux tests sur disabled tabs + count pill + tab gating.
tsc --noEmit+eslint --max-warnings=0+pnpm buildclean.- CHANGELOG.md entrée sprint 13.
Out of scope (explicite)
- ❌ 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)