wip: refactor to cards with individual participant forms

This commit is contained in:
Björn Fromme
2025-10-14 21:27:32 +02:00
parent a02490ea36
commit 6e818c3480
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'],
]);