Introduction

Module Training Wizard

Le Training Wizard est un assistant multi-etapes permettant la creation et la modification de formations dans l'application Qualiscope Pro.

Ce module guide l'utilisateur RH a travers un processus structure en 5 etapes :

  1. Informations de la formation - Saisie des donnees generales
  2. Selection des participants - Choix des collaborateurs a former
  3. Tiers de reference - Attribution des managers/referents
  4. Cohortes et sessions - Organisation des groupes Non finalisé
  5. Validation - Recapitulatif avant creation definitive
Note : L'étape 4 (Cohortes et sessions) n'est pas encore finalisée. La génération automatique des sessions à partir du nombre de participants n'est pas implémentée.
Cas d'usage : Creation de formation, sauvegarde brouillon, reprise de brouillon, attribution automatique des N+1.
Stack Technique
  • Backend Symfony 7
  • ORM Doctrine
  • Frontend Twig + Bootstrap 5
  • JS Stimulus (Hotwired)
  • Form Symfony Form Flow

Détail des 5 étapes du Wizard

Sécurité : Rôle requis ROLE_ORGANIZATION_HUMAN_RESOURCE
Session : Clé dynamique training_creation_flow_new (nouvelle création) ou training_creation_flow_{id} (reprise brouillon) • Protection CSRF activée
1
Informations de la formation
TrainingInfoType • TrainingInfoDto
Champs du formulaire
ChampTypeRequis
titleTextType
isCertifyingChoiceType (Oui/Non)
modalityEnumType (IN/HYBRID/REMOTE)
isInternalChoiceType (Interne/Externe)
organizerNameTextType
startDateDateType
endDateDateType
expectedParticipantsIntegerType
contextTextareaType
Contraintes de validation
  • title NotBlank
  • title Length(max: 255)
  • endDate GreaterThan(startDate)
  • expectedParticipants Positive
Session : trainingInfo.*
2
Sélection des participants
ParticipantSelectionType • ParticipantSelectionDto
Champs du formulaire
ChampTypeRequis
selectedParticipantIdsChoiceType (Checkboxes)
Pagination : 10 participants par page avec ParticipantSelectionMergeListener pour préserver les sélections entre pages.
Contraintes de validation
  • selectedParticipantIds Count(min: 1)
Message : "Sélectionnez au moins un participant."
Session : participantSelection.selectedParticipantIds[]
3
Tiers de référence
ReferenceThirdPartyType • ParticipantReferenceDto
Champs du formulaire (par participant)
ChampTypeDescription
participantProfileIdHiddenTypeID du participant
referenceThirdPartyIdChoiceTypeID du manager sélectionné
hierarchicalLinkHiddenTypeType de lien (N+1, etc.)
statusHiddenTypeÉtat de la ligne
À compléter À confirmer Confirmé
Contraintes de validation
  • status Choice(['confirmed', 'to_complete', 'to_confirm'])
  • Callback Tous doivent être "confirmed"
Auto-résolution : Le manager (N+1) est proposé automatiquement via TrainingCreationReferenceResolver.
Session : referenceThirdParty.participantReferences[]
4
Sessions et Cohortes
SessionsCohortsType • SessionsCohortsDto
Champs du formulaire
ChampTypeRequis
numberOfSessionsIntegerType (1-10)
sessionsCollectionType
└ sessionNumberHiddenTypeNuméro séquence
└ participantCountIntegerType (readonly)Nb participants
cohortStatusChoiceType
Contraintes de validation
  • numberOfSessions Positive (min: 1)
  • cohortStatus Choice(['pending', 'in_progress', 'completed'])
Session : sessionsCohorts.{numberOfSessions, sessions[], cohortStatus}
5
Validation et Récapitulatif
ValidationSummaryType • Aucun DTO (mapped: false)
Données affichées (lecture seule)
  • Titre de la formation
  • Modalité (Présentiel / Hybride / Distanciel)
  • Nom de l'organisateur
  • Dates de début et fin
  • Nombre de participants attendus / sélectionnés
  • Nombre de sessions et moyenne par session
Informations
Aucune saisie requise
Cette étape permet de vérifier les données avant la création définitive.
Session : Aucune donnée supplémentaire stockée. Affiche les données des étapes 1-4.
Structure complète en session
// Cle dynamique pour isoler chaque wizard
session['training_creation_flow_new'] = TrainingCreationDto { ... }     // Nouvelle creation
session['training_creation_flow_42']  = TrainingCreationDto { ... }     // Reprise brouillon ID 42

// Structure du DTO
TrainingCreationDto {
    currentStep: 'trainingInfo' | 'participantSelection' | 'referenceThirdParty' | 'sessionsCohorts' | 'validationSummary'
    trainingInfo: { title, isCertifying, modality, isInternal, organizerName, startDate, endDate, expectedParticipants, context }
    participantSelection: { selectedParticipantIds: [int, int, ...] }
    referenceThirdParty: { participantReferences: [{ participantProfileId, referenceThirdPartyId, hierarchicalLink, status }, ...] }
    sessionsCohorts: { numberOfSessions, sessions: [{ sessionNumber, participantCount }, ...], cohortStatus }
}

Dashboard de Synthese

3
Entites
7
DTOs
3
Controllers
8
Services
14
Templates
10
Form Types
Repartition des fichiers (53 total)
Entites
DTOs
Forms
Ctrl
Services
Twig
Autres

Architecture

Couches applicatives
FRONTEND
Twig + Stimulus JS
CONTROLLER
TrainingCreationController
SERVICES
Flow / Persistence / Mapping / Reference
DTO / FORM TYPES
TrainingCreationDto + Form Flow
ENTITIES
Training, TrainingSession, TrainingParticipant
DATABASE
MySQL / PostgreSQL
Structure des fichiers
src/
├── Entity/Scoring/
│   ├── Training.php
│   ├── TrainingSession.php
│   └── TrainingParticipant.php
├── Controller/Workspace/Scoring/
│   └── TrainingCreationController.php
├── Form/Scoring/TrainingCreation/
│   ├── Data/
│   │   ├── TrainingCreationDto.php
│   │   └── Step/
│   │       ├── TrainingInfoDto.php
│   │       ├── ParticipantSelectionDto.php
│   │       ├── ReferenceThirdPartyDto.php
│   │       ├── ParticipantReferenceDto.php
│   │       ├── SessionsCohortsDto.php
│   │       └── SessionDto.php
│   └── Type/
│       ├── TrainingCreationFlowType.php
│       ├── TrainingCreationNavigatorType.php
│       └── Step/
│           ├── TrainingInfoType.php
│           ├── ParticipantSelectionType.php
│           ├── ReferenceThirdPartyType.php
│           ├── ParticipantReferenceEntryType.php
│           ├── SessionsCohortsType.php
│           ├── SessionEntryType.php
│           └── ValidationSummaryType.php
├── Service/Workspace/Scoring/TrainingCreation/
│   ├── Flow/
│   │   ├── TrainingCreationFlowHandler.php
│   │   ├── TrainingCreationFlowHandlerInterface.php
│   │   └── TrainingCreationStepperStateBuilder.php
│   ├── Mapping/
│   │   └── TrainingCreationPrefillService.php
│   ├── Persistence/
│   │   ├── TrainingCreationOrchestratorInterface.php
│   │   └── TrainingCreationOrchestratorService.php
│   └── Reference/
│       ├── ResolvedReference.php
│       ├── TrainingCreationReferenceResolver.php
│       └── TrainingCreationReferenceSynchronizer.php
├── Service/Form/Flow/
│   ├── AbstractStepperStateBuilder.php
│   └── AbstractStepSynchronizer.php
├── templates/workspace/scoring/training_creation/
│   ├── index.html.twig
│   ├── results.html.twig
│   ├── _partials/
│   │   ├── _navigator.html.twig
│   │   └── _stepper.html.twig
│   ├── modals/_reference_third_party_modal.html.twig
│   └── steps/_*.html.twig
└── assets/controllers/training_creation/
    └── reference_third_party_modal_controller.js

Workflow du Wizard

1
Informations de la formation

Saisie des donnees generales de la formation

TrainingInfoDto TrainingInfoType _training_info.html.twig
Champs : titre, modalite, dates, effectif prevu, contexte...
2
Selection des participants

Choix des collaborateurs via tableau pagine avec checkboxes

ParticipantSelectionDto ParticipantSelectionType _participant_selection.html.twig
Fusion des selections entre pages via ParticipantSelectionMergeListener
3
Tiers de reference

Attribution des managers/N+1 pour chaque participant

ReferenceThirdPartyDto ReferenceThirdPartyType _reference_third_party.html.twig
Resolution automatique via TrainingCreationReferenceResolver
4
Cohortes et Sessions

Organisation des groupes de formation

SessionsCohortsDto SessionsCohortsType _sessions_cohorts.html.twig
5
Validation finale

Recapitulatif et confirmation

ValidationSummaryType _validation_summary.html.twig
createTraining() Status: ACTIVE

Entites Doctrine

Training.php
src/Entity/Scoring/Training.php
Description

Entite principale representant une formation dans le domaine metier.

Traits utilises
TraitId TraitTimestamp TraitHasOrganization
Proprietes
Propriete Type Nullable Description
label string Intitule de la formation
description text Description detaillee
startDate DateTimeImmutable Date de debut
endDate DateTimeImmutable Date de fin
status EnumTrainingStatus Statut (DRAFT, ACTIVE, COMPLETED)
modality EnumTrainingType Modalite (presentiel, distanciel)
participants Collection - OneToMany vers TrainingParticipant
sessions Collection - OneToMany vers TrainingSession
Relations
Training
|
+---- OneToMany TrainingParticipant (cascade: persist, remove)
|
+---- OneToMany TrainingSession (cascade: persist, remove)
|
+---- ManyToOne SkillDomain
|
+---- OneToMany Campaign
TrainingSession.php
src/Entity/Scoring/TrainingSession.php

Represente une session au sein d'une formation. Une formation peut avoir plusieurs sessions pour repartir les participants.

Propriete Type Description
training Training Formation parente (ManyToOne)
sessionNumber int Numero de session (defaut: 1)
participants Collection Participants de cette session
TrainingParticipant.php
src/Entity/Scoring/TrainingParticipant.php

Entite d'association entre une formation et un profil utilisateur, avec la notion de tiers de reference.

Propriete Type Description
participantProfile OrganizationUserProfile Profil du participant
referenceThirdParty OrganizationUserProfile Tiers de reference (manager N+1)
session TrainingSession Session assignee
referenceStatus string to_confirm | to_complete | confirmed
Statuts de reference
to_confirm Tiers propose, en attente de confirmation
to_complete Aucun tiers trouve
confirmed Tiers valide

Enums PHP

EnumTrainingStatus
src/Enums/Scoring/EnumTrainingStatus.php
enum EnumTrainingStatus: string { case DRAFT = 'draft'; case ACTIVE = 'active'; case COMPLETED = 'completed'; }
Les valeurs de EnumTrainingStatus ne doivent pas etre affichees avec leur nom technique. Les affichages utilisent les cles training_status.draft, training_status.active et training_status.completed, traduites dans translations/messages+intl-icu.fr.yaml.
EnumTrainingType
src/Enums/Scoring/EnumTrainingType.php
enum EnumTrainingType: string { case IN_PERSON = 'in_person'; // Presentiel case REMOTE = 'remote'; // Distanciel case BLENDED = 'blended'; // Hybride }
Dans les formulaires, les valeurs de EnumTrainingType ne doivent pas etre affichees avec leur nom technique. Le champ EnumType utilise les cles training_type.in_person, training_type.remote et training_type.blended, traduites dans translations/messages+intl-icu.fr.yaml.

Mapping Formulaire → Base de données

Flux de données : Formulaire (Form Type) → DTO (session) → Orchestrator → Entité Doctrine → Base de données
Étape 1 : Informations formation → training
Champ Form (TrainingInfoDto) Colonne BDD Type
titlelabelvarchar(255)
isCertifyingis_certifyingboolean
modalitymodality + typeenum (EnumTrainingType)
isInternalis_internalboolean
organizerNametrainer_namevarchar(255)
startDatestart_datedate
endDateend_datedate
expectedParticipantsexpected_participantsint
contextcontexttext
(automatique)statusenum (DRAFT / ACTIVE)
(automatique)organization_idFK → organization
Étape 2 : Participants → training_participant
Champ Form (ParticipantSelectionDto) Colonne BDD Type
selectedParticipantIds[]participant_profile_idFK → organization_user_profile
(automatique)training_idFK → training
Étape 3 : Tiers référence → training_participant
Champ Form (ParticipantReferenceDto) Colonne BDD Type
referenceThirdPartyIdreference_third_party_idFK → organization_user_profile
statusreference_statusvarchar(32)
hierarchicalLink(non persisté)
hierarchicalLink est affiché dans le wizard mais n'est pas stocké en BDD.
Étape 4 : Sessions → training_session Non finalisé
Champ Form (SessionDto) Colonne BDD Type
sessionNumbersession_numberint
participantIds[]session_id sur training_participantFK → training_session
(automatique)training_idFK → training
Schéma relationnel
┌─────────────────────┐       ┌──────────────────────────┐       ┌─────────────────────┐
│      training       │       │    training_participant   │       │   training_session  │
├─────────────────────┤       ├──────────────────────────┤       ├─────────────────────┤
│ id                  │◄──────│ training_id (FK)         │       │ id                  │
│ label               │       │ participant_profile_id   │───────│ training_id (FK)    │
│ is_certifying       │       │ reference_third_party_id │       │ session_number      │
│ modality            │       │ reference_status         │       └─────────────────────┘
│ is_internal         │       │ session_id (FK) ─────────┼───────────────┘
│ trainer_name        │       └──────────────────────────┘
│ start_date          │                   │
│ end_date            │                   │
│ expected_participants│                  ▼
│ context             │       ┌──────────────────────────┐
│ status              │       │ organization_user_profile │
│ organization_id     │       ├──────────────────────────┤
└─────────────────────┘       │ id                       │
                              │ firstname                │
                              │ lastname                 │
                              │ organization_id          │
                              └──────────────────────────┘

DTOs (Data Transfer Objects)

Pourquoi des DTOs ? Decouplage formulaire/entites, validation par etape avec groupes, agregation de donnees, etat transitoire avant persistance.
TrainingCreationDto (Principal)
src/Form/Scoring/TrainingCreation/Data/TrainingCreationDto.php

Conteneur principal regroupant tous les DTOs des etapes du wizard.

class TrainingCreationDto { public string $currentStep = 'trainingInfo'; #[Assert\Valid(groups: ['trainingInfo'])] public TrainingInfoDto $trainingInfo; #[Assert\Valid(groups: ['participantSelection'])] public ParticipantSelectionDto $participantSelection; #[Assert\Valid(groups: ['referenceThirdParty'])] public ReferenceThirdPartyDto $referenceThirdParty; #[Assert\Valid(groups: ['sessionsCohorts'])] public SessionsCohortsDto $sessionsCohorts; }
TrainingInfoDto (Etape 1)
src/Form/Scoring/TrainingCreation/Data/Step/TrainingInfoDto.php
Propriete Type Validations
title string NotBlank Length(max:255)
isCertifying bool NotNull
modality EnumTrainingType NotNull
startDate DateTimeInterface NotNull
endDate DateTimeInterface NotNull GreaterThan(startDate)
expectedParticipants int NotNull Positive
ParticipantSelectionDto (Etape 2)
src/Form/Scoring/TrainingCreation/Data/Step/ParticipantSelectionDto.php
class ParticipantSelectionDto { #[Assert\Count(min: 1, minMessage: 'Selectionnez au moins un participant.')] public array $selectedParticipantIds = []; public function getParticipantCount(): int { return \count($this->selectedParticipantIds); } }
Count(min:1) Garantit au moins un participant selectionne
ReferenceThirdPartyDto (Etape 3)
src/Form/Scoring/TrainingCreation/Data/Step/ReferenceThirdPartyDto.php
Callback de validation : Verifie que TOUS les tiers sont confirmes avant de passer a l'etape suivante.
#[Assert\Callback(groups: ['referenceThirdParty'])] public function validateAllReferencesConfirmed(ExecutionContextInterface $context): void { $unconfirmedCount = 0; foreach ($this->participantReferences as $reference) { if ($reference->status !== 'confirmed') { $unconfirmedCount++; } } // Ajoute violation si participants non confirmes }

Form Types

TrainingCreationFlowType (Flow Principal)
src/Form/Scoring/TrainingCreation/Type/TrainingCreationFlowType.php

Definit la structure multi-etapes du wizard via Symfony Form Flow.

public function buildFormFlow(FormFlowBuilderInterface $builder): void { $builder ->addStep('trainingInfo', TrainingInfoType::class) ->addStep('participantSelection', ParticipantSelectionType::class) ->addStep('referenceThirdParty', ReferenceThirdPartyType::class) ->addStep('sessionsCohorts', SessionsCohortsType::class) ->addStep('validationSummary', ValidationSummaryType::class) ->add('navigator', FlowNavigatorType::class); }

  • data_class : TrainingCreationDto
  • step_property_path : 'currentStep'
  • data_storage : SessionDataStorage (persistance en session)
  • auto_reset : false (permet la reprise)
  • page : borne automatiquement dans ReferenceThirdPartyType pour eviter une page vide si l'utilisateur arrive depuis une page de participants hors limites
FlowNavigatorType
src/Form/Flow/Type/FlowNavigatorType.php

Gere les boutons de navigation du wizard.

PreviousFlowType
NextFlowType
FinishFlowType
saveDraft (validate: false)

Controllers

TrainingCreationController
src/Controller/Workspace/Scoring/TrainingCreationController.php
ROLE_ORGANIZATION_HUMAN_RESOURCE Accessible uniquement aux RH
Routes
Route Methode URL Description
app_workspace_training_index GET /workspace/training Liste des formations
app_workspace_training_create GET POST /workspace/training/create Creation wizard
app_workspace_training_resume GET POST /workspace/training/{id}/resume Reprise brouillon
app_workspace_training_create_results GET /workspace/training/create/results Page de confirmation
app_workspace_training_show GET /workspace/training/{id} Detail formation

public function index(Organization $organization, int $page = 1): Response

Affiche la liste paginee des formations de l'organisation courante.

public function create(Request $request, Organization $organization, User $user, int $page = 1): Response

Lance le wizard pour creer une nouvelle formation. Delegue a TrainingCreationFlowHandler.

public function resume(Training $training, Request $request, Organization $organization, User $user): Response

Reprend un brouillon existant. Utilise TrainingCreationPrefillService pour pre-remplir le DTO.

public function createResults(Request $request): Response

Affiche la page de confirmation apres creation et consomme l'identifiant stocke en session.

public function show(Training $training, Organization $organization): Response

Affiche le detail d'une formation apres verification de son appartenance a l'organisation courante.

Services

Organisation : la logique TrainingCreation est decoupee par responsabilite : Flow/ pour la coordination du wizard, Persistence/ pour la creation des entites, Mapping/ pour le pre-remplissage DTO, et Reference/ pour la synchronisation/resolution des tiers.
TrainingCreationFlowHandler
src/Service/Workspace/Scoring/TrainingCreation/Flow/TrainingCreationFlowHandler.php

Orchestre le flux du wizard en coordonnant formulaire, validation et actions.

Flux de traitement
flowchart TD A[handle] --> B[loadAndSyncSessionData] B --> C[Create Form Flow] C --> D{isSubmitted?} D -->|Non| E[renderStep] D -->|Oui| F{handleReferenceStepSync} F --> G{handleSaveDraft?} G -->|Oui| H[orchestrator.saveDraft] H --> I[Redirect: index] G -->|Non| J{handleFinish?} J -->|Oui| K[orchestrator.createTraining] K --> L[Redirect: results] J -->|Non| E

public function handle(Request $request, Organization $organization, User $user, TrainingCreationDto $dto, ?Training $training, int $page): Response
  1. Charger et synchroniser les donnees de session
  2. Creer et traiter le formulaire
  3. Gerer les cas : sync references, save draft, finish
  4. Afficher l'etape courante ou rediriger
TrainingCreationOrchestratorService
src/Service/Workspace/Scoring/TrainingCreation/Persistence/TrainingCreationOrchestratorService.php

Service metier responsable de la creation/mise a jour des entites Training a partir du DTO.

createTraining()
  • Transaction Doctrine
  • Hydrate entite Training
  • Ajoute participants
  • Applique tiers de reference
  • Cree sessions
  • Status: ACTIVE
saveDraft()
  • Meme logique sans transaction complete
  • Titre par defaut si vide
  • Status: DRAFT
TrainingCreationPrefillService
src/Service/Workspace/Scoring/TrainingCreation/Mapping/TrainingCreationPrefillService.php

Reconstruit le DTO du wizard a partir d'une formation brouillon lors de la reprise.

TrainingInfo
Recopie titre, modalite, dates, contexte, organisme et effectif attendu.
Participants / References
Restaure les participants selectionnes et les tiers de reference deja saisis.
Sessions
Recompose les sessions/cohortes avec les IDs des participants rattaches.
TrainingCreationReferenceSynchronizer
src/Service/Workspace/Scoring/TrainingCreation/Reference/TrainingCreationReferenceSynchronizer.php

Synchronise la liste des references avec les participants selectionnes.

Fonctionnement :
  1. Supprime les references orphelines (participants desélectionnes)
  2. Charge les profils avec leurs relations (management)
  3. Pour les nouveaux participants, tente la resolution automatique du N+1
  4. Definit le statut : to_confirm si N+1 trouve, to_complete sinon
TrainingCreationReferenceResolver
src/Service/Workspace/Scoring/TrainingCreation/Reference/TrainingCreationReferenceResolver.php

Resout automatiquement le tiers de reference (manager N+1) pour un participant.

public function resolveForParticipant(OrganizationUserProfile $participant): ?ResolvedReference { $management = $participant->getManagement(); if (null !== $management) { $director = $management->getDirector(); if (null !== $director) { return new ResolvedReference( referenceProfileId: $director->getId(), referenceFullName: $director->getFullName(), hierarchicalLink: 'Manager / N+1', ); } } return null; }

Repository

TrainingRepository
src/Repository/Scoring/TrainingRepository.php

Recupere les formations d'une organisation avec leur eligibilite a une campagne.

return [ 'trainings' => $trainings, 'eligibility_by_training_id' => $eligibilityByTrainingId, ];

Recupere les formations auxquelles un expert est associe via les campagnes.

Event Subscribers

ParticipantSelectionMergeListener
src/EventListener/Form/ParticipantSelectionMergeListener.php

Fusionne les selections de participants entre les pages de pagination.

Problematique : Quand l'utilisateur navigue entre pages, seules les checkboxes de la page courante sont soumises.
Evenements ecoutes
FormEvents::PRE_SUBMIT FormEvents::SUBMIT
Logique
  1. PRE_SUBMIT memorise les IDs deja selectionnes avant mapping
  2. Le ChoiceType valide uniquement les choix visibles de la page courante
  3. SUBMIT fusionne les choix valides avec les selections des autres pages
  4. Les IDs hors page ne sont jamais reinjectes dans le payload brut du ChoiceType
ReferenceThirdPartyMergeListener
src/EventListener/Form/ReferenceThirdPartyMergeListener.php

Fusionne les modifications des tiers de reference entre les pages.

Evenement ecoute
FormEvents::PRE_SUBMIT
Logique
  1. Part de l'etat complet stocke dans le DTO de session
  2. Ignore les sous-formulaires incomplets sans participantProfileId
  3. Met a jour uniquement les references de la page courante
  4. Reconstruit un tableau numerique compatible avec CollectionType

Securite

TrainingVoter
src/Security/Voter/Training/TrainingVoter.php

Controle l'acces aux formations pour les experts.

Attribut supporte
VIEW
Diagramme de decision
Utilisateur authentifie ?
NonREFUSE
Oui
A un profil Expert ?
NonREFUSE
Oui
Expert associe a une campagne de la formation ?
NonREFUSE
OuiAUTORISE

Templates Twig

index.html.twig

Template principal du wizard

Variables recues :
  • form
  • currentStep
  • data (TrainingCreationDto)
  • stepper_visible_steps
_training_info.html.twig

Etape 1 - Informations

Champs affiches :
  • Titre, certifiant, modalite
  • Interne/externe, organisateur
  • Dates, effectif, contexte
_participant_selection.html.twig

Etape 2 - Participants

Fonctionnalites :
  • Tableau pagine avec checkboxes
  • Compteur de selections
  • Pagination Bootstrap
_reference_third_party.html.twig

Etape 3 - Tiers de reference

Fonctionnalites :
  • Badges de statut colores
  • Boutons : Modifier, Confirmer, Vider
  • Modal de modification
_sessions_cohorts.html.twig

Etape 4 - Sessions

Affichage :
  • Nombre total participants
  • Nombre de sessions
  • Statut de la cohorte
_validation_summary.html.twig

Etape 5 - Validation

Recapitulatif :
  • Toutes les informations saisies
  • Statistiques (participants, sessions)

CSS du wizard

Gestion actuelle des styles
templates/workspace/base.html.twig + assets/src/workspace/styles/app.scss

Le wizard Training ne possede pas de fichier SCSS dedie. Le rendu est volontairement compose avec Bootstrap, les styles communs du workspace et quelques classes de contexte placees dans les templates.

Socle vendor

templates/base.html.twig charge Bootstrap, Font Awesome, Choices et Dropzone. Le wizard reutilise donc les classes btn, table, alert, badge, modal et pagination.

Bundle workspace

templates/workspace/base.html.twig force l'entrypoint Encore workspace. Les styles viennent de assets/src/workspace/styles/app.scss et du theme workspace.

Classes de contexte

Les templates ajoutent des classes metier comme training-creation, training-creation-results ou workspace-training pour garder un point d'accroche clair si un style specifique devient necessaire.

Flux de chargement CSS
flowchart LR A[templates/base.html.twig] --> B[Vendor CSS: Bootstrap, Font Awesome, Choices, Dropzone] A --> C[Encore entrypoint workspace] C --> D[assets/src/workspace/styles/theme.scss] C --> E[assets/src/workspace/styles/app.scss] E --> F[Composants communs: table, form, button, nav, sidebar] F --> G[Templates wizard Training]
Exemples dans les templates
{# templates/workspace/scoring/training_creation/index.html.twig #} <article class="training-creation bg-light p-4"> {# templates/workspace/scoring/training/index.html.twig #} <div class="workspace-training"> <div class="table-responsive workspace-training__table-wrap rounded-3 border shadow-sm"> {# Pagination reutilisable #} {% include "common/parts/pagination.html.twig" with { page_count: trainings.pages, current: trainings.page, } %}
Regle de maintenance

Tant que le wizard peut etre rendu avec Bootstrap et les composants workspace existants, ne pas creer de CSS specifique. Ajouter un fichier dedie seulement si plusieurs styles metier deviennent repetes ou difficiles a exprimer avec les utilitaires Bootstrap.

Si ce seuil est atteint, creer par exemple assets/src/workspace/styles/pages/training-creation/_wizard.scss, l'importer dans assets/src/workspace/styles/app.scss, et scope toutes les regles sous .training-creation pour eviter les effets de bord.

JavaScript (Stimulus)

reference_third_party_modal_controller.js
assets/controllers/training_creation/reference_third_party_modal_controller.js

Controleur Stimulus gerant la modal de modification des tiers de reference.

Targets Stimulus
  • participantId
  • participantName
  • currentReference
  • hierarchicalLink
  • searchInput
  • referenceSelect
Evenements ecoutes
show.bs.modal hidden.bs.modal click .js-reference-edit click .js-reference-confirm click .js-reference-clear

  1. Recupere participantId et referenceId selectionne
  2. Met a jour le champ hidden du formulaire Symfony
  3. Met a jour le statut (confirmed ou to_complete)
  4. Active/desactive le bouton Confirmer
  5. Ferme la modal Bootstrap

Met a jour le tiers d'un participant dans le formulaire, le tableau et les data-attributes.

// Met a jour le select hidden document.querySelector(`select[data-reference-input-for="${participantId}"]`); // Met a jour le label affiche document.querySelector(`[data-reference-label-for="${participantId}"]`);

Modales Bootstrap

_reference_third_party_modal.html.twig
templates/workspace/scoring/training_creation/modals/_reference_third_party_modal.html.twig
Demonstration visuelle
Cycle de vie
sequenceDiagram participant U as Utilisateur participant B as Bouton Edit participant M as Modal participant C as Controller Stimulus participant F as Formulaire U->>B: Clic B->>C: handleEditClick() C->>M: Bootstrap.show() M->>C: show.bs.modal C->>C: fillFromTrigger() U->>M: Selectionne reference U->>M: Clic Enregistrer M->>C: save() C->>F: setReference() C->>F: setStatus() C->>M: Bootstrap.hide() M->>C: hidden.bs.modal C->>C: reset()

Diagrammes Mermaid

Architecture globale
graph TB subgraph Frontend T[Twig Templates] S[Stimulus JS] end subgraph Backend C[Controller] FH[FlowHandler] OS[OrchestratorService] RS[ReferenceSynchronizer] RR[ReferenceResolver] end subgraph Data DTO[DTOs] E[Entities] DB[(Database)] end T --> C S --> T C --> FH FH --> OS FH --> RS RS --> RR OS --> DTO DTO --> E E --> DB
Workflow de creation
stateDiagram-v2 [*] --> TrainingInfo TrainingInfo --> ParticipantSelection : Continuer ParticipantSelection --> ReferenceThirdParty : Continuer ReferenceThirdParty --> SessionsCohorts : Continuer SessionsCohorts --> ValidationSummary : Continuer ValidationSummary --> [*] : Valider TrainingInfo --> Draft : Brouillon ParticipantSelection --> Draft : Brouillon ReferenceThirdParty --> Draft : Brouillon SessionsCohorts --> Draft : Brouillon Draft --> [*]

Reutilisabilite

Objectif : Ces composants sont concus pour etre reutilises dans d'autres wizards/flows de l'application. Ils encapsulent des comportements generiques independants du contexte metier.
Pagination reutilisable du wizard
templates/common/parts/form_pagination.html.twig
Twig partial POST form Wizard compatible
Pourquoi un composant different ?

Le template classique pagination.html.twig genere des liens GET. Dans un wizard, changer de page doit d'abord soumettre le formulaire courant pour conserver les selections, declencher les listeners de merge et garder le DTO de session coherent.

Liste classique

common/parts/pagination.html.twig

  • Utilise des balises <a href>
  • Navigation GET simple
  • Adapte pour les listes sans formulaire actif
Wizard multi-pages

common/parts/form_pagination.html.twig

  • Utilise des boutons type="submit"
  • Poste la page courante avant redirection
  • Envoie _target_page au FlowHandler
Utilisation dans une etape du wizard
{% include "common/parts/form_pagination.html.twig" with {
    page_count: pagination.pages,
    current: pagination.page,
} %}
Principe technique
<button
    type="submit"
    class="page-link"
    name="_target_page"
    value="{{ page }}"
    formaction="{{ paginate(current) }}"
    formmethod="post"
    formnovalidate
>
    {{ page }}
</button>
Regle : utiliser form_pagination.html.twig dans les etapes du wizard qui contiennent un formulaire pagine. Utiliser pagination.html.twig uniquement pour les listes GET classiques comme /workspace/training.
Pattern FlowHandler
Service d'orchestration du flux wizard
Service Coordinator Pattern Reutilisable
Probleme resolu

Sans FlowHandler, le controller contient toute la logique du wizard :

  • Chargement/synchronisation des donnees session
  • Creation et gestion du FormFlow
  • Detection des boutons cliques (brouillon, terminer, suivant)
  • Appels a l'orchestrator pour persister
  • Rendu du template avec le stepper

Resultat : Controller de 150+ lignes, difficile a tester et impossible a reutiliser.

Solution : FlowHandler comme coordinateur

Le FlowHandler encapsule toute la logique de coordination du wizard. Le controller ne fait que deleguer :

Controller
  • Recoit la requete HTTP
  • Extrait les parametres
  • Delegue au FlowHandler
  • Retourne la Response

~10 lignes de code
FlowHandler
  • Coordonne le flux
  • Gere les boutons
  • Synchronise la session
  • Delegue la persistance

Logique metier isolee
Orchestrator
  • Persiste les entites
  • Gere les transactions
  • Cree les relations
  • Retourne l'entite

Persistance isolee

// ❌ SANS FlowHandler - Tout dans le controller
class TrainingCreationController extends AbstractController
{
    public function create(Request $request, ...): Response
    {
        // 1. Charger les donnees de session
        $sessionData = $request->getSession()->get('training_flow');
        if ($sessionData instanceof TrainingCreationDto) {
            $dto = $sessionData;
            $this->referenceSynchronizer->sync($dto);
        }

        // 2. Creer et gerer le flow
        $flow = $this->formFactory->create(TrainingCreationFlowType::class, $dto, [...]);
        $flow->handleRequest($request);
        $stepForm = $flow->getStepForm();

        // 3. Gerer le bouton "Brouillon"
        $navigator = $stepForm->get('navigator');
        if ($navigator->has('saveDraft') && $navigator->get('saveDraft')->isClicked()) {
            $this->orchestrator->saveDraft($dto, $organization, $user);
            $this->addFlash('success', 'Brouillon enregistre.');
            return $this->redirectToRoute('app_workspace_training_index');
        }

        // 4. Gerer le bouton "Terminer"
        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            $training = $this->orchestrator->createTraining($dto, $organization, $user);
            $request->getSession()->set('result_id', $training->getId());
            return $this->redirectToRoute('app_workspace_training_create_results');
        }

        // 5. Gerer la synchronisation des references
        if ('referenceThirdParty' === $dto->currentStep) {
            $this->referenceSynchronizer->sync($dto);
            // ... encore plus de logique ...
        }

        // 6. Construire le stepper
        $stepper = $this->stepperStateBuilder->buildState($flow, $dto);

        // 7. Rendre le template
        return $this->render('training_creation/index.html.twig', [
            'form' => $stepForm->createView(),
            'currentStep' => $dto->currentStep,
            'stepper_visible_steps' => $stepper['visible_steps'],
            'stepper_cursor_index' => $stepper['cursor_index'],
        ]);
    }
}

// ✅ AVEC FlowHandler - Controller minimal
#[Route('/create', name: 'create', methods: ['GET', 'POST'])]
public function create(
    Request $request,
    #[CurrentOrganization] Organization $organization,
    #[CurrentUser] User $user,
    #[MapQueryParameter] int $page = 1,
): Response {
    return $this->flowHandler->handle(
        request: $request,
        organization: $organization,
        user: $user,
        dto: new TrainingCreationDto(),
        training: null,
        page: $page,
    );
}
Avantages : Controller testable, logique reutilisable, separation des responsabilites.

<?php

declare(strict_types=1);

namespace App\Service\Workspace\Scoring\TrainingCreation\Flow;

use App\Service\Workspace\Scoring\TrainingCreation\Persistence\TrainingCreationOrchestratorInterface;
use App\Service\Workspace\Scoring\TrainingCreation\Reference\TrainingCreationReferenceSynchronizer;

final class TrainingCreationFlowHandler implements TrainingCreationFlowHandlerInterface
{
    // Cles de session dynamiques pour isoler chaque wizard
    private const string SESSION_FLOW_KEY_PREFIX = 'training_creation_flow_';
    private const string SESSION_FLOW_KEY_NEW = 'training_creation_flow_new';
    private const string SESSION_RESULT_KEY = 'training_creation_result';

    public function __construct(
        private readonly FormFactoryInterface $formFactory,
        private readonly Environment $twig,
        private readonly UrlGeneratorInterface $urlGenerator,
        private readonly TrainingCreationReferenceSynchronizer $referenceSynchronizer,
        private readonly TrainingCreationOrchestratorInterface $orchestratorService,
        private readonly TrainingCreationStepperStateBuilder $stepperStateBuilder,
    ) {}

    public function handle(
        Request $request,
        Organization $organization,
        User $user,
        TrainingCreationDto $dto,
        ?Training $training = null,
        int $page = 1,
    ): Response {
        // 1. Generer la cle de session dynamique
        $sessionKey = $this->getSessionKey($training);

        // 2. Charger et synchroniser les donnees session
        $dto = $this->loadAndSyncSessionData($request, $dto, $sessionKey);

        // 3. Creer le flow et gerer la requete
        $flow = $this->formFactory
            ->create(TrainingCreationFlowType::class, $dto, [...])
            ->handleRequest($request);

        // 4. Gerer les differents scenarios (methodes privees)
        if ($redirect = $this->handlePagination(..., $sessionKey)) return $redirect;
        if ($redirect = $this->handleReferenceStepSync(..., $sessionKey)) return $redirect;
        if ($redirect = $this->handleSaveDraft(..., $sessionKey)) return $redirect;
        if ($redirect = $this->handleFinish(..., $sessionKey)) return $redirect;

        // 5. Rendre l'etape courante
        return $this->renderStep($flow, $stepForm, $data);
    }

    // Cle dynamique: 'training_creation_flow_new' ou 'training_creation_flow_{id}'
    private function getSessionKey(?Training $training): string
    {
        return $training ? self::SESSION_FLOW_KEY_PREFIX . $training->getId()
                         : self::SESSION_FLOW_KEY_NEW;
    }

    private function clearFlowSession(Request $request, string $sessionKey): void
    {
        $request->getSession()->remove($sessionKey);
    }

    // Methodes privees pour chaque responsabilite
    private function loadAndSyncSessionData(..., string $sessionKey): TrainingCreationDto { ... }
    private function handlePagination(..., string $sessionKey): ?RedirectResponse { ... }
    private function handleReferenceStepSync(..., string $sessionKey): ?Response { ... }
    private function handleSaveDraft(..., string $sessionKey): ?Response { ... }
    private function handleFinish(..., string $sessionKey): ?Response { ... }
    private function renderStep(...): Response { ... }
}

<?php

declare(strict_types=1);

namespace App\Service\Workspace\Scoring\TrainingCreation\Flow;

/**
 * Handles the Training creation wizard flow.
 */
interface TrainingCreationFlowHandlerInterface
{
    public function handle(
        Request $request,
        Organization $organization,
        User $user,
        TrainingCreationDto $dto,
        ?Training $training = null,
        int $page = 1,
    ): Response;
}
Testabilite : L'interface permet de mocker le FlowHandler dans les tests du controller.

Etapes pour creer un nouveau wizard (ex: CampaignCreation)
  1. Creer l'interface
    interface CampaignCreationFlowHandlerInterface
    {
        public function handle(Request $request, Organization $org, ...): Response;
    }
  2. Creer le FlowHandler (copier la structure)
    final class CampaignCreationFlowHandler implements CampaignCreationFlowHandlerInterface
    {
        public function handle(...): Response
        {
            $dto = $this->loadAndSyncSessionData(...);
            $flow = $this->formFactory->create(CampaignCreationFlowType::class, ...);
    
            if ($redirect = $this->handleSaveDraft(...)) return $redirect;
            if ($redirect = $this->handleFinish(...)) return $redirect;
    
            return $this->renderStep(...);
        }
    }
  3. Creer le Controller minimal
    public function create(...): Response {
        return $this->flowHandler->handle(...);
    }
Architecture en couches
flowchart TB subgraph Controller["Controller (HTTP)"] C1[Recoit Request] C2[Delegue au FlowHandler] C3[Retourne Response] end subgraph FlowHandler["FlowHandler (Coordination)"] F1[loadAndSyncSessionData] F2[handleReferenceStepSync] F3[handleSaveDraft] F4[handleFinish] F5[renderStep] end subgraph Orchestrator["Orchestrator (Persistance)"] O1[createTraining] O2[saveDraft] end C1 --> C2 C2 --> F1 F1 --> F2 F2 --> F3 F3 --> F4 F4 --> F5 F3 -.-> O2 F4 -.-> O1 F5 --> C3 style Controller fill:#e3f2fd style FlowHandler fill:#e8f5e9 style Orchestrator fill:#fff3e0
Comparaison avec DrhOnboarding
Aspect TrainingCreation DrhOnboarding
FlowHandler Oui Non (logique dans controller)
Orchestrator Oui Oui
Separation 3 couches 2 couches
Testabilite Elevee Moyenne
Recommandation : Le pattern FlowHandler du Training Wizard est plus propre que DrhOnboarding et devrait servir de reference pour les futurs wizards.
AbstractStepSynchronizer
src/Service/Form/Flow/AbstractStepSynchronizer.php
Abstract Class Template Method Pattern Reutilisable
Probleme resolu

Dans un wizard multi-etapes, une etape peut dependre des donnees d'une autre :

  • Etape 2 : Selection de participants (checkboxes)
  • Etape 3 : Assignation d'un manager a chaque participant

Probleme : Si l'utilisateur revient a l'etape 2 et modifie sa selection, l'etape 3 doit se mettre a jour (supprimer les desselectionnes, ajouter les nouveaux).

Solution : Template Method Pattern

La classe abstraite fournit l'algorithme de synchronisation, les classes concretes definissent :

Algorithme (fixe)
  1. Recuperer les IDs sources
  2. Supprimer les items orphelins
  3. Mettre a jour les items existants
  4. Creer les nouveaux items
Methodes abstraites (a implementer)
  • getSourceIds(dto)
  • getTargetCollection(dto)
  • getItemSourceId(item)
  • createItem(sourceId)
  • hydrateItem(item) (optionnel)

<?php

declare(strict_types=1);

namespace App\Service\Form\Flow;

use Doctrine\Common\Collections\Collection;

/**
 * Synchronise les donnees entre deux etapes d'un wizard.
 *
 * @template TDto of object
 * @template TItem of object
 */
abstract class AbstractStepSynchronizer
{
    /**
     * Synchronise l'etape cible avec l'etape source.
     */
    public function sync(object $dto): void
    {
        $sourceIds = $this->getSourceIds($dto);
        $targetCollection = $this->getTargetCollection($dto);

        // 1. Supprimer les items dont la source n'existe plus
        foreach ($targetCollection as $key => $item) {
            if (!in_array($this->getItemSourceId($item), $sourceIds, true)) {
                $targetCollection->remove($key);
            }
        }

        // 2. Identifier les IDs existants et mettre a jour l'affichage
        $existingSourceIds = [];
        foreach ($targetCollection as $item) {
            $existingSourceIds[] = $this->getItemSourceId($item);
            $this->hydrateItem($item);
        }

        // 3. Ajouter les nouveaux items
        foreach ($sourceIds as $sourceId) {
            if (in_array($sourceId, $existingSourceIds, true)) {
                continue;
            }

            $newItem = $this->createItem($sourceId);
            $this->hydrateItem($newItem);
            $targetCollection->add($newItem);
        }
    }

    /** @return list<int> */
    abstract protected function getSourceIds(object $dto): array;

    /** @return Collection<int, TItem> */
    abstract protected function getTargetCollection(object $dto): Collection;

    abstract protected function getItemSourceId(object $item): int;

    /** @return TItem */
    abstract protected function createItem(int $sourceId): object;

    /** Optionnel : hydrate les donnees d'affichage */
    protected function hydrateItem(object $item): void {}
}

<?php

declare(strict_types=1);

namespace App\Service\Workspace\Scoring\TrainingCreation\Reference;

use App\Service\Form\Flow\AbstractStepSynchronizer;

/**
 * Synchronise les references de tiers avec les participants selectionnes.
 *
 * @extends AbstractStepSynchronizer<TrainingCreationDto, ParticipantReferenceDto>
 */
final class TrainingCreationReferenceSynchronizer extends AbstractStepSynchronizer
{
    public function __construct(
        private readonly OrganizationUserProfileRepository $profileRepository,
        private readonly TrainingCreationReferenceResolver $referenceResolver,
    ) {}

    protected function getSourceIds(object $dto): array
    {
        // IDs des participants selectionnes a l'etape 2
        return array_values(array_unique(array_map(
            'intval',
            $dto->participantSelection->selectedParticipantIds,
        )));
    }

    protected function getTargetCollection(object $dto): Collection
    {
        // Collection des references a l'etape 3
        return $dto->referenceThirdParty->participantReferences;
    }

    protected function getItemSourceId(object $item): int
    {
        return $item->participantProfileId;
    }

    protected function createItem(int $sourceId): object
    {
        $reference = new ParticipantReferenceDto();
        $reference->participantProfileId = $sourceId;

        // Tenter de resoudre automatiquement le manager
        $resolved = $this->referenceResolver->resolveForParticipant(...);

        if ($resolved) {
            $reference->referenceThirdPartyId = $resolved->referenceProfileId;
            $reference->status = 'to_confirm';
        } else {
            $reference->status = 'to_complete';
        }

        return $reference;
    }

    protected function hydrateItem(object $item): void
    {
        // Ajouter nom/prenom pour l'affichage
        $item->participantFirstname = $profile?->getFirstname();
        $item->participantLastname = $profile?->getLastname();
    }
}

// Dans TrainingCreationFlowHandler

public function __construct(
    // ...
    private readonly TrainingCreationReferenceSynchronizer $referenceSynchronizer,
) {}

// La cle de session est dynamique (voir getSessionKey())
private function loadAndSyncSessionData(
    Request $request,
    TrainingCreationDto $dto,
    string $sessionKey
): TrainingCreationDto {
    $sessionData = $request->getSession()->get($sessionKey);

    if ($sessionData instanceof TrainingCreationDto) {
        $dto = $sessionData;

        // Synchroniser les references avec les participants selectionnes
        $this->referenceSynchronizer->sync($dto);

        $request->getSession()->set($sessionKey, $dto);
    }

    return $dto;
}
Quand appeler sync() : A chaque chargement du wizard, avant d'afficher l'etape cible.
Schema de synchronisation
flowchart LR subgraph "Etape 2 (Source)" S1[☑ Alice] S2[☑ Bob] S3[☐ Charlie] end subgraph "sync()" SYNC[AbstractStepSynchronizer] end subgraph "Etape 3 (Cible)" T1[Alice → Manager: Jean] T2[Bob → Manager: Marie] T3[Charlie supprime] end S1 --> SYNC S2 --> SYNC S3 -.-> SYNC SYNC --> T1 SYNC --> T2 SYNC -.->|remove| T3 style S3 fill:#f8d7da style T3 fill:#f8d7da style SYNC fill:#d1e7dd
Diagramme de classe
classDiagram class AbstractStepSynchronizer { <<abstract>> +sync(dto) void #getSourceIds(dto)* array #getTargetCollection(dto)* Collection #getItemSourceId(item)* int #createItem(sourceId)* object #hydrateItem(item) void } class TrainingCreationReferenceSynchronizer { -profileRepository -referenceResolver #getSourceIds(dto) array #getTargetCollection(dto) Collection #getItemSourceId(item) int #createItem(sourceId) object #hydrateItem(item) void } class OtherWizardSynchronizer { #getSourceIds(dto) array #getTargetCollection(dto) Collection #getItemSourceId(item) int #createItem(sourceId) object } AbstractStepSynchronizer <|-- TrainingCreationReferenceSynchronizer AbstractStepSynchronizer <|-- OtherWizardSynchronizer note for AbstractStepSynchronizer "Template Method Pattern\nsync() = algorithme fixe\ngetSourceIds(), createItem() = parties variables"
AbstractStepperStateBuilder
src/Service/Form/Flow/AbstractStepperStateBuilder.php
Abstract Class Template Method Pattern Reutilisable
Probleme resolu

Lors de l'affichage d'un wizard multi-etapes, il faut determiner :

  • Quelles etapes sont visibles (certaines peuvent etre "skippees")
  • A quelle position se trouve l'utilisateur dans le flow
  • Comment construire l'etat du stepper pour le template Twig
Solution : Template Method Pattern

La classe abstraite fournit un algorithme commun buildState() tout en deleguant la recuperation de l'etape courante a une methode abstraite getCurrentStep().

<?php

namespace App\Service\Form\Flow;

use Symfony\Component\Form\Flow\FormFlowInterface;

abstract class AbstractStepperStateBuilder
{
    /**
     * Construit l'état du stepper pour l'affichage dans le template.
     *
     * @param FormFlowInterface $flow Le flow Symfony Form Flow en cours
     * @param object $data Le DTO contenant les données du wizard
     * @return array{visible_steps: list<array{name: string, index: int}>, cursor_index: int}
     */
    public function buildState(FormFlowInterface $flow, object $data): array
    {
        // 1. Récupère la config du flow
        $config = $flow->getConfig();

        // 2. Extraire l'ordre des étapes (tableau des noms d'étapes)
        $stepOrder = array_keys($config->getSteps());

        // 3. Récupérer l'étape courante depuis le DTO via la méthode abstraite
        $currentStep = $this->getCurrentStep($data);

        // 4. Trouver l'index de l'étape courante dans le tableau
        $cursorIndex = array_search($currentStep, $stepOrder, true);
        if (false === $cursorIndex) {
            $cursorIndex = 0;
        }

        // 5. Construire la liste des étapes visibles (en excluant les étapes skippées)
        $visibleSteps = [];
        foreach ($stepOrder as $index => $stepName) {
            if ($config->getStep($stepName)->isSkipped($data)) {
                continue;
            }
            $visibleSteps[] = [
                'name' => $stepName,
                'index' => $index,
            ];
        }

        return [
            'visible_steps' => $visibleSteps,
            'cursor_index' => (int) $cursorIndex,
        ];
    }

    /**
     * Méthode abstraite : chaque wizard définit comment récupérer l'étape courante.
     */
    abstract protected function getCurrentStep(object $data): string;
}

<?php

namespace App\Service\Workspace\Scoring\TrainingCreation\Flow;

use App\Form\Scoring\TrainingCreation\Data\TrainingCreationDto;
use App\Service\Form\Flow\AbstractStepperStateBuilder;

/**
 * Implémentation concrète pour le wizard Training.
 */
final class TrainingCreationStepperStateBuilder extends AbstractStepperStateBuilder
{
    protected function getCurrentStep(object $data): string
    {
        // Cast du DTO générique vers le type spécifique
        assert($data instanceof TrainingCreationDto);

        // Retourne le nom de l'étape courante stocké dans le DTO
        return $data->currentStep ?? 'step1_basic_info';
    }
}
Avantage : Pour créer un nouveau wizard, il suffit d'étendre cette classe et d'implémenter uniquement getCurrentStep().
Diagramme de classe
classDiagram class AbstractStepperStateBuilder { <<abstract>> +buildState(flow, data) array #getCurrentStep(data)* string } class TrainingCreationStepperStateBuilder { #getCurrentStep(data) string } class OtherWizardStepperBuilder { #getCurrentStep(data) string } AbstractStepperStateBuilder <|-- TrainingCreationStepperStateBuilder AbstractStepperStateBuilder <|-- OtherWizardStepperBuilder note for AbstractStepperStateBuilder "Template Method Pattern\nbuildState() = algorithme fixe\ngetCurrentStep() = partie variable"
FlowNavigatorType
src/Form/Flow/Type/FlowNavigatorType.php
Form Type Symfony Form Flow Reutilisable
Probleme resolu

Chaque wizard multi-etapes necessite des boutons de navigation (Precedent, Suivant, Terminer). Sans composant reutilisable, ce code est duplique dans chaque FormType de wizard.

Solution : Form Type reutilisable

FlowNavigatorType encapsule tous les boutons de navigation avec :

  • Affichage conditionnel automatique (premier/dernier step)
  • Labels et styles configurables via les options
  • Bouton "Enregistrer brouillon" optionnel
Options disponibles
Option Type Default Description
previous_label string 'Precedent' Label du bouton retour
next_label string 'Continuer' Label du bouton suivant
finish_label string 'Terminer' Label du bouton final
save_draft_label string 'Enregistrer et fermer' Label du bouton brouillon
previous_attr array ['class' => 'btn btn-outline-secondary'] Attributs HTML du bouton
next_attr array ['class' => 'btn btn-primary'] Attributs HTML du bouton
finish_attr array ['class' => 'btn btn-success'] Attributs HTML du bouton
save_draft_attr array ['class' => 'btn btn-outline-primary'] Attributs HTML du bouton
show_save_draft bool false Afficher le bouton brouillon
label_html bool false Autoriser HTML dans les labels

<?php

namespace App\Form\Flow\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Flow\FormFlowCursor;
use Symfony\Component\Form\Flow\ButtonFlowInterface;
use Symfony\Component\Form\Flow\Type\FinishFlowType;
use Symfony\Component\Form\Flow\FormFlowInterface;
use Symfony\Component\Form\Flow\Type\NextFlowType;
use Symfony\Component\Form\Flow\Type\PreviousFlowType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class FlowNavigatorType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            // BOUTON PRÉCÉDENT
            // Visible seulement si on n'est pas au premier step
            ->add('previous', PreviousFlowType::class, [
                'label' => $options['previous_label'],
                'label_html' => $options['label_html'],
                'translation_domain' => false,
                'include_if' => fn (FormFlowCursor $cursor) => !$cursor->isFirstStep(),
                'clear_submission' => false,
                'validate' => false,
                'validation_groups' => [],
                'attr' => $options['previous_attr'],
            ])

            // BOUTON SUIVANT
            // Visible seulement si on n'est pas au dernier step
            ->add('next', NextFlowType::class, [
                'label' => $options['next_label'],
                'label_html' => $options['label_html'],
                'translation_domain' => false,
                'include_if' => fn (FormFlowCursor $cursor) => !$cursor->isLastStep(),
                'attr' => $options['next_attr'],
            ])

            // BOUTON TERMINER
            // Visible seulement au dernier step
            ->add('finish', FinishFlowType::class, [
                'label' => $options['finish_label'],
                'label_html' => $options['label_html'],
                'translation_domain' => false,
                'include_if' => fn (FormFlowCursor $cursor) => $cursor->isLastStep(),
                'attr' => $options['finish_attr'],
            ]);

        // BOUTON BROUILLON (Optionnel)
        // Toujours visible si activé, ne valide pas le formulaire
        if ($options['show_save_draft']) {
            $builder->add('saveDraft', FinishFlowType::class, [
                'label' => $options['save_draft_label'],
                'translation_domain' => false,
                'include_if' => fn (FormFlowCursor $cursor) => true,
                'handler' => fn (mixed $data, ButtonFlowInterface $button, FormFlowInterface $flow) => null,
                'validate' => false,
                'attr' => $options['save_draft_attr'],
            ]);
        }
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'label' => false,
            'mapped' => false,
            'label_html' => false,

            'previous_label' => 'Précédent',
            'next_label' => 'Continuer',
            'finish_label' => 'Terminer',
            'save_draft_label' => 'Enregistrer et fermer',

            'previous_attr' => ['class' => 'btn btn-outline-secondary'],
            'next_attr' => ['class' => 'btn btn-primary'],
            'finish_attr' => ['class' => 'btn btn-success'],
            'save_draft_attr' => ['class' => 'btn btn-outline-primary'],

            'show_save_draft' => false,
        ]);
    }
}

<?php

// Dans TrainingCreationFlowType.php (ou tout autre wizard)

use App\Form\Flow\Type\FlowNavigatorType;

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    // ... ajout des champs du formulaire ...

    // Ajout des boutons de navigation
    $builder->add('navigator', FlowNavigatorType::class, [
        // Labels personnalisés avec icônes HTML
        'previous_label' => '<i class="fa-solid fa-arrow-left me-1"></i> Retour',
        'next_label' => 'Continuer <i class="fa-solid fa-arrow-right ms-1"></i>',
        'finish_label' => '<i class="fa-solid fa-check me-1"></i> Valider',
        'save_draft_label' => '<i class="fa-solid fa-floppy-disk me-1"></i> Brouillon',

        // Activer le rendu HTML dans les labels
        'label_html' => true,

        // Activer le bouton brouillon
        'show_save_draft' => true,

        // Classes CSS personnalisées
        'next_attr' => ['class' => 'btn btn-primary btn-lg'],
        'finish_attr' => ['class' => 'btn btn-success btn-lg'],
    ]);
}
Note : Le composant utilise FormFlowCursor pour determiner automatiquement quel bouton afficher selon la position dans le wizard.

{# templates/workspace/scoring/training_creation/_form.html.twig #}

<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
    {# Bouton Précédent (à gauche) #}
    <div>
        {{ form_widget(form.navigator.previous) }}
    </div>

    {# Boutons à droite : Brouillon + Suivant/Terminer #}
    <div class="d-flex gap-2">
        {% if form.navigator.saveDraft is defined %}
            {{ form_widget(form.navigator.saveDraft) }}
        {% endif %}
        {{ form_widget(form.navigator.next) }}
        {{ form_widget(form.navigator.finish) }}
    </div>
</div>
Comportement selon la position
flowchart LR subgraph "Étape 1 (Première)" A1[❌ Précédent] --> A2[✅ Suivant] A2 --> A3[❌ Terminer] end subgraph "Étapes intermédiaires" B1[✅ Précédent] --> B2[✅ Suivant] B2 --> B3[❌ Terminer] end subgraph "Dernière étape" C1[✅ Précédent] --> C2[❌ Suivant] C2 --> C3[✅ Terminer] end style A1 fill:#f8d7da style A3 fill:#f8d7da style B3 fill:#f8d7da style C2 fill:#f8d7da style A2 fill:#d1e7dd style B1 fill:#d1e7dd style B2 fill:#d1e7dd style C1 fill:#d1e7dd style C3 fill:#d1e7dd
Systeme de Pagination
Trait + Extension Twig + Template + EventListener
Doctrine Twig Extension Event Subscriber Reutilisable
Probleme resolu

Gerer la pagination dans un contexte de wizard multi-etapes pose plusieurs defis :

  • Paginer les resultats Doctrine de maniere standardisee
  • Generer les URLs de pagination en conservant les parametres de route
  • Afficher une pagination "intelligente" (fenetre glissante)
  • Soumettre le formulaire lors d'un changement de page dans un wizard
  • Conserver les selections et les tiers de reference entre les pages
Architecture en 4 couches
1. TraitPaginate
src/Repository/Traits/
Trait Doctrine reutilisable dans les repositories
2. PaginationExtension
src/Extension/
Fonctions Twig pour URLs et affichage
3. Template Twig
templates/common/parts/
pagination.html.twig pour GET, form_pagination.html.twig pour formulaires
4. MergeListener
src/EventListener/Form/
Fusion des selections entre pages

<?php

namespace App\Repository\Traits;

use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator;

/**
 * Trait reutilisable pour paginer les resultats Doctrine.
 * @template T of object
 */
trait TraitPaginate
{
    /**
     * @return array{items: T[], total: int, page: int, limit: int, pages: int}
     */
    private function paginate(QueryBuilder $queryBuilder, int $page = 1, int $limit = 10): array
    {
        $offset = ($page - 1) * $limit;

        $queryBuilder->setMaxResults($limit)
            ->setFirstResult($offset);

        $paginator = new Paginator($queryBuilder);
        $totalResults = count($paginator);

        return [
            'items' => iterator_to_array($paginator),
            'total' => $totalResults,
            'page' => $page,
            'limit' => $limit,
            'pages' => (int) ceil($totalResults / $limit),
        ];
    }
}
Utilisation : use TraitPaginate; dans n'importe quel Repository Doctrine.

<?php

namespace App\Extension;

use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigTest;

class PaginationExtension extends AbstractExtension
{
    public function __construct(
        private readonly RouterInterface $router,
        private readonly RequestStack $requestStack,
    ) {}

    public function getFunctions(): array
    {
        return [
            // Genere l'URL pour une page donnee
            new TwigFunction('paginate', $this->paginate(...)),
            // Retourne les numeros de pages a afficher (fenetre glissante)
            new TwigFunction('short_pagination', $this->getShortPagination(...)),
        ];
    }

    public function getTests(): array
    {
        return [
            // Test si des pages sont cachees avant la fenetre
            new TwigTest('short_pagination_hidden_before', ...),
            // Test si des pages sont cachees apres la fenetre
            new TwigTest('short_pagination_hidden_after', ...),
        ];
    }

    /**
     * Genere l'URL de pagination en conservant tous les parametres.
     */
    public function paginate(int $page, bool $relative = false): string
    {
        $request = $this->requestStack->getMainRequest();
        $name = $request->get('_route');
        $parameters = $request->get('_route_params');
        $queryParams = $request->query->all();

        // Retirer le parametre page existant
        unset($queryParams['page']);

        return $this->router->generate($name, [
            ...$parameters,
            ...$queryParams,
            ...(1 === $page ? [] : ['page' => $page]),
        ]);
    }

    /**
     * Fenetre glissante : affiche current-2 a current+2.
     * Ex: page 5 sur 10 → [3, 4, 5, 6, 7]
     */
    public function getShortPagination(int $total, int $current): array
    {
        $floor = max(1, $current - 2);
        $ceil = min($total, $current + 2);
        return range($floor, $ceil);
    }
}

{# templates/common/parts/pagination.html.twig #}
{# Pagination GET classique : liens <a href="{{ paginate(page) }}"> #}

{# templates/common/parts/form_pagination.html.twig #}
{# Pagination dans un formulaire : boutons submit + page cible #}
<button
    type="submit"
    class="page-link"
    name="_target_page"
    value="{{ page }}"
    formaction="{{ paginate(current) }}"
    formmethod="post"
    formnovalidate
>
    {{ page }}
</button>
Regle : utiliser pagination.html.twig pour les listes GET classiques, et form_pagination.html.twig dans le wizard pour declencher les MergeListener avant le changement de page.

Probleme : Quand l'utilisateur change de page, seules les checkboxes de la page courante sont soumises. Les selections des autres pages seraient perdues !
<?php

namespace App\EventListener\Form;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

/**
 * Fusionne les selections entre les pages de pagination.
 */
class ParticipantSelectionMergeListener implements EventSubscriberInterface
{
    /**
     * @param int[] $currentPageIds IDs affiches sur la page courante
     */
    public function __construct(
        private readonly array $currentPageIds,
    ) {}

    /** @var int[] */
    private array $previouslySelected = [];

    public static function getSubscribedEvents(): array
    {
        return [
            FormEvents::PRE_SUBMIT => 'storePreviousSelection',
            FormEvents::SUBMIT => 'mergeSelection',
        ];
    }

    public function storePreviousSelection(FormEvent $event): void
    {
        $existingData = $event->getForm()->getData();
        $this->previouslySelected = array_map('intval', $existingData?->selectedParticipantIds ?? []);
    }

    public function mergeSelection(FormEvent $event): void
    {
        $data = $event->getData();
        $checkedOnCurrentPage = array_map('intval', $data->selectedParticipantIds);
        $fromOtherPages = array_diff($this->previouslySelected, $this->currentPageIds);

        $data->selectedParticipantIds = array_values(array_unique(array_merge(
            $fromOtherPages,
            $checkedOnCurrentPage,
        )));

        $event->setData($data);
    }
}

<?php

// Dans ParticipantSelectionType.php

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    // ... autres champs ...

    // Ajouter le listener avec les IDs de la page courante
    $builder->addEventSubscriber(
        new ParticipantSelectionMergeListener(
            currentPageIds: array_map(
                fn($p) => $p->getId(),
                $options['paginated_participants']['items'] ?? []
            )
        )
    );
}

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'paginated_participants' => [], // Resultat de TraitPaginate
    ]);
}
Flux de conservation des selections
sequenceDiagram participant User participant Page1 as Page 1 (IDs 1-10) participant Page2 as Page 2 (IDs 11-20) participant Listener as MergeListener participant Session as FormFlow Session User->>Page1: Coche IDs [2, 5, 8] Page1->>Session: Stocke [2, 5, 8] User->>Page2: Navigue vers page 2 Note over Page2: Affiche IDs 11-20 User->>Page2: Coche IDs [12, 15] Page2->>Listener: PRE_SUBMIT memorise [2, 5, 8] Page2->>Listener: SUBMIT recoit choix valides [12, 15] Listener->>Listener: Garde autres pages: [2, 5, 8] Listener->>Listener: Fusionne: [2, 5, 8] + [12, 15] Listener->>Session: Stocke [2, 5, 8, 12, 15] Note over Session: Selections conservees !
Pattern Modal Stimulus
assets/controllers/ + templates/modals/
Stimulus JS Bootstrap Modal Reutilisable
Probleme resolu

Implementer des modales interactives qui :

  • Se pre-remplissent avec les donnees de l'element declencheur
  • Mettent a jour le formulaire Symfony sans rechargement de page
  • Filtrent dynamiquement les options d'un select
  • Gerent correctement le focus et l'accessibilite
Solution : Controller Stimulus + Template Twig
Controller Stimulus
  • Ecoute les evenements Bootstrap (show.bs.modal, hidden.bs.modal)
  • Utilise les data-* attributes du trigger
  • Event delegation pour les actions du tableau
  • Mise a jour du DOM sans recharger la page
Template Twig
  • Structure Bootstrap Modal standard
  • Targets Stimulus pour les zones dynamiques
  • Actions Stimulus sur les inputs/boutons
  • Partial reutilisable (_modal.html.twig)

import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';

/**
 * Pattern reutilisable pour une modal interactive.
 * A adapter selon le contexte metier.
 */
export default class extends Controller {
    // Targets = elements dynamiques de la modal
    static targets = [
        'participantId',      // Input hidden pour l'ID
        'participantName',    // Zone d'affichage du nom
        'searchInput',        // Champ de recherche/filtre
        'referenceSelect',    // Select des options
    ];

    // Configuration statique (labels, classes CSS...)
    static statusLabels = {
        to_complete: 'A completer',
        confirmed: 'Confirme',
    };

    connect() {
        // Reference au bouton declencheur (pour restaurer le focus)
        this.triggerElement = null;

        // Ecouter l'ouverture de la modal
        this.element.addEventListener('show.bs.modal', (event) => {
            this.open(event);
        });

        // Gerer le focus avant fermeture (accessibilite)
        this.element.addEventListener('hide.bs.modal', () => {
            if (this.element.contains(document.activeElement)) {
                document.activeElement.blur();
            }
        });

        // Reset apres fermeture + restaurer focus
        this.element.addEventListener('hidden.bs.modal', () => {
            this.reset();
            this.restoreTriggerFocus();
        });

        // Event delegation pour les boutons du tableau
        this.handleEditClick = this.handleEditClick.bind(this);
        document.addEventListener('click', this.handleEditClick);
    }

    disconnect() {
        document.removeEventListener('click', this.handleEditClick);
    }

    handleEditClick(event) {
        const button = event.target.closest('.js-edit-button');
        if (button) {
            event.preventDefault();
            this.triggerElement = button;
            this.fillFromTrigger(button);
            Modal.getOrCreateInstance(this.element).show();
        }
    }

    /**
     * Pre-remplit la modal depuis les data-* du trigger.
     */
    fillFromTrigger(trigger) {
        this.participantIdTarget.value = trigger.dataset.participantId || '';
        this.participantNameTarget.textContent = trigger.dataset.participantName || '-';
        this.referenceSelectTarget.value = trigger.dataset.currentReferenceId || '';
    }

    /**
     * Filtre les options du select.
     */
    filterReferences() {
        const query = this.searchInputTarget.value.toLowerCase().trim();
        const options = this.referenceSelectTarget.querySelectorAll('option[data-name]');

        options.forEach((option) => {
            const name = option.dataset.name || '';
            option.style.display = name.includes(query) ? '' : 'none';
        });
    }

    /**
     * Sauvegarde et met a jour le formulaire Symfony.
     */
    save() {
        const participantId = this.participantIdTarget.value;
        const referenceId = this.referenceSelectTarget.value;

        // Mettre a jour le champ Symfony (select hidden)
        const formInput = document.querySelector(
            `select[data-reference-input-for="${participantId}"]`
        );
        if (formInput) {
            formInput.value = referenceId;
            formInput.dispatchEvent(new Event('change', { bubbles: true }));
        }

        // Mettre a jour l'affichage dans le tableau
        // ... (selon le contexte metier)

        Modal.getInstance(this.element)?.hide();
    }

    reset() {
        this.participantIdTarget.value = '';
        this.participantNameTarget.textContent = '-';
        this.searchInputTarget.value = '';
        this.filterReferences();
    }

    restoreTriggerFocus() {
        if (this.triggerElement && document.contains(this.triggerElement)) {
            this.triggerElement.focus();
        }
        this.triggerElement = null;
    }
}

{# templates/modals/_example_modal.html.twig #}

<div
    class="modal fade"
    id="example-modal"
    tabindex="-1"
    aria-labelledby="example-modal-label"
    aria-hidden="true"
    data-controller="example-modal"
>
    <div class="modal-dialog modal-dialog-centered modal-lg">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title" id="example-modal-label">
                    Modification de :
                    <span data-example-modal-target="participantName">-</span>
                </h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
            </div>

            <div class="modal-body">
                {# Input hidden pour stocker l'ID #}
                <input type="hidden" data-example-modal-target="participantId">

                {# Zone de recherche #}
                <div class="mb-3">
                    <label class="form-label">Recherche</label>
                    <input
                        type="search"
                        class="form-control"
                        data-example-modal-target="searchInput"
                        data-action="input->example-modal#filterReferences"
                    >
                </div>

                {# Select des options #}
                <div class="mb-3">
                    <label class="form-label">Selection</label>
                    <select class="form-select" data-example-modal-target="referenceSelect">
                        <option value="">- Selectionner -</option>
                        {% for item in available_items %}
                            <option value="{{ item.id }}" data-name="{{ item.name|lower }}">
                                {{ item.name }}
                            </option>
                        {% endfor %}
                    </select>
                </div>
            </div>

            <div class="modal-footer">
                <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
                    Annuler
                </button>
                <button type="button" class="btn btn-primary" data-action="click->example-modal#save">
                    Enregistrer
                </button>
            </div>
        </div>
    </div>
</div>

{# Bouton declencheur dans le tableau #}
<button
    type="button"
    class="btn btn-sm btn-outline-primary js-edit-button"
    data-bs-toggle="modal"
    data-bs-target="#example-modal"
    data-participant-id="{{ participant.id }}"
    data-participant-name="{{ participant.fullName }}"
    data-current-reference-id="{{ participant.referenceId }}"
    data-current-reference-name="{{ participant.referenceName }}"
>
    <i class="fa-solid fa-pen"></i> Modifier
</button>
Principe : Les donnees sont passees via les data-* attributes du bouton. Le controller Stimulus les lit pour pre-remplir la modal.
Architecture du pattern Modal
sequenceDiagram participant User participant Trigger as Bouton Trigger participant Controller as Stimulus Controller participant Modal as Bootstrap Modal participant Form as Formulaire Symfony User->>Trigger: Click Trigger->>Controller: handleEditClick() Controller->>Controller: fillFromTrigger(data-*) Controller->>Modal: show() Modal->>User: Affiche modal pre-remplie User->>Controller: Modifie + click Save Controller->>Form: Met a jour select[data-reference-input-for] Controller->>Controller: Met a jour affichage tableau Controller->>Modal: hide() Modal->>Controller: hidden.bs.modal Controller->>Controller: reset() + restoreTriggerFocus() Controller->>Trigger: focus()
Checklist pour reutiliser ce pattern
A faire
  1. Creer le controller Stimulus dans assets/controllers/
  2. Definir les static targets selon les zones dynamiques
  3. Creer le template Twig dans templates/modals/
  4. Ajouter data-controller sur la div modal
  5. Ajouter data-*-target sur les elements dynamiques
  6. Ajouter data-action sur les inputs/boutons
  7. Passer les donnees via data-* sur le trigger
Points d'attention
  • Toujours gerer hidden.bs.modal pour reset
  • Restaurer le focus sur le trigger (accessibilite)
  • Utiliser blur() avant hide()
  • Nettoyer les event listeners dans disconnect()
  • Utiliser event delegation pour les boutons du tableau
Points cles pour la reutilisabilite
Pattern FlowHandler
  • Pattern : Coordinator + Interface
  • Principe : Controller minimal, logique isolee
  • Reutiliser : Copier structure + adapter methodes
AbstractStepSynchronizer
  • Pattern : Template Method
  • Principe : Sync source → cible entre etapes
  • Reutiliser : Etendre + implementer 4 methodes
AbstractStepperStateBuilder
  • Pattern : Template Method
  • Principe : Algorithme fixe + partie variable
  • Reutiliser : Etendre + getCurrentStep()
FlowNavigatorType
  • Pattern : Form Type configurable
  • Principe : Options + affichage conditionnel
  • Reutiliser : Ajouter dans buildForm()
Systeme Pagination
  • Pattern : Trait + Extension + Listener
  • Principe : Separation des responsabilites
  • Reutiliser : use TraitPaginate + include
Pattern Modal Stimulus
  • Pattern : Controller + Template
  • Principe : Data attributes + Events
  • Reutiliser : Copier + adapter targets

Tests

Objectif : Les tests ciblent d'abord le coeur metier du wizard, puis les mecanismes de synchronisation et de validation. Ils sont lances dans le conteneur Docker PHP pour utiliser la meme version PHP que l'application.
P1 - Persistance

Verifie que le wizard transforme correctement les DTOs en entites persistables : formation active, brouillon, participants, sessions et tiers de reference.

  • Creation d'une formation active
  • Sauvegarde en brouillon
  • Mise a jour d'un brouillon
  • Rollback si erreur base de donnees
P1/P2 - Synchronisation

Securise les donnees paginees du wizard. Les selections faites sur une page ne doivent pas etre perdues quand l'utilisateur change de page.

  • Participants conserves entre pages
  • Tiers de reference conserves entre pages
  • References ajoutees ou supprimees selon la selection
  • Donnees d'affichage hydratees
P2 - Validation

Verifie les contraintes des DTOs avant la persistance. Ces tests bloquent les donnees incompletes ou incoherentes au niveau formulaire.

  • Titre obligatoire
  • Date de fin apres date de debut
  • Au moins un participant
  • Tous les tiers confirmes
  • Statuts de reference valides
Fichiers de tests ajoutes
tests/Service, tests/Form
Fichier Role Priorite
TrainingCreationOrchestratorServiceTest.php Controle la persistance metier du wizard : formation, brouillon, relations, transaction. P1
TrainingCreationReferenceSynchronizerTest.php Controle la synchronisation entre participants selectionnes et tiers de reference. P1
ParticipantSelectionMergeListenerTest.php Controle la fusion des participants selectionnes entre pages de pagination. P1
ReferenceThirdPartyMergeListenerTest.php Controle la fusion des tiers de reference entre pages de pagination. P2
TrainingCreationReferenceResolverTest.php Controle la resolution automatique du manager / N+1. P2
TrainingCreationDtoValidationTest.php Controle les contraintes de validation des DTOs du wizard. P2
Scenarios testes
Ce que chaque test verifie concretement
Persistance du wizard
  • Creation active : un DTO complet cree une formation active avec ses participants, sessions et tiers de reference.
  • Brouillon : une sauvegarde avec titre vide donne le libelle par defaut Brouillon formation et le statut DRAFT.
  • Reprise brouillon : une formation existante est mise a jour, et les anciennes relations wizard sont remplacees.
  • Transaction : si flush() echoue, le service appelle rollback() et ne fait pas de commit().
Synchronisation et pagination
  • Participants : les selections faites sur les autres pages restent conservees.
  • Deselection : seuls les participants decoches sur la page courante sont retires.
  • Tiers de reference : les modifications des autres pages sont conservees, celles de la page courante sont remplacees.
  • References : les references sont ajoutees pour les nouveaux participants et supprimees pour les participants retires.
  • Soumissions vides : une page sans selection conserve uniquement les donnees hors page courante.
  • Donnees invalides : les elements soumis non conformes sont ignores sans casser le traitement.
  • IDs : les doublons sont supprimes et les IDs texte sont convertis en entiers.
  • Page non contrainte : si la liste des profils de page courante est vide, tous les tiers soumis sont acceptes.
Resolution du tiers de reference
  • Manager trouve : si le management a un directeur different du participant, il devient le tiers propose.
  • Aucun management : le resolver retourne null.
  • Aucun directeur : le resolver retourne null.
  • Auto-reference : si le directeur est le participant lui-meme, aucun tiers n'est propose.
Validation formulaire
  • Infos formation : le titre est obligatoire et la date de fin doit etre apres la date de debut.
  • Participants : au moins un participant doit etre selectionne.
  • Tiers : toutes les references doivent etre confirmees avant de continuer.
  • Statuts : seuls confirmed, to_complete et to_confirm sont acceptes.
Logique de couverture
graph LR DTO[DTOs valides] --> ORCH[OrchestratorService] ORCH --> TRAINING[Training] ORCH --> PARTICIPANTS[TrainingParticipant] ORCH --> SESSIONS[TrainingSession] SELECT[Selection paginee] --> PML[ParticipantSelectionMergeListener] PML --> SYNC[ReferenceSynchronizer] SYNC --> RESOLVER[ReferenceResolver] SYNC --> REFERENCES[Tiers de reference] VALIDATION[Validation DTO] --> DTO
Commande de verification

Les tests doivent etre executes dans Docker pour utiliser PHP 8.4 et l'environnement du projet.

docker compose exec php php bin/phpunit \
  tests/Service/Scoring/TrainingCreation/TrainingCreationOrchestratorServiceTest.php \
  tests/Service/Scoring/TrainingCreation/TrainingCreationReferenceSynchronizerTest.php \
  tests/Service/EventListener/Form/ParticipantSelectionMergeListenerTest.php \
  tests/Service/EventListener/Form/ReferenceThirdPartyMergeListenerTest.php \
  tests/Service/Scoring/TrainingCreation/TrainingCreationReferenceResolverTest.php \
  tests/Form/Scoring/TrainingCreation/Data/TrainingCreationDtoValidationTest.php
Resultat attendu : 33 tests, 104 assertions, aucun echec.
Resultats des controles qualite
Commandes executees dans Docker
Controle Commande Resultat
PHPUnit cible docker compose exec php php bin/phpunit ... OK 33 tests, 104 assertions
PHPStan docker compose exec php php -d memory_limit=-1 vendor/bin/phpstan --no-progress OK aucune erreur
PHP-CS-Fixer docker compose exec php php -d memory_limit=-1 vendor/bin/php-cs-fixer check OK aucun fichier a corriger
PHPCS docker compose exec php php -d memory_limit=-1 vendor/bin/phpcs OK aucune erreur de style
PHPMD docker compose exec php php -d memory_limit=-1 vendor/bin/phpmd src text phpmd.xml.dist Partiel reste des alertes structurelles hors tests
PHPMD : les alertes restantes concernent surtout des choix de structure existants : entite Training avec beaucoup de champs, parametres imposes par Symfony non utilises, et hooks volontairement extensibles. Les alertes deja traitees dans cette passe sont IfStatementAssignment dans TrainingCreationFlowHandler et ExcessiveMethodLength dans WorkspaceTrainingType.

Resume Pedagogique

DTOs

Objets de transfert de donnees permettant le decouplage entre formulaires et entites. Validation par groupes pour chaque etape du wizard.

Services

Logique metier separee des controleurs. Injection de dependances via le constructeur. Responsabilite unique par service.

Form Flow

Composant Symfony pour wizards multi-etapes. Persistance en session entre les requetes. Navigation precedent/suivant automatique.

Event Subscribers

Interceptent les evenements du formulaire. Permettent la fusion des donnees paginees. PRE_SUBMIT pour modifier les donnees avant validation.

Doctrine

ORM pour la persistance des entites. Relations OneToMany avec cascade. Transactions pour l'integrite des donnees.

Stimulus

Framework JavaScript leger de Hotwired. Controllers avec targets et actions. Integration native avec Symfony UX.

Bonnes pratiques observees
  • Separation des responsabilites : Controller, Services, DTOs, Entities
  • Pattern Value Object : ResolvedReference (immutable)
  • Transactions Doctrine : begin/commit/rollback dans createTraining()
  • Validation declarative : Contraintes sur les proprietes des DTOs
  • Securite : Voter pour controle d'acces fin
  • Reutilisabilite : AbstractStepperStateBuilder, FlowNavigatorType