wip: refactor to cards with individual participant forms

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 33e1aea80c
commit de8cbf6178
22 changed files with 866 additions and 212 deletions
+289 -179
View File
@@ -4,7 +4,81 @@
Transform create (step 2) and edit flows from accordion-style "all forms at once" to a card overview with lazy-loaded individual participant forms. This addresses UX and performance issues with large groups (50+ participants).
**Status**: Planning Complete - Ready for Implementation
**Status**: Planning Complete - Ready for Implementation (SIMPLIFIED ARCHITECTURE - 2025-01-14)
## Architecture Revision (2025-01-14)
**Major Simplification**: Removed wrapper forms and DTOs!
**What Changed:**
- ❌ Removed: `ParticipantFormDto` - not needed
- ❌ Removed: `ParticipantFormType` wrapper - not needed
- ✅ Simplified: Pass `BookingDto` via form options (`booking_context`)
- ✅ Simplified: Reuse `BookingParticipantType` directly
- ✅ Simplified: Field handlers called manually in controller
**Why:**
The initial plan had unnecessary abstraction layers. We realized we can simply pass the `BookingDto` as a form option instead of wrapping everything. This is cleaner, more explicit, and easier to understand.
## Implementation Discovery: Field Handler Mode Awareness (2025-01-14)
**Problem Encountered:**
When promoting participant forms to standalone (autonomous) forms in the card flow, field handlers needed to be called from within `BookingParticipantType`'s PRE_SUBMIT event. This created a critical issue in edit mode:
- The BPN API **does not return insurance data** for privacy/security reasons
- In edit mode, the insurance field handler would see missing insurance data in submitted forms
- The handler would incorrectly interpret this as "user wants to clear insurance"
- Result: Insurance data would be lost during edit operations
**Root Cause:**
Field handlers had no way to distinguish between:
1. **Create mode**: Missing data = user didn't select anything (clear it)
2. **Edit mode**: Missing data = API didn't provide it (preserve existing value)
**Solution Implemented:**
Added mode awareness to the field handler system:
```php
// Interface change
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool;
// Insurance handler implementation
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
// Skip processing in edit mode - API doesn't return insurance data
if ($mode === BookingDto::MODE_EDIT) {
return false;
}
// Process normally in create mode
return true;
}
```
**Architecture Changes:**
- ✅ Added `$mode` parameter to `ParticipantFieldHandlerInterface::shouldProcess()`
- ✅ Updated `AbstractParticipantFieldHandler` with mode parameter
- ✅ Added helper methods: `isEditMode()`, `isCreateMode()` for future use
- ✅ Updated `ParticipantFieldHandlerRegistry` to pass `$bookingDto->mode` to handlers
- ✅ Updated all 13+ field handlers to accept mode parameter
- ✅ Insurance handler skips processing entirely in edit mode
- ✅ All other handlers continue processing normally in both modes
**Why Only Insurance Needs Special Treatment:**
- **Insurance**: API doesn't return data → must skip processing in edit mode
- **Services** (ski pass, rentals, courses): API returns data → process normally
- **Transportation**: API returns data → process normally
- **Room assignments**: API returns data → process normally
- **All other fields**: API returns data → process normally
**Benefits:**
- ✅ Insurance data preserved correctly in edit mode
- ✅ Explicit mode-aware behavior (no implicit assumptions)
- ✅ Clean separation: only handlers that need it check mode
- ✅ Future-proof: easy to add mode-specific logic to other handlers if needed
- ✅ Tests updated to verify edit mode behavior
**Implementation Status:****COMPLETED** (2025-01-14)
## Goals
@@ -26,6 +100,27 @@ Transform create (step 2) and edit flows from accordion-style "all forms at once
| **Room Assignment** | Keep in participant form | Existing behavior maintained |
| **Migration Strategy** | Separate controllers (-v2 routes) | Safe parallel development, easy comparison |
| **Lazy Loading** | Forms loaded via HTMX on demand | Critical for performance with 50+ participants |
| **BookingDto Context** | Pass via form options | Simple, explicit, no wrappers needed |
## Key Simplification
**No wrapper forms or DTOs needed!** We pass `BookingDto` directly via form options:
```php
// Controller creates form with explicit context
$form = $this->createForm(BookingParticipantType::class, $participant, [
'booking_context' => $bookingDto, // Passed explicitly
'edit_mode' => false,
]);
```
**Benefits:**
- ✅ No `ParticipantFormDto` wrapper
- ✅ No `ParticipantFormType` wrapper
- ✅ Reuse existing `BookingParticipantType` directly
- ✅ Explicit data flow (no "magic" form tree traversal)
- ✅ Field handlers called manually in controller (like current Step 2)
- ✅ Clean, maintainable, easy to understand
---
@@ -114,198 +209,193 @@ public function processFieldsForParticipant(
---
### 1.3 Create `ParticipantFormDto`
### 1.3 Update `BookingParticipantType` to Accept `booking_context` Option
**File**: `src/Form/Model/ParticipantFormDto.php`
**File**: `src/Form/BookingParticipantType.php`
**Purpose**: Dedicated DTO for editing a single participant with full booking context
**Purpose**: Make form type autonomous - it processes its own field handlers when used standalone
**Why This Is Needed**:
The existing `BookingParticipantType` uses `getBookingDtoFromForm()` which traverses up the form tree to find a parent form with `BookingDto` as its data. This DTO provides that context while maintaining clean separation between the booking flow state and individual participant editing.
**Why This Simplification**:
In the card flow, there's no parent form to orchestrate field handler execution. The form type becomes **self-contained and autonomous** - it receives `BookingDto` via options and processes its own field handlers in PRE_SUBMIT.
**Architecture Benefits**:
-Clean separation: `BookingDto` = full booking flow state, `ParticipantFormDto` = single participant editing context
- ✅ No pollution of BookingDto with editing-specific properties
-Type-safe and explicit relationships
-Participant has full booking context for conditional logic
- ✅ Field state providers work unchanged
-No wrapper forms needed
- ✅ No extra DTOs needed
-No parent form needed
-Explicit data flow via options
- ✅ Field handlers called automatically in PRE_SUBMIT
- ✅ Controller stays thin - just creates form and handles result
- ✅ Form type is responsible for its own data processing
**Implementation**:
**Key Insight**: We're **promoting the child form to be in charge**. It's no longer a dumb child controlled by a parent - it's a smart, autonomous form that handles everything itself.
**Modification to `__construct()` - Add Field Handler Registry**:
```php
public function __construct(
private readonly FieldOptionsProviderInterface $fieldOptionsProvider,
private readonly CreateFieldStateProvider $createFieldStateProvider,
private readonly EditFieldStateProvider $editFieldStateProvider,
private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry, // NEW: for autonomous processing
) {
}
```
**Modification to `configureOptions()`**:
```php
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ParticipantDto::class,
'selected_rooms' => [],
'edit_mode' => false,
'booking_context' => null, // NEW: Optional BookingDto for card flows
]);
$resolver->setAllowedTypes('selected_rooms', 'array');
$resolver->setAllowedTypes('edit_mode', 'bool');
$resolver->setAllowedTypes('booking_context', ['null', BookingDto::class]);
}
```
**Modification to `buildForm()`**:
```php
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// Select field state provider based on edit_mode option
$this->fieldStateProvider = $options['edit_mode']
? $this->editFieldStateProvider
: $this->createFieldStateProvider;
// Capture booking context for use in event listeners
$bookingContext = $options['booking_context'];
$builder
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($bookingContext) {
$this->onPreSetData($event, $bookingContext);
})
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($bookingContext) {
// Process field handlers FIRST (before form binding and validation)
// This ensures data is cleaned before Symfony processes it
if (null !== $bookingContext) {
$this->processFieldHandlers($event, $bookingContext);
}
// Then rebuild fields with updated states
$this->onPreSubmit($event, $bookingContext);
});
}
/**
* DTO for editing a single participant within a booking context.
* Process field handlers for this participant.
*
* This DTO encapsulates the participant being edited along with the full
* booking context needed for field state evaluation, pricing calculations,
* and conditional logic.
* Field handlers are executed in PRE_SUBMIT to clean and transform data
* before Symfony binds it to the form. This matches the pattern used in
* the old BookingCreateStep2Type parent form.
*/
class ParticipantFormDto
private function processFieldHandlers(FormEvent $event, BookingDto $bookingContext): void
{
public function __construct(
public BookingDto $bookingContext,
public ParticipantDto $participant,
) {}
$form = $event->getForm();
$submittedData = $event->getData();
if (false === is_array($submittedData)) {
return;
}
/** @var ParticipantDto $participant */
$participant = $form->getData();
if (null === $participant || false === property_exists($participant, 'index')) {
return;
}
// Process all field handlers for this participant in dependency order
$this->fieldHandlerRegistry->processFieldsForParticipant(
$submittedData,
$bookingContext,
$participant->index
);
}
private function onPreSetData(FormEvent $event, ?BookingDto $bookingContext): void
{
/** @var ParticipantDto|null $participantData */
$participantData = $event->getData();
$form = $event->getForm();
if (null === $participantData) {
return;
}
// Card flow: BookingDto passed via options
// Accordion flow (if we had one): traverse form tree
$bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form);
if (null === $bookingDto) {
return;
}
// Add base fields with states applied
$this->addBaseFields($form, $bookingDto, $participantData->index);
// Add dynamic fields
$this->addDynamicFields($form, $bookingDto, $participantData->index);
}
private function onPreSubmit(FormEvent $event, ?BookingDto $bookingContext): void
{
$submittedData = $event->getData();
$form = $event->getForm();
if (false === is_array($submittedData)) {
return;
}
// Card flow: BookingDto passed via options
// Accordion flow (if we had one): traverse form tree
$bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form);
if (null === $bookingDto) {
return;
}
// Get participant index from form data
$participantData = $form->getData();
if (null === $participantData || false === property_exists($participantData, 'index')) {
return;
}
// Rebuild all fields with updated states based on submitted data
$this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData);
}
```
**Key Points**:
- `bookingContext` provides full booking state for field conditions
- `participant` is the participant being edited
- Participant index available via `$participant->index` (no separate property needed)
**Todos**:
- [ ] Create DTO class in `src/Form/Model/`
- [ ] Add constructor with type hints
- [ ] Write unit tests for DTO instantiation
- [ ] Test with field state providers
---
### 1.4 Create `ParticipantFormType`
**File**: `src/Form/ParticipantFormType.php`
**Purpose**: Form type for editing a single participant with automatic field handler processing
**Implementation**:
**Usage in Controllers**:
```php
class ParticipantFormType extends AbstractType
{
public function __construct(
private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('participant', BookingParticipantType::class, [
'edit_mode' => $options['edit_mode'],
])
->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']);
}
/**
* Process field handlers before form binding and validation.
*
* This follows the same pattern as BookingCreateStep2Type - field handlers
* are called during PRE_SUBMIT to clean and validate data before binding.
*/
public function onPreSubmit(FormEvent $event): void
{
$form = $event->getForm();
$submittedData = $event->getData();
/** @var ParticipantFormDto $participantFormDto */
$participantFormDto = $form->getData();
$bookingDto = $participantFormDto->bookingContext;
$participantIndex = $participantFormDto->participant->index;
// Extract participant data from submitted form
$participantSubmittedData = $submittedData['participant'] ?? [];
// Process field handlers for this single participant
$this->participantFieldHandlerRegistry->processFieldsForParticipant(
$participantSubmittedData,
$bookingDto,
$participantIndex
);
// Note: No need to sync data back - the DTO is updated by reference
// and handleRequest() will bind the updated values
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ParticipantFormDto::class,
'edit_mode' => false,
]);
$resolver->setAllowedTypes('edit_mode', 'bool');
}
}
// Card flow: Pass booking context explicitly
$form = $this->createForm(BookingParticipantType::class, $participant, [
'booking_context' => $bookingDto,
'edit_mode' => false,
]);
```
**Key Points**:
- Field handlers called automatically in PRE_SUBMIT event (matches existing pattern in `BookingCreateStep2Type`)
- Controller doesn't need to call field handlers manually
- Uses `processFieldsForParticipant()` to process only the current participant
- Root form data is `ParticipantFormDto` which provides `BookingDto` context via `bookingContext` property
**Todos**:
- [ ] Create form type with DI for field handler registry
- [ ] Implement buildForm with participant field
- [ ] Implement onPreSubmit event listener
- [ ] Add proper PHPDoc
- [ ] Test field handler execution
- [ ] Test validation behavior
---
### 1.5 Update `FormTraversalTrait`
**File**: `src/Form/Service/Trait/FormTraversalTrait.php`
**Purpose**: Support both `BookingDto` and `ParticipantFormDto` as root form data
**Modification**:
```php
trait FormTraversalTrait
{
/**
* Gets the BookingDto from the root of the form tree.
*
* Supports both direct BookingDto (used in current flows) and
* ParticipantFormDto wrapper (used in card-based flows).
*
* @param FormInterface $form The form to start traversing from
*
* @return BookingDto|null The booking DTO or null if not found
*/
public function getBookingDtoFromForm(FormInterface $form): ?BookingDto
{
// Traverse up the form tree to get the root form's data
$rootForm = $form;
while ($rootForm->getParent()) {
$rootForm = $rootForm->getParent();
}
$data = $rootForm->getData();
// Direct BookingDto (used in current flows)
if ($data instanceof BookingDto) {
return $data;
}
// ParticipantFormDto wrapper (used in card-based flows)
if ($data instanceof ParticipantFormDto) {
return $data->bookingContext;
}
return null;
}
}
```
**Why This Works**:
- Existing flows use `BookingDto` directly → no changes needed
- New card flows use `ParticipantFormDto` → extracts `bookingContext`
- Field state providers work with both approaches seamlessly
- `BookingParticipantType` remains completely unchanged
**Todos**:
- [ ] Add `ParticipantFormDto` support to trait
- [ ] Add `use` statement for `ParticipantFormDto`
- [ ] Test with existing flows (should work unchanged)
- [ ] Test with new card flows
- [ ] Add `ParticipantFieldHandlerRegistry` to constructor (DI)
- [ ] Add `booking_context` option to `configureOptions()`
- [ ] Update `buildForm()` to capture `booking_context` and add PRE_SUBMIT listener
- [ ] Add `processFieldHandlers()` private method to call registry
- [ ] Update `onPreSetData()` to accept and use `$bookingContext` parameter
- [ ] Update `onPreSubmit()` to accept and use `$bookingContext` parameter
- [ ] Test with card flow (booking_context provided)
- [ ] Verify field handlers execute automatically
- [ ] Verify field state providers work correctly
---
### 1.6 Create `ParticipantCardFlowTrait`
### 1.4 Create `ParticipantCardFlowTrait`
**File**: `src/Controller/Booking/ParticipantCardFlowTrait.php`
@@ -415,23 +505,35 @@ Implementation steps:
Implementation steps:
1. Load BookingDto from session
2. Validate participant index
3. Create form for `$bookingDto->participants[$index]` using `BookingParticipantType`
4. Set HTMX attributes for refresh endpoint
5. `$form->handleRequest($request)`
6. If submitted and valid:
- Extract participant data from submitted form
- Process field handlers via `processFieldsForParticipant()`
3. Create form for `$bookingDto->participants[$index]` using `BookingParticipantType` with `booking_context` option:
```php
$form = $this->createForm(BookingParticipantType::class, $participant, [
'booking_context' => $bookingDto,
'edit_mode' => false,
'validation_groups' => ['booking_create_step_2'],
]);
```
4. `$form->handleRequest($request)`
- **Note**: Field handlers are called automatically by the form in PRE_SUBMIT
5. If submitted and valid:
- Save BookingDto to session
- HTMX redirect to cards view
7. Calculate summary data for sidebar
8. Render `_participant_form_standalone.html.twig` with form and summary
6. Calculate summary data for sidebar
7. Render `_participant_form_standalone.html.twig` with form and summary
**Action: `refreshParticipantForm(int $index, Request $request): Response`**
Implementation steps:
1. Load BookingDto from session
2. Enrich with fresh availability data
3. Create form with `validation_groups: false`
3. Create form with `booking_context` option and `validation_groups: false`:
```php
$form = $this->createForm(BookingParticipantType::class, $participant, [
'booking_context' => $bookingDto,
'edit_mode' => false,
'validation_groups' => false,
]);
```
4. `$form->handleRequest($request)`
5. Extract participant data from submitted form
6. Process field handlers via `processFieldsForParticipant()`
@@ -922,8 +1024,16 @@ Standard Symfony pattern - single action for both GET and POST:
public function editParticipant(int $index, Request $request): Response
{
$bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE);
$participant = $bookingDto->participants[$index] ?? null;
$form = $this->createParticipantForm($bookingDto, $index, [
if (null === $participant) {
throw new \InvalidArgumentException('Invalid participant index');
}
// Create form with booking context passed explicitly
$form = $this->createForm(BookingParticipantType::class, $participant, [
'booking_context' => $bookingDto,
'edit_mode' => false,
'validation_groups' => ['booking_create_step_2'],
]);
@@ -36,14 +36,15 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
*
* Default implementation checks if the field exists in the submitted data.
* Override this method for more complex processing conditions (e.g., only
* process if certain other conditions are met).
* process if certain other conditions are met, or skip processing in edit mode).
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool True if the handler should process this field, false otherwise
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return isset($submittedData[$this->getFieldName()]);
}
@@ -67,6 +68,35 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
return $participants[$participantIndex] ?? null;
}
/**
* Checks if the booking flow is in edit mode.
*
* Handlers may need to behave differently in edit mode, particularly when
* dealing with data that isn't returned by the BPN API (like insurance selections).
* In edit mode, missing data doesn't mean "clear this field" - it means the API
* doesn't provide it and it should be preserved as-is.
*
* @param BookingDto $bookingDto The booking DTO to check
*
* @return bool True if in edit mode, false otherwise
*/
protected function isEditMode(BookingDto $bookingDto): bool
{
return $bookingDto->mode === BookingDto::MODE_EDIT;
}
/**
* Checks if the booking flow is in create mode.
*
* @param BookingDto $bookingDto The booking DTO to check
*
* @return bool True if in create mode, false otherwise
*/
protected function isCreateMode(BookingDto $bookingDto): bool
{
return $bookingDto->mode === BookingDto::MODE_CREATE;
}
/**
* Safely extracts a field value from submitted participant data.
*
@@ -39,8 +39,12 @@ interface ParticipantFieldHandlerInterface
/**
* Determines if this handler should process the field based on submitted participant data.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The participant index being processed
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool;
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool;
/**
* Returns field state modifications that should be applied after processing.
@@ -59,11 +59,12 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* services are selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -84,6 +85,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* 5. Updates participant with filtered valid selections
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
@@ -40,11 +40,12 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
* services are selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -39,11 +39,12 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
* the bulkInsuranceBooking checkbox - their insurance is controlled by the handler.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool True if this is the applicant, false otherwise
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return 0 === $participantIndex; // Only process for applicant
}
@@ -59,11 +59,12 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
* services are selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -77,6 +78,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
* no longer appropriate for the participant's age are automatically removed.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
@@ -132,13 +133,42 @@ class ParticipantFieldHandlerRegistry
}
// Let each handler decide if it should process this participant's data
if ($handler->shouldProcess($participantData, (int) $participantIndex)) {
if ($handler->shouldProcess($participantData, $bookingDto->mode, (int) $participantIndex)) {
$handler->processField($participantData, $bookingDto, (int) $participantIndex);
}
}
}
}
/**
* Processes field handlers for a single participant.
*
* This method is used by the card-based booking flows where participants are
* edited individually. Unlike processFields() which processes all participants,
* this method applies all handlers to only the specified participant.
*
* Handlers are executed in dependency order to ensure proper data consistency.
*
* @param array<string, mixed> $participantData Submitted data for one participant
* @param BookingDto $bookingDto The booking DTO to update
* @param int $participantIndex Index of participant to process
*/
public function processFieldsForParticipant(array $participantData, BookingDto $bookingDto, int $participantIndex): void
{
// Get handlers sorted by dependency order (uses cache if available)
$sortedHandlerNames = $this->getSortedHandlers();
// Apply each handler to the specified participant
foreach ($sortedHandlerNames as $handlerName) {
$handler = $this->handlers[$handlerName];
// Let each handler decide if it should process this participant's data
if ($handler->shouldProcess($participantData, $bookingDto->mode, $participantIndex)) {
$handler->processField($participantData, $bookingDto, $participantIndex);
}
}
}
/**
* Returns handler names sorted by dependency order using cached results when possible.
*
@@ -376,7 +406,7 @@ class ParticipantFieldHandlerRegistry
}
// Handle Address objects -> convert to array of properties
if ($value instanceof \App\BusProNet\Model\Address) {
if ($value instanceof Address) {
return [
'street' => $value->street,
'postCode' => $value->postCode,
@@ -76,27 +76,34 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
/**
* Determines if this handler should process the field based on submitted data.
*
* For insurance selection fields, we always need to process to handle cases
* where the selection is cleared (field not present in data). This ensures
* the participant DTO is updated with null when no insurance is selected.
* Insurance handler should NOT process in edit mode because the BPN API does not
* return insurance data. In edit mode, insurance data must be preserved as-is
* and passed through to the update endpoint unchanged.
*
* However, when bulk insurance booking is active for a dependent participant,
* we skip processing to prevent overwriting the insurance assigned by the
* bulk insurance handler.
* In create mode, we always need to process to handle cases where the selection
* is cleared (field not present in data). However, when bulk insurance booking
* is active for a dependent participant, we skip processing to prevent overwriting
* the insurance assigned by the bulk insurance handler.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool True if processing should occur, false if bulk insurance handles it
* @return bool True if processing should occur, false otherwise
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
// In edit mode, insurance data is not available from API - skip processing entirely
if ($mode === BookingDto::MODE_EDIT) {
return false;
}
// Skip processing for dependent participants when bulk insurance booking is active
if ($participantIndex > 0 && $this->isBulkInsuranceBookingActive($submittedData)) {
return false;
}
return true; // Always process to handle deselection cases
return true; // Always process in create mode to handle deselection cases
}
/**
@@ -52,11 +52,12 @@ class ParticipantLicensePlateFieldHandler extends AbstractParticipantFieldHandle
* the participant DTO is updated correctly when parking availability changes.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for license plate fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -37,11 +37,12 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
* participant DTO is updated correctly when transportation switches to bus-only.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for parking selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process for state changes
}
@@ -30,7 +30,7 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler
return ['transportationOutbound', 'transportationInbound'];
}
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle clearing pickup
}
@@ -56,11 +56,12 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan
* rental insurance is selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -74,6 +75,7 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan
* is no longer appropriate for the participant's age, it is automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
@@ -48,11 +48,12 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
* services are selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -60,11 +60,12 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
* skipass is selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -79,6 +80,7 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
* age or exceeds the travel date range, it is automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
@@ -32,11 +32,12 @@ class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFi
* participant DTO is updated with null when no transportation is selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for transportation selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -32,11 +32,12 @@ class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantF
* participant DTO is updated with null when no transportation is selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for transportation selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormInterface;
* This trait contains common form navigation logic used across different
* form services and types. It centralizes the logic for finding root forms
* and extracting booking DTOs from form hierarchies.
*
* Note: Card-based flows pass BookingDto via form options instead of
* relying on form tree traversal.
*/
trait FormTraversalTrait
{
@@ -20,8 +23,8 @@ trait FormTraversalTrait
* Gets the BookingDto from the root of the form tree.
*
* This helper method traverses up the form tree to find the root form
* and extracts the BookingDto data. This is shared logic used
* by both create and edit participant forms.
* and extracts the BookingDto data. This is used as a fallback when
* BookingDto is not passed explicitly via form options.
*
* @param FormInterface $form The form to start traversing from
*
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Form\Model\BookingDto;
/**
* Extracts card display data for participants in card-based booking flows.
*
* Provides participant name, room assignment, and individual pricing for
* display in the card overview UI.
*/
class ParticipantCardDataService
{
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculator,
) {
}
/**
* Get card data for a single participant.
*
* @return array{name: string, roomName: string, price: string}
*/
public function getCardData(BookingDto $bookingDto, int $index): array
{
$participant = $bookingDto->participants[$index] ?? null;
if (null === $participant) {
throw new \InvalidArgumentException(sprintf('Participant at index %d does not exist', $index));
}
// Extract participant name with fallback
$name = $this->getParticipantName($participant, $index);
// Extract room name
$roomName = $this->getRoomName($bookingDto, $participant);
// Calculate and format individual price
$price = $this->getFormattedPrice($bookingDto, $index);
return [
'name' => $name,
'roomName' => $roomName,
'price' => $price,
];
}
/**
* Get card data for all participants.
*
* @return array<int, array{name: string, roomName: string, price: string}>
*/
public function getAllCardsData(BookingDto $bookingDto): array
{
$cardsData = [];
foreach ($bookingDto->participants as $index => $participant) {
$cardsData[$index] = $this->getCardData($bookingDto, $index);
}
return $cardsData;
}
/**
* Get participant name with fallback to generic label.
*/
private function getParticipantName(object $participant, int $index): string
{
$firstName = $participant->firstName ?? '';
$lastName = $participant->lastName ?? '';
$name = trim($firstName . ' ' . $lastName);
if ('' === $name) {
return sprintf('Teilnehmer %d', $index + 1);
}
return $name;
}
/**
* Get room name from travel model.
*/
private function getRoomName(BookingDto $bookingDto, object $participant): string
{
$roomId = $participant->assignedRoomId ?? null;
if (null === $roomId) {
return 'Kein Zimmer zugewiesen';
}
$room = $bookingDto->travel->getRoomById($roomId);
if (null === $room) {
return 'Unbekanntes Zimmer';
}
return $room->name;
}
/**
* Calculate and format individual participant price.
*/
private function getFormattedPrice(BookingDto $bookingDto, int $index): string
{
$prices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
$price = $prices[$index] ?? 0.0;
return number_format($price, 2, ',', '.') . ' €';
}
}
@@ -32,16 +32,37 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
{
$dependencies = $this->handler->getDependencies();
$this->assertEquals(['dateOfBirth'], $dependencies);
// Insurance handler depends on all price-affecting fields to ensure accurate reassignment
$expectedDependencies = [
'dateOfBirth',
'skiPass',
'rentals',
'courses',
'additionalServices',
'board',
'transportationOutbound',
'transportationInbound',
'pickup',
'parking',
];
$this->assertEquals($expectedDependencies, $dependencies);
}
public function testShouldProcessAlwaysReturnsTrue(): void
public function testShouldProcessReturnsTrueInCreateMode(): void
{
$result = $this->handler->shouldProcess([], 0);
$result = $this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0);
$this->assertTrue($result);
}
public function testShouldProcessReturnsFalseInEditMode(): void
{
$result = $this->handler->shouldProcess([], BookingDto::MODE_EDIT, 0);
$this->assertFalse($result, 'Insurance handler should not process in edit mode as API does not return insurance data');
}
public function testProcessFieldSetsInsuranceToNullWhenNoParticipant(): void
{
$bookingDto = $this->createMockBookingDto();
@@ -31,8 +31,8 @@ class ParticipantLicensePlateFieldHandlerTest extends TestCase
public function testShouldProcessAlwaysReturnsTrue(): void
{
$this->assertTrue($this->handler->shouldProcess([], 0));
$this->assertTrue($this->handler->shouldProcess(['some' => 'data'], 5));
$this->assertTrue($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0));
$this->assertTrue($this->handler->shouldProcess(['some' => 'data'], BookingDto::MODE_EDIT, 5));
}
public function testProcessFieldWithoutParticipant(): void
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\BookingPriceCalculatorService;
use App\Service\ParticipantCardDataService;
use PHPUnit\Framework\TestCase;
class ParticipantCardDataServiceTest extends TestCase
{
private ParticipantCardDataService $service;
private BookingPriceCalculatorService $priceCalculator;
protected function setUp(): void
{
$this->priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$this->service = new ParticipantCardDataService($this->priceCalculator);
}
public function testGetCardDataWithFullParticipantData(): void
{
// Create test room
$room = new Room();
$room->id = 1;
$room->name = 'Doppelzimmer';
$room->price = 100.0;
$travel = new Travel();
$travel->rooms = [$room];
// Create participant with full data
$participant = new ParticipantDto();
$participant->firstName = 'Max';
$participant->lastName = 'Mustermann';
$participant->assignedRoomId = 1;
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
// Mock price calculation
$this->priceCalculator
->expects($this->once())
->method('calculateAllParticipantIndividualPrices')
->with($bookingDto)
->willReturn([450.50]);
$result = $this->service->getCardData($bookingDto, 0);
$this->assertEquals('Max Mustermann', $result['name']);
$this->assertEquals('Doppelzimmer', $result['roomName']);
$this->assertEquals('450,50 €', $result['price']);
}
public function testGetCardDataWithPartialName(): void
{
$travel = new Travel();
$travel->rooms = [];
// Participant with only first name
$participant = new ParticipantDto();
$participant->firstName = 'Max';
$participant->lastName = null;
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([0.0]);
$result = $this->service->getCardData($bookingDto, 0);
$this->assertEquals('Max', $result['name']);
}
public function testGetCardDataWithNoName(): void
{
$travel = new Travel();
$travel->rooms = [];
// Participant without name
$participant = new ParticipantDto();
$participant->firstName = null;
$participant->lastName = null;
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([0.0]);
$result = $this->service->getCardData($bookingDto, 0);
$this->assertEquals('Teilnehmer 1', $result['name']);
}
public function testGetCardDataWithEmptyName(): void
{
$travel = new Travel();
$travel->rooms = [];
// Participant with empty strings for names
$participant = new ParticipantDto();
$participant->firstName = ' ';
$participant->lastName = ' ';
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([0.0]);
$result = $this->service->getCardData($bookingDto, 0);
$this->assertEquals('Teilnehmer 1', $result['name']);
}
public function testGetCardDataWithNoRoomAssignment(): void
{
$travel = new Travel();
$travel->rooms = [];
$participant = new ParticipantDto();
$participant->firstName = 'Max';
$participant->lastName = 'Mustermann';
$participant->assignedRoomId = null;
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([0.0]);
$result = $this->service->getCardData($bookingDto, 0);
$this->assertEquals('Kein Zimmer zugewiesen', $result['roomName']);
}
public function testGetCardDataWithUnknownRoom(): void
{
$travel = new Travel();
$travel->rooms = [];
$participant = new ParticipantDto();
$participant->firstName = 'Max';
$participant->lastName = 'Mustermann';
$participant->assignedRoomId = 999; // Non-existent room
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([0.0]);
$result = $this->service->getCardData($bookingDto, 0);
$this->assertEquals('Unbekanntes Zimmer', $result['roomName']);
}
public function testGetCardDataWithZeroPrice(): void
{
$travel = new Travel();
$travel->rooms = [];
$participant = new ParticipantDto();
$participant->firstName = 'Max';
$participant->lastName = 'Mustermann';
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([0.0]);
$result = $this->service->getCardData($bookingDto, 0);
$this->assertEquals('0,00 €', $result['price']);
}
public function testGetCardDataWithInvalidIndex(): void
{
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [];
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Participant at index 0 does not exist');
$this->service->getCardData($bookingDto, 0);
}
public function testGetAllCardsDataWithMultipleParticipants(): void
{
// Create test rooms
$room1 = new Room();
$room1->id = 1;
$room1->name = 'Einzelzimmer';
$room2 = new Room();
$room2->id = 2;
$room2->name = 'Doppelzimmer';
$travel = new Travel();
$travel->rooms = [$room1, $room2];
// Create participants
$participant1 = new ParticipantDto();
$participant1->firstName = 'Max';
$participant1->lastName = 'Mustermann';
$participant1->assignedRoomId = 1;
$participant2 = new ParticipantDto();
$participant2->firstName = 'Anna';
$participant2->lastName = 'Schmidt';
$participant2->assignedRoomId = 2;
$participant3 = new ParticipantDto();
$participant3->firstName = null;
$participant3->lastName = null;
$participant3->assignedRoomId = 2;
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2, $participant3];
// Mock price calculation
$this->priceCalculator
->expects($this->exactly(3))
->method('calculateAllParticipantIndividualPrices')
->with($bookingDto)
->willReturn([450.0, 500.0, 480.0]);
$result = $this->service->getAllCardsData($bookingDto);
$this->assertCount(3, $result);
// First participant
$this->assertEquals('Max Mustermann', $result[0]['name']);
$this->assertEquals('Einzelzimmer', $result[0]['roomName']);
$this->assertEquals('450,00 €', $result[0]['price']);
// Second participant
$this->assertEquals('Anna Schmidt', $result[1]['name']);
$this->assertEquals('Doppelzimmer', $result[1]['roomName']);
$this->assertEquals('500,00 €', $result[1]['price']);
// Third participant (no name)
$this->assertEquals('Teilnehmer 3', $result[2]['name']);
$this->assertEquals('Doppelzimmer', $result[2]['roomName']);
$this->assertEquals('480,00 €', $result[2]['price']);
}
public function testGetAllCardsDataWithEmptyParticipants(): void
{
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [];
$result = $this->service->getAllCardsData($bookingDto);
$this->assertEmpty($result);
}
public function testPriceFormattingWithLargeAmount(): void
{
$travel = new Travel();
$travel->rooms = [];
$participant = new ParticipantDto();
$participant->firstName = 'Max';
$participant->lastName = 'Mustermann';
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([1234.56]);
$result = $this->service->getCardData($bookingDto, 0);
$this->assertEquals('1.234,56 €', $result['price']);
}
public function testFallbackNameIndexingIsOneBasedNotZeroBased(): void
{
$travel = new Travel();
$travel->rooms = [];
$participant1 = new ParticipantDto();
$participant2 = new ParticipantDto();
$participant3 = new ParticipantDto();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2, $participant3];
$this->priceCalculator
->method('calculateAllParticipantIndividualPrices')
->willReturn([0.0, 0.0, 0.0]);
$result1 = $this->service->getCardData($bookingDto, 0);
$result2 = $this->service->getCardData($bookingDto, 1);
$result3 = $this->service->getCardData($bookingDto, 2);
$this->assertEquals('Teilnehmer 1', $result1['name']);
$this->assertEquals('Teilnehmer 2', $result2['name']);
$this->assertEquals('Teilnehmer 3', $result3['name']);
}
}