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 :
- Informations de la formation - Saisie des donnees generales
- Selection des participants - Choix des collaborateurs a former
- Tiers de reference - Attribution des managers/referents
- Cohortes et sessions - Organisation des groupes Non finalisé
- Validation - Recapitulatif avant creation definitive
Stack Technique
- Backend Symfony 7
- ORM Doctrine
- Frontend Twig + Bootstrap 5
- JS Stimulus (Hotwired)
- Form Symfony Form Flow
Les fixtures ne définissent aucun director sur les entités Management.
Résultat : le dropdown tiers est vide et l'auto-résolution du N+1 ne se déclenche jamais.
numberOfSessions est saisi mais ne génère pas les sessions automatiquement.
participantIds est absent du form → les sessions sont créées en BDD sans aucun participant.
Dashboard de Synthese
Repartition des fichiers (53 total)
Architecture
Couches applicatives
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/
│ │ ├── TrainingCreationFlowService.php
│ │ ├── TrainingCreationFlowServiceInterface.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
ROLE_ORGANIZATION_HUMAN_RESOURCE •
Session : training_creation_flow_new | training_creation_flow_{id} •
CSRF : activé
Saisie des donnees generales de la formation
title, isCertifying, modality, isInternal, organizerName, startDate, endDate, expectedParticipants, contextValidation : NotBlank(title) endDate > startDate Positive(expectedParticipants)
Choix des collaborateurs via tableau pagine avec checkboxes
selectedParticipantIds[] (checkboxes)Validation : Count(min: 1)
Pagination : 10/page •
ParticipantSelectionMergeListener preserve les selections
Attribution des managers/N+1 pour chaque participant
participantProfileId, referenceThirdPartyId, hierarchicalLink, statusStatuts : to_complete to_confirm confirmed
Validation : Tous les participants doivent etre
confirmedAuto-resolution :
TrainingCreationReferenceResolver propose le N+1
Organisation des groupes de formation
numberOfSessions, sessions[], cohortStatusValidation : Positive(numberOfSessions)
Recapitulatif et confirmation (lecture seule)
Action : createTraining() → Status: ACTIVE
Structure session (TrainingCreationDto)
session['training_creation_flow_new'] = { // ou training_creation_flow_{id}
currentStep: 'trainingInfo' | 'participantSelection' | 'referenceThirdParty' | 'sessionsCohorts' | 'validationSummary',
trainingInfo: { title, isCertifying, modality, isInternal, organizerName, startDate, endDate, expectedParticipants, context },
participantSelection: { selectedParticipantIds: [int, ...] },
referenceThirdParty: { participantReferences: [{ participantProfileId, referenceThirdPartyId, hierarchicalLink, status }, ...] },
sessionsCohorts: { numberOfSessions, sessions: [{ sessionNumber, participantCount }, ...], cohortStatus }
}
Entites Doctrine
Training.php
Description
Entite principale representant une formation dans le domaine metier.
Traits utilises
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
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
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
Enums PHP
EnumTrainingStatus
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
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
Étape 1 : Informations formation
→ training
| Champ DTO (TrainingInfoDto) | Colonne BDD | Type | |
|---|---|---|---|
title |
renommé | label |
varchar(255) |
isCertifying |
direct | is_certifying |
boolean |
modality |
renommé | modality + type |
enum (EnumTrainingType) |
isInternal |
direct | is_internal |
boolean |
organizerName |
renommé | trainer_name |
varchar(255) |
startDate |
direct | start_date |
date |
endDate |
direct | end_date |
date |
expectedParticipants |
direct | expected_participants |
int |
context |
direct | context |
text |
| — | auto | status |
enum (DRAFT / ACTIVE) |
| — | auto | organization_id |
FK → organization |
Étape 2 : Participants
→ training_participant
| Champ DTO (ParticipantSelectionDto) | Colonne BDD | Type | |
|---|---|---|---|
selectedParticipantIds[] |
renommé | participant_profile_id |
FK → organization_user_profile |
| — | auto | training_id |
FK → training |
Étape 3 : Tiers référence
→ training_participant
| Champ DTO (ParticipantReferenceDto) | Colonne BDD | Type | |
|---|---|---|---|
referenceThirdPartyId |
direct | reference_third_party_id |
FK → organization_user_profile |
status |
renommé | reference_status |
varchar(32) |
hierarchicalLink |
non persisté | — | — |
hierarchicalLink est affiché dans le wizard mais n'est pas stocké en BDD.
Étape 4 : Sessions Non finalisé
→ training_session
| Champ DTO (SessionDto) | Colonne BDD | Type | |
|---|---|---|---|
sessionNumber |
direct | session_number |
int |
participantIds[] |
renommé | session_id sur training_participant |
FK → training_session |
| — | auto | training_id |
FK → training |
Schéma relationnel
DTOs (Data Transfer Objects)
TrainingCreationDto (Principal)
Conteneur principal regroupant tous les DTOs des etapes du wizard.
TrainingInfoDto (Etape 1)
| 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)
ReferenceThirdPartyDto (Etape 3)
Form Types
TrainingCreationFlowType (Flow Principal)
Definit la structure multi-etapes du wizard via Symfony Form Flow.
data_class: TrainingCreationDtostep_property_path: 'currentStep'data_storage: SessionDataStorage (persistance en session)auto_reset: false (permet la reprise)page: borne automatiquement dansReferenceThirdPartyTypepour eviter une page vide si l'utilisateur arrive depuis une page de participants hors limites
SessionsCohortsType + SessionEntryType Non finalisé
Gèrent l'étape 4 du wizard : nombre de sessions, sessions individuelles et statut de cohorte.
L'utilisateur saisit numberOfSessions = 3 mais rien ne crée automatiquement 3 SessionDto dans la collection.
Il faudrait un mécanisme JS ou un event listener qui initialise les sessions à partir de ce nombre.
participantIds absent du form
SessionDto a bien un champ participantIds[] mais SessionEntryType ne l'ajoute pas.
Les sessions sont créées en BDD sans aucun participant.
cohortStatus sans entité ni colonne BDD
cohortStatus est saisi dans le form et validé dans le DTO, mais aucune entité ne le stocke.
TrainingSession n'a pas de champ cohort_status, et l'orchestrator ne le persiste pas.
cohortStatus
Même si l'entité était créée, addSessions() dans l'orchestrator ne lit que sessionNumber et participantIds.
cohortStatus n'est jamais transmis à l'entité.
- JS sur
numberOfSessionspour générer les sous-formulaires dynamiquement - Ajouter
participantIdsdansSessionEntryType - Ajouter
cohort_statussurTrainingSession(migration) et son setter - Mettre à jour
addSessions()dans l'orchestrator pour persistercohortStatus
FlowNavigatorType
Gere les boutons de navigation du wizard.
Controllers
TrainingCreationController
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 |
Affiche la liste paginee des formations de l'organisation courante.
Lance le wizard pour creer une nouvelle formation. Delegue a TrainingCreationFlowService.
Reprend un brouillon existant. Utilise TrainingCreationPrefillService pour pre-remplir le DTO.
Affiche la page de confirmation apres creation et consomme l'identifiant stocke en session.
Affiche le detail d'une formation apres verification de son appartenance a l'organisation courante.
Services
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.
TrainingCreationFlowService
Orchestre le flux du wizard en coordonnant formulaire, validation et actions.
- Charger et synchroniser les donnees de session
- Creer et traiter le formulaire
- Gerer les cas : sync references, save draft, finish
- Afficher l'etape courante ou rediriger
Genere une cle de session dynamique pour isoler chaque wizard.
training = null→training_creation_flow_newtraining = {id: 42}→training_creation_flow_42
Charge le DTO depuis la session et synchronise les references.
- Si session existe → charge le DTO sauvegarde
- Appelle
referenceSynchronizer->sync() - Met a jour la session avec les donnees synchronisees
Gere la navigation entre pages (etapes 2 et 3 avec pagination).
- Detecte
_target_pagedans la requete - Sauvegarde le DTO en session
- Redirige vers la page cible
Synchronise les tiers de reference quand on arrive a l'etape 3.
- Active uniquement sur
currentStep = referenceThirdParty - Appelle
referenceSynchronizer->sync() - Redirige si soumission sans payload reference
Sauvegarde la formation en brouillon.
- Detecte clic sur bouton
saveDraft - Appelle
orchestrator->saveDraft() - Nettoie la session et redirige vers index
Finalise la creation de la formation.
- Verifie
isSubmitted && isValid && isFinished - Appelle
orchestrator->createTraining() - Stocke l'ID resultat en session et redirige vers results
Nettoie la session du flow apres sauvegarde ou finalisation.
Affiche l'etape courante du wizard.
- Construit l'etat du stepper via
stepperStateBuilder - Rend le template avec form, currentStep, data, stepper
TrainingCreationOrchestratorService
Service metier responsable de la creation/mise a jour des entites Training a partir du DTO.
- Transaction Doctrine
- Hydrate entite Training
- Ajoute participants
- Applique tiers de reference
- Cree sessions
- Status: ACTIVE
- Meme logique sans transaction complete
- Titre par defaut si vide
- Status: DRAFT
TrainingCreationPrefillService
Reconstruit le DTO du wizard a partir d'une formation brouillon lors de la reprise.
Logique de sélection d'un tiers de référence
Seuls les profils ayant managedDepartment IS NOT NULL ou managedOrganization IS NOT NULL apparaissent dans le <select> — les simples employés sont exclus.
À l'arrivée à l'étape 3, le synchronizer crée un ParticipantReferenceDto par participant et appelle le resolver :
- Stratégie 1 : cherche le
directordumanagementdu participant → pré-remplit le tiers,hierarchicalLink = "Manager / N+1",status = to_confirm - Aucun trouvé → tiers vide,
status = to_complete
| to_confirm | Tiers trouvé auto, l'utilisateur doit valider |
| to_complete | Aucun N+1 trouvé, sélection manuelle requise |
| confirmed | Validé par l'utilisateur |
Le formulaire est paginé (10 participants/page). Sur PRE_SUBMIT, le listener repart de l'état complet stocké en session et ne met à jour que les références de la page soumise — les autres pages ne sont jamais écrasées.
hierarchicalLink est affiché dans le wizard mais non persisté en BDD.TrainingCreationReferenceSynchronizer
Synchronise la liste des references avec les participants selectionnes.
- Supprime les references orphelines (participants desélectionnes)
- Charge les profils avec leurs relations (management)
- Pour les nouveaux participants, tente la resolution automatique du N+1
- Definit le statut :
to_confirmsi N+1 trouve,to_completesinon
TrainingCreationReferenceResolver
Resout automatiquement le tiers de reference (manager N+1) pour un participant.
Problème : fixtures incompatibles avec la sélection de tiers
Les fixtures actuelles ne définissent aucun manager, ce qui rend la feature de sélection de tiers non testable en dev/test.
La query filtre managedDepartment IS NOT NULL OR managedOrganization IS NOT NULL.
Aucun profil fixture n'a ces champs renseignés → le <select> est vide pour toutes les organisations.
Le resolver cherche $management->getDirector().
Les Management fixtures ne définissent pas de director → retourne toujours null → status = to_complete systématiquement.
Fixture actuelle (fixtures/dev/organization/management.yaml)
Fixture actuelle (fixtures/dev/user/profile/organization_user_profile.yaml — extrait)
Correction à apporter
profile_hr_organization_1auramanagedDepartment = direction_rhautomatiquement (inverse OneToOne)- Il apparaîtra dans le dropdown tiers pour les profils de
organization_1 - Les participants de
organization_1dansdirection_rhauront leur tiers auto-résolu → statusto_confirm
Repository
TrainingRepository
Recupere les formations d'une organisation avec leur eligibilite a une campagne.
Recupere les formations auxquelles un expert est associe via les campagnes.
Event Subscribers
ParticipantSelectionMergeListener
Fusionne les selections de participants entre les pages de pagination.
Evenements ecoutes
FormEvents::PRE_SUBMIT FormEvents::SUBMITLogique
PRE_SUBMITmemorise les IDs deja selectionnes avant mapping- Le
ChoiceTypevalide uniquement les choix visibles de la page courante SUBMITfusionne les choix valides avec les selections des autres pages- Les IDs hors page ne sont jamais reinjectes dans le payload brut du
ChoiceType
ReferenceThirdPartyMergeListener
Fusionne les modifications des tiers de reference entre les pages.
Evenement ecoute
FormEvents::PRE_SUBMITLogique
- Part de l'etat complet stocke dans le DTO de session
- Ignore les sous-formulaires incomplets sans
participantProfileId - Met a jour uniquement les references de la page courante
- Reconstruit un tableau numerique compatible avec
CollectionType
Securite
TrainingVoter
Controle l'acces aux formations pour les experts.
Attribut supporte
VIEWDiagramme de decision
Templates Twig
Template principal du wizard
Variables recues :
formcurrentStepdata(TrainingCreationDto)stepper_visible_steps
Etape 1 - Informations
Champs affiches :
- Titre, certifiant, modalite
- Interne/externe, organisateur
- Dates, effectif, contexte
Etape 2 - Participants
Fonctionnalites :
- Tableau pagine avec checkboxes
- Compteur de selections
- Pagination Bootstrap
Etape 3 - Tiers de reference
Fonctionnalites :
- Badges de statut colores
- Boutons : Modifier, Confirmer, Vider
- Modal de modification
Etape 4 - Sessions
Affichage :
- Nombre total participants
- Nombre de sessions
- Statut de la cohorte
Etape 5 - Validation
Recapitulatif :
- Toutes les informations saisies
- Statistiques (participants, sessions)
CSS du wizard
Gestion actuelle des styles
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
Exemples dans les templates
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
Controleur Stimulus gerant la modal de modification des tiers de reference.
Targets Stimulus
- participantId
- participantName
- currentReference
- hierarchicalLink
- searchInput
- referenceSelect
Evenements ecoutes
- Recupere participantId et referenceId selectionne
- Met a jour le champ hidden du formulaire Symfony
- Met a jour le statut (confirmed ou to_complete)
- Active/desactive le bouton Confirmer
- Ferme la modal Bootstrap
Met a jour le tiers d'un participant dans le formulaire, le tableau et les data-attributes.
Modales Bootstrap
_reference_third_party_modal.html.twig
Demonstration visuelle
Cycle de vie
Diagrammes Mermaid
Reutilisabilite
Pagination reutilisable du wizard
templates/common/parts/form_pagination.html.twigPourquoi 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.
common/parts/pagination.html.twig
- Utilise des balises
<a href> - Navigation GET simple
- Adapte pour les listes sans formulaire actif
common/parts/form_pagination.html.twig
- Utilise des boutons
type="submit" - Poste la page courante avant redirection
- Envoie
_target_pageauFlowService
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>
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.
AbstractStepSynchronizer
src/Service/Form/Flow/AbstractStepSynchronizer.phpProbleme 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 :
- Recuperer les IDs sources
- Supprimer les items orphelins
- Mettre a jour les items existants
- Creer les nouveaux items
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 TrainingCreationFlowService
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;
}
Schema de synchronisation
Diagramme de classe
AbstractStepperStateBuilder
src/Service/Form/Flow/AbstractStepperStateBuilder.phpProbleme 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';
}
}
getCurrentStep().
Diagramme de classe
Systeme de Pagination
Trait + Extension Twig + Template + EventListenerProbleme 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
src/Repository/Traits/
Trait Doctrine reutilisable dans les repositories
src/Extension/
Fonctions Twig pour URLs et affichage
templates/common/parts/
pagination.html.twig pour GET, form_pagination.html.twig pour formulaires
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),
];
}
}
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>
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.
<?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
Pattern Modal Stimulus
assets/controllers/ + templates/modals/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
- 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
- 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>
data-* attributes du bouton.
Le controller Stimulus les lit pour pre-remplir la modal.
Architecture du pattern Modal
Checklist pour reutiliser ce pattern
- Creer le controller Stimulus dans
assets/controllers/ - Definir les
static targetsselon les zones dynamiques - Creer le template Twig dans
templates/modals/ - Ajouter
data-controllersur la div modal - Ajouter
data-*-targetsur les elements dynamiques - Ajouter
data-actionsur les inputs/boutons - Passer les donnees via
data-*sur le trigger
- Toujours gerer
hidden.bs.modalpour reset - Restaurer le focus sur le trigger (accessibilite)
- Utiliser
blur()avanthide() - Nettoyer les event listeners dans
disconnect() - Utiliser event delegation pour les boutons du tableau
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
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
| 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
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 formationet le statutDRAFT. - Reprise brouillon : une formation existante est mise a jour, et les anciennes relations wizard sont remplacees.
- Transaction : si
flush()echoue, le service appellerollback()et ne fait pas decommit().
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_completeetto_confirmsont acceptes.
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
Resultats des controles qualite
| 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 |
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 TrainingCreationFlowService 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.
- 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