mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-05-18 08:18:15 +02:00
capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) : - 60 entrées intégrées dans knowledge/ (backend, frontend, workflow) - 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md, frontend/patterns/general.md, workflow/patterns/general.md - 6 doublons rejetés - Mise à jour des READMEs index pour refléter les nouvelles entrées - 95_a_capitaliser.md restauré à sa structure initiale - 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant - 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI, prisma migrate diffs cosmétiques Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,10 @@ Avant toute proposition frontend, identifie le fichier dont le nom et la descrip
|
||||
|
||||
| Fichier | Domaine | Entrées clés |
|
||||
|---------|---------|--------------|
|
||||
| `state.md` | State management, UI states, Zustand, listes paginées | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère |
|
||||
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité |
|
||||
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique |
|
||||
| `state.md` | State management, UI states, Zustand, listes paginées, refactor monolithe Vue | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère, refactor monolithe Vue sous-lots Go/No-Go, convention `pages/<module>/`, `styles.css` partagé non-scoped, annuaire client-side TTL |
|
||||
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité, AppInput Outlined Material thème dark, fusion DRY composants jumeaux par prop discriminante |
|
||||
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique, factorisation page mode dynamique via `meta.mode` typé |
|
||||
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes |
|
||||
| `nextjs.md` | Next.js App Router, embeds, ESLint | Click-to-load embeds tiers, ESLint flat config Next.js |
|
||||
| `tests.md` | Tests styles React Native, Jest node env | Tests de styles sans renderer JSX |
|
||||
| `tests.md` | Tests styles React Native, smoke checks, mount + mock composable | Tests de styles sans renderer JSX, smoke checks `readFileSync`, classe CSS modifier vs texte, cleanup E2E best-effort, helpers SW purs, mount + mock composable, assertions React Email |
|
||||
| `general.md` | Focus visible, inputs date HTML5, journaux/audit logs, pages admin | Focus visible interne pour overflow clip, restyle global `<input type="date">`, UI patterns journaux d'audit, structuration pages admin (eyebrows + grille filtres + variante danger) |
|
||||
|
||||
@@ -128,3 +128,166 @@ setItems((prev) => [...prev, created]); // pas de router.refresh()
|
||||
```
|
||||
|
||||
**Pour les entités avec relations :** utiliser un helper `findItemById(tenantId, id)` appelé après la mutation pour retourner la forme complète avec les relations résolues.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-app-input-outlined-material-dark"></a>
|
||||
## Pattern : AppInput Outlined Material adapté thème dark
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : homogénéiser tous les inputs de l'app avec un design "outlined Material" adapté à un thème dark custom (label flottant, encoche opaque calée sur la card parente).
|
||||
- **Contexte** : projet Vue/React avec un thème dark où les inputs natifs cassent le design system (couleur d'encoche, débordement Safari iOS, fond input transparent).
|
||||
- **Quand l'utiliser** : design system app-wide où tous les inputs doivent suivre la même grammaire visuelle.
|
||||
- **Quand l'éviter** : design strictement neutre (inputs natifs OS-style) ou framework UI déjà opinioné (Vuetify, Material UI).
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- design cohérent sur tous les formulaires (login, profil, modales)
|
||||
- encoche calée sur la **card parente**, pas sur le bg global → fusion visuelle propre
|
||||
- `appearance: none` + `min-width: 0` + `min-height: 48px` corrigent les inputs date Safari iOS
|
||||
- **Limites / vigilance** :
|
||||
- les pièges (couleur d'encoche, débordement, fond transparent) sont non-évidents et coûtent du temps à chaque itération si non documentés
|
||||
- `inheritAttrs: false` + séparation manuelle class/style obligatoires pour permettre le layout grid externe sans fuite des attrs HTML
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 01-05-2026
|
||||
- Contexte technique : Vue 3 Composition API — RL799_V2
|
||||
|
||||
### Composant central
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string | number;
|
||||
label: string; // requis — pas d'input sans label
|
||||
type?: string;
|
||||
staticLabel?: boolean; // label toujours haut (utile pour selects)
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
// Séparation manuelle class/style — permet layout grid externe (--col-2)
|
||||
// sans que les attrs HTML fuitent sur le wrapper
|
||||
const wrapperClass = computed(() => attrs.class);
|
||||
const wrapperStyle = computed(() => attrs.style);
|
||||
const inputAttrs = computed(() => {
|
||||
const { class: _c, style: _s, ...rest } = attrs;
|
||||
return rest;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label :class="['app-input', wrapperClass]" :style="wrapperStyle">
|
||||
<input
|
||||
:value="modelValue"
|
||||
placeholder=" "
|
||||
v-bind="inputAttrs"
|
||||
class="app-input__control"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<span class="app-input__label">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-input__control {
|
||||
width: 100%;
|
||||
min-width: 0; /* CRITIQUE Safari iOS — sinon débordement */
|
||||
min-height: 48px; /* homogénéise date/datetime-local */
|
||||
padding: 12px;
|
||||
background: transparent; /* fusion totale avec la card parente */
|
||||
border: 1px solid var(--color-border-base);
|
||||
border-radius: 6px;
|
||||
appearance: none; /* CRITIQUE iOS pour datetime-local */
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Label flottant quand input rempli OU focus */
|
||||
.app-input__control:focus + .app-input__label,
|
||||
.app-input__control:not(:placeholder-shown) + .app-input__label {
|
||||
top: 0;
|
||||
font-size: 0.75rem;
|
||||
/* Encoche opaque calée sur la CARD parente, pas le bg global */
|
||||
background: var(--app-input-notch-bg, var(--color-surface-raised));
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Pièges documentés
|
||||
|
||||
1. **Encoche du mauvais bg** : utiliser `--color-bg-elevated` (canvas global) au lieu de `--color-surface-raised` (card) → patch coloré visible. Toujours caler sur la couleur de la card parente. Si l'input vit dans un contexte différent (modale, header), exposer `--app-input-notch-bg` en custom property pour override.
|
||||
2. **Bordure traverse le label** : si le label flottant n'a pas de `background` opaque, la bordure passe derrière le texte. L'encoche n'est pas optionnelle.
|
||||
3. **Fond transparent obligatoire** : si le fond de l'input est différent de la card, l'encoche révèle un patch. Solution : `background: transparent` → fusion totale.
|
||||
4. **`placeholder=" "` imposé** : le sélecteur `:placeholder-shown` ne marche que si un placeholder existe. Un espace suffit, n'apparaît pas visuellement.
|
||||
5. **`inheritAttrs: false` + séparation class/style** : sans ça, un parent qui pose `class="--col-2"` voit cette classe ET tous les attrs HTML atterrir sur le wrapper.
|
||||
|
||||
### Variantes
|
||||
|
||||
- **`AppSelect`** : même base, label toujours flottant haut. Chevron SVG `stroke="currentColor"` 1.5px.
|
||||
- **`AppTextarea`** : label toujours flottant haut, pas de slot trailing.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-fusion-dry-composants-jumeaux"></a>
|
||||
## Pattern : Fusion DRY de composants jumeaux par prop discriminante
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : factoriser deux composants partageant la même UI à 80 %+ avec des contextes d'appel différents, sans extraire un 3ᵉ composant `Body` qui multiplie les fichiers et les indirections.
|
||||
- **Contexte** : ex `ConvocationResponseCard` (autonome, charge ses données) + `ConvocationResponseForm` (reçoit la convocation du parent) — même UI, deux modes de consommation.
|
||||
- **Quand l'utiliser** : diff entre les deux composants tient en une dizaine de `computed`/`v-if` discriminés sur **un seul flag de mode**.
|
||||
- **Quand l'éviter** :
|
||||
- cycles de vie ou stores différents (un consomme un store Pinia, l'autre est purement contrôlé) — le `computed` discriminant pollue tout le composant
|
||||
- UI diverge à > 30 % (sections présentes dans l'un, absentes dans l'autre)
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- une seule source de vérité pour markup et styles
|
||||
- tests structurels consolidés sur un seul fichier
|
||||
- évolution UX synchronisée par construction
|
||||
- **Limites / vigilance** :
|
||||
- **anti-pattern à refuser** : extraire un 3ᵉ composant `Body` partagé entre les deux composants originaux. Multiplie les fichiers, ajoute une couche d'indirection (props drilling, events bubbling) sans réduire la complexité réelle
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 01-05-2026
|
||||
- Contexte technique : Vue 3 — RL799_V2 (530 lignes dupliquées → 310 lignes uniques)
|
||||
|
||||
### Implémentation
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
// Mode A : Card autonome (consomme un dataset complet)
|
||||
data?: ProchaineTenueData;
|
||||
// Mode B : Form simple (reçoit juste l'objet à éditer)
|
||||
convocation?: ProchaineTenueConvocation;
|
||||
}>();
|
||||
|
||||
const isCardMode = computed(() => props.data !== undefined);
|
||||
|
||||
// Convocation dérivée selon le mode → le reste du composant manipule
|
||||
// uniquement `convocation.value`, sans se soucier du mode
|
||||
const convocation = computed(() => {
|
||||
if (isCardMode.value) {
|
||||
const primary = props.data!.primaryGrade;
|
||||
return props.data!.gradeInfos[primary]?.convocation;
|
||||
}
|
||||
return props.convocation;
|
||||
});
|
||||
|
||||
// Émission typée en union — chaque mode émet son type approprié
|
||||
const emit = defineEmits<{
|
||||
updated: [ProchaineTenueData | ConvocationResponseData];
|
||||
}>();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Critère de décision
|
||||
|
||||
Si le diff entre les deux composants tient en une dizaine de `computed`/`v-if` discriminés sur **un seul flag de mode**, fusionner. Si ça déborde, garder distincts.
|
||||
|
||||
377
knowledge/frontend/patterns/general.md
Normal file
377
knowledge/frontend/patterns/general.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Frontend — Patterns : Général
|
||||
|
||||
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/patterns/README.md` pour l'index complet.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-focus-visible-interne-overflow-clip"></a>
|
||||
## Pattern : Focus visible interne pour champs sous overflow clip
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : préserver le focus visible des champs `<input>` / `<select>` quand l'app applique `overflow-x: clip` sur ses conteneurs (PageShell, panel, layout) — le focus outline natif est dessiné **hors** de la box du champ et se fait clipper.
|
||||
- **Contexte** : app mobile-first où `overflow-x: clip` (ou `hidden`) est fréquent sur les conteneurs pour empêcher le débordement horizontal.
|
||||
- **Quand l'utiliser** : tout projet avec une chaîne de parents en `overflow: hidden|clip` autour des champs de saisie.
|
||||
- **Quand l'éviter** : si la chaîne de parents n'a pas d'`overflow: hidden|clip` — l'outline natif suffit.
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- aucun pixel ne sort de la box, donc aucun clip possible
|
||||
- `box-shadow: inset` + `border-color` est portable Chrome/Firefox/Safari
|
||||
- **Limites / vigilance** :
|
||||
- `outline-offset: -2px` marche sur Chrome mais le rendu varie : Firefox/Safari peuvent ignorer selon la combinaison `outline-style`
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 27-04-2026
|
||||
- Contexte technique : CSS / mobile-first — RL799_V2
|
||||
|
||||
### Pattern correctif (par composant)
|
||||
|
||||
```css
|
||||
.my-input:focus-visible,
|
||||
.my-select:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
```
|
||||
|
||||
### Application globale au thème (recommandée pour mobile-first)
|
||||
|
||||
Plutôt que de répéter le pattern dans chaque composant, le pousser dans le fichier de thème global. Tous les inputs/selects/textareas en bénéficient automatiquement.
|
||||
|
||||
```css
|
||||
/* Dans theme/<theme>.css ou globals.css, hors :root */
|
||||
input[type='text']:focus-visible,
|
||||
input[type='search']:focus-visible,
|
||||
input[type='email']:focus-visible,
|
||||
input[type='tel']:focus-visible,
|
||||
input[type='url']:focus-visible,
|
||||
input[type='number']:focus-visible,
|
||||
input[type='password']:focus-visible,
|
||||
input[type='date']:focus-visible,
|
||||
input[type='datetime-local']:focus-visible,
|
||||
input[type='time']:focus-visible,
|
||||
input[type='month']:focus-visible,
|
||||
input[type='week']:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
```
|
||||
|
||||
### Pourquoi sélecteurs d'attribut explicites et pas `input:focus-visible`
|
||||
|
||||
`input` couvre aussi `type='checkbox'`, `type='radio'`, `type='file'`, `type='range'`, `type='color'` qui ont leur propre design natif. La règle pourrait casser leur rendu (carrés or autour des cases à cocher, etc.). On liste explicitement les types texte/saisie.
|
||||
|
||||
### Conflits avec composants ayant leur propre `:focus`
|
||||
|
||||
Un composant qui a déjà `.my-input:focus { ... }` (sans `-visible`) gardera la priorité par spécificité (classe > tag). La règle globale ne joue que pour les composants qui n'ont rien défini → sûr à introduire en mid-projet.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-restyle-input-date-sans-wrapper-js"></a>
|
||||
## Pattern : Restyle global de `<input type="date">` sans wrapper JS
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : aligner les inputs date HTML5 sur l'identité visuelle du thème via une règle CSS globale, sans wrapper JS custom.
|
||||
- **Contexte** : projet avec un thème dark/light custom où les inputs date natifs (icône calendrier blanche, placeholder gris OS, popover light par défaut) cassent le design.
|
||||
- **Quand l'utiliser** : 80 % des cas (audit log, formulaires admin, profils) où la datepicker custom serait du sur-engineering.
|
||||
- **Quand l'éviter** :
|
||||
- validation custom synchrone (range, dates blackout, format spécifique)
|
||||
- format d'affichage différent (DD/MM vs MM/DD vs ISO)
|
||||
- intégration profonde dans un design system
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- `color-scheme` + `accent-color` donnent un popover natif cohérent gratuitement
|
||||
- filtre SVG pour teinter l'icône calendrier
|
||||
- zéro JavaScript, zéro bundle additionnel
|
||||
- **Limites / vigilance** :
|
||||
- **Firefox** : `accent-color` respecté, mais pas de pseudo-element pour customiser le placeholder
|
||||
- **Safari iOS** : popover sheet OS, peu personnalisable. Acceptable car cohérent avec le reste des UI iOS natives
|
||||
- filtre `filter()` à calibrer pour matcher la couleur du thème — chaque thème nécessite son tuning
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 27-04-2026
|
||||
- Contexte technique : CSS / inputs date HTML5 — RL799_V2
|
||||
|
||||
### Pattern minimal
|
||||
|
||||
```css
|
||||
/* Toutes les variantes de pickers HTML5 */
|
||||
input[type='date'],
|
||||
input[type='datetime-local'],
|
||||
input[type='time'],
|
||||
input[type='month'],
|
||||
input[type='week'] {
|
||||
color-scheme: dark; /* ou 'light' selon le thème de l'app */
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Place-holder OS (jj/mm/aaaa) — couleur soft */
|
||||
input[type='date']::-webkit-datetime-edit-fields-wrapper,
|
||||
input[type='datetime-local']::-webkit-datetime-edit-fields-wrapper {
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
|
||||
/* Une fois saisie, couleur normale */
|
||||
input[type='date']:not(:placeholder-shown)::-webkit-datetime-edit-fields-wrapper {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Icône calendrier teintée (filtre SVG noir → couleur d'accent soft) */
|
||||
input[type='date']::-webkit-calendar-picker-indicator,
|
||||
input[type='datetime-local']::-webkit-calendar-picker-indicator,
|
||||
input[type='time']::-webkit-calendar-picker-indicator,
|
||||
input[type='month']::-webkit-calendar-picker-indicator,
|
||||
input[type='week']::-webkit-calendar-picker-indicator {
|
||||
/* Calibrer pour matcher la couleur du thème */
|
||||
filter: invert(70%) sepia(40%) saturate(450%) hue-rotate(5deg) brightness(95%);
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- Construire un mini calendrier JS custom pour gagner 5 % d'esthétique → effort énorme (a11y clavier, focus management, mobile, edge cases), bénéfice marginal
|
||||
- Hardcoder les couleurs `filter()` au lieu d'utiliser des tokens du thème
|
||||
- Restyler sans `color-scheme: dark/light` → le popover natif reste en mode clair sur thème sombre
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-ui-journaux-audit-logs"></a>
|
||||
## Pattern : UI pour journaux / audit logs / timelines
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : passer d'un rendu naïf en cards uniformes "acteur · code · cible (uuid) · metadata" à une lecture rapide pour l'admin en surveillance, sans toucher au backend.
|
||||
- **Contexte** : tout projet finit par afficher un journal d'événements (audit, activité, historique, timeline) avec metadata variable.
|
||||
- **Quand l'utiliser** : journal avec ≥ 10 types d'actions et besoin de scanner rapidement.
|
||||
- **Quand l'éviter** : log technique brut destiné aux devs (un `<pre>` peut suffire).
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- 5 patterns combinables qui améliorent radicalement la scanabilité
|
||||
- aucun changement backend (le DTO reste plat)
|
||||
- **Limites / vigilance** :
|
||||
- reset de l'état d'expansion à chaque rechargement (l'expansion est éphémère, pas une préférence durable)
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 27-04-2026
|
||||
- Contexte technique : Vue 3 / CSS — RL799_V2 (Journal d'audit admin, 45+ types d'actions)
|
||||
|
||||
### 1. `<optgroup>` dérivé du préfixe label
|
||||
|
||||
Quand l'API retourne un catalogue d'actions avec convention `Catégorie — Libellé` (ex : `Soirée — annulation`, `Tenue — création`), dériver les groupes côté front au lieu de modifier le DTO.
|
||||
|
||||
```typescript
|
||||
const groupsFromLabels = computed(() => {
|
||||
const groups = new Map<string, Entry[]>();
|
||||
for (const opt of catalog.value) {
|
||||
const sep = opt.label.indexOf(' — ');
|
||||
const category = sep > 0 ? opt.label.slice(0, sep) : 'Divers';
|
||||
groups.set(category, [...(groups.get(category) ?? []), opt]);
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'fr', { sensitivity: 'base' }));
|
||||
});
|
||||
```
|
||||
|
||||
```html
|
||||
<select>
|
||||
<option value="">Toutes les actions</option>
|
||||
<optgroup v-for="[cat, opts] in groupsFromLabels" :label="cat" :key="cat">
|
||||
<option v-for="o in opts" :value="o.value" :key="o.value">{{ o.label }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
```
|
||||
|
||||
Bénéfice : 1 seul clic pour filtrer (vs cascade 2 selects), accessible natif, zéro modif backend.
|
||||
|
||||
### 2. Code couleur sémantique par catégorie
|
||||
|
||||
Barre de couleur de 3 px à gauche de chaque card de la liste, mappée sur la catégorie de l'événement. Transforme un mur de cards uniformes en lecture instantanée.
|
||||
|
||||
```css
|
||||
.log-item {
|
||||
border-left: 3px solid var(--log-cat-color, var(--color-border));
|
||||
}
|
||||
.log-item--cat-soiree { --log-cat-color: var(--color-accent-primary); }
|
||||
.log-item--cat-rgpd { --log-cat-color: var(--color-accent-danger); }
|
||||
```
|
||||
|
||||
Pourquoi pas le fond complet : trop bruyant, perd la sobriété d'un journal admin. La barre latérale signale sans crier.
|
||||
|
||||
### 3. UUIDs rétrogradés en monospace soft
|
||||
|
||||
Les UUIDs / IDs techniques affichés en plein texte cassent la lecture humaine. Les détecter via regex et les rendre en font monospace + couleur soft + taille réduite, sans les masquer (utiles pour forensics).
|
||||
|
||||
```typescript
|
||||
const UUID_RE = /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
|
||||
```
|
||||
|
||||
```css
|
||||
.uuid {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-soft);
|
||||
word-break: break-all;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Date relative + absolue en tooltip
|
||||
|
||||
Pour la lecture humaine, "il y a 5 min" / "hier" / "il y a 3 j" bat toujours "27 avril 2026 à 08:37". La date absolue reste accessible en `title` du `<time>` pour les forensics.
|
||||
|
||||
```typescript
|
||||
const formatRelative = (iso: string) => {
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (diff < 60) return 'à l\'instant';
|
||||
if (diff < 3600) return `il y a ${Math.round(diff / 60)} min`;
|
||||
if (diff < 86400) return `il y a ${Math.round(diff / 3600)} h`;
|
||||
if (diff < 172800) return 'hier';
|
||||
if (diff < 604800) return `il y a ${Math.round(diff / 86400)} j`;
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Détails techniques repliables
|
||||
|
||||
La metadata détaillée (clés/valeurs verboses, IDs internes, raisons null) sert à l'investigation, pas à la lecture courante. La masquer derrière un `Voir les détails / Masquer les détails`, et reset l'état d'expansion à chaque rechargement.
|
||||
|
||||
```typescript
|
||||
const expanded = ref<Set<string>>(new Set());
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(expanded.value);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
expanded.value = next;
|
||||
};
|
||||
// Reset après chaque load
|
||||
const loadList = async () => {
|
||||
/* … */
|
||||
expanded.value = new Set();
|
||||
};
|
||||
```
|
||||
|
||||
Le bouton "voir détails" n'apparaît **que si** la card a effectivement de la metadata. Un toggle vide pollue la grille visuelle.
|
||||
|
||||
### Anti-patterns à éviter
|
||||
|
||||
- Cards uniformes en couleur/border quel que soit le type d'événement → tue la scanabilité
|
||||
- Date absolue toujours visible (`27 avril 2026 à 08:37`) sur des cards serrées → bruit cognitif inutile
|
||||
- UUIDs en plein texte sans rétrogradation visuelle
|
||||
- Cascade 2 selects quand un `<optgroup>` natif suffit
|
||||
- Bouton "voir détails" affiché même sans détails à voir
|
||||
- Persister l'expansion entre navigations / paginations → état orphelin
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-structuration-pages-admin"></a>
|
||||
## Pattern : Structuration de pages admin (eyebrows + grille filtres + variante danger)
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : poser une grammaire visuelle commune sur les écrans admin (filtres + liste/form + sections multiples) sans framework lourd.
|
||||
- **Contexte** : module Admin avec plusieurs panels (Audit, Utilisateurs, Corbeille, etc.) qui partagent la même structure.
|
||||
- **Quand l'utiliser** : ≥ 3 panels admin avec structure similaire.
|
||||
- **Quand l'éviter** : page admin unique sans cohérence inter-panels à maintenir.
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 27-04-2026
|
||||
- Contexte technique : Vue 3 / CSS — RL799_V2 (5 panels admin)
|
||||
|
||||
### 1. Eyebrows de section
|
||||
|
||||
Au lieu de titres H2/H3 trop forts, utiliser des "eyebrows" : mini-labels uppercase, letter-spacing élargi, taille `caption`, couleur d'accent.
|
||||
|
||||
```css
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-accent);
|
||||
margin: 0;
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<p class="eyebrow">Filtres</p>
|
||||
<div class="my-filters"><!-- … --></div>
|
||||
|
||||
<p class="eyebrow">Résultats <span class="eyebrow-count">· {{ total }} entrées</span></p>
|
||||
<div class="my-list"><!-- … --></div>
|
||||
```
|
||||
|
||||
Le compteur (`· N entrées`) est en `font-weight: normal`, sans uppercase, en `color-text-secondary` — typographie en cascade.
|
||||
|
||||
Bénéfice : structure visuelle sans concurrencer un éventuel titre de page. Cohérence inter-écrans dans tout un module si l'eyebrow est utilisé partout.
|
||||
|
||||
Quand ne pas afficher l'eyebrow `Résultats` : sur loading / error / empty state. Le `StateBlock` (ou équivalent) prend le relais.
|
||||
|
||||
### 2. Grille de filtres hiérarchique
|
||||
|
||||
Quand 3+ filtres dont l'un est dominant (typiquement une recherche), au lieu d'un flex-wrap chaotique, utiliser une grille avec le filtre primaire en pleine largeur :
|
||||
|
||||
```html
|
||||
<div class="filters">
|
||||
<label class="filters__label filters__label--full">Recherche
|
||||
<input type="search">
|
||||
</label>
|
||||
<label class="filters__label">Statut <select><!-- … --></select></label>
|
||||
<label class="filters__label">Rôle <select><!-- … --></select></label>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.filters__label--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Variante `danger` pour actions destructives
|
||||
|
||||
Sur un écran qui mélange actions constructives et destructives (ex : saisie initiale + bypass admin) :
|
||||
|
||||
```css
|
||||
.btn--danger {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 48%, transparent);
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.card--danger {
|
||||
border-left: 3px solid var(--color-danger);
|
||||
}
|
||||
```
|
||||
|
||||
`color-mix` produit un rouge **soft** (12-20 % saturation), pas un flash rouge violent.
|
||||
|
||||
**Anti-pattern** : `background: red` / `color: red` directs ou hex hardcodés (`#ef4444`). Toujours via tokens du thème.
|
||||
|
||||
### Anti-patterns à éviter
|
||||
|
||||
- Filtres en `flex-wrap` qui produisent 3 lignes asymétriques selon le viewport
|
||||
- Hint général qui répète ce que l'empty state dit déjà → 1 message, 1 niveau d'info
|
||||
- Confondre "titre de page" (l'onglet actif suffit souvent) et "structure de section" (eyebrows)
|
||||
- Action destructive en variant primary or → danger explicite
|
||||
@@ -198,3 +198,82 @@ Sans focus trap, le clavier peut sortir de la sheet/panel et casser l'ordre de n
|
||||
- Restaurer le focus au trigger à la fermeture.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-factorisation-page-meta-mode"></a>
|
||||
## Pattern : Factorisation page mode dynamique via `route.meta.mode` typé
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : factoriser un composant Vue qui partage 95 % de sa logique entre plusieurs routes ne différant que par le wording, sans recourir à `route.name` (fragile) ou query string (manipulable).
|
||||
- **Contexte** : projet Vue avec deux routes (ex : `/invitation` + `/reset-password`) qui partagent la mécanique technique stricte (validation token + saisie mot de passe + consume) et ne diffèrent que par le wording.
|
||||
- **Quand l'utiliser** : factorisation justifiée par un partage > 90 % de logique entre routes.
|
||||
- **Quand l'éviter** : routes qui diffèrent fonctionnellement au-delà du wording — préférer deux composants distincts.
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- explicite, déclaratif, type-checkable
|
||||
- augmenter `RouteMeta` dans `vue-router` pour qu'un mode manquant produise une erreur build
|
||||
- **Limites / vigilance** :
|
||||
- wording centralisé en un seul `computed` — pas de `v-if` éparpillés dans le template
|
||||
- endpoint dynamique en un seul `if/else` dans le handler
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 28-04-2026
|
||||
- Contexte technique : Vue 3 / vue-router — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
{
|
||||
path: '/invitation',
|
||||
name: 'invitation',
|
||||
component: ResetPasswordPage,
|
||||
meta: { requiresGuest: true, mode: 'invitation' as const },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: ResetPasswordPage,
|
||||
meta: { requiresGuest: true, mode: 'reset' as const },
|
||||
},
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ResetPasswordPage.vue
|
||||
const pageMode = computed<PageMode>(() => {
|
||||
const meta = route.meta as { mode?: PageMode } | undefined;
|
||||
return meta?.mode === 'invitation' ? 'invitation' : 'reset';
|
||||
});
|
||||
|
||||
const wording = computed(() => {
|
||||
if (pageMode.value === 'invitation') {
|
||||
return { eyebrow: 'Bienvenue', title: 'Définissez votre mot de passe', /* … */ };
|
||||
}
|
||||
return { eyebrow: 'Réinitialisation', title: '...', /* … */ };
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (pageMode.value === 'invitation') {
|
||||
await consumeInvitationToken(token.value, newPassword.value);
|
||||
} else {
|
||||
await consumeResetToken(token.value, newPassword.value);
|
||||
}
|
||||
// Post-consume uniforme : redirige /login pour les deux modes
|
||||
};
|
||||
```
|
||||
|
||||
### Règles d'or
|
||||
|
||||
- `meta.mode` typé en literal union (`'invitation' | 'reset'`)
|
||||
- A11y obligatoire : autofocus sur le champ password au mount post-validation, `<label for>` associés, `<AppMessage variant="error">` avec `role="alert"`, `<AppMessage variant="success">` avec `aria-live="polite"`
|
||||
- Test obligatoire : monter la page avec chaque `meta.mode` et vérifier que le wording correspond
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- Détecter le mode via `route.name` (fragile, couplage implicite, signal d'intention faible)
|
||||
- Détecter via query string (pollue URL, manipulable)
|
||||
|
||||
---
|
||||
|
||||
@@ -208,3 +208,270 @@ Sans état transitoire formel, les guards lisent des valeurs incomplètes et dé
|
||||
- Store : `hydrateStatus` + promesse partagée en cours pour les appels concurrents.
|
||||
- Guards : `await hydrate()` avant toute décision d'accès.
|
||||
- UI : fallback de rendu tant que l'hydratation n'est pas `ready`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-refactor-monolithe-vue-sous-lots"></a>
|
||||
## Pattern : Refactor monolithe Vue — sous-lots Go/No-Go + ordre topologique
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : découper un composant Vue monolithique (> 1500 lignes script ou > 2000 lignes total) en composables + sous-composants livrés en commits successifs validés un à un.
|
||||
- **Contexte** : page Vue avec plusieurs responsabilités métier mêlées, peu ou pas de `describe`, sans sous-découpage interne.
|
||||
- **Quand l'utiliser** : fichier > 1500 lignes script, fenêtre de calme sans PR concurrente prévue, tests E2E robustes en place.
|
||||
- **Quand l'éviter** : page < 1000 lignes, pas de tests E2E pour servir de filet, ou planning serré sans Go/No-Go possible entre commits.
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- aucune régression à chaque sous-lot validé indépendamment
|
||||
- helpers réutilisables émergent naturellement
|
||||
- reporter vitest / Playwright groupe les échecs par responsabilité
|
||||
- **Limites / vigilance** :
|
||||
- les `data-testid` E2E doivent être **copiés-collés exactement** dans les composants enfants (sinon les E2E rotent silencieusement)
|
||||
- les bindings template doivent rester alignés avec les noms destructurés du composable
|
||||
- le typecheck `tsc --noEmit` ne suffit pas — utiliser `vue-tsc` (cf. `frontend/risques/state.md` risque-templates-vue-references-orphelines)
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 29-04-2026
|
||||
- Contexte technique : Vue 3 / Composition API — RL799_V2 (4 pages refactorées, 17 commits, 644/644 tests verts)
|
||||
|
||||
### Stratégie en 3 étapes
|
||||
|
||||
1. **Audit complet préalable** : sections du template avec plages de lignes + rôle métier + `v-if` clé, blocs script regroupés par responsabilité avec évaluation `autonome` vs `couplé`, imports + composables + DTOs consommés, tests existants, couplages externes (deep-links, sélecteurs CSS).
|
||||
2. **Plan en sous-lots ordonnés par risque croissant** :
|
||||
- L1 : composables purement autonomes (zéro dépendance interne)
|
||||
- L2 : composants enfants auto-contenus (modales)
|
||||
- L3 : composables avec couplage modéré (cache + watchers)
|
||||
- L4 : composables avec couplage métier subtil (cascade, propagation)
|
||||
- L5 : composant enfant complexe (D&D, drag-handle conditionnel)
|
||||
3. **Go/No-Go explicite entre chaque lot** : 1 commit thématique par lot avec validation typecheck + tests + diff montré au pair avant push.
|
||||
|
||||
### Ordre topologique des dépendances dans le script post-refactor
|
||||
|
||||
Quand plusieurs composables se consomment mutuellement, respecter strictement l'ordre topologique (la déclaration de la donnée doit précéder son usage, sous peine de TDZ) :
|
||||
|
||||
```typescript
|
||||
// 1. Data centrale de la page
|
||||
const currentSoiree = ref<SoireeData | null>(null);
|
||||
const error = ref('');
|
||||
|
||||
// 2. Composables qui ne consomment que la data centrale
|
||||
const { lifecycle, isLiveView } = useSoireeLifecycle(currentSoiree, allTenuesCancelled);
|
||||
|
||||
// 3. Composables qui produisent des refs consommées par d'autres
|
||||
const { responsesData } = useResponseTracking(currentSoiree, error);
|
||||
|
||||
// 4. Composables qui consomment les refs produites
|
||||
const { pastActiveGrade } = useGradeSelection(soireeTenues, responsesData);
|
||||
```
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- ❌ Grouper plusieurs préoccupations dans un même composable juste parce qu'elles sont voisines dans le script
|
||||
- ❌ Sortir un composable qui consomme directement `process.cwd()` ou un store global sans le passer en argument (couplage caché)
|
||||
- ❌ Extraire le CSS scoped vers le composant enfant **avant** de vérifier que toutes les classes y sont effectivement utilisées (certaines classes vivent en CSS global)
|
||||
- ❌ Sauter le grep des références orphelines avant de supprimer un bloc
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `data-testid` copiés-collés exactement dans les composants enfants
|
||||
- [ ] Bindings template alignés avec les noms destructurés
|
||||
- [ ] Props/events des composants enfants alignés avec les usages
|
||||
- [ ] `vue-tsc` (pas `tsc`) en vérification typecheck
|
||||
- [ ] QA visuel obligatoire post-refactor (mount réel en browser)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-convention-pages-module-scope"></a>
|
||||
## Pattern : Convention `pages/<module>/{composables,components,utils,__tests__}/`
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : structurer une app Vue qui dépasse 20 pages avec plusieurs domaines métier, en regroupant la logique extraite par module.
|
||||
- **Contexte** : app Vue avec routing par page et logique extraite (composables, sous-composants, tests).
|
||||
- **Quand l'utiliser** : page > 1000 lignes envisage le scope, > 1500 lignes le crée systématiquement.
|
||||
- **Quand l'éviter** : page < 500 lignes sans logique extraite — laisser au niveau racine.
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- un module métier = un sous-dossier, navigation simplifiée
|
||||
- l'alias `@/pages/<module>/<X>` rend les fichiers résilients aux déplacements
|
||||
- les tests d'un module vivent avec lui (pas dans un dossier global qui mélange tout)
|
||||
- **Limites / vigilance** :
|
||||
- les tests scopés calculent leur `root` avec **4 niveaux** de remontée (`'../../../..'`), pas 3 — source d'erreur fréquente lors d'un déplacement
|
||||
- le dossier `pages/__tests__/` global reste réservé aux tests transverses + tests des pages legacy
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 29-04-2026
|
||||
- Contexte technique : Vue 3 / Vite / Vitest — RL799_V2 (5 modules scopés, 35 fichiers extraits)
|
||||
|
||||
### Structure type
|
||||
|
||||
```
|
||||
pages/<module>/
|
||||
├── <Module>Page.vue # page principale (carcasse + template)
|
||||
├── <Autre>Page.vue # autres pages du même module si existent
|
||||
├── composables/ # logique métier extraite
|
||||
│ ├── use<X>.ts
|
||||
│ └── use<Y>.ts
|
||||
├── components/ # sous-composants .vue scopés au module
|
||||
├── utils/ # helpers purs (formatters, defensive wrappers)
|
||||
├── styles.css # CSS partagé non-scoped (cf. pattern dédié)
|
||||
└── __tests__/ # tests scopés au module
|
||||
```
|
||||
|
||||
### Ce qui reste dans `pages/__tests__/` global
|
||||
|
||||
Trois cas légitimes uniquement :
|
||||
|
||||
1. **Tests transverses** qui couvrent plusieurs modules (`lifecycleUnification.test.mjs`)
|
||||
2. **Tests d'infrastructure** non rattachés à un module métier (`OfflineIntegration.test.mjs` pour le SW PWA)
|
||||
3. **Tests des pages encore à plat** (legacy non-encore scopées) — `LoginPage.test.mjs` reste à `pages/__tests__/` tant que `LoginPage.vue` est à `pages/`
|
||||
|
||||
### Calcul de `root` dans les tests scopés
|
||||
|
||||
```typescript
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
// 4 niveaux : __tests__ → <module> → pages → src → frontend
|
||||
const root = resolve(here, '../../../..');
|
||||
```
|
||||
|
||||
Si le test crashe avec `ENOENT: no such file … '<frontend>/src/src/pages/...'`, c'est que le `root` n'a pas été ajusté.
|
||||
|
||||
### Imports : alias `@/` plutôt que relatif
|
||||
|
||||
Toujours utiliser l'alias `@/pages/<module>/<X>` plutôt que `./X` ou `../X`. Bénéfice : déplacer un fichier ne casse pas ses imports internes (juste les imports depuis l'extérieur, qu'on met à jour via sed bulk).
|
||||
|
||||
### Critère extraction composable vs composant
|
||||
|
||||
| Cas | Préférer composable | Préférer composant |
|
||||
|-----|---------------------|---------------------|
|
||||
| Logique pure (state + actions, pas de markup) | ✓ | |
|
||||
| Modale auto-contenue, > 30 lignes template | | ✓ |
|
||||
| Form > 50 lignes avec validation | | ✓ |
|
||||
| Plusieurs refs/computeds entrelacés | ✓ | |
|
||||
| CSS spécifique > 50 lignes | | ✓ (avec styles.css si partagé) |
|
||||
|
||||
Préférence générale : composables script-only quand possible (risque CSS nul, plus simple à tester).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-styles-css-module-non-scoped"></a>
|
||||
## Pattern : `styles.css` partagé non-scoped pour modules avec composants extraits
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : partager des classes CSS entre la page parente et ses sous-composants extraits sans dupliquer le CSS dans chaque `<style scoped>` enfant.
|
||||
- **Contexte** : refactor d'une page Vue monolithique en N composants enfants (modales, forms) qui partagent des classes communes.
|
||||
- **Quand l'utiliser** : ≥ 2 composants enfants partagent des classes (modales, forms, badges du module).
|
||||
- **Quand l'éviter** : composants enfants strictement indépendants, ou règle CSS utilisée nulle part ailleurs.
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- une seule définition par classe partagée
|
||||
- dérive impossible (un changement profite à tous les composants du module)
|
||||
- **Limites / vigilance** :
|
||||
- tentation de tout sortir en non-scoped "au cas où" → refusé, pollue le namespace global
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 29-04-2026
|
||||
- Contexte technique : Vue 3 / scoped CSS — RL799_V2 (152 lignes CSS migrées dans `pages/venerable/styles.css`)
|
||||
|
||||
### Pattern
|
||||
|
||||
```vue
|
||||
<!-- pages/<module>/<Module>Page.vue -->
|
||||
<template>
|
||||
<!-- … -->
|
||||
</template>
|
||||
|
||||
<!-- Styles partagés du module : importés en non-scoped pour atteindre
|
||||
les composants enfants extraits (modales/forms) qui ne peuvent
|
||||
pas hériter d'un `<style scoped>` parent -->
|
||||
<style src="@/pages/<module>/styles.css"></style>
|
||||
|
||||
<style scoped>
|
||||
/* Classes spécifiques au layout de la page parente uniquement */
|
||||
</style>
|
||||
```
|
||||
|
||||
Les composants enfants utilisent les classes sans rien importer :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="vm-modal"> <!-- vient de styles.css -->
|
||||
<h2 class="vm-modal__title">…</h2>
|
||||
<button class="primary">…</button> <!-- vient du CSS global -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Quoi mettre dans `styles.css`
|
||||
|
||||
- Classes utilisées par > 1 composant enfant du module
|
||||
- Classes utilisées par la page ET par un composant enfant
|
||||
- Conventions/tokens visuels propres au module
|
||||
|
||||
### Quoi NE PAS y mettre
|
||||
|
||||
- Classes utilisées uniquement dans la page parente → `<style scoped>` de la page
|
||||
- Classes utilisées uniquement dans un seul composant enfant → `<style scoped>` du composant
|
||||
- Classes globales (`primary`, `ghost`, etc.) qui vivent déjà dans `style.css` global
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-annuaire-client-side-ttl-refresh"></a>
|
||||
## Pattern : Annuaire client-side avec TTL + refresh + `lastFetchedAt`
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : permettre un load complet en mémoire au mount avec filtre client (annuaire de N membres, catalog de produits) tout en évitant le refetch inutile entre ouvertures.
|
||||
- **Contexte** : composable Vue qui charge un dataset moyen (< quelques milliers d'items) consommé par filtres client.
|
||||
- **Quand l'utiliser** : modale ou page consultée fréquemment qui n'a pas besoin de pagination serveur.
|
||||
- **Quand l'éviter** : dataset > 10k items (utiliser pagination/keyset), ou besoin de temps réel cross-clients (basculer SSE/WS).
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- cache TTL configurable (par défaut 5 min) → pas de refetch entre ouvertures
|
||||
- `refresh()` méthode publique pour forcer après création/update
|
||||
- `lastFetchedAt` exposé pour debug / UI ("annuaire mis à jour il y a X")
|
||||
- **Limites / vigilance** :
|
||||
- si user B crée une entrée pendant que user A a la modale ouverte, A ne voit pas l'entrée tant qu'il ne ferme/rouvre pas ou clique refresh
|
||||
- TTL atténue mais ne résout pas — pour temps réel, basculer SSE/WS
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 01-05-2026
|
||||
- Contexte technique : Vue 3 / Composition API — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
const useEntityDirectory = (options: { ttlMs?: number } = {}) => {
|
||||
const ttlMs = options.ttlMs ?? 5 * 60 * 1000;
|
||||
const directory = ref<Entry[]>([]);
|
||||
const lastFetchedAt = ref<Date | null>(null);
|
||||
|
||||
const isCacheStale = (): boolean => {
|
||||
if (!lastFetchedAt.value) return true;
|
||||
return Date.now() - lastFetchedAt.value.getTime() > ttlMs;
|
||||
};
|
||||
|
||||
const loadDirectory = async (params: { force?: boolean } = {}) => {
|
||||
if (!params.force && !isCacheStale() && directory.value.length > 0) return;
|
||||
const data = await api.fetchDirectory();
|
||||
directory.value = data;
|
||||
lastFetchedAt.value = new Date();
|
||||
};
|
||||
|
||||
const refresh = () => loadDirectory({ force: true });
|
||||
return { directory, lastFetchedAt, loadDirectory, refresh };
|
||||
};
|
||||
```
|
||||
|
||||
@@ -153,3 +153,368 @@ Les classes et la structure DOM changent fréquemment sans régression fonctionn
|
||||
- Préférer `data-testid` paramétré par identifiant métier stable.
|
||||
- Éviter `locator.first()` si l'ordre peut muter.
|
||||
- Isoler les tests mutateurs avec stratégie de remise à l'état (snapshot/restore).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-tests-statiques-vitest-smoke-checks"></a>
|
||||
## Pattern : Tests statiques `readFileSync` annotés comme smoke checks
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : utiliser les tests `readFileSync + includes()` pour leur force réelle (présence de signaux structurels) tout en évitant la fausse confiance qu'ils donnent sur le comportement runtime.
|
||||
- **Contexte** : projets Vue où le pattern `readFileSync` est largement utilisé pour vérifier la présence de testid, rôle ARIA, appel de service.
|
||||
- **Quand l'utiliser** : assertions sur **présence** d'un pattern (testid, import, regex interdite). À doubler par un mount pour les comportements interactifs.
|
||||
- **Quand l'éviter** : composants interactifs (focus trap, submit delegation, navigation clavier) — utiliser un test mount.
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- rapide à écrire et lire, résistant aux refactors de structure
|
||||
- bon pour vérifier la présence de comportements clés sans monter
|
||||
- **Limites / vigilance** :
|
||||
- **ne valide PAS** que le focus trap cycle correctement, que le composant émet les bons événements, que la navigation clavier Escape ferme la modal
|
||||
- **ne distingue pas** si une référence est dans le `<script>` ou le `<template>` (variable supprimée du script mais référencée dans le template → string-match passe, crash runtime)
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 21-04-2026
|
||||
- Contexte technique : Vue 3 / Vitest — RL799_V2
|
||||
|
||||
### Annotation honnête recommandée
|
||||
|
||||
```typescript
|
||||
// Note honnête : ces assertions sont des smoke checks structurels, pas une
|
||||
// vérification runtime du focus trap. Le comportement réel (Tab cycle, Escape,
|
||||
// focus restauré) se valide manuellement en QA ou via un test happy-dom dédié.
|
||||
test('AppDialog : pattern previousActiveElement présent', () => {
|
||||
expect(content.includes('previousActiveElement')).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
### Règle
|
||||
|
||||
Si un comportement runtime est critique (focus trap, submit delegation, validation conditionnelle), écrire un test happy-dom séparé avec `@vue/test-utils`. Le smoke check ne dispense pas du test comportemental — il documente juste la présence d'un signal.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-asserter-classe-css-modifier-vs-texte"></a>
|
||||
## Pattern : Asserter classe CSS modifier vs texte (E2E robuste aux refactors visuels)
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : rendre les tests E2E robustes aux refactors visuels (texte → icône SVG, label v1 → label v2, i18n future).
|
||||
- **Contexte** : tests E2E qui valident un état rendu d'un composant.
|
||||
- **Quand l'utiliser** : composant utilisant une convention BEM stricte avec modifier sémantique (`.grade-badge--apprenti`, `.status-badge--published`).
|
||||
- **Quand l'éviter** : framework CSS-in-JS qui génère des classes hashées (`_grade_x4f3z`) — la classe modifier n'est pas accessible.
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- la classe modifier porte la **sémantique** et change beaucoup moins souvent que le texte
|
||||
- typiquement liée à la prop ou au state, pas au rendu
|
||||
- **Limites / vigilance** :
|
||||
- ne remplace pas la validation visuelle (snapshot, screenshot) si le besoin est de protéger le rendu pixel-perfect
|
||||
- reste pertinent : validation de contenu utilisateur (notes, prix) où le rendu textuel **est** la spec
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 25-04-2026
|
||||
- Contexte technique : Playwright / Vue 3 — RL799_V2
|
||||
|
||||
### Exemple
|
||||
|
||||
```typescript
|
||||
// ❌ Fragile : casse au refactor texte → SVG ou label v1 → v2
|
||||
await expect(badge).toHaveText('A∴');
|
||||
|
||||
// ✅ Robuste : casse uniquement si le grade change ou si BEM est rompu
|
||||
await expect(badge).toHaveClass(/grade-badge--apprenti/);
|
||||
```
|
||||
|
||||
### Cas combinable
|
||||
|
||||
- Regex tolérante sur le texte (`/convoqu/i`) pour les cas où le texte est la seule prise (badges sans classe modifier)
|
||||
- Validation visuelle (snapshot) en complément quand le rendu pixel-perfect est protégé
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-cleanup-e2e-best-effort"></a>
|
||||
## Pattern : Cleanup E2E best-effort (try/catch + timeout court)
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : empêcher qu'un cleanup post-test (réinitialisation d'un statut, suppression d'une fixture) fasse échouer un scénario métier qui a déjà passé.
|
||||
- **Contexte** : tests Playwright avec `finally { await cleanup() }` qui font des PATCH/DELETE/POST sur l'API.
|
||||
- **Quand l'utiliser** : tout cleanup post-test non critique pour les tests suivants (parce qu'on a un seed ou un autre cleanup global).
|
||||
- **Quand l'éviter** : si le cleanup est critique pour l'isolation (UNIQUE constraint au prochain test) — monter le timeout à 60 s plutôt que silencer.
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- une lenteur transitoire de l'API de cleanup (audit log, notif fanout, lock DB) ne fait plus passer le test du vert au rouge
|
||||
- la dette d'état après échec de cleanup est mineure (le test suivant restaure souvent ou un seed le fera)
|
||||
- **Limites / vigilance** :
|
||||
- setup pré-test (`beforeEach`) : un setup qui échoue **doit** faire échouer le test, sinon on teste un état inconnu
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 25-04-2026
|
||||
- Contexte technique : Playwright — RL799_V2
|
||||
|
||||
### Pattern
|
||||
|
||||
```typescript
|
||||
async function restoreEntry(page: Page, snap: Snapshot): Promise<void> {
|
||||
try {
|
||||
await page.request.patch(`/api/.../entries/${snap.entryId}/status`, {
|
||||
data: { status: snap.status },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 10_000,
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[spec-name] restoreEntry échoué (cleanup best-effort):', err);
|
||||
}
|
||||
}
|
||||
|
||||
test('mon scénario métier', async ({ page }) => {
|
||||
const snap = await snapshotEntry(page, USER_ID);
|
||||
try {
|
||||
// … scénario métier …
|
||||
} finally {
|
||||
await restoreEntry(page, snap); // best-effort
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-helpers-sw-purs-extraits"></a>
|
||||
## Pattern : Helpers Service Worker purs extraits pour tests Vitest
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : permettre des tests unitaires Vitest fiables sur la logique du Service Worker, sans monter de stubs élaborés pour `self` / `registration` / `caches` / `clients`.
|
||||
- **Contexte** : SW custom (mode `injectManifest` ou similaire) qui contient de la logique non triviale (parsing payload push, validation linkUrl anti open-redirect, sélection client focused).
|
||||
- **Quand l'utiliser** : dès que le SW dépasse 50 lignes ou contient une regex / une condition métier.
|
||||
- **Quand l'éviter** : SW trivial (juste un `precacheAndRoute(self.__WB_MANIFEST)`).
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- tests Vitest standards, pas de mock `self`/`registration`
|
||||
- logique partageable avec d'autres parties du frontend (regex linkUrl partagée serveur ↔ SW ↔ store)
|
||||
- le `sw.ts` final reste lisible : précache + routes + thin event handlers
|
||||
- **Limites / vigilance** :
|
||||
- les listeners `addEventListener('push'|...)` restent non testés unitairement → couverture par E2E + checklist DevTools manuelle
|
||||
- bien isoler les imports : pas de dépendance Vue/Pinia dans `sw-helpers.ts` (le SW n'a pas d'accès au DOM)
|
||||
- regex dupliquée serveur ↔ SW : extraire dans `@<scope>/shared` quand possible (cf. `pattern-regex-critique-partagee-anti-divergence` dans `backend/patterns/contracts.md`)
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 28-04-2026
|
||||
- Contexte technique : Vite + vite-plugin-pwa `injectManifest` / Vitest — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// apps/frontend/src/sw-helpers.ts (testable pure)
|
||||
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
|
||||
|
||||
export const isInternalPath = (url: string): boolean =>
|
||||
INTERNAL_PATH_REGEX.test(url);
|
||||
|
||||
export const parsePushPayload = (
|
||||
eventData: { json: () => unknown } | null | undefined,
|
||||
): SwPushPayload => {
|
||||
try {
|
||||
const raw = eventData?.json() as Record<string, unknown> | null;
|
||||
if (!raw || typeof raw.title !== 'string') throw new Error('invalid');
|
||||
return {
|
||||
title: raw.title.slice(0, 80),
|
||||
body: typeof raw.body === 'string' ? raw.body.slice(0, 160) : undefined,
|
||||
linkUrl: typeof raw.linkUrl === 'string' && isInternalPath(raw.linkUrl) ? raw.linkUrl : '/',
|
||||
};
|
||||
} catch {
|
||||
return { title: 'AppDefault', body: 'Notification', linkUrl: '/' };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// apps/frontend/src/sw.ts (thin wrappers)
|
||||
/// <reference lib="webworker" />
|
||||
import { isInternalPath, parsePushPayload, selectFocusedClient } from './sw-helpers';
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const payload = parsePushPayload(event.data ?? undefined);
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const focused = selectFocusedClient(clients);
|
||||
if (focused) {
|
||||
focused.postMessage({ type: 'push:received', payload });
|
||||
return;
|
||||
}
|
||||
await self.registration.showNotification(payload.title, {
|
||||
body: payload.body,
|
||||
data: { url: payload.linkUrl },
|
||||
});
|
||||
})());
|
||||
});
|
||||
```
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Toute logique conditionnelle / regex / parsing extraite dans `sw-helpers.ts`
|
||||
- [ ] `sw.ts` ne contient que precache, routes runtime, thin `addEventListener` qui appellent les helpers
|
||||
- [ ] Helpers testés avec Vitest standard (pas de mock `self`)
|
||||
- [ ] Listeners SW couverts par E2E + checklist manuelle DevTools post-build
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-test-mount-mock-composable-controllable"></a>
|
||||
## Pattern : Test mount + mock composable contrôlable
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : tester un composant Vue qui consomme un composable réactif en contrôlant l'état du composable depuis le test, sans monter de fixtures lourdes.
|
||||
- **Contexte** : composant `<script setup>` qui appelle `const x = useFooBar()` et utilise `x.isReady.value` / `x.action()` dans le template.
|
||||
- **Quand l'utiliser** : composant interactif avec branches conditionnelles dépendant du composable (5 états du footer push, modes admin vs user).
|
||||
- **Quand l'éviter** : composant trivial sans logique conditionnelle (wrapper de markup).
|
||||
|
||||
### Analyse
|
||||
|
||||
- **Avantages** :
|
||||
- couvre la cohérence script ↔ template (un attribut renommé dans le composable casse le test)
|
||||
- test indépendant de la VRAIE implémentation (pas de duplication des stubs DOM `Notification`, `serviceWorker`, etc.)
|
||||
- chaque scénario UI testable isolément en mutant l'état du mock entre tests
|
||||
- **Limites / vigilance** :
|
||||
- le mock doit retourner exactement la même SHAPE que le composable réel — un test peut passer alors que le composable a évolué et casse en runtime
|
||||
- mitigation : déclarer un type `ReturnType<typeof useFooBar>` ou `export type UseFooBar` exporté par le composable, et typer le mock dessus
|
||||
- `vi.mock` est hoisté → définir l'état du mock dans `beforeEach`, pas en module-level
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 28-04-2026
|
||||
- Contexte technique : Vitest + @vue/test-utils + Vue 3 — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// composable
|
||||
export type UsePushNotifications = {
|
||||
isSupported: ComputedRef<boolean>;
|
||||
isSubscribed: Ref<boolean>;
|
||||
subscribe: () => Promise<void>;
|
||||
};
|
||||
export const usePushNotifications = (): UsePushNotifications => { /* … */ };
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Test du composant
|
||||
import { computed, ref } from 'vue';
|
||||
import type { UsePushNotifications } from '@/composables/usePushNotifications';
|
||||
|
||||
let mockState: { isSupported: boolean; isSubscribed: boolean };
|
||||
const subscribeSpy = vi.fn();
|
||||
|
||||
vi.mock('@/composables/usePushNotifications', () => ({
|
||||
usePushNotifications: (): UsePushNotifications => ({
|
||||
isSupported: computed(() => mockState.isSupported),
|
||||
isSubscribed: ref(mockState.isSubscribed),
|
||||
subscribe: subscribeSpy,
|
||||
}),
|
||||
}));
|
||||
|
||||
import MyComponent from '@/components/MyComponent.vue';
|
||||
|
||||
beforeEach(() => {
|
||||
subscribeSpy.mockReset();
|
||||
mockState = { isSupported: true, isSubscribed: false };
|
||||
});
|
||||
|
||||
test('CTA visible quand !isSubscribed', () => {
|
||||
mount(MyComponent);
|
||||
expect(document.querySelector('[data-testid="cta"]')).not.toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Composable expose un type `Use<Name>` réutilisable dans les tests
|
||||
- [ ] Mock retourne une shape conforme à ce type
|
||||
- [ ] État du mock défini dans `beforeEach` (réinitialisé entre tests)
|
||||
- [ ] Tests utilisent `data-testid` (pas de couplage CSS class)
|
||||
- [ ] Couvre TOUS les états observables (pas que le happy path)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-assertions-html-react-email"></a>
|
||||
## Pattern : Assertions sur le HTML rendu par React Email — pièges à éviter
|
||||
|
||||
### Synthèse
|
||||
|
||||
- **Objectif** : écrire des tests Vitest fiables sur le HTML produit par un template React Email sans buter sur les commentaires JSX, les glyphes Unicode bruts, et les `<link rel="preload">` injectés automatiquement.
|
||||
- **Contexte** : tests qui vérifient la présence ou l'absence de patterns dans le HTML rendu (côté mail Resend ou côté PDF Puppeteer en `previewOnly`).
|
||||
- **Quand l'utiliser** : assertions sur le HTML rendu par `@react-email/components`.
|
||||
- **Quand l'éviter** : tests qui valident uniquement la présence d'un composant React (snapshot par exemple).
|
||||
|
||||
### Validation
|
||||
|
||||
- Validé le : 29-04-2026
|
||||
- Contexte technique : Vitest / React Email — RL799_V2
|
||||
|
||||
### Piège 1 — Commentaires React entre fragments JSX
|
||||
|
||||
React Email insère `<!-- -->` entre 2 fragments `{var}` adjacents :
|
||||
|
||||
```html
|
||||
<p>V∴M∴ <!-- -->Pierre Vénérable</p>
|
||||
```
|
||||
|
||||
Une regex `/V∴M∴ Pierre/` échoue. Helper utile :
|
||||
|
||||
```typescript
|
||||
const matchAroundComments = (_source: string, ...parts: string[]): RegExp => {
|
||||
const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
return new RegExp(escaped.join('\\s*(?:<!--[^>]*-->\\s*)*'), 'i');
|
||||
};
|
||||
|
||||
assert.match(html, matchAroundComments(html, 'V∴M∴ ', 'Pierre Vénérable'));
|
||||
```
|
||||
|
||||
### Piège 2 — Caractères Unicode bruts dans les constantes shared
|
||||
|
||||
Une constante avec `’` (U+2019) brut est rendue **telle quelle** dans le HTML par React Email (pas d'encodage). Une regex avec `'` ASCII ou `'` échoue.
|
||||
|
||||
```typescript
|
||||
assert.match(html, /Dans l(?:'|’|'|')attente/);
|
||||
```
|
||||
|
||||
### Piège 3 — `<link rel="preload">` injecté automatiquement par `<Img>`
|
||||
|
||||
Le composant `<Img>` de `@react-email/components` injecte `<link rel="preload" as="image" href="…">` dans le `<head>` en mode mail (pas en mode `previewOnly`). Une assertion `expect(html).not.toMatch(/<link\s+rel/i)` échoue sur le mail.
|
||||
|
||||
Solution : autoriser les `<link rel>` qui pointent vers `appBaseUrl` (URL maîtrisée), bloquer les autres :
|
||||
|
||||
```typescript
|
||||
const linkMatches = html.match(/<link\s+[^>]*href=["']([^"']+)["']/gi) ?? [];
|
||||
for (const linkTag of linkMatches) {
|
||||
const href = /href=["']([^"']+)["']/i.exec(linkTag)?.[1];
|
||||
assert.ok(href.startsWith(appBaseUrl) || href.startsWith('data:'));
|
||||
}
|
||||
```
|
||||
|
||||
### Piège 4 — Assertions trop génériques sur des termes ambigus
|
||||
|
||||
Une regex `/V∴M∴/` pour vérifier "ligne signature V∴M∴ vacante absente" matche aussi le footer "Le V∴M∴ et les officiers..." qui contient le même token. Solution : cibler un pattern plus précis du contexte (la classe CSS du style `vmSignature` : `font-weight:600`).
|
||||
|
||||
### Garde-fou Puppeteer mode PDF
|
||||
|
||||
En mode `previewOnly`, le HTML doit être strictement offline-safe :
|
||||
|
||||
```typescript
|
||||
assert.doesNotMatch(html, /<link\s+rel/i); // pas de preload (≠ mode mail)
|
||||
assert.doesNotMatch(html, /<script\s+src/i);
|
||||
assert.doesNotMatch(html, /<img[^>]+src=["'](?!data:)/i); // pas de http(s)
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user