wip: refactor to cards with individual participant forms
This commit is contained in:
+289
-179
@@ -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'],
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user