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
Détail des 5 étapes du Wizard
ROLE_ORGANIZATION_HUMAN_RESOURCESession : Clé dynamique
training_creation_flow_new (nouvelle création) ou training_creation_flow_{id} (reprise brouillon) • Protection CSRF activée
Informations de la formation
TrainingInfoType • TrainingInfoDtoChamps du formulaire
| Champ | Type | Requis |
|---|---|---|
title | TextType | |
isCertifying | ChoiceType (Oui/Non) | |
modality | EnumType (IN/HYBRID/REMOTE) | |
isInternal | ChoiceType (Interne/Externe) | |
organizerName | TextType | |
startDate | DateType | |
endDate | DateType | |
expectedParticipants | IntegerType | |
context | TextareaType |
Contraintes de validation
-
titleNotBlank -
titleLength(max: 255) -
endDateGreaterThan(startDate) -
expectedParticipantsPositive
trainingInfo.*
Sélection des participants
ParticipantSelectionType • ParticipantSelectionDtoChamps du formulaire
| Champ | Type | Requis |
|---|---|---|
selectedParticipantIds | ChoiceType (Checkboxes) |
ParticipantSelectionMergeListener pour préserver les sélections entre pages.
Contraintes de validation
-
selectedParticipantIdsCount(min: 1)
participantSelection.selectedParticipantIds[]
Tiers de référence
ReferenceThirdPartyType • ParticipantReferenceDtoChamps du formulaire (par participant)
| Champ | Type | Description |
|---|---|---|
participantProfileId | HiddenType | ID du participant |
referenceThirdPartyId | ChoiceType | ID du manager sélectionné |
hierarchicalLink | HiddenType | Type de lien (N+1, etc.) |
status | HiddenType | État de la ligne |
Contraintes de validation
-
statusChoice(['confirmed', 'to_complete', 'to_confirm']) - Callback Tous doivent être "confirmed"
TrainingCreationReferenceResolver.
referenceThirdParty.participantReferences[]
Sessions et Cohortes
SessionsCohortsType • SessionsCohortsDtoChamps du formulaire
| Champ | Type | Requis |
|---|---|---|
numberOfSessions | IntegerType (1-10) | |
sessions | CollectionType | |
└ sessionNumber | HiddenType | Numéro séquence |
└ participantCount | IntegerType (readonly) | Nb participants |
cohortStatus | ChoiceType |
Contraintes de validation
-
numberOfSessionsPositive (min: 1) -
cohortStatusChoice(['pending', 'in_progress', 'completed'])
sessionsCohorts.{numberOfSessions, sessions[], cohortStatus}
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
Cette étape permet de vérifier les données avant la création définitive.
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
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/
│ │ ├── 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
Saisie des donnees generales de la formation
Choix des collaborateurs via tableau pagine avec checkboxes
Attribution des managers/N+1 pour chaque participant
Organisation des groupes de formation
Recapitulatif et confirmation
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 Form (TrainingInfoDto) | Colonne BDD | Type |
|---|---|---|
title | label | varchar(255) |
isCertifying | is_certifying | boolean |
modality | modality + type | enum (EnumTrainingType) |
isInternal | is_internal | boolean |
organizerName | trainer_name | varchar(255) |
startDate | start_date | date |
endDate | end_date | date |
expectedParticipants | expected_participants | int |
context | context | text |
| (automatique) | status | enum (DRAFT / ACTIVE) |
| (automatique) | organization_id | FK → organization |
Étape 2 : Participants → training_participant
| Champ Form (ParticipantSelectionDto) | Colonne BDD | Type |
|---|---|---|
selectedParticipantIds[] | participant_profile_id | FK → organization_user_profile |
| (automatique) | training_id | FK → training |
Étape 3 : Tiers référence → training_participant
| Champ Form (ParticipantReferenceDto) | Colonne BDD | Type |
|---|---|---|
referenceThirdPartyId | reference_third_party_id | FK → organization_user_profile |
status | reference_status | varchar(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 |
|---|---|---|
sessionNumber | session_number | int |
participantIds[] | session_id sur training_participant | FK → training_session |
| (automatique) | training_id | FK → 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)
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
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 TrainingCreationFlowHandler.
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.
TrainingCreationFlowHandler
Orchestre le flux du wizard en coordonnant formulaire, validation et actions.
Flux de traitement
- 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
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.
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.
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_pageauFlowHandler
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.
Pattern FlowHandler
Service d'orchestration du flux wizardProbleme 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 :
- Recoit la requete HTTP
- Extrait les parametres
- Delegue au FlowHandler
- Retourne la Response
~10 lignes de code
- Coordonne le flux
- Gere les boutons
- Synchronise la session
- Delegue la persistance
Logique metier isolee
- 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,
);
}
<?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;
}
Etapes pour creer un nouveau wizard (ex: CampaignCreation)
-
Creer l'interface
interface CampaignCreationFlowHandlerInterface { public function handle(Request $request, Organization $org, ...): Response; } -
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(...); } } -
Creer le Controller minimal
public function create(...): Response { return $this->flowHandler->handle(...); }
Architecture en couches
Comparaison avec DrhOnboarding
| Aspect | TrainingCreation | DrhOnboarding |
|---|---|---|
| FlowHandler | Oui | Non (logique dans controller) |
| Orchestrator | Oui | Oui |
| Separation | 3 couches | 2 couches |
| Testabilite | Elevee | Moyenne |
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 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;
}
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
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
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 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.
- 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