feat(m7-amend): full-bleed scenario table with inline edit + docs

Frontend half of the 2026-05-15 amendment (backend shipped in 447f152).

- `MissionScenarioTable` component: per-scenario <table> with 7 cols
  (Test | Procédure | Exécution | Source de log | Commentaires | Logs
  SIEM | Cyber Incident) + Actions cell. Read mode truncates; double-
  click toggles a row into edit mode where each cell becomes the right
  control. detection_level lives inside the Commentaires cell as a
  pill + select (no 8th column).
- MissionDetailPage Tests tab uses the new component, lifts
  `editingTestId` so only one row across the whole mission is editable
  at a time. Esc reverts (prompt if dirty), double-click on a different
  row with a dirty draft also prompts.
- Full-bleed escape via `calc(50% - 50vw)` (same recipe as the M4 MITRE
  picker). 7 dense columns breathe on wide screens, no horizontal scroll.
- `draftDiff(test, draft)` returns `null` when nothing changed → no PUT
  on a no-op save. The diff carries only touched fields so the server's
  per-field perm gate stays clean.
- Datetime semantics: both datetime-local inputs reuse the M7 verbatim
  recipe (`iso.slice(0, 16)` + `${local}:00Z`), zero TZ shift.

Docs
- tasks/testing-m7.md §3.0 documents the column matrix + edit workflow.
- tasks/lessons.md captures the Pydantic ctx-serialisation pitfall, the
  naïve-datetime guard, the table-edit pattern.
- CHANGELOG section moves "Frontend (in progress)" → "Frontend (shipped)"
  and details the diff.

49 Playwright tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-15 14:51:28 +02:00
parent 447f15213a
commit 9fc78e0832
6 changed files with 775 additions and 109 deletions

View File

@@ -78,6 +78,21 @@ project: Metamorph
- **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`.
- **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
## 2026-05-15 — M7 amendement : 5 champs blue + vue tabulaire
- **`e.errors()` de Pydantic v2 embarque l'exception originale dans `ctx`** quand un `AfterValidator` lève. `jsonify(e.errors())` crash avec `TypeError: Object of type ValueError is not JSON serializable`. Fix project-wide : `e.errors(include_context=False, include_url=False)` — strippe le ctx et l'URL doc, garde le reste qui est déjà JSON-safe. Sed global sur `backend/app/api/*.py`. Mémo : si la stack ajoute un nouveau handler `except ValidationError as e:`, prendre le même pattern.
- **Pydantic `EmailStr` reste trop strict pour le projet** (lessons M2 captured this). Pour le destinataire d'alerte, j'ai utilisé `Annotated[str, AfterValidator(_validate_email_shape)]` avec une regex `^[^@\s]+@[^@\s]+\.[^@\s]+$`. Pas de validation TLD. Si un futur champ "production" exige de la rigueur, on aura besoin de deux types : `_InternalEmail` (permissive) et `_PublicEmail` (strict). Pas le cas aujourd'hui — outil interne.
- **Naïve datetime + `timestamptz` = piège silencieux**. Postgres interprète la datetime naïve dans la session TZ ; sur l'API la majorité des clients enverra `2026-05-15T11:00:00` sans `Z`. Réponse : `Annotated[datetime, AfterValidator(_ensure_aware_datetime)]` qui rejette `tzinfo is None` avec 400. Le front respecte déjà la convention (append `:00Z` au datetime-local). À reproduire sur tout nouveau champ `timestamptz` accepté en write.
- **Élargir `MissionTestView` (la vue nested dans `GET /missions/{id}`) est OK** tant qu'on garde la requête en O(1) — j'ai ajouté ~15 fields mais batch-load les détections + last-actor users en 2 queries totales, peu importe le nombre de tests. Sans le batch, c'était un classique N+1.
- **Pattern édition inline en table** :
- State `editingTestId` *au-dessus* du tableau (un seul row en édition à la fois sur toute la mission).
- `draft` localement à `MissionScenarioTable`, copié depuis le test à l'entrée d'édition.
- `draftDiff(test, draft)` retourne `null` si rien n'a changé (évite un `PUT` vide).
- `useEffect([editingTestId])` re-derive le draft seulement quand l'identité change (pas sur polling refetch — leçon M7 déjà capturée).
- `window.confirm` sur Esc-with-dirty et sur double-click d'une autre ligne avec dirty draft.
- **Full-bleed escape `max-w-page`** : `marginLeft: 'calc(50% - 50vw)'` + `marginRight: 'calc(50% - 50vw)'` + `width: '100vw'`. Pattern déjà inventé pour le picker MITRE en M4 ; testé OK pour 7 colonnes denses. À factoriser dans un composant `<FullBleed>` si on en a un 3ᵉ usage.
- **`detection_level` rendu en pill dans la cellule Commentaires** plutôt que comme 8ᵉ colonne : la spec listait 7 colonnes héritées d'Excel ; ajouter une 8ᵉ aurait cassé le mental model du user. Pill au-dessus du commentaire est plus naturel + économise l'espace horizontal.
## 2026-05-14 — M7 execution + evidence + activity
- **`logging.LogRecord` reserves `created`** — same trap as `name` (M3 lessons): `extra={"created": n}` raises `KeyError: "Attempt to overwrite 'created' in LogRecord"`. Pattern: prefix with the entity (`rows_created`). The `created` is the LogRecord timestamp, hence the conflict. Reserved-key cheatsheet (kept growing): `name, msg, args, levelname, levelno, pathname, filename, module, funcName, created, msecs, lineno, thread, threadName, process`.