Skip to content

Inline edit — édition en place sur les fiches détail

1. Responsibilities

  • Permettre la modification de chaque champ d'une fiche Contact ou Entreprise sans ouvrir la modale ContactFormModal / CompanyFormModal.
  • Au survol (desktop) ou au tap (mobile), afficher un contour + icône crayon sur le champ.
  • Sauvegarder automatiquement sur blur ou Enter, annuler sur Escape.
  • Valider le payload côté client via les schémas Zod existants (UpdateContactSchema, UpdateCompanySchema) avant d'envoyer le PATCH.
  • Conserver les modales existantes comme parcours de création et fallback d'édition.

2. UX

ContexteComportement
Desktop hover/focusContour --color-border-strong + fond --bg-sunken + icône crayon opacity: 1.
Mobile (<640px)Icône crayon opacity: 0.55 en permanence, zone de tap ≥ 44 px.
Click / tapBascule en mode édition, input autofocus, sélection complète pour les inputs texte.
Enter (single-line)Sauvegarde + retour mode lecture.
Cmd/Ctrl+Enter (textarea)Sauvegarde. Enter seul = nouvelle ligne.
EscapeAnnule, restaure la valeur précédente, retour mode lecture.
Blur (clic hors champ)Sauvegarde.
OfflineLe composant est désactivé via useOfflineMode().isOffline.

3. Component API

Fichier : apps/web/components/inline-edit/InlineEditField.vue

PropTypeDescription
modelValueV | null | undefinedValeur courante (toute primitive ou tableau pour les tags).
fieldNamestringClé du champ dans le payload PATCH ('email', 'companyId', 'tags', …).
type'text' | 'email' | 'tel' | 'url' | 'textarea' | 'select' | 'tags' | 'company'Détermine l'éditeur rendu en mode édition.
options{ value: string; label: string }[]Obligatoire pour type='select'.
schemaZodTypeAnySchéma Zod par champ (UpdateContactSchema.shape.email). Validation synchrone avant onSave.
onSave(payload: Record<string, unknown>) => Promise<unknown>Callback qui envoie le PATCH ; lever permet de déclencher rollback + toast.
labelstringUtilisé pour aria-label="Modifier &lt;label&gt;" et placeholder « Ajouter <label> ».
disabledbooleanDésactive le déclencheur.
formatDisplay(v: V) =&gt; stringFormatte la valeur affichée par défaut.
testIdstringSuffixe pour data-testid="inline-edit-&lt;testId&gt;" (défaut : fieldName).

Slots :

  • display (scoped : { value, isEmpty }) — surcharge le rendu en mode lecture (lien mailto:, badge statut, lien entreprise, etc.).

Émissions : saved (avec la nouvelle valeur) et cancelled.

4. Configuration des champs

ContactDetailHeader.vue

fieldNametypeSchéma
firstNametextUpdateContactSchema.shape.firstName
lastNametextUpdateContactSchema.shape.lastName
statusselect (CONTACT_STATUSES)— (validation enum côté serveur)
roletextUpdateContactSchema.shape.role
companyIdcompany (CompanyCombobox)
emailemailUpdateContactSchema.shape.email
linkedinUrlurlUpdateContactSchema.shape.linkedinUrl
phonetelUpdateContactSchema.shape.phone
tagstags (TagsInput)
notestextareaUpdateContactSchema.shape.notes

pages/companies/[id].vue

fieldNametypeSchéma
nametextUpdateCompanySchema.shape.name
industrytextUpdateCompanySchema.shape.industry
sizeselect (COMPANY_SIZES)
domaintextUpdateCompanySchema.shape.domain
notestextareaUpdateCompanySchema.shape.notes

5. Validation

  • Synchrone (client) : si schema est fourni, safeParse la valeur normalisée. En cas d'erreur, le premier issues[0].message est affiché en rouge sous l'éditeur et l'édition reste ouverte (l'utilisateur peut corriger).
  • Réseau (serveur) : si onSave rejette, la valeur précédente est restaurée et un toast toast.error('Impossible de mettre à jour ce champ.') est affiché. L'édition se ferme.
  • La validation enum (status, size) est faite par la class-validator côté backend (cf. apps/api/src/contacts/dto/create-contact.dto.ts). Un PATCH avec une valeur invalide retourne 400 → toast d'erreur.

6. State flow

Le caller (ex. ContactDetailHeader.vue) fournit une fonction saveContactField :

ts
async function saveContactField(payload: Record<string, unknown>) {
  const key = ['contact', contactId.value]
  const previous = qc.getQueryData<any>(key)
  qc.setQueryData<any>(key, (old: any) => (old ? { ...old, ...payload } : old))
  try {
    await updateContact(payload)        // useUpdateContact(contactId)
  } catch (e) {
    qc.setQueryData(key, previous)      // rollback
    toast.error('Impossible de mettre à jour ce champ.')
    throw e                             // remonte au composant pour exit edit-mode
  }
}

useUpdateContact invalide ensuite ['contact', id] + ['contacts'] (idem useUpdateCompany).

7. Tests

API (Jest)

  • apps/api/src/contacts/contacts.service.spec.ts
    • "met à jour un seul champ (PATCH partiel via inline-edit)"
    • "accepte companyId=null pour dissocier l'entreprise (inline-edit)"
  • apps/api/src/companies/companies.service.spec.ts
    • "met à jour un seul champ (PATCH partiel via inline-edit)"

E2E (Playwright)

  • apps/e2e/tests/contacts.spec.tsdescribe('Inline edit fiche contact')
  • apps/e2e/tests/companies.spec.tsdescribe('Inline edit fiche entreprise')

Convention de sélecteur introduite : data-testid="inline-edit-&lt;field&gt;" sur le wrapper. Première utilisation de data-testid dans ce repo, motivée par le fait que le texte visible est la valeur (qui change pendant le test) et rend les sélecteurs getByText fragiles.

8. Limitations

  • Pas de file d'attente offline : useOfflineMode().isOffline désactive le composant ; aucune écriture n'est mise en queue.
  • Pas de gestion de conflit multi-utilisateur : last-write-wins. Une édition concurrente écrase l'autre, sans détection.
  • Pas d'audit log : les modifications inline ne sont pas tracées séparément des modifications via modale.
  • Pas d'inline-edit dans les listes (/contacts, /companies) : limité aux pages détail.
  • Race optimistic ↔ invalidation : sur réseau lent, la valeur optimiste peut clignoter quand le refetch arrive après le PATCH. Comportement acceptable, serveur est source de vérité.