Skip to content

Vue 3 Symfony Form ​

Une librairie Vue 3 robuste permettant de générer automatiquement un formulaire dynamique basé sur le retour JSON du composant createView() de Symfony Form.


Sommaire ​


Documentation Détaillée ​

TIP

Des guides exhaustifs sont disponibles pour approfondir les fonctionnalités avancées :


Installation & Configuration ​

  1. Installez le package (selon votre gestionnaire) :
    bash
    npm install @codifyo/form-vue
  2. Dans votre point d'entrée (main.ts ou main.js), installez le plugin :
    typescript
    import { createApp } from 'vue';
    import App from './App.vue';
    import SymfonyFormPlugin from '@codifyo/form-vue';
    
    const app = createApp(App);
    app.use(SymfonyFormPlugin);
    app.mount('#app');

Initialisation des Données (Important) ​

Les données JSON de configuration renvoyées par Symfony ne doivent pas être passées telles quelles au composant racine <symfony-form>. Elles doivent impérativement être converties en une arborescence d'instances de la classe FormField pour que la validation stricte des props de Vue fonctionne de bout en bout.

Exemple :

typescript
import { FormField } from '@codifyo/form-vue';

// 1. On parse le retour textuel JSON fourni par l'API Symfony
const rawJson = JSON.parse(jsonStringFromSymfony);

// 2. On instancie récursivement tous les objets en classes FormField
const parsedFormJson = FormField.fromJson(rawJson);

Chargement Dynamique des Choix (Choice Providers) ​

Le champ ChoiceType (block_prefixes: ["form", "choice"]) de Symfony permet nativement d'utiliser des choix statiques envoyés dans le JSON (dans la clé choices). Toutefois, pour de longues listes de données ou pour des options dépendantes du contexte, il est souvent préférable de charger ces données asynchrone depuis une API (Lazy Loading).

La librairie permet cela via le mécanisme des Choice Providers.

1. Configurer Symfony (Backend) ​

Dans le builder Symfony, vous pouvez définir les options choices_provider (le nom identifiant le provider sur le front) et choices_provider_options (les paramètres optionnels envoyés au front). Note: Cette configuration se fait via les attr ou une extension custom du form type Symfony pour injecter ces variables dans la vue vars.

JSON final généré par Symfony :

json
{
  "vars": {
    "name": "users",
    "full_name": "form[users]",
    "block_prefixes": ["form", "choice"],
    "multiple": true,
    "choices_provider": "api_users",
    "choices_provider_options": { "role": "admin" }
  }
}

2. Définir le Provider (Frontend Vue 3) ​

Un Choice Provider est une simple fonction qui reçoit les options et doit retourner un objet contenant :

  • choices : Une ref contenant le tableau des choix.
  • load (optionnel) : Une fonction appelée pour recharger les données (utile pour faire de la recherche asynchrone).
typescript
import { ref } from 'vue';

const myChoiceProviders = {
  
  // Clé correspondant à "choices_provider" du JSON
  api_users: (options) => {
    const choices = ref([]);
    
    // La méthode load sera passée au composant select final (ex: vue-select)
    const load = async (query = '') => {
        // Options contient ici { role: "admin" }
        const response = await fetch(`/api/users?q=${query}&role=${options.role}`);
        choices.value = await response.json();
    };

    // Chargement initial au montage du composant
    load();

    return { choices, load };
  }
  
};

3. Passer les Providers au Formulaire ​

Passez l'objet contenant tous vos providers via la prop choiceProviders du composant <symfony-form>. La librairie utilisera le système de provide / inject natif de Vue 3 pour le distribuer automatiquement à tous les sous-composants, sans aucun prop-drilling !

vue
<template>
  <symfony-form 
    :form="parsedFormJson" 
    v-model="formData" 
    :choiceProviders="myChoiceProviders" 
  />
</template>

<script setup>
import SymfonyForm from '@codifyo/form-vue';
import { ref } from 'vue';

const formData = ref({});
const parsedFormJson = { /* ... */ };

const myChoiceProviders = {
  // ... définitions de vos providers ...
};
</script>

Bonus : Remplacement du <select> par un composant riche ​

Par défaut, la librairie utilise la balise native <select>. Celle-ci ne gère pas de barre de recherche. Si vous souhaitez bénéficier pleinement du Lazy Loading dynamique asynchrone (via la méthode load), vous pouvez surcharger le widget standard en enregistrant votre propre composant compatible (comme vue-select).

Votre composant de Select recevra alors automatiquement la prop load, que vous pourrez lier nativement !


Configuration Globale : Editeur Riche (TinyMCE) ​

La librairie utilise @tinymce/tinymce-vue pour le rendu des champs texte riches (WisiwygWidget). Par défaut, ce composant tente de charger le moteur TinyMCE depuis leur Cloud payant, ce qui affichera un avertissement "A valid API key is required" si vous n'avez pas de clé API valide.

Pour résoudre ce problème et rendre l'éditeur totalement agnostique, vous pouvez (et devez) injecter une configuration globale via le système de provide de Vue.

Dans votre composant racine ou main.ts, fournissez un objet tinymceOptions :

typescript
import { provide } from 'vue';

// Configuration de TinyMCE pour toute l'application
provide('tinymceOptions', {
  // L'URL de votre script TinyMCE (auto-hébergé ou via CDN public comme cdnjs)
  // Cela empêchera l'appel au Cloud et retirera l'avertissement de clé API !
  scriptSrc: 'https://cdnjs.cloudflare.com/ajax/libs/tinymce/7.1.1/tinymce.min.js',
  
  // (Optionnel) Base URL si vous auto-hébergez vos propres thèmes/skins
  // base_url: '/mes-assets/tinymce',
  
  // (Optionnel) N'importe quelle option d'initialisation native de TinyMCE
  initOptions: {
    branding: false,
    menubar: false,
    // language: 'fr_FR', // Nécessite de configurer 'language_url' si le CDN ne l'inclut pas
  },

  // (Optionnel) Configuration avancée des plugins (natifs ou personnalisés)
  custom_plugins: {
    // Exemple 1: Configurer un plugin natif (ex: 'table')
    // TinyMCE chargera le plugin, la librairie ajoutera automatiquement les boutons et options.
    'table': {
      toolbar_append: ' | table tabledelete | tableprops tablerowprops tablecellprops',
      init_options: { table_default_attributes: { border: '1' } }
    },

    // Exemple 2: Créer un plugin totalement sur mesure (Plugin Factory)
    // Vous pouvez passer une fonction qui lit la configuration spécifique du champ (ex: `form.vars`).
    'dynamic_expression': (form) => ({
        // La fonction/classe qui sera passée à tinymce.PluginManager.add()
        setup: MyCustomExpressionPluginClass, 
        toolbar_append: ' | dynamic_expression',
        init_options: {
            // Lecture dynamique d'une option métier propre à ce champ précis !
            expression_config: form.vars.expression_builder_options,
            content_style: '.tinymce-expression { background-color: #02faf2; }'
        }
    })
  }
});

Note : Pour activer un plugin, il suffit d'envoyer son nom dans la propriété plugins du champ depuis votre backend, ou via le FormBuilder JS (form.add('champ', 'wysiwyg', { plugins: ['table', 'dynamic_expression'] })). La librairie s'occupera d'appeler PluginManager.add, d'ajouter les boutons à la toolbar et de fusionner les options sans la moindre plomberie de votre côté !


Créer un Custom Widget (Widget Personnalisé) ​

Vous pouvez très facilement remplacer n'importe quel type de champ Symfony par votre propre composant Vue (ou un composant issu d'une librairie tierce comme Vuetify ou ElementPlus).

1. Créer le composant Vue ​

Votre composant doit accepter la propriété form (définie via FormHelper.getProps()) et utiliser defineModel() pour lier la valeur.

vue
<!-- components/MyCustomDatePicker.vue -->
<script setup lang="ts">
import { defineModel } from 'vue';
import { FormHelper } from '@codifyo/form-vue';

// 1. Déclarer le modèle bidirectionnel (Vue 3.4+)
const model = defineModel();

// 2. Hériter des propriétés standard de la librairie
const props = defineProps(FormHelper.getProps());

// Vous pouvez accéder à la configuration Symfony via props.form.vars
// ex: props.form.vars.id, props.form.vars.required, etc.
</script>

<template>
  <div class="my-custom-wrapper">
    <!-- Remplacez ceci par votre composant métier (ex: VueDatePicker) -->
    <input 
      type="date" 
      v-model="model" 
      class="super-custom-input"
      :id="form.vars.id"
      :required="form.vars.required"
      :disabled="form.vars.disabled"
    />
  </div>
</template>

2. Enregistrer le Widget ​

Lors de l'initialisation de votre application, utilisez la fonction registerFormWidget pour associer un block_prefix Symfony à votre composant.

typescript
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import SymfonyFormPlugin, { registerFormWidget } from '@codifyo/form-vue';
import MyCustomDatePicker from './components/MyCustomDatePicker.vue';

const app = createApp(App);
app.use(SymfonyFormPlugin);

// Dès que la librairie rencontrera un champ Symfony avec le prefix "date",
// elle utilisera automatiquement MyCustomDatePicker !
registerFormWidget('date', MyCustomDatePicker);

app.mount('#app');

TIP

Le premier argument de registerFormWidget correspond au block_prefix Symfony. Vous pouvez écraser des types natifs (text, number, choice) ou cibler vos propres Custom FormTypes (ex: si vous avez créé un MyAppDateRangeType en PHP, utilisez my_app_date_range).


Personnalisation du Thème (UI Classes) ​

La librairie génère un balisage agnostique qui s'appuie sur un dictionnaire de classes CSS. Par défaut, ce dictionnaire est configuré pour Bootstrap 4.6.

Vous pouvez surcharger n'importe quelle classe CSS (ou fournir votre propre thème, ex: Tailwind CSS ou Bootstrap 5) lors de l'initialisation du plugin :

typescript
app.use(SymfonyFormPlugin, {
  themes: {
    default: {
      input: 'form-control',         // Champ texte classique
      inputError: 'is-invalid',      // Classe ajoutée en cas d'erreur
      label: 'form-label',           // Label standard
      checkInput: 'form-check-input',// Input pour Checkbox/Radio
      checkLabel: 'form-check-label',// Label pour Checkbox/Radio
      checkContainer: 'form-check',  // Conteneur global Checkbox/Radio
      checkContainerSwitch: 'custom-control custom-switch', // Conteneur Switch BS4
      buttonDefault: 'btn btn-primary',
      // ...
    }
  }
});

Composants Avancés : Checkboxes et Switches ​

La librairie génère automatiquement la structure HTML complexe requise par les frameworks CSS modernes (l'input avant le label, enveloppés dans un conteneur dédié).

Activer le mode "Switch" (Interrupteur) ​

Vous pouvez transformer visuellement une simple Checkbox en interrupteur (Switch) très facilement depuis votre backend Symfony.

Option 1 : Via les attributs (attr) Passez simplement switch => true dans le tableau d'attributs de votre champ :

php
$builder->add('active', CheckboxType::class, [
    'attr' => ['switch' => true]
]);

Option 2 : Via un FormType personnalisé Si vous avez créé un type Symfony dédié (ex: SwitchType), assurez-vous qu'il déclare le block prefix switch :

php
public function getBlockPrefix(): string
{
    return 'switch';
}

La librairie l'identifiera automatiquement et appliquera la classe checkContainerSwitch ainsi que l'attribut ARIA role="switch" pour l'accessibilité.

Compatibilité Bootstrap 5 (Switches) ​

Par défaut, les interrupteurs utilisent le balisage natif de Bootstrap 4.6 (custom-control custom-switch). Si vous migrez vers Bootstrap 5, la structure HTML requise pour un switch est différente.

Vous pouvez facilement adapter la librairie pour Bootstrap 5 en surchargeant le thème par défaut :

typescript
app.use(SymfonyFormPlugin, {
  themes: {
    default: {
      checkContainerSwitch: 'form-check form-switch',
      checkInputSwitch: 'form-check-input',
      checkLabelSwitch: 'form-check-label',
    }
  }
});

Support de l'option inherited_data ​

La librairie prend en charge nativement l'option inherited_data de Symfony Form. Lorsque vous configurez un sous-formulaire ou un groupe de champs avec 'inherited_data' => true côté Symfony, la librairie :

  1. Ne crée pas de sous-objet intermédiaire dans votre modèle réactif (v-model).
  2. Map directement les champs enfants sur l'objet modèle du formulaire parent.
  3. Gère de manière intelligente le nettoyage (removeExtra), en évitant de supprimer les propriétés héritées ou partagées entre le formulaire parent et ses enfants.

Exemple Symfony :

php
$builder->add('personnal_info', PersonnalInfoType::class, [
    'inherited_data' => true,
]);

Les champs définis dans PersonnalInfoType (ex: first_name, last_name) modifieront directement et réactivement les propriétés à la racine de votre modèle Vue 3.


Affichage Conditionnel de Champs (data-condition) ​

Vous pouvez afficher ou masquer dynamiquement n'importe quel champ en fonction de l'état réactif d'autres champs (v-model) en renseignant l'attribut HTML data-condition côté Symfony :

php
$builder->add('deliveryAddress', TextareaType::class, [
    'label' => 'Adresse de livraison',
    'attr' => [
        'data-condition' => 'model.hasDifferentAddress === true',
    ],
]);

L'expression JavaScript passée à data-condition reçoit l'objet réactif model représentant l'ensemble des données du formulaire.

TIP

Consultez la documentation détaillée sur l'Affichage Conditionnel de Champs pour des exemples avancés (conditions composées, optional chaining, et gestion dynamique des collections avec __name__).


Gestion des Conflits ​

La librairie intègre nativement un système de détection de conflits, particulièrement utile pour afficher à l'utilisateur les modifications par rapport à une valeur de base (par exemple, afficher la valeur d'origine d'un champ avant de la sauvegarder).

Lorsqu'une différence est détectée, une alerte s'affiche sous le champ concerné indiquant la valeur de référence avec un bouton permettant de la restaurer.

Activer la gestion des conflits ​

Utilisez les props referenceData et showConflicts sur le composant racine <symfony-form> :

vue
<symfony-form 
  :form="parsedForm" 
  v-model="formData" 
  :referenceData="originalData"
  :showConflicts="true"
/>

L'objet referenceData doit avoir la même structure que l'objet v-model. Le système gère intelligemment les champs complexes (tableaux, choix imbriqués, etc.) pour afficher le libellé texte correspondant à la valeur.

Traduction et personnalisation ​

Vous pouvez personnaliser les libellés de l'alerte de conflit via la prop optionnelle conflictTranslations :

vue
<symfony-form 
  ...
  :conflictTranslations="{
    referenceValue: 'Ancienne valeur :',
    empty: 'Non renseigné',
    restore: 'Annuler la modif'
  }"
/>

Accessibilité & Traductions ​

Les composants visent la conformité WCAG 2.2 AA : champs reliés à leur label, aide, erreurs et avertissements via aria-describedby, aria-required / aria-invalid, régions live pour les erreurs et l'affichage des champs conditionnels.

Focus à la soumission ​

Si le formulaire est invalide à la soumission, le focus est placé sur le premier champ en erreur. Désactivable avec :focusInvalid="false".

Traduire les libellés ​

Tous les textes affichés ou annoncés par la librairie sont personnalisables via la prop translations (voir l'interface exportée FormTranslations et defaultTranslations). Les paramètres {name}, {count}… sont remplacés à l'affichage.

vue
<symfony-form
  ...
  :translations="{
    fileDropPrompt: 'Drop a file here or click to browse',
    fileRemove: 'Remove {name}',
    collectionAdd: 'Add an item',
    fieldShown: 'Field “{label}” shown'
  }"
/>

Les options Symfony add_label, delete_label, empty_label, sort_up_label et sort_down_label d'une collection restent prioritaires.

Options utiles ​

  • Préfixe / suffixe en icône : ajoutez un label pour qu'il soit lu par les lecteurs d'écran, ex. 'prepend_input' => ['icon' => 'fas fa-euro-sign', 'label' => 'euros'].
  • Bloc repliable : heading_level (1 à 6, défaut 2) fixe le niveau du titre.
  • Thème : la classe visuallyHidden (défaut sr-only, Bootstrap 4) masque visuellement les labels destinés aux seuls lecteurs d'écran. Pour Bootstrap 5, définissez visuallyHidden: 'visually-hidden'.

Tests & Assurance Qualité ​

La librairie est couverte par une suite complète de 199 tests unitaires, de composants et fonctionnels sous Vitest et @vue/test-utils avec des seuils de couverture stricts :

  • Lignes : > 84%
  • Instructions (Statements) : > 81%
  • Branches : > 73%
  • Fonctions : > 77%

Commandes disponibles ​

bash
# Exécution de l'ensemble de la suite de tests
npm test -- --run

# Rapport détaillé de couverture de code (V8)
npx vitest run --coverage

# Vérification du typage TypeScript strict et build de distribution
npm run build