Save France
Composables

useTroubleshootingForm

Composable pour la gestion du formulaire multi-étapes de dépannage

[[toc]]

Description

Composable central pour la gestion du formulaire multi-étapes de dépannage. Il gère l'état global du formulaire, la navigation entre les étapes, la validation avec Zod, et la persistance des données via sessionStorage.

Vue d'ensemble

Le composable useTroubleshootingForm est le cœur du système de formulaire de dépannage. Il centralise :

  • La gestion de l'état du formulaire (adresse, contrat, diagnostic, disponibilités)
  • La navigation entre les 6 étapes
  • La persistance côté client avec sessionStorage
  • Le suivi des étapes complétées
  • Le stockage de la réponse API après soumission
flowchart TD
    A[useTroubleshootingForm] --> B[État réactif]
    A --> C[Navigation]
    A --> D[Persistance]
    A --> E[Validation]
    
    B --> B1[formData ref]
    B --> B2[completedSteps ref]
    B --> B3[apiResponse ref]
    
    C --> C1[nextStep/prevStep]
    C --> C2[goToStep]
    C --> C3[isStepAccessible]
    
    D --> D1[sessionStorage]
    D --> D2[watch auto-save]
    D --> D3[loadFromStorage]
    
    E --> E1[Schémas Zod]
    E --> E2[addressSchema]
    E --> E3[contactSchema]
    E --> E4[diagnosisSchema]

Architecture technique

Gestion de l'état

Le composable utilise ref() plutôt que useState() pour éviter les conflits d'hydration SSR. Les données sont chargées depuis sessionStorage au montage et sauvegardées automatiquement via watch.

// Clés de stockage
const STORAGE_KEY = "troubleshooting_form_data";
const COMPLETED_STEPS_KEY = "troubleshooting_completed_steps";
const API_RESPONSE_KEY = "troubleshooting_api_response";

// Chargement depuis sessionStorage
const loadFromStorage = () => {
  if (import.meta.client) {
    try {
      const stored = sessionStorage.getItem(STORAGE_KEY);
      if (stored) return JSON.parse(stored);
    } catch (error) {
      console.error("Error loading form data from storage:", error);
    }
  }
  return initialState;
};

// État réactif
const formData = ref<TroubleshootingForm>(loadFromStorage());

// Sauvegarde automatique
if (import.meta.client) {
  watch(
    formData,
    (newData) => {
      try {
        sessionStorage.setItem(STORAGE_KEY, JSON.stringify(newData));
      } catch (error) {
        console.error("Error saving form data to storage:", error);
      }
    },
    { deep: true }
  );
}

Constantes exportées

Le composable exporte des constantes utilisables en dehors du contexte Nuxt (par exemple dans definePageMeta).

// Étapes disponibles
export const STEPS = {
  POSTAL_CODE: "postal-code",
  CONTRACT: "contrat",
  DIAGNOSIS: "diagnostic",
  AVAILABILITY: "disponibilites",
  CONTACT: "contact",
  CONFIRMATION: "confirmation",
} as const;

export type StepKey = (typeof STEPS)[keyof typeof STEPS];

// Ordre des étapes
export const STEP_ORDER: StepKey[] = [
  STEPS.POSTAL_CODE,
  STEPS.CONTRACT,
  STEPS.DIAGNOSIS,
  STEPS.AVAILABILITY,
  STEPS.CONTACT,
];

API du composable

État du formulaire

formData: Readonly<Ref<TroubleshootingForm>>

État réactif en lecture seule contenant toutes les données du formulaire.

const { formData } = useTroubleshootingForm();

console.log(formData.value.address.postalCode);
console.log(formData.value.diagnosis);

Structure :

type TroubleshootingForm = {
  address: {
    postalCode: string;    // Code postal (5 chiffres)
    city: string;          // Ville
    street: string;        // Adresse complète
    name: string;          // Prénom
    lastname: string;      // Nom
    email: string;         // Email
    phone: string;         // Téléphone
  };
  hasContract: boolean;    // Possède un contrat d'entretien
  diagnosis: string[];     // IDs des diagnostics sélectionnés
  details: string;         // Description détaillée du problème
  availability: string[];  // Créneaux disponibles (morning, afternoon)
  availabilityDetails: string; // Précisions sur les disponibilités
}

completedSteps: Readonly<Ref<Set<StepKey>>>

Set des étapes complétées par l'utilisateur.

const { completedSteps } = useTroubleshootingForm();

if (completedSteps.value.has(STEPS.POSTAL_CODE)) {
  console.log("L'utilisateur a complété l'étape code postal");
}

apiResponse: Readonly<Ref<any>>

Réponse de l'API après soumission du formulaire.

const { apiResponse } = useTroubleshootingForm();

if (apiResponse.value) {
  console.log("ID de la demande:", apiResponse.value.id);
  console.log("Statut:", apiResponse.value.status);
}

currentStep: Ref<number>

Index de l'étape actuelle (0 à 5).

const { currentStep, totalSteps } = useTroubleshootingForm();

console.log(`Étape ${currentStep.value + 1} sur ${totalSteps}`);

totalSteps: number

Nombre total d'étapes (6 avec confirmation).

nextStep(): void

Passe à l'étape suivante si disponible.

const { nextStep, currentStep } = useTroubleshootingForm();

nextStep();
console.log("Nouvelle étape:", currentStep.value);

prevStep(): void

Revient à l'étape précédente si disponible.

const { prevStep, currentStep } = useTroubleshootingForm();

prevStep();
console.log("Étape précédente:", currentStep.value);

goToStep(stepKey: StepKey): void

Navigation directe vers une étape spécifique.

const { goToStep } = useTroubleshootingForm();

goToStep(STEPS.DIAGNOSIS);

Paramètres :

  • stepKey : Clé de l'étape (depuis STEPS)

isStepAccessible(stepKey: StepKey): boolean

Vérifie si une étape est accessible (étapes précédentes complétées).

const { isStepAccessible, markStepComplete } = useTroubleshootingForm();

// Marquer la première étape comme complétée
markStepComplete(STEPS.POSTAL_CODE);

// Vérifier l'accès à la deuxième étape
if (isStepAccessible(STEPS.CONTRACT)) {
  console.log("L'étape contrat est accessible");
}

Paramètres :

  • stepKey : Clé de l'étape à vérifier

Retourne : true si l'étape est accessible, false sinon

getNextStep(): StepKey | null

Retourne la clé de l'étape suivante ou null si c'est la dernière.

const { getNextStep } = useTroubleshootingForm();

const next = getNextStep();
if (next) {
  console.log("Prochaine étape:", next);
}

getPrevStep(): StepKey | null

Retourne la clé de l'étape précédente ou null si c'est la première.

const { getPrevStep } = useTroubleshootingForm();

const prev = getPrevStep();
if (prev) {
  console.log("Étape précédente:", prev);
}

Gestion des données

updateForm(data: Partial<TroubleshootingForm>): void

Met à jour partiellement les données du formulaire.

const { updateForm, formData } = useTroubleshootingForm();

// Mise à jour de l'adresse
updateForm({
  address: {
    ...formData.value.address,
    postalCode: "33300",
    city: "Bordeaux",
    street: "125 Quai des Chartrons",
  },
});

// Mise à jour du diagnostic
updateForm({
  diagnosis: ["13", "15"],
  details: "La pompe à chaleur fait un bruit anormal",
});

Paramètres :

  • data : Objet partiel contenant les champs à mettre à jour

Note : Les données sont automatiquement sauvegardées dans sessionStorage via watch.

markStepComplete(stepKey: StepKey): void

Marque une étape comme complétée.

const { markStepComplete } = useTroubleshootingForm();

// Après validation réussie d'une étape
markStepComplete(STEPS.POSTAL_CODE);

Paramètres :

  • stepKey : Clé de l'étape à marquer comme complétée

resetForm(): void

Réinitialise le formulaire à son état initial et efface toutes les données de sessionStorage.

const { resetForm } = useTroubleshootingForm();

// Après soumission ou pour recommencer
resetForm();

Actions effectuées :

  • Réinitialise formData à l'état initial
  • Vide completedSteps
  • Efface apiResponse
  • Remet currentStep à 0
  • Supprime les données de sessionStorage

Gestion de la réponse API

setApiResponse(response: any): void

Stocke la réponse de l'API après soumission du formulaire.

const { setApiResponse } = useTroubleshootingForm();

try {
  const response = await $fetch("/api/troubleshooting", {
    method: "POST",
    body: troubleshootingData,
  });
  
  setApiResponse(response);
  await navigateTo(`/depannage/confirmation?id=${response.id}`);
} catch (error) {
  console.error("Erreur:", error);
}

Paramètres :

  • response : Objet contenant la réponse de l'API

getApiResponseById(id: string): any | null

Récupère la réponse API si son ID correspond.

const { getApiResponseById } = useTroubleshootingForm();

const response = getApiResponseById("019b22da-84ef-7f14-9396-03c67440a810");
if (response) {
  console.log("Statut:", response.status);
}

Paramètres :

  • id : ID de la demande à vérifier

Retourne : L'objet réponse si l'ID correspond, null sinon

clearApiResponse(): void

Efface la réponse API stockée.

const { clearApiResponse } = useTroubleshootingForm();

// Avant de commencer une nouvelle demande
clearApiResponse();

Progression

getStepProgress(): { completed: number; total: number; percentage: number }

Retourne la progression du formulaire.

const { getStepProgress } = useTroubleshootingForm();

const progress = getStepProgress();
console.log(`${progress.completed} / ${progress.total} (${progress.percentage}%)`);

Retourne :

  • completed : Nombre d'étapes complétées
  • total : Nombre total d'étapes
  • percentage : Pourcentage de progression

Schémas de validation

Le composable exporte plusieurs schémas Zod pour la validation des données.

postalCodeSchema

Validation du code postal et de l'adresse.

import { postalCodeSchema } from "~/composables/useTroubleshootingForm";

const result = postalCodeSchema.safeParse({
  postalCode: "33300",
  city: "Bordeaux",
  street: "125 Quai des Chartrons",
});

if (!result.success) {
  console.error(result.error.errors);
}

Règles :

  • postalCode : Exactement 5 chiffres
  • city : Non vide
  • street : Non vide

contactSchema

Validation des informations de contact.

import { contactSchema } from "~/composables/useTroubleshootingForm";

const result = contactSchema.safeParse({
  name: "Lucas",
  lastname: "Dupont",
  email: "lucas@example.com",
  phone: "06 75 85 85 56",
});

Règles :

  • name : Minimum 2 caractères
  • lastname : Minimum 2 caractères
  • email : Format email valide
  • phone : Minimum 10 caractères

contractSchema

Validation de l'information sur le contrat existant.

import { contractSchema } from "~/composables/useTroubleshootingForm";

const result = contractSchema.safeParse({
  hasContract: false,
});

Règles :

  • hasContract : Booléen obligatoire

diagnosisSchema

Validation du diagnostic et des détails.

import { diagnosisSchema } from "~/composables/useTroubleshootingForm";

const result = diagnosisSchema.safeParse({
  diagnosis: ["13", "15"],
  details: "La pompe à chaleur fait un bruit anormal depuis ce matin",
});

Règles :

  • diagnosis : Au moins un diagnostic sélectionné
  • details : Minimum 10 caractères

availabilitySchema

Validation des créneaux de disponibilité.

import { availabilitySchema } from "~/composables/useTroubleshootingForm";

const result = availabilitySchema.safeParse({
  availability: ["morning", "afternoon"],
  availabilityDetails: "Disponible toute la semaine sauf jeudi",
});

Règles :

  • availability : Au moins un créneau sélectionné
  • availabilityDetails : Optionnel

troubleshootingSchema

Schéma complet combinant tous les autres.

import { troubleshootingSchema } from "~/composables/useTroubleshootingForm";

const result = troubleshootingSchema.safeParse(formData.value);
if (!result.success) {
  console.error("Validation échouée:", result.error);
}

Exemple d'utilisation complète

Dans une page d'étape

<template>
  <UCard>
    <UForm :schema="postalCodeSchema" :state="form" @submit="handleSubmit">
      <UFormField label="Code postal" name="postalCode">
        <UInput v-model="form.postalCode" placeholder="33300" />
      </UFormField>
      
      <UFormField label="Ville" name="city">
        <UInput v-model="form.city" placeholder="Bordeaux" />
      </UFormField>
      
      <UFormField label="Adresse" name="street">
        <UInput v-model="form.street" placeholder="125 Quai des Chartrons" />
      </UFormField>
      
      <div class="flex gap-4 mt-6">
        <UButton
          v-if="getPrevStep()"
          @click="handlePrev"
          color="neutral"
          variant="outline"
          icon="i-heroicons-arrow-left"
        >
          Précédent
        </UButton>
        
        <UButton type="submit" icon="i-heroicons-arrow-right" trailing>
          Suivant
        </UButton>
      </div>
    </UForm>
  </UCard>
</template>

<script setup lang="ts">
import { STEPS, postalCodeSchema } from "~/composables/useTroubleshootingForm";

definePageMeta({
  layout: "troubleshooting",
  middleware: ["auth", "troubleshooting-step"],
});

const {
  formData,
  updateForm,
  markStepComplete,
  nextStep,
  prevStep,
  getPrevStep,
} = useTroubleshootingForm();

const form = reactive({
  postalCode: formData.value.address.postalCode,
  city: formData.value.address.city,
  street: formData.value.address.street,
});

const handleSubmit = () => {
  // Mise à jour des données
  updateForm({
    address: {
      ...formData.value.address,
      postalCode: form.postalCode,
      city: form.city,
      street: form.street,
    },
  });
  
  // Marquer l'étape comme complétée
  markStepComplete(STEPS.POSTAL_CODE);
  
  // Passer à l'étape suivante
  nextStep();
};

const handlePrev = () => {
  prevStep();
};
</script>

Dans la page de soumission finale

<script setup lang="ts">
import { STEPS } from "~/composables/useTroubleshootingForm";
import { useAuth } from "~/composables/useAuth";

const {
  formData,
  updateForm,
  markStepComplete,
  setApiResponse,
} = useTroubleshootingForm();

const { getToken } = useAuth();
const toast = useToast();
const isSubmitting = ref(false);

const handleSubmit = async () => {
  isSubmitting.value = true;
  
  try {
    const token = getToken();
    
    const response = await $fetch("/api/troubleshooting", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
      },
      body: {
        address: formData.value.address,
        hasContract: formData.value.hasContract,
        diagnosis: formData.value.diagnosis,
        details: formData.value.details,
        availability: formData.value.availability,
        availabilityDetails: formData.value.availabilityDetails,
      },
    });
    
    // Stocker la réponse
    setApiResponse(response);
    
    // Marquer l'étape comme complétée
    markStepComplete(STEPS.CONTACT);
    
    // Rediriger vers la confirmation
    await navigateTo(`/depannage/confirmation?id=${response.id}`);
  } catch (error: any) {
    toast.add({
      title: "Erreur",
      description: error.data?.detail || "Une erreur est survenue",
      color: "error",
    });
  } finally {
    isSubmitting.value = false;
  }
};
</script>

Dans la page de confirmation

<script setup lang="ts">
const route = useRoute();
const requestId = route.query.id as string;

const { clearApiResponse, resetForm } = useTroubleshootingForm();
const { getToken } = useAuth();

// Récupération des données depuis l'API
const { data: requestData } = await useFetch(
  `/api/troubleshooting/request/${requestId}`,
  {
    headers: {
      Authorization: `Bearer ${getToken()}`,
    },
  }
);

// Nouvelle demande
const handleNewRequest = async () => {
  clearApiResponse();
  resetForm();
  await navigateTo("/depannage/postal-code");
};
</script>

Détails techniques

Pourquoi ref() au lieu de useState() ?

useState() est SSR-friendly mais peut causer des conflits d'hydration lorsque les données sont également stockées dans sessionStorage. En utilisant ref(), on garantit que :

  1. Les données sont chargées depuis sessionStorage uniquement côté client
  2. Pas de différence entre le rendu serveur et client
  3. Pas d'écrasement des données au rafraîchissement

Clés de stockage

Trois clés distinctes permettent de gérer séparément :

  1. troubleshooting_form_data : Données du formulaire en cours
  2. troubleshooting_completed_steps : Étapes validées (pour contrôle d'accès)
  3. troubleshooting_api_response : Réponse API après soumission (pour confirmation)

Isolation par session

sessionStorage garantit que :

  • Les données sont isolées par onglet/fenêtre
  • Les données sont effacées à la fermeture de l'onglet
  • Pas de conflit entre plusieurs formulaires ouverts

Différences avec useForm

CritèreuseTroubleshootingFormuseForm
UsageFormulaire de dépannageFormulaire de contrat d'entretien
Étapes6 étapes5 étapes
DonnéesAdresse + Contact fusionnésSéparés
Persistanceref() + sessionStorageuseState()
ValidationSchémas Zod spécifiquesSchémas Zod différents
API ResponseStockée pour confirmationNon stockée

Bonnes pratiques

1. Toujours valider avant mise à jour

const result = schema.safeParse(form);
if (!result.success) {
  // Afficher les erreurs
  return;
}
updateForm(result.data);

2. Marquer les étapes comme complétées

// Après validation réussie
markStepComplete(STEPS.POSTAL_CODE);

3. Vérifier l'accessibilité avant navigation

if (isStepAccessible(STEPS.CONTRACT)) {
  goToStep(STEPS.CONTRACT);
}

4. Réinitialiser après soumission

// Nouvelle demande
clearApiResponse();
resetForm();
await navigateTo("/depannage/postal-code");

5. Ne pas modifier directement formData.value

// ❌ Mauvais
formData.value.address.postalCode = "33300";

// ✅ Bon
updateForm({
  address: {
    ...formData.value.address,
    postalCode: "33300",
  },
});

Liens utiles

Résumé

  • Composable central pour le formulaire de dépannage multi-étapes
  • Persistance côté client avec ref() + sessionStorage pour éviter les conflits SSR
  • Navigation contrôlée avec vérification d'accessibilité des étapes
  • Validation robuste via schémas Zod exportés
  • Stockage de la réponse API pour affichage dans la page de confirmation
  • API complète avec gestion d'état, navigation, et persistance
  • Suivi de progression et réinitialisation flexible