Files
myep/REFACTORING_PARTICIPANT_CARDS.md
T

1428 lines
50 KiB
Markdown

# Participant Card View Refactoring Plan
## Overview
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 (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
- **Performance**: Lazy load forms on demand instead of rendering all at once
- **UX**: Cleaner overview with card-based navigation
- **Scalability**: Handle 50+ participant bookings efficiently
- **Code Reuse**: Maximize shared code between create and edit flows
- **Safe Migration**: Keep old flows intact during development
## Architecture Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Form Rendering** | Replace entire content area (Option C) | Best for mobile, prevents concurrency issues |
| **Form Submission** | Explicit "Speichern" button | Not all fields trigger refresh, cleaner UX |
| **Card Content** | Name, room, price only | Minimal, focused information |
| **Validation** | On form submission only | No card-level indicators needed |
| **Card Price Updates** | Full re-render on return to cards | Cards not visible during form editing |
| **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
---
## Phase 1: Shared Foundation
### 1.1 Create `ParticipantCardDataService`
**File**: `src/Service/ParticipantCardDataService.php`
**Purpose**: Extract card display data from BookingDto
**Methods**:
```php
/**
* Get card data for a single participant
*
* @return array{name: string, roomName: string, price: string}
*/
public function getCardData(BookingDto $bookingDto, int $index): array
/**
* Get card data for all participants
*
* @return array<int, array{name: string, roomName: string, price: string}>
*/
public function getAllCardsData(BookingDto $bookingDto): array
```
**Implementation Details**:
- **Name**: `$participant->firstName . ' ' . $participant->lastName` or fallback to "Teilnehmer {index+1}"
- **Room**: Look up from `$bookingDto->travel->getRoomById($participant->assignedRoomId)`
- **Price**: Use `BookingPriceCalculatorService::calculateAllParticipantIndividualPrices()`, format as `number_format($price, 2, ',', '.') . ' €'`
**Dependencies**:
- `BookingPriceCalculatorService` (existing)
- Access to Travel model for room lookups
**Test Coverage**:
- Unit test: Verify name fallback for participants without names
- Unit test: Verify price formatting
- Unit test: Verify room name lookup
- Unit test: Handle missing room assignment gracefully
**Todos**:
- [ ] Create service class with DI configuration
- [ ] Implement `getCardData()` method
- [ ] Implement `getAllCardsData()` method
- [ ] Write unit tests
- [ ] Handle edge cases (missing room, missing names)
---
### 1.2 Extend `ParticipantFieldHandlerRegistry`
**File**: `src/Form/Service/ParticipantFieldHandlerRegistry.php`
**New Method**:
```php
/**
* Process field handlers for a single participant
*
* @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
```
**Implementation**:
- Extract loop body from existing `processFields()` method
- Apply all handlers to single participant in dependency order
- Reuse existing handler sorting and execution logic
**Todos**:
- [ ] Add `processFieldsForParticipant()` method to registry
- [ ] Extract handler execution logic from loop
- [ ] Maintain dependency order execution
- [ ] Test with existing field handlers
- [ ] Verify field sync logic works for single participant
---
### 1.3 Update `BookingParticipantType` to Accept `booking_context` Option
**File**: `src/Form/BookingParticipantType.php`
**Purpose**: Make form type autonomous - it processes its own field handlers when used standalone
**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**:
- ✅ 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
**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);
});
}
/**
* Process field handlers for this participant.
*
* 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.
*/
private function processFieldHandlers(FormEvent $event, BookingDto $bookingContext): void
{
$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);
}
```
**Usage in Controllers**:
```php
// Card flow: Pass booking context explicitly
$form = $this->createForm(BookingParticipantType::class, $participant, [
'booking_context' => $bookingDto,
'edit_mode' => false,
]);
```
**Todos**:
- [ ] 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.4 Create `ParticipantCardFlowTrait`
**File**: `src/Controller/Booking/ParticipantCardFlowTrait.php`
**Purpose**: Share common controller logic between create and edit flows
**Methods**:
```php
/**
* Load BookingDto from session or throw exception
*/
private function loadBookingDtoOrFail(Request $request, string $mode): BookingDto
/**
* Generate card data for all participants
*/
private function generateAllCardsData(BookingDto $bookingDto): array
/**
* Create form for single participant
*/
private function createParticipantForm(
BookingDto $bookingDto,
int $index,
array $options = []
): FormInterface
/**
* Calculate summary data (pricing, room counts, etc.)
*/
private function calculateSummaryData(BookingDto $bookingDto): array
/**
* Process single participant form refresh
*/
private function handleParticipantRefresh(
Request $request,
BookingDto $bookingDto,
int $index,
string $refreshRouteName
): Response
```
**Todos**:
- [ ] Create trait file
- [ ] Implement `loadBookingDtoOrFail()`
- [ ] Implement `generateAllCardsData()` using `ParticipantCardDataService`
- [ ] Implement `createParticipantForm()`
- [ ] Implement `calculateSummaryData()`
- [ ] Implement `handleParticipantRefresh()` with OOB swap logic
- [ ] Add type hints and PHPDoc
---
## Phase 2: Create Flow (Step 2 Refactored)
### 2.1 Create `CreateStep2RefactoredController`
**File**: `src/Controller/Booking/CreateStep2RefactoredController.php`
**Routes**:
| Method | Route | Name | Purpose |
|--------|-------|------|---------|
| GET/POST | `/bookings/create/participants-v2` | `app_booking_create_step_2_v2_cards` | Display card grid |
| GET/POST | `/bookings/create/participants-v2/{index}` | `app_booking_create_step_2_v2_participant` | Show/submit participant form |
| POST | `/bookings/create/participants-v2/{index}/refresh` | `app_booking_create_step_2_v2_participant_refresh` | HTMX refresh without validation |
**Controller Structure**:
```php
class CreateStep2RefactoredController extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
use ParticipantCardFlowTrait;
public function __construct(
private readonly BookingService $bookingService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly TravelDataService $travelDataService,
private readonly RoomAssignmentService $roomAssignmentService,
private readonly ParticipantCardDataService $participantCardService,
private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry,
) {
}
}
```
**Action: `showCards(Request $request): Response`**
Implementation steps:
1. Load BookingDto from session (reuse `getOrCreateBookingCreateDto()`)
2. Enrich with fresh availability data
3. Validate step access
4. Ensure correct number of participants
5. Auto-assign rooms if needed
6. Preselect mandatory services
7. Save BookingDto to session
8. Generate all cards data via `ParticipantCardDataService`
9. Calculate summary data
10. Render `create_step_2_v2_cards.html.twig`
**Action: `editParticipant(int $index, Request $request): Response`**
Implementation steps:
1. Load BookingDto from session
2. Validate participant index
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
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 `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()`
7. Preselect mandatory services
8. Save BookingDto to session
9. Collect notifications from participant DTO
10. Calculate summary data
11. Render form + sidebar using `htmxOobResponse()` with blocks:
- `participant_form` (target)
- `booking_summary` (OOB swap)
12. Add notifications to HX-Trigger header if present
**Todos**:
- [ ] Create controller class with DI
- [ ] Implement `showCards()` action
- [ ] Implement `editParticipant()` action
- [ ] Implement `refreshParticipantForm()` action
- [ ] Add route annotations
- [ ] Handle edge cases (invalid index, session expiry)
- [ ] Add error handling and logging
---
### 2.2 Templates for Create Flow
**Main Template: `templates/booking/create_step_2_v2_cards.html.twig`**
Structure:
```twig
{% extends 'layout.html.twig' %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8">
{# Main content area - cards grid #}
<div id="main-content" class="col-span-2">
<h2>Teilnehmer</h2>
<div id="participant-cards-grid" class="space-y-4">
{% for participant in bookingDto.participants %}
{% include 'booking/_participant_card.html.twig' with {
'cardData': cardsData[loop.index0],
'index': loop.index0,
'mode': 'create'
} %}
{% endfor %}
</div>
<div class="flex justify-between mt-8">
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary">
Zurück
</a>
<button type="submit" class="button bg-button bg-button--secondary">
Weiter
</button>
</div>
</div>
{# Sidebar summary #}
{% block booking_summary %}
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'participantCount': participantsCount,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': assignmentCounts
} %}
</div>
{% endblock %}
</div>
{% endblock %}
```
**Todos**:
- [ ] Create main template file
- [ ] Add grid layout structure
- [ ] Include card loop
- [ ] Add navigation buttons
- [ ] Include sidebar summary
---
**Card Partial: `templates/booking/_participant_card.html.twig`**
Structure:
```twig
{# Compact participant card with name, room, price, and edit button #}
<div id="participant-card-{{ index }}" class="border rounded p-4 flex justify-between items-center">
<div>
<h3 class="font-semibold">{{ cardData.name }}</h3>
<p class="text-sm text-gray-600">{{ cardData.roomName }}</p>
</div>
<div class="flex items-center gap-4">
<span class="font-medium">{{ cardData.price }}</span>
<button type="button"
class="button bg-button bg-button--secondary"
hx-get="{{ path('app_booking_create_step_2_v2_participant', {index: index}) }}"
hx-target="#main-content"
hx-swap="innerHTML">
Bearbeiten
</button>
</div>
</div>
```
**Features**:
- Compact layout
- Name (or "Teilnehmer X" fallback)
- Room name
- Price formatted as "450,00 €"
- "Bearbeiten" button with HTMX attributes
- Target replaces entire `#main-content` area
**Todos**:
- [ ] Create card template
- [ ] Add HTMX attributes for navigation
- [ ] Style with TailwindCSS
- [ ] Test responsive layout
- [ ] Handle long names gracefully
---
**Form Standalone: `templates/booking/_participant_form_standalone.html.twig`**
Structure:
```twig
{# Standalone participant form view (replaces main content area) #}
<div id="participant-form-view">
<h2>{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}</h2>
{{ form_start(form, {
'attr': {
'novalidate': 'novalidate',
'hx-post': path('app_booking_create_step_2_v2_participant', {index: participantIndex}),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{% block participant_form %}
<div id="participant-form" class="space-y-4">
{# Personal data section #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.dateOfBirth, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_v2_participant_refresh', {index: participantIndex}),
'hx-swap': 'none'
}
}) }}
{{ form_row(form.gender) }}
{{ form_row(form.nationality) }}
</div>
{# Contact information #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.email) }}
{{ form_row(form.mobile) }}
</div>
{# Address #}
{% if form.address is defined %}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.address.street) }}
{{ form_row(form.address.postCode) }}
{{ form_row(form.address.city) }}
{{ form_row(form.address.country) }}
</div>
{% endif %}
{# Body dimensions #}
{% if form.bodyDimensions is defined %}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.bodyDimensions.height) }}
{{ form_row(form.bodyDimensions.shoeSize) }}
{{ form_row(form.bodyDimensions.weight) }}
</div>
{% endif %}
{# Room assignment #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.assignedRoomId, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_v2_participant_refresh', {index: participantIndex}),
'hx-swap': 'none'
}
}) }}
{% if form.remarksRoom is defined %}
{{ form_row(form.remarksRoom) }}
{% endif %}
</div>
{# Service selection - same structure as current templates #}
{# Age eligibility checks, conditional field rendering, etc. #}
{# Transportation, insurance, etc. #}
</div>
{% endblock %}
<div class="flex gap-4 mt-8">
<button type="button"
class="button bg-button bg-button--secondary"
hx-get="{{ path('app_booking_create_step_2_v2_cards') }}"
hx-target="#main-content"
hx-swap="innerHTML">
Abbrechen
</button>
<button type="submit" class="button bg-button bg-button--secondary">
Speicheren
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
```
**Key Features**:
- Reuses existing form field structure from `create_step_2.html.twig`
- HTMX attributes on fields that trigger refresh
- "Abbrechen" returns to cards (GET request, no save)
- "Speichern" submits form (POST request, validation, save on success)
- Form action posts to same route (Symfony convention)
**Todos**:
- [ ] Create form template
- [ ] Copy form field structure from current template
- [ ] Add HTMX attributes for refresh triggers
- [ ] Add navigation buttons (Abbrechen/Speichern)
- [ ] Test all conditional field logic
- [ ] Ensure age eligibility checks work
- [ ] Verify field state providers work correctly
---
## Phase 3: Edit Flow Refactored
### 3.1 Create `EditRefactoredController`
**File**: `src/Controller/Booking/EditRefactoredController.php`
**Routes**:
| Method | Route | Name | Purpose |
|--------|-------|------|---------|
| GET/POST | `/bookings/{id}/edit-v2` | `app_booking_edit_v2_cards` | Display card grid |
| GET/POST | `/bookings/{id}/edit-v2/participants/{index}` | `app_booking_edit_v2_participant` | Show/submit participant form |
| POST | `/bookings/{id}/edit-v2/participants/{index}/refresh` | `app_booking_edit_v2_participant_refresh` | HTMX refresh |
| POST | `/bookings/{id}/edit-v2/reload` | `app_booking_edit_v2_reload` | Discard changes, reload from API |
**Differences from Create Flow**:
- Load booking data from API on first visit (via `BookingDataTrait`)
- Store in session with `MODE_EDIT`
- Handle canceled participants (status 'S'):
- Show "storniert" badge on card
- Disable "Bearbeiten" button
- Show surcharges info when clicked
- Final submission calls `ApiClient::updateBooking()` instead of navigating to step 3
- Apply mutability constraints via `EditFieldStateProvider`
**Controller Structure**:
```php
class EditRefactoredController extends AbstractController
{
use BookingDataTrait;
use HxTrait;
use ParticipantCardFlowTrait;
public function __construct(
private readonly ApiClient $apiClient,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly TravelDataService $travelDataService,
private readonly BookingService $bookingService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly ParticipantCardDataService $participantCardService,
private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry,
private readonly CacheInterface $cache,
private readonly Security $security,
private readonly Crypt $crypt,
private readonly LoggerInterface $logger,
) {
}
}
```
**Action Implementations**: Similar to create flow, with edit-specific logic
**Todos**:
- [ ] Create controller class with DI
- [ ] Implement `showCards()` action (load from API on first visit)
- [ ] Implement `editParticipant()` action (handle canceled participants)
- [ ] Implement `refreshParticipantForm()` action (apply mutability constraints)
- [ ] Implement `reloadFromApi()` action (discard session changes)
- [ ] Add route annotations
- [ ] Handle edit-specific edge cases
- [ ] Test API integration
---
### 3.2 Templates for Edit Flow
**Main Template: `templates/booking/edit_v2_cards.html.twig`**
Similar to create flow, with differences:
- Header shows "Buchung bearbeiten"
- "Reload" button to discard changes
- Different navigation (Zurück to bookings list, Aktualisieren to save)
- Final submit button calls API update
**Card Template**: Reuse `_participant_card.html.twig` with mode parameter
- Add "storniert" badge for canceled participants
- Disable button for canceled participants
**Form Template**: Reuse `_participant_form_standalone.html.twig` with mode parameter
- EditFieldStateProvider applies mutability constraints
- Show applicant address placeholders
**Todos**:
- [ ] Create main template for edit
- [ ] Add reload button with confirmation
- [ ] Modify card template to handle canceled participants
- [ ] Test form template with edit mode
- [ ] Verify mutability constraints applied correctly
---
## Phase 4: Integration & Testing
### 4.1 Functional Testing Checklist
**Card View**:
- [ ] Card grid renders all participants correctly
- [ ] Card names show participant names or "Teilnehmer X" fallback
- [ ] Card room names display correctly
- [ ] Card prices formatted as "450,00 €"
- [ ] "Bearbeiten" button on each card
- [ ] Clicking "Bearbeiten" loads participant form
- [ ] Navigation buttons work (Zurück/Weiter or Zurück/Aktualisieren)
**Participant Form**:
- [ ] Form loads for correct participant
- [ ] All form fields render correctly
- [ ] Field states applied (readonly, disabled, hidden, required)
- [ ] Conditional fields appear/disappear based on data
- [ ] Age eligibility checks work
- [ ] Service fields populated correctly
**HTMX Interactions**:
- [ ] Field changes trigger refresh endpoint
- [ ] Form refreshes with updated data
- [ ] Sidebar summary updates (OOB swap)
- [ ] Notifications display via toast
- [ ] "Abbrechen" returns to cards without saving
- [ ] "Speichern" submits form with validation
- [ ] Valid submission redirects to cards
- [ ] Invalid submission shows validation errors
**Field Handlers**:
- [ ] Date of birth enables age-dependent services
- [ ] Room assignment updates counts
- [ ] Ski pass selection filters rentals by duration
- [ ] Rental selection shows insurance checkbox
- [ ] Bulk insurance updates dependent participants
- [ ] Transportation selection shows/hides pickup/parking
- [ ] License plate field appears with parking selection
- [ ] Insurance auto-reassignment on price changes
- [ ] Rental clearing when ski pass changes
**Data Consistency**:
- [ ] Session DTO updated correctly after form save
- [ ] Session DTO updated correctly after refresh
- [ ] Card data matches DTO after returning from form
- [ ] Prices recalculated correctly
- [ ] Room assignments preserved
- [ ] Service selections preserved
**Edit Flow Specific**:
- [ ] Booking data loads from API on first visit
- [ ] Session stores data correctly (MODE_EDIT)
- [ ] Canceled participants show badge
- [ ] Canceled participants have disabled button
- [ ] Mutability constraints applied
- [ ] Final submission calls API correctly
- [ ] Reload discards session changes
---
### 4.2 Performance Testing
**Metrics to Measure**:
- [ ] Card grid load time with 50 participants: < 1 second
- [ ] Participant form load time: < 200ms (lazy loading benefit)
- [ ] Field refresh response time: < 500ms
- [ ] Memory usage with 50 participants: reasonable
- [ ] No N+1 queries in card data generation
- [ ] Session size doesn't grow excessively
**Performance Tests**:
- [ ] Create booking with 2-3 participants (baseline)
- [ ] Create booking with 10 participants
- [ ] Create booking with 25 participants
- [ ] Create booking with 50 participants
- [ ] Create booking with 100 participants (stress test)
- [ ] Measure database queries per action
- [ ] Measure memory usage per action
- [ ] Profile with Symfony profiler
---
### 4.3 Comparison Testing
**Compare Old vs New Flow**:
- [ ] Session DTO structure identical
- [ ] Final booking API payload identical
- [ ] Pricing calculations identical
- [ ] Field handler behavior identical
- [ ] Validation rules identical
- [ ] Service selection logic identical
- [ ] Room assignment logic identical
- [ ] Insurance matching logic identical
**Test Scenarios**:
- [ ] Simple booking (2 adults, no services)
- [ ] Family booking (2 adults, 2 children, services)
- [ ] Complex booking (mixed ages, all services, insurance)
- [ ] Large group (50 participants)
- [ ] Edge cases (age limits, mandatory services, mutability)
---
## Phase 5: Code Sharing Summary
### Shared Components (100%)
**Services**:
- `ParticipantCardDataService` (new)
- `BookingService` (existing)
- `BookingPriceCalculatorService` (existing)
- `TravelDataService` (existing)
- `RoomAssignmentService` (existing)
- `ParticipantFieldHandlerRegistry` (existing, extended)
- All field handlers (existing)
**Form Types**:
- `BookingParticipantType` (existing)
- `AddressType`, `BodyDimensionsType`, etc. (existing)
- All field types (existing)
**Templates**:
- `_participant_card.html.twig` (new, shared)
- `_participant_form_standalone.html.twig` (new, shared)
- `_summary.html.twig` (existing, shared)
**Traits**:
- `ParticipantCardFlowTrait` (new, shared)
- `HxTrait` (existing, shared)
### Controller-Specific (~20%)
**Create Flow**:
- `CreateStep2RefactoredController` routes
- Initial step validation
- Navigation to step 3 on success
- Template: `create_step_2_v2_cards.html.twig`
**Edit Flow**:
- `EditRefactoredController` routes
- Initial API data loading
- API update on success
- Canceled participant handling
- Reload functionality
- Template: `edit_v2_cards.html.twig`
---
## Technical Details
### Form Handling Pattern
Standard Symfony pattern - single action for both GET and POST:
```php
public function editParticipant(int $index, Request $request): Response
{
$bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE);
$participant = $bookingDto->participants[$index] ?? null;
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'],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Extract submitted data
$submittedData = $request->request->all();
$participantData = $submittedData['booking_participant'] ?? [];
// Process field handlers for this participant
$this->fieldHandlerRegistry->processFieldsForParticipant(
$participantData,
$bookingDto,
$index
);
// Save to session
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
// Redirect back to cards
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2_v2_cards'));
}
// Render form (GET or invalid POST)
return $this->render('booking/_participant_form_standalone.html.twig', [
'form' => $form,
'participantIndex' => $index,
'bookingDto' => $bookingDto,
// ... summary data
]);
}
```
### Card Data Structure
```php
[
'name' => 'Max Mustermann', // or "Teilnehmer 1"
'roomName' => 'Doppelzimmer',
'price' => '450,00 €'
]
```
### HTMX Flow Diagram
```
┌─────────────────────────────────────────────┐
│ CARDS VIEW (GET /participants-v2) │
│ │
│ [Teilnehmer 1 | Doppelzimmer | 450,00 €] │
│ [Teilnehmer 2 | Einzelzimmer | 520,00 €] │
│ [Teilnehmer 3 | Doppelzimmer | 450,00 €] │
│ │
│ [Zurück] [Weiter] │
└─────────────────────────────────────────────┘
│ Click "Bearbeiten"
│ hx-get="/participants-v2/0"
│ hx-target="#main-content"
┌─────────────────────────────────────────────┐
│ FORM VIEW (GET /participants-v2/0) │
│ │
│ Teilnehmer 1 │
│ │
│ [Vorname] [Nachname] │
│ [Geburtsdatum] [Geschlecht] │
│ ... │
│ [Skipass ▼] ◄────────────┐ │
│ [Kurse ▼] │ │
│ │ Field change │
│ [Abbrechen] [Speichern] │ POST refresh │
└───────────────────────────┼─────────────────┘
│ hx-post="/participants-v2/0/refresh"
│ validation_groups: false
│ Returns: form + sidebar OOB swap
┌─────────────────────────────────────────────┐
│ FORM VIEW (refreshed) │
│ - Form updated with new field states │
│ - Sidebar summary updated (OOB) │
│ - Notifications shown via toast │
└─────────────────────────────────────────────┘
│ Click "Speichern"
│ POST /participants-v2/0
│ validation_groups: ['booking_create_step_2']
┌──────┴──────┐
│ Validation │
└──────┬──────┘
┌───────┴────────┐
│ │
Valid Invalid
│ │
▼ ▼
HX-Redirect Return form
to cards with errors
┌─────────────────────────────────────────────┐
│ CARDS VIEW (all re-rendered) │
│ │
│ [Max Mustermann | Doppelzimmer | 580,00 €] │ ← Updated!
│ [Teilnehmer 2 | Einzelzimmer | 520,00 €] │
│ [Teilnehmer 3 | Doppelzimmer | 450,00 €] │
└─────────────────────────────────────────────┘
```
### Session Flow
1. **Cards View**: Load DTO from session, render cards
2. **Form View**: Load DTO from session, create form for participant[index]
3. **Form Refresh**: Load DTO, update with field changes (no validation), save to session
4. **Form Submit**: Load DTO, validate, run handlers, save to session, redirect
5. **Back to Cards**: Load DTO from session (with all changes), render cards
**Key**: DTO is single source of truth in session, updated incrementally
---
## Implementation Sequence
### Week 1: Foundation & Create Flow
**Day 1-2**: Foundation
- [ ] Create `ParticipantCardDataService` with tests
- [ ] Add `processFieldsForParticipant()` to registry with tests
- [ ] Create `ParticipantCardFlowTrait`
**Day 3**: Templates
- [ ] Create `_participant_card.html.twig`
- [ ] Create `_participant_form_standalone.html.twig`
- [ ] Create `create_step_2_v2_cards.html.twig`
**Day 4-5**: Create Flow Controller
- [ ] Implement `CreateStep2RefactoredController`
- [ ] All three routes/actions
- [ ] Test with small bookings (2-3 participants)
- [ ] Test field refresh logic
- [ ] Test validation
### Week 2: Edit Flow & Testing
**Day 6-7**: Edit Flow
- [ ] Implement `EditRefactoredController`
- [ ] Reuse trait methods
- [ ] Handle canceled participants
- [ ] Create edit templates
- [ ] Test API integration
**Day 8-9**: Integration Testing
- [ ] Compare outputs (old vs new flows)
- [ ] Test all field handlers
- [ ] Test bulk insurance
- [ ] Test edge cases
- [ ] Performance test with 50+ participants
**Day 10**: Cleanup & Documentation
- [ ] Code review
- [ ] Update documentation
- [ ] Test both flows side by side
- [ ] Mark as ready for production consideration
---
## Migration Strategy
### Current State (Before Refactoring)
- Old routes: `/bookings/create/participants` and `/bookings/{id}/edit`
- Old controllers: `CreateStep2Controller`, `EditController`
- Old templates: `create_step_2.html.twig`, `edit.html.twig`
### Development State (During Refactoring)
- **Old routes remain functional and unchanged**
- New routes: `/bookings/create/participants-v2` and `/bookings/{id}/edit-v2`
- New controllers: `CreateStep2RefactoredController`, `EditRefactoredController`
- New templates: `create_step_2_v2_cards.html.twig`, `edit_v2_cards.html.twig`
- Both flows coexist - **critical for testing and comparison**
### Transition State (Switching to New Flow)
**Phase 1: Testing & Validation (During Development)**
- [ ] Deploy both flows to production (both accessible via their routes)
- [ ] Test NEW flow manually in production environment
- [ ] OLD routes remain the default in all navigation
- [ ] NEW routes accessible directly for testing (e.g., via direct URL)
- [ ] Compare bookings created through both flows
- [ ] Verify session DTO structure is identical
- [ ] Confirm pricing calculations match exactly
**Phase 2: Navigation Switch (Single Deployment)**
- [ ] Update all navigation links to point to NEW routes
- [ ] Update internal redirects to use NEW routes
- [ ] OLD routes remain accessible (direct URL access works)
- [ ] Deploy navigation changes
- [ ] Monitor error rates and booking success rates closely
- [ ] **If issues found**: Deploy rollback (revert navigation to OLD routes)
**Phase 3: Stabilization (2-4 weeks)**
- [ ] Monitor NEW flow in production with all traffic
- [ ] OLD routes still accessible but deprecated
- [ ] No new features added to OLD flow
- [ ] Document any issues found and resolved
**Phase 4: Cleanup (After Confidence Established)**
- [ ] Remove OLD controllers (`CreateStep2Controller`, `EditController`)
- [ ] Remove OLD templates (`create_step_2.html.twig`, `edit.html.twig`)
- [ ] Remove OLD routes from routing configuration
- [ ] (Optional) Remove `-v2` suffix from route names and paths
### Critical Transition Checklist
**Before Switching Navigation**:
- [ ] All functional tests pass for NEW flow
- [ ] Performance tests show acceptable load times
- [ ] Comparison tests confirm identical DTO structure
- [ ] Field handlers produce identical results
- [ ] Pricing calculations match exactly
- [ ] Session compatibility verified
- [ ] HTMX refresh logic tested extensively
- [ ] Bulk insurance functionality verified
- [ ] All age-dependent service filtering works
- [ ] Transportation service conditional logic works
- [ ] Insurance auto-reassignment tested
- [ ] Edit flow API integration verified
- [ ] Large booking tests (50+ participants) pass
**Session Compatibility**:
- ✅ Both flows use identical `BookingDto` structure
- ✅ Session keys are mode-specific (`MODE_CREATE`, `MODE_EDIT`)
- ✅ Users can switch between OLD and NEW flows without losing data
- ✅ No session migration needed
**Rollback Plan**:
If critical issues discovered:
1. **Immediate**: Deploy navigation changes to revert links to OLD routes
2. **Investigation**: Analyze logs, compare booking data, identify root cause
3. **Fix**: Apply fixes to NEW flow controllers/templates
4. **Retest**: Verify fixes in development environment
5. **Redeploy**: Update navigation to NEW routes again
### Monitoring During Transition
**Key Metrics to Track**:
- [ ] Booking creation success rate (OLD vs NEW)
- [ ] Average page load time for card grid
- [ ] Average form load time (lazy loading)
- [ ] Field refresh response times
- [ ] Validation error rates
- [ ] Session expiry rates
- [ ] Browser console errors
- [ ] Server error logs
- [ ] Database query counts
- [ ] Memory usage per request
**Logging Strategy**:
Add structured logging to NEW flow controllers:
```php
$this->logger->info('Refactored flow: Card view loaded', [
'participant_count' => count($bookingDto->participants),
'load_time_ms' => $loadTime,
'user_id' => $user->getId(),
]);
$this->logger->info('Refactored flow: Participant form saved', [
'participant_index' => $index,
'had_validation_errors' => !$form->isValid(),
'field_handler_execution_time_ms' => $handlerTime,
]);
```
**Comparison Analysis**:
Since bookings are created via BPN API (not database), compare OLD vs NEW flows by:
- Analyzing application logs for booking creation success/failure rates
- Comparing API response times between OLD and NEW flows
- Monitoring Symfony profiler data for both flows
- Tracking error rates in application logs
### Post-Transition Cleanup
After successful transition (2+ months with no issues):
**Step 1: Remove OLD Controllers and Templates**
- [ ] Delete `CreateStep2Controller.php`
- [ ] Delete `EditController.php`
- [ ] Delete `create_step_2.html.twig`
- [ ] Delete `edit.html.twig`
- [ ] Remove OLD routes from routing configuration
**Step 2: (Optional) Rename Routes**
- [ ] Rename `app_booking_create_step_2_v2_cards` → `app_booking_create_step_2`
- [ ] Rename `app_booking_create_step_2_v2_participant` → `app_booking_create_step_2_participant`
- [ ] Rename `app_booking_edit_v2_cards` → `app_booking_edit`
- [ ] Update all navigation links and redirects
- [ ] Test thoroughly after rename
**Step 3: Verify Navigation**
- [ ] Ensure all links point to correct routes (after cleanup)
- [ ] Check for any hardcoded OLD route references
- [ ] Test navigation flow end-to-end
**Step 4: Documentation Updates**
- [ ] Update developer documentation
- [ ] Remove references to "refactored" or "v2" terminology
- [ ] Update onboarding guides for new developers
### Common Pitfalls to Avoid
**❌ DON'T**:
- Switch navigation to NEW flow without thorough testing in production first
- Remove OLD controllers/routes immediately after navigation switch
- Ignore session compatibility between flows
- Switch without monitoring tools in place
- Forget to update ALL navigation links (easy to miss some)
**✅ DO**:
- Test NEW flow extensively via direct URL access before navigation switch
- Keep OLD flow accessible for weeks/months as safety net
- Monitor metrics closely after navigation switch
- Have rollback plan ready (revert navigation links)
- Document all navigation link locations before switch
---
---
## Success Criteria
### Performance
- [ ] Card grid loads in <1 second with 50 participants
- [ ] Form loads instantly (lazy loading)
- [ ] Field refresh responds in <500ms
- [ ] Memory usage reasonable with large bookings
### Functionality
- [ ] All field handlers work identically to old flow
- [ ] Validation rules applied correctly
- [ ] Session DTO structure matches old flow
- [ ] Final booking creation/update produces identical results
### Code Quality
- [ ] 80%+ code reuse between create and edit flows
- [ ] Full test coverage for new services
- [ ] Clean separation of concerns
- [ ] Documented and maintainable
---
## Notes & Considerations
### Why No OOB Swaps for Cards?
When editing a participant form, the cards view is not rendered - the entire #main-content is replaced with the form. When returning to cards (via "Speichern" or "Abbrechen"), the entire cards view is re-rendered fresh from the session DTO. Therefore, no need for OOB swaps of individual card prices.
### Why Lazy Loading?
With 50+ participants, rendering all forms at once (current accordion approach) causes:
- Long initial page load time
- High memory usage
- Heavy DOM with hundreds of form fields
Lazy loading loads only the form for the participant being edited, dramatically improving performance.
### Why Separate Controllers?
Keeps old implementation intact for:
- Comparison testing during development
- Fallback if issues discovered
- Easier code review (clear diff between old and new)
- Lower risk deployment
### Field Handler Registry Change
Adding `processFieldsForParticipant()` allows processing a single participant's data instead of looping through all participants. This is necessary because the new flow processes participants individually, not all at once.
---
## Open Questions
- [ ] Should we add a progress indicator showing X/Y participants completed?
- [ ] Should we add keyboard shortcuts (e.g., Ctrl+S to save, Esc to cancel)?
- [ ] Should we add a "Save & Next" button to quickly move through participants?
- [ ] Should we store completion state per participant to show visual progress?
---
## References
- Current Implementation: `src/Controller/Booking/CreateStep2Controller.php`
- Current Template: `templates/booking/create_step_2.html.twig`
- Field Handlers: `src/Form/Service/`
- HTMX Trait: `src/Htmx/HxTrait.php`