Files
_Assistant_Lead_Tech/knowledge/frontend/patterns/forms.md
MaksTinyWorkshop b3417ad77b 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>
2026-05-02 22:12:44 +02:00

12 KiB
Raw Blame History

Frontend — Patterns : Forms

Extrait de la base de connaissance Lead_tech. Voir knowledge/frontend/patterns/README.md pour l'index complet.


Pattern : Formulaire robuste avec validation et erreurs explicites

Synthèse

  • Objectif : garantir des formulaires fiables, compréhensibles et maintenables.
  • Contexte : toute interface avec saisie utilisateur et règles métier.
  • Quand l'utiliser : dès qu'un formulaire dépasse un simple champ isolé.
  • Quand l'éviter : formulaires ultra-simples sans validation réelle.

Analyse

  • Avantages :
    • UX claire (l'utilisateur sait quoi corriger)
    • Moins d'erreurs silencieuses
    • Base saine pour tests et accessibilité
  • Limites / vigilance :
    • Peut sembler verbeux sans discipline
    • Risque de duplication si mal factorisé

Validation

  • Validé le : 25-01-2026
  • Contexte technique : Front-end agnostique, API HTTP

Implémentation (exemple minimal)

- Validation côté client (format, champs requis)
- Validation côté serveur (règles métier)
- Mapping explicite des erreurs serveur → champs UI
- Aucun submit silencieux

Checklist

  • Messages d'erreur compréhensibles et localisés
  • Validation client + serveur cohérente
  • Focus automatique sur le champ en erreur
  • États loading / disabled gérés
  • Tests sur cas valides et invalides

Pattern : Toggle optimiste avec rollback (React Server Action)

Synthèse

  • Objectif : masquer la latence serveur sur un toggle boolean en mettant à jour l'UI immédiatement, avec rollback en cas d'erreur.
  • Contexte : toggles boolean (visibilité, disponibilité, settings) où la latence doit être masquée.
  • Quand l'utiliser : toggles sans besoin de re-fetcher l'entité entière après mutation.
  • Quand l'éviter : mutations qui retournent des données complexes → préférer le pattern "Server Action retournant l'entité".

Validation

  • Validé le : 21-03-2026
  • Contexte technique : React / Next.js App Router — app-template-resto

Implémentation

const [optimistic, setOptimistic] = useState(initialValue);

async function handleToggle() {
  const prev = optimistic;
  setOptimistic(!prev);          // update immédiat
  try {
    await toggleAction(!prev);
    router.refresh();            // synchronise le Server Component parent
  } catch {
    setOptimistic(prev);         // rollback si erreur
  }
}

Pattern : Server Action retournant l'entité — élimination de router.refresh() sur create/edit

Synthèse

  • Objectif : mettre à jour l'état local directement avec les données réelles retournées par le serveur, sans round-trip SSR supplémentaire.
  • Contexte : liste d'items managée côté client (useState) avec création et modification via Server Actions.
  • Quand l'utiliser : create et edit d'entités dans une liste. Plus performant que toggle optimiste + router.refresh().
  • Quand l'éviter : simples toggles boolean → le pattern optimiste avec rollback suffit.

Analyse

  • Avantages vs toggle optimiste + router.refresh() :
    • Zéro aller-retour SSR supplémentaire (~500ms2s économisés sur mobile)
    • État local garanti cohérent avec la DB (données réelles, pas calculées localement)
    • Pas de flash de rechargement
  • Limites / vigilance :
    • revalidatePath reste nécessaire pour invalider le cache des pages publiques

Validation

  • Validé le : 22-03-2026
  • Contexte technique : React / Next.js App Router — app-template-resto story 3.8

Implémentation

// Repository — retourne l'entité complète
export async function createItem(tenantId: string, data: Input): Promise<ItemRow> {
  return prisma.item.create({ data: { tenantId, ...data }, select: { ...fullSelect } });
}

// Action — retourne la donnée au client
export async function createItemAction(formData: FormData): Promise<ItemRow> {
  const actor = await requireOwner();
  const item = await createItem(actor.tenantId, input);
  revalidatePath("/dashboard/..."); // invalider cache pages publiques
  return item; // ← clé : retourner l'entité
}

// Client — mise à jour locale sans round-trip SSR
const created = await createItemAction(formData);
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.


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

<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.

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

<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.