diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3788194..c36a9b8 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -2,12 +2,11 @@ $finder = (new PhpCsFixer\Finder()) ->in(__DIR__) - ->exclude('var') -; + ->exclude('var'); return (new PhpCsFixer\Config()) ->setRules([ '@Symfony' => true, + '@DoctrineAnnotation' => true, ]) - ->setFinder($finder) -; + ->setFinder($finder); diff --git a/assets/controllers/toggle_controller.js b/assets/controllers/toggle_controller.js index 3b6ed2a..65461c8 100644 --- a/assets/controllers/toggle_controller.js +++ b/assets/controllers/toggle_controller.js @@ -1,18 +1,26 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { - - static classes = [ 'closed' ] - static targets = [ 'toggle', 'icon' ] + static classes = ['closed'] + static targets = ['toggle', 'icon'] static values = { open: { type: Boolean, default: true, }, + storageKey: { + type: String, + default: '' + } } initialize() { this.target = this.hasToggleTarget ? this.toggleTarget : this.element + + // Restore state from storage if storageKey is provided + if (this.hasStorageKey()) { + this.restoreState() + } } toggle() { @@ -30,10 +38,28 @@ export default class extends Controller { openValueChanged(open) { this.target.classList.toggle(this.closedClass, false === open) - if (false === this.hasIconTarget) { - return + if (this.hasIconTarget) { + this.iconTarget.classList.toggle('rotate-90', true === open) } - this.iconTarget.classList.toggle('rotate-90', true === open) + // Save state to storage if storageKey is provided + if (this.hasStorageKey()) { + this.saveState() + } + } + + hasStorageKey() { + return this.storageKeyValue && this.storageKeyValue.trim() !== '' + } + + saveState() { + sessionStorage.setItem(`toggle_${this.storageKeyValue}`, this.openValue.toString()) + } + + restoreState() { + const savedState = sessionStorage.getItem(`toggle_${this.storageKeyValue}`) + if (savedState !== null) { + this.openValue = savedState === 'true' + } } } diff --git a/config/services.yaml b/config/services.yaml index a229569..b803881 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -63,15 +63,15 @@ services: $preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%' $enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%' - App\Form\Service\ParticipantRoomChoiceLoaderFactory: + App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory: arguments: $choiceListFactory: '@form.choice_list_factory.default' # Participant Field Handler Registry with hybrid handler configuration - App\Form\ParticipantFieldHandler\ParticipantFieldHandlerRegistry: + App\Form\Service\ParticipantFieldHandlerRegistry: arguments: $handlers: # Simple handlers (no dependencies) - use class names - - 'App\Form\ParticipantFieldHandler\ParticipantAssignedRoomFieldHandler' + - 'App\Form\Service\ParticipantAssignedRoomFieldHandler' # Complex handlers (with dependencies) would use service references like: # - '@participant.complex.handler' diff --git a/docs/FIELD_STATE_SYSTEM.md b/docs/FIELD_STATE_SYSTEM.md index b71164c..81f0c77 100644 --- a/docs/FIELD_STATE_SYSTEM.md +++ b/docs/FIELD_STATE_SYSTEM.md @@ -4,14 +4,29 @@ This system provides a flexible architecture for implementing conditional field ## Architecture Overview -The system consists of several key components: +The system consists of several key components organized in a clean namespace structure: -1. **FieldConditionInterface** - Defines the contract for condition evaluation -2. **Concrete Conditions** - Implement specific business logic (age ranges, field values, etc.) -3. **CompositeCondition** - Combines conditions with AND/OR/NOT logic -4. **FieldStateProviderInterface** - Manages field state calculation -5. **ParticipantFieldOptionsProvider** - Enhanced to support state conditions -6. **Form Integration** - Applied in BookingCreateParticipantType +### Core Interfaces (`src/Form/Service/Contract/`) +1. **FieldStateProviderInterface** - Defines the contract for field state management +2. **FieldOptionsProviderInterface** - Defines the contract for field option generation + +### Abstract Base Classes (`src/Form/Service/Abstract/`) +3. **AbstractFieldStateProvider** - Common field state functionality +4. **AbstractFieldOptionsProvider** - Common field option functionality + +### Concrete Implementations (`src/Form/Service/`) +5. **CreateFieldStateProvider** - Field states for booking creation workflow +6. **EditFieldStateProvider** - Field states for booking edit workflow +7. **ParticipantFieldOptionsProvider** - Dynamic field option generation + +### Condition System (`src/Form/Service/Condition/`) +8. **FieldConditionInterface** - Defines the contract for condition evaluation +9. **Concrete Conditions** - Implement specific business logic (age ranges, field values, etc.) +10. **CompositeCondition** - Combines conditions with AND/OR/NOT logic + +### Form Integration +11. **BookingCreateParticipantType** - Uses CreateFieldStateProvider +12. **BookingEditParticipantType** - Uses EditFieldStateProvider ## Usage Examples @@ -87,10 +102,12 @@ $this->fieldStateConditions['specialServices'] = [ ## Adding New Conditions -To register field state conditions, add them to the `registerFieldStateConditions()` method in `ParticipantFieldOptionsProvider`: +### For Create Workflow +To register field state conditions for the booking creation workflow, add them to the `registerFieldStateConditions()` method in `CreateFieldStateProvider`: ```php -private function registerFieldStateConditions(): void +// src/Form/Service/CreateFieldStateProvider.php +protected function registerFieldStateConditions(): void { // Age-based readonly state $this->fieldStateConditions['assignedRoomId'] = [ @@ -113,6 +130,40 @@ private function registerFieldStateConditions(): void } ``` +### For Edit Workflow +To register field state conditions for the booking edit workflow, add them to the `registerFieldStateConditions()` method in `EditFieldStateProvider`: + +```php +// src/Form/Service/EditFieldStateProvider.php +protected function registerFieldStateConditions(): void +{ + // Make personal data readonly for applicants or non-mutable fields + $personalDataFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile']; + foreach ($personalDataFields as $field) { + $this->fieldStateConditions[$field] = [ + 'readonly' => CompositeCondition::or( + new ApplicantCondition(), + new MutabilityCondition() + ), + ]; + } +} +``` + +### Adding Field Options +To register dynamic field options, add them to the `registerFieldOptionProviders()` method in `ParticipantFieldOptionsProvider`: + +```php +// src/Form/Service/ParticipantFieldOptionsProvider.php +protected function registerFieldOptionProviders(): void +{ + $this->fieldOptionProviders['newField'] = fn(BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'New Field Label', + 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex), + ]; +} +``` + ## Performance Considerations - Conditions use lazy evaluation and short-circuit logic @@ -136,6 +187,9 @@ The system supports real-time field state updates: Create new condition classes implementing `FieldConditionInterface`: ```php +// src/Form/Service/Condition/CustomBusinessRuleCondition.php +use App\Form\Service\Contract\FieldConditionInterface; + class CustomBusinessRuleCondition implements FieldConditionInterface { public function evaluate(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool @@ -156,4 +210,30 @@ class CustomBusinessRuleCondition implements FieldConditionInterface } ``` +### Custom Field Options Providers + +To create more complex field option logic, extend `AbstractFieldOptionsProvider`: + +```php +// src/Form/Service/CustomFieldOptionsProvider.php +use App\Form\Service\Abstract\AbstractFieldOptionsProvider; + +class CustomFieldOptionsProvider extends AbstractFieldOptionsProvider +{ + protected function registerFieldOptionProviders(): void + { + $this->fieldOptionProviders['customField'] = fn(BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'Custom Field', + 'choices' => $this->generateCustomChoices($bookingDto, $participantIndex), + ]; + } + + private function generateCustomChoices(BookingDtoInterface $bookingDto, int $participantIndex): array + { + // Custom choice generation logic + return []; + } +} +``` + This system provides a powerful, maintainable foundation for complex conditional field behavior while maintaining clean separation of concerns and extensibility. \ No newline at end of file diff --git a/src/Controller/Booking/CreateStep1Controller.php b/src/Controller/Booking/CreateStep1Controller.php index 8811a95..0bd4035 100644 --- a/src/Controller/Booking/CreateStep1Controller.php +++ b/src/Controller/Booking/CreateStep1Controller.php @@ -34,8 +34,8 @@ class CreateStep1Controller extends AbstractController { $bookingCreateDto = $this->bookingService->getOrCreateBookingCreateDto($request); - // Capture the current room selection state before form processing - $oldRoomSelectionSnapshot = $this->bookingService->createRoomSelectionSnapshot($bookingCreateDto); + // Get or create baseline snapshot for change detection + $oldRoomSelectionSnapshot = $this->bookingService->getOrCreateBaselineSnapshot($request, $bookingCreateDto); $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); @@ -52,9 +52,13 @@ class CreateStep1Controller extends AbstractController if ($this->bookingService->hasRoomSelectionChanged($oldRoomSelectionSnapshot, $bookingCreateDto)) { $this->bookingService->resetParticipantAssignments($bookingCreateDto); } + $bookingCreateDto->currentStep = 2; $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + // Clear baseline snapshot when moving to step 2 + $this->bookingService->clearBaselineSnapshot($request); + return $this->redirectToRoute('app_booking_create_step_2'); } diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index de4d6b3..bcbffad 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -5,7 +5,8 @@ namespace App\Form; use App\BusProNet\Form\CountryType; use App\Form\Model\BookingDtoInterface; use App\Form\Model\ParticipantDto; -use App\Form\Service\ParticipantFieldOptionsProvider; +use App\Form\Service\Contract\FieldOptionsProviderInterface; +use App\Form\Service\CreateFieldStateProvider; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\BirthdayType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -20,7 +21,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class BookingCreateParticipantType extends AbstractType { public function __construct( - private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider, + private readonly FieldOptionsProviderInterface $fieldOptionsProvider, + private readonly CreateFieldStateProvider $fieldStateProvider, ) { } @@ -85,7 +87,7 @@ class BookingCreateParticipantType extends AbstractType } // Get the booking DTO from the root form - $bookingDto = $this->fieldOptionsProvider->getBookingDtoFromForm($form); + $bookingDto = $this->fieldStateProvider->getBookingDtoFromForm($form); if (null === $bookingDto) { return; @@ -108,7 +110,7 @@ class BookingCreateParticipantType extends AbstractType } // Get the booking DTO from the root form - $bookingDto = $this->fieldOptionsProvider->getBookingDtoFromForm($form); + $bookingDto = $this->fieldStateProvider->getBookingDtoFromForm($form); if (null === $bookingDto) { return; @@ -138,7 +140,7 @@ class BookingCreateParticipantType extends AbstractType private function applyFieldStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void { // Get all fields that have state conditions - $allFieldStates = $this->fieldOptionsProvider->getAllFieldStates($bookingDto, $participantIndex, $formData); + $allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex, $formData); foreach ($allFieldStates as $fieldName => $fieldState) { if ($form->has($fieldName)) { @@ -169,8 +171,8 @@ class BookingCreateParticipantType extends AbstractType $fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex); // Apply dynamic field state if conditions exist - if ($this->fieldOptionsProvider->hasStateConditions($fieldName)) { - $fieldState = $this->fieldOptionsProvider->getFieldState($fieldName, $bookingDto, $participantIndex); + if ($this->fieldStateProvider->hasStateConditions($fieldName)) { + $fieldState = $this->fieldStateProvider->getFieldState($fieldName, $bookingDto, $participantIndex); $fieldOptions = $this->mergeFieldState($fieldOptions, $fieldState); } diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index 705dd45..f4496b3 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -3,7 +3,7 @@ namespace App\Form; use App\Form\Model\BookingCreateDto; -use App\Form\ParticipantFieldHandler\ParticipantFieldHandlerRegistry; +use App\Form\Service\ParticipantFieldHandlerRegistry; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; diff --git a/src/Form/BookingEditType.php b/src/Form/BookingEditType.php index 816a2e9..6d4911a 100644 --- a/src/Form/BookingEditType.php +++ b/src/Form/BookingEditType.php @@ -5,7 +5,7 @@ namespace App\Form; use App\BusProNet\Constants; use App\BusProNet\Model\Service; use App\Form\Model\BookingEditDto; -use App\Form\ParticipantFieldHandler\ParticipantFieldHandlerRegistry; +use App\Form\Service\ParticipantFieldHandlerRegistry; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; diff --git a/src/Form/RoomSelectType.php b/src/Form/RoomSelectType.php index ed8e56d..77b34f1 100644 --- a/src/Form/RoomSelectType.php +++ b/src/Form/RoomSelectType.php @@ -23,12 +23,10 @@ class RoomSelectType extends AbstractType $form->add('quantity', ChoiceType::class, [ 'label' => 'Anzahl '.$data->roomLabel, - 'required' => false, - 'placeholder' => '-', - 'choices' => array_combine(range(1, $data->maxQuantity), range(1, $data->maxQuantity)), + 'required' => true, + 'choices' => ['-' => 0] + array_combine(range(1, $data->maxQuantity), range(1, $data->maxQuantity)), ]); - }) - ; + }); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php new file mode 100644 index 0000000..80becd4 --- /dev/null +++ b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php @@ -0,0 +1,91 @@ + Field option providers indexed by field name */ + protected array $fieldOptionProviders = []; + + public function __construct() + { + $this->registerFieldOptionProviders(); + } + + /** + * Retrieves form field options for a specified dynamic field. + * + * This method looks up the appropriate option provider for the field + * and executes it with the current booking and participant context to + * generate dynamic field options. + * + * The returned array contains Symfony form field options that will be + * used when building the form field. These options are merged with any + * static options defined in the form type. + * + * @param string $fieldName The name of the field to configure + * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param int $participantIndex The index of the participant being configured + * + * @return array Symfony form field options, or empty array if field not supported + */ + public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array + { + // Check if we have a provider for this field + if (!isset($this->fieldOptionProviders[$fieldName])) { + return []; + } + + // Execute the provider with current context to generate dynamic options + return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex); + } + + /** + * Checks whether a field has option provider support. + * + * This method allows form builders to determine if a field can be + * dynamically configured by this service. It's useful for deciding + * whether to use static field options or dynamic configuration. + * + * @param string $fieldName The name of the field to check + * + * @return bool True if the field has registered option providers, false otherwise + */ + public function hasFieldOptions(string $fieldName): bool + { + return isset($this->fieldOptionProviders[$fieldName]); + } + + /** + * Registers field option providers during service initialization. + * + * This method must be implemented by concrete classes to define their + * specific field option providers. Each provider is a callable that + * receives the booking DTO and participant index and returns appropriate + * Symfony form field options. + * + * Example implementation: + * + * protected function registerFieldOptionProviders(): void + * { + * $this->fieldOptionProviders['fieldName'] = fn($bookingDto, $participantIndex) => [ + * 'label' => 'Field Label', + * 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex), + * ]; + * } + */ + abstract protected function registerFieldOptionProviders(): void; +} \ No newline at end of file diff --git a/src/Form/Service/Abstract/AbstractFieldStateProvider.php b/src/Form/Service/Abstract/AbstractFieldStateProvider.php new file mode 100644 index 0000000..46a0305 --- /dev/null +++ b/src/Form/Service/Abstract/AbstractFieldStateProvider.php @@ -0,0 +1,144 @@ +> Field state conditions indexed by field name and state type */ + protected array $fieldStateConditions = []; + + public function __construct() + { + $this->registerFieldStateConditions(); + } + + /** + * Calculates the dynamic state for a specified field. + * + * Evaluates all configured conditions for a field and returns the appropriate + * state modifications. State conditions are organized by state type (readonly, + * disabled, etc.) and evaluated independently. + * + * @param string $fieldName The name of the field to evaluate + * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return array Symfony form field options for state modifications + */ + public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array + { + if (!isset($this->fieldStateConditions[$fieldName])) { + return []; + } + + $stateModifications = []; + $attributes = []; + + foreach ($this->fieldStateConditions[$fieldName] as $stateType => $condition) { + if ($condition->evaluate($bookingDto, $participantIndex, $formData)) { + switch ($stateType) { + case 'readonly': + $attributes['readonly'] = true; + break; + case 'disabled': + $stateModifications['disabled'] = true; + break; + case 'required': + $stateModifications['required'] = true; + break; + case 'hidden': + $attributes['style'] = ($attributes['style'] ?? '').' display: none;'; + break; + } + } + } + + if (!empty($attributes)) { + $stateModifications['attr'] = $attributes; + } + + return $stateModifications; + } + + /** + * Checks whether a field has state conditions configured. + * + * @param string $fieldName The name of the field to check + * + * @return bool True if the field has state conditions configured + */ + public function hasStateConditions(string $fieldName): bool + { + return isset($this->fieldStateConditions[$fieldName]) && !empty($this->fieldStateConditions[$fieldName]); + } + + /** + * Returns field names that trigger state re-evaluation for a given field. + * + * @param string $fieldName The name of the field to get dependencies for + * + * @return string[] Array of field names that affect the specified field's state + */ + public function getFieldStateDependencies(string $fieldName): array + { + if (!isset($this->fieldStateConditions[$fieldName])) { + return []; + } + + $dependencies = []; + + foreach ($this->fieldStateConditions[$fieldName] as $condition) { + $dependencies = array_merge($dependencies, $condition->getDependentFields()); + } + + return array_unique($dependencies); + } + + /** + * Calculates field states for all configured fields at once. + * + * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return array> Field states indexed by field name + */ + public function getAllFieldStates(BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array + { + $allStates = []; + + foreach (array_keys($this->fieldStateConditions) as $fieldName) { + $fieldState = $this->getFieldState($fieldName, $bookingDto, $participantIndex, $formData); + if (!empty($fieldState)) { + $allStates[$fieldName] = $fieldState; + } + } + + return $allStates; + } + + /** + * Registers field state conditions during service initialization. + * + * This method must be implemented by concrete classes to define their + * specific field state conditions. + */ + abstract protected function registerFieldStateConditions(): void; +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php similarity index 98% rename from src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php rename to src/Form/Service/Abstract/AbstractParticipantFieldHandler.php index aec5f14..b9cd677 100644 --- a/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php +++ b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler; +namespace App\Form\Service\Abstract; use App\Form\Model\BookingDtoInterface; +use App\Form\Service\Contract\ParticipantFieldHandlerInterface; /** * Abstract base class providing common functionality for participant field handlers. @@ -150,4 +151,4 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle { return []; } -} +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/Condition/AgeRangeCondition.php b/src/Form/Service/Condition/AgeRangeCondition.php similarity index 98% rename from src/Form/ParticipantFieldHandler/Condition/AgeRangeCondition.php rename to src/Form/Service/Condition/AgeRangeCondition.php index 856d0a4..2b3ef48 100644 --- a/src/Form/ParticipantFieldHandler/Condition/AgeRangeCondition.php +++ b/src/Form/Service/Condition/AgeRangeCondition.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler\Condition; +namespace App\Form\Service\Condition; use App\Form\Model\BookingDtoInterface; +use App\Form\Service\Contract\FieldConditionInterface; /** * Condition that evaluates participant age against specified range criteria. diff --git a/src/Form/ParticipantFieldHandler/Condition/ApplicantCondition.php b/src/Form/Service/Condition/ApplicantCondition.php similarity index 93% rename from src/Form/ParticipantFieldHandler/Condition/ApplicantCondition.php rename to src/Form/Service/Condition/ApplicantCondition.php index 5d7523d..237420b 100644 --- a/src/Form/ParticipantFieldHandler/Condition/ApplicantCondition.php +++ b/src/Form/Service/Condition/ApplicantCondition.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler\Condition; +namespace App\Form\Service\Condition; use App\Form\Model\BookingDtoInterface; +use App\Form\Service\Contract\FieldConditionInterface; /** * Condition that checks if the participant is the applicant. @@ -41,4 +42,4 @@ class ApplicantCondition implements FieldConditionInterface { return 'Checks if the participant is the applicant (index 0)'; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/Condition/CompositeCondition.php b/src/Form/Service/Condition/CompositeCondition.php similarity index 98% rename from src/Form/ParticipantFieldHandler/Condition/CompositeCondition.php rename to src/Form/Service/Condition/CompositeCondition.php index 2b5a1dc..9ddac2e 100644 --- a/src/Form/ParticipantFieldHandler/Condition/CompositeCondition.php +++ b/src/Form/Service/Condition/CompositeCondition.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler\Condition; +namespace App\Form\Service\Condition; use App\Form\Model\BookingDtoInterface; +use App\Form\Service\Contract\FieldConditionInterface; /** * Composite condition that combines multiple conditions with logical operators. @@ -230,4 +231,4 @@ class CompositeCondition implements FieldConditionInterface throw new \InvalidArgumentException(sprintf('%s operator requires at least one condition', $operator)); } } -} +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/Condition/FieldValueCondition.php b/src/Form/Service/Condition/FieldValueCondition.php similarity index 99% rename from src/Form/ParticipantFieldHandler/Condition/FieldValueCondition.php rename to src/Form/Service/Condition/FieldValueCondition.php index 66c7fb9..2bc385a 100644 --- a/src/Form/ParticipantFieldHandler/Condition/FieldValueCondition.php +++ b/src/Form/Service/Condition/FieldValueCondition.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler\Condition; +namespace App\Form\Service\Condition; use App\Form\Model\BookingDtoInterface; +use App\Form\Service\Contract\FieldConditionInterface; /** * Condition that evaluates field states based on other field values. @@ -262,4 +263,4 @@ class FieldValueCondition implements FieldConditionInterface throw new \InvalidArgumentException(sprintf('Operator "%s" does not accept expectedValue parameter', $operator)); } } -} +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/Condition/MutabilityCondition.php b/src/Form/Service/Condition/MutabilityCondition.php similarity index 94% rename from src/Form/ParticipantFieldHandler/Condition/MutabilityCondition.php rename to src/Form/Service/Condition/MutabilityCondition.php index 6dde404..b59cce4 100644 --- a/src/Form/ParticipantFieldHandler/Condition/MutabilityCondition.php +++ b/src/Form/Service/Condition/MutabilityCondition.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler\Condition; +namespace App\Form\Service\Condition; use App\Form\Model\BookingDtoInterface; +use App\Form\Service\Contract\FieldConditionInterface; /** * Condition that checks if a participant's personal data is mutable in the edit flow. @@ -45,4 +46,4 @@ class MutabilityCondition implements FieldConditionInterface { return 'Checks if the participant\'s personal data is mutable (edit flow)'; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/Condition/FieldConditionInterface.php b/src/Form/Service/Contract/FieldConditionInterface.php similarity index 96% rename from src/Form/ParticipantFieldHandler/Condition/FieldConditionInterface.php rename to src/Form/Service/Contract/FieldConditionInterface.php index e9710c4..4cf8192 100644 --- a/src/Form/ParticipantFieldHandler/Condition/FieldConditionInterface.php +++ b/src/Form/Service/Contract/FieldConditionInterface.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler\Condition; +namespace App\Form\Service\Contract; -use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingDtoInterface; /** diff --git a/src/Form/Service/Contract/FieldOptionsProviderInterface.php b/src/Form/Service/Contract/FieldOptionsProviderInterface.php new file mode 100644 index 0000000..a1d72c9 --- /dev/null +++ b/src/Form/Service/Contract/FieldOptionsProviderInterface.php @@ -0,0 +1,64 @@ + Symfony form field options, or empty array if field not supported + */ + public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array; + + /** + * Checks whether a field has option provider support. + * + * This method allows form builders to determine if a field can be + * dynamically configured by this service. It's useful for deciding + * whether to use static field options or dynamic configuration. + * + * @param string $fieldName The name of the field to check + * + * @return bool True if the field has registered option providers, false otherwise + */ + public function hasFieldOptions(string $fieldName): bool; +} \ No newline at end of file diff --git a/src/Form/Service/FieldStateProviderInterface.php b/src/Form/Service/Contract/FieldStateProviderInterface.php similarity index 99% rename from src/Form/Service/FieldStateProviderInterface.php rename to src/Form/Service/Contract/FieldStateProviderInterface.php index 21baef7..544b36b 100644 --- a/src/Form/Service/FieldStateProviderInterface.php +++ b/src/Form/Service/Contract/FieldStateProviderInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Form\Service; +namespace App\Form\Service\Contract; use App\Form\Model\BookingDtoInterface; diff --git a/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php similarity index 98% rename from src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php rename to src/Form/Service/Contract/ParticipantFieldHandlerInterface.php index a34d344..02cbbd9 100644 --- a/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php +++ b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler; +namespace App\Form\Service\Contract; use App\Form\Model\BookingDtoInterface; diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php new file mode 100644 index 0000000..f19746e --- /dev/null +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -0,0 +1,68 @@ +fieldStateConditions['fieldName'] = [ + * 'readonly' => new SomeCondition(), + * 'disabled' => CompositeCondition::and( + * new AgeRangeCondition(null, 17), + * FieldValueCondition::equals('someField', 'someValue') + * ), + * ]; + * + * Condition Types: + * - 'readonly': Field is visible but not editable + * - 'disabled': Field interaction is disabled + * - 'required': Field becomes mandatory + * - 'hidden': Field is not displayed + */ + protected function registerFieldStateConditions(): void + { + // Example field state conditions would be registered here + // For demonstration purposes, here are some example patterns: + + // Example 1: Make assignedRoomId readonly for participants under 18 + // $this->fieldStateConditions['assignedRoomId'] = [ + // 'readonly' => new AgeRangeCondition(null, 17), + // ]; + + // Example 2: Disable service selection if no room is assigned + // $this->fieldStateConditions['serviceSelection'] = [ + // 'disabled' => FieldValueCondition::isEmpty('assignedRoomId'), + // ]; + + // Example 3: Complex condition with multiple criteria + // $this->fieldStateConditions['advancedOptions'] = [ + // 'hidden' => CompositeCondition::or( + // new AgeRangeCondition(null, 15), + // FieldValueCondition::equals('userType', 'basic') + // ), + // ]; + } +} diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index f8986d7..59d4cae 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -4,11 +4,10 @@ declare(strict_types=1); namespace App\Form\Service; -use App\Form\Model\BookingDtoInterface; -use App\Form\ParticipantFieldHandler\Condition\ApplicantCondition; -use App\Form\ParticipantFieldHandler\Condition\CompositeCondition; -use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface; -use App\Form\ParticipantFieldHandler\Condition\MutabilityCondition; +use App\Form\Service\Condition\ApplicantCondition; +use App\Form\Service\Condition\CompositeCondition; +use App\Form\Service\Condition\MutabilityCondition; +use App\Form\Service\Abstract\AbstractFieldStateProvider; /** * Field state provider for the booking edit workflow. @@ -16,27 +15,19 @@ use App\Form\ParticipantFieldHandler\Condition\MutabilityCondition; * This service calculates dynamic field states (readonly, disabled, etc.) * for participant fields in the edit flow, using edit-specific conditions. * - * It is designed to be extensible and composable, allowing reuse of - * existing condition classes and easy registration of new logic. + * It extends the common field state functionality provided by + * AbstractFieldStateProvider and adds edit-specific field state logic. */ -class EditFieldStateProvider implements FieldStateProviderInterface +class EditFieldStateProvider extends AbstractFieldStateProvider { - use FormTraversalTrait; - - /** @var array> */ - private array $fieldStateConditions = []; - - public function __construct() - { - $this->registerFieldStateConditions(); - } - /** * Registers field state conditions for the edit workflow. * - * Add or modify conditions as needed for your domain. + * This method defines the conditional logic for field states in the + * booking edit process. It makes personal data fields readonly when + * the participant is the applicant or when the field is not mutable. */ - private function registerFieldStateConditions(): void + protected function registerFieldStateConditions(): void { // Make all personal data fields readonly if not mutable OR if applicant $personalDataFields = [ @@ -56,75 +47,5 @@ class EditFieldStateProvider implements FieldStateProviderInterface ), ]; } - - // Example: Only applicant can edit email - $this->fieldStateConditions['email']['readonly'] = new ApplicantCondition(); - } - - public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array - { - if (!isset($this->fieldStateConditions[$fieldName])) { - return []; - } - - $stateModifications = []; - $attributes = []; - - foreach ($this->fieldStateConditions[$fieldName] as $stateType => $condition) { - if ($condition->evaluate($bookingDto, $participantIndex, $formData)) { - switch ($stateType) { - case 'readonly': - $attributes['readonly'] = true; - break; - case 'disabled': - $stateModifications['disabled'] = true; - break; - case 'required': - $stateModifications['required'] = true; - break; - case 'hidden': - $attributes['style'] = ($attributes['style'] ?? '').' display: none;'; - break; - } - } - } - - if (!empty($attributes)) { - $stateModifications['attr'] = $attributes; - } - - return $stateModifications; - } - - public function hasStateConditions(string $fieldName): bool - { - return isset($this->fieldStateConditions[$fieldName]) && !empty($this->fieldStateConditions[$fieldName]); - } - - public function getFieldStateDependencies(string $fieldName): array - { - if (!isset($this->fieldStateConditions[$fieldName])) { - return []; - } - - $dependencies = []; - foreach ($this->fieldStateConditions[$fieldName] as $condition) { - $dependencies = array_merge($dependencies, $condition->getDependentFields()); - } - - return array_unique($dependencies); - } - - public function getAllFieldStates(BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array - { - $allStates = []; - foreach (array_keys($this->fieldStateConditions) as $fieldName) { - $fieldState = $this->getFieldState($fieldName, $bookingDto, $participantIndex, $formData); - if (!empty($fieldState)) { - $allStates[$fieldName] = $fieldState; - } - } - - return $allStates; } } diff --git a/src/Form/Service/ParticipantRoomChoiceLoaderFactory.php b/src/Form/Service/Factory/ParticipantRoomChoiceLoaderFactory.php similarity index 96% rename from src/Form/Service/ParticipantRoomChoiceLoaderFactory.php rename to src/Form/Service/Factory/ParticipantRoomChoiceLoaderFactory.php index 5a07322..6dcb5ef 100644 --- a/src/Form/Service/ParticipantRoomChoiceLoaderFactory.php +++ b/src/Form/Service/Factory/ParticipantRoomChoiceLoaderFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Form\Service; +namespace App\Form\Service\Factory; use App\Form\ChoiceLoader\ParticipantRoomChoiceLoader; use App\Form\Model\ParticipantDto; diff --git a/src/Form/ParticipantFieldHandler/ParticipantAssignedRoomFieldHandler.php b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php similarity index 96% rename from src/Form/ParticipantFieldHandler/ParticipantAssignedRoomFieldHandler.php rename to src/Form/Service/ParticipantAssignedRoomFieldHandler.php index 2f97aec..5d95274 100644 --- a/src/Form/ParticipantFieldHandler/ParticipantAssignedRoomFieldHandler.php +++ b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler; +namespace App\Form\Service; use App\Form\Model\BookingDtoInterface; +use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** * Handles processing of the assignedRoomId field for booking participants. @@ -70,4 +71,4 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle // Convert form string to integer, handling empty selections as null $participant->assignedRoomId = $this->normalizeIntValue($roomId); } -} +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php similarity index 98% rename from src/Form/ParticipantFieldHandler/ParticipantFieldHandlerRegistry.php rename to src/Form/Service/ParticipantFieldHandlerRegistry.php index 5eea8ed..df32253 100644 --- a/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Form\ParticipantFieldHandler; +namespace App\Form\Service; use App\Form\Model\BookingDtoInterface; +use App\Form\Service\Contract\ParticipantFieldHandlerInterface; /** * Registry for managing and executing participant field handlers in dependency order. @@ -200,4 +201,4 @@ class ParticipantFieldHandlerRegistry return $result; } -} +} \ No newline at end of file diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index ca2594b..9ed746b 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -6,98 +6,42 @@ namespace App\Form\Service; use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingDtoInterface; -use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface; +use App\Form\Service\Abstract\AbstractFieldOptionsProvider; +use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory; /** - * Provides dynamic field options and state for participant form fields. + * Provides dynamic field options for participant form fields. * - * This service acts as both a field option provider and field state provider - * for the participant form system. It generates context-aware Symfony form - * field options and calculates dynamic field states based on conditional logic. + * This service generates context-aware Symfony form field options for + * dynamic fields in the participant form system. It handles fields that + * require options to be calculated based on the current booking context, + * participant data, and business logic. * * Key Responsibilities: * - Manages field option providers for dynamic fields - * - Calculates field states based on conditional logic * - Provides context-aware field configurations * - Handles interdependent field relationships - * - Supports extensible field option and state generation + * - Supports extensible field option generation * - * The provider uses a callable pattern for field options and a condition-based - * system for field states, enabling complex conditional field behavior. + * The provider uses a callable pattern for field options, enabling + * lazy evaluation and complex conditional field behavior. */ -class ParticipantFieldOptionsProvider implements FieldStateProviderInterface +class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider { - use FormTraversalTrait; - - /** @var array Field option providers indexed by field name */ - private array $fieldOptionProviders = []; - - /** @var array> Field state conditions indexed by field name and state type */ - private array $fieldStateConditions = []; - /** * Initializes the provider with required dependencies. * - * The provider automatically registers all field option providers and - * field state conditions during construction to ensure they're available - * for form building. This approach keeps all field configuration logic - * centralized and makes it easy to add new dynamic fields. + * The provider automatically registers all field option providers during + * construction to ensure they're available for form building. This approach + * keeps all field configuration logic centralized and makes it easy to add + * new dynamic fields. * * @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders */ public function __construct( private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory, ) { - $this->registerFieldOptionProviders(); - $this->registerFieldStateConditions(); - } - - /** - * Retrieves form field options for a specified dynamic field. - * - * This is the main entry point for getting field configurations. It looks up - * the appropriate option provider for the field and executes it with the - * current booking and participant context to generate dynamic field options. - * - * The returned array contains Symfony form field options such as: - * - 'label' - The field label - * - 'placeholder' - Placeholder text - * - 'choices' - Available choices for choice fields - * - 'choice_loader' - Dynamic choice loader for complex choices - * - 'disabled' - Whether the field should be disabled - * - 'required' - Whether the field is required - * - * @param string $fieldName The name of the field to configure - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) - * @param int $participantIndex The index of the participant being configured - * - * @return array Symfony form field options, or empty array if field not supported - */ - public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array - { - // Check if we have a provider for this field - if (!isset($this->fieldOptionProviders[$fieldName])) { - return []; - } - - // Execute the provider with current context to generate dynamic options - return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex); - } - - /** - * Checks whether a field has option provider support. - * - * This method allows form builders to determine if a field can be - * dynamically configured by this service. It's useful for deciding - * whether to use static field options or dynamic configuration. - * - * @param string $fieldName The name of the field to check - * - * @return bool True if the field has registered option providers, false otherwise - */ - public function hasFieldOptions(string $fieldName): bool - { - return isset($this->fieldOptionProviders[$fieldName]); + parent::__construct(); } /** @@ -121,7 +65,7 @@ class ParticipantFieldOptionsProvider implements FieldStateProviderInterface * - Easy to test individual field logic * - Supports complex interdependencies */ - private function registerFieldOptionProviders(): void + protected function registerFieldOptionProviders(): void { // Room assignment field provider (only available for create workflow) $this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ @@ -151,158 +95,4 @@ class ParticipantFieldOptionsProvider implements FieldStateProviderInterface // ], // ]; } - - /** - * Calculates the dynamic state for a specified field. - * - * Evaluates all configured conditions for a field and returns the appropriate - * state modifications. State conditions are organized by state type (readonly, - * disabled, etc.) and evaluated independently. - * - * @param string $fieldName The name of the field to evaluate - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) - * @param int $participantIndex The index of the participant being evaluated - * @param array $formData Current form data for condition evaluation - * - * @return array Symfony form field options for state modifications - */ - public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array - { - if (!isset($this->fieldStateConditions[$fieldName])) { - return []; - } - - $stateModifications = []; - $attributes = []; - - foreach ($this->fieldStateConditions[$fieldName] as $stateType => $condition) { - if ($condition->evaluate($bookingDto, $participantIndex, $formData)) { - switch ($stateType) { - case 'readonly': - $attributes['readonly'] = true; - break; - case 'disabled': - $stateModifications['disabled'] = true; - break; - case 'required': - $stateModifications['required'] = true; - break; - case 'hidden': - $attributes['style'] = ($attributes['style'] ?? '').' display: none;'; - break; - } - } - } - - if (!empty($attributes)) { - $stateModifications['attr'] = $attributes; - } - - return $stateModifications; - } - - /** - * Checks whether a field has state conditions configured. - * - * @param string $fieldName The name of the field to check - * - * @return bool True if the field has state conditions configured - */ - public function hasStateConditions(string $fieldName): bool - { - return isset($this->fieldStateConditions[$fieldName]) && !empty($this->fieldStateConditions[$fieldName]); - } - - /** - * Returns field names that trigger state re-evaluation for a given field. - * - * @param string $fieldName The name of the field to get dependencies for - * - * @return string[] Array of field names that affect the specified field's state - */ - public function getFieldStateDependencies(string $fieldName): array - { - if (!isset($this->fieldStateConditions[$fieldName])) { - return []; - } - - $dependencies = []; - - foreach ($this->fieldStateConditions[$fieldName] as $condition) { - $dependencies = array_merge($dependencies, $condition->getDependentFields()); - } - - return array_unique($dependencies); - } - - /** - * Calculates field states for all configured fields at once. - * - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) - * @param int $participantIndex The index of the participant being evaluated - * @param array $formData Current form data for condition evaluation - * - * @return array> Field states indexed by field name - */ - public function getAllFieldStates(BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array - { - $allStates = []; - - foreach (array_keys($this->fieldStateConditions) as $fieldName) { - $fieldState = $this->getFieldState($fieldName, $bookingDto, $participantIndex, $formData); - if (!empty($fieldState)) { - $allStates[$fieldName] = $fieldState; - } - } - - return $allStates; - } - - /** - * Registers field state conditions during service initialization. - * - * This method defines the conditional logic for field states. Each field - * can have multiple state conditions (readonly, disabled, hidden, required) - * that are evaluated independently. - * - * Adding new field state conditions: - * To add conditional state logic for a field, register conditions here: - * - * $this->fieldStateConditions['fieldName'] = [ - * 'readonly' => new SomeCondition(), - * 'disabled' => CompositeCondition::and( - * new AgeRangeCondition(null, 17), - * FieldValueCondition::equals('someField', 'someValue') - * ), - * ]; - * - * Condition Types: - * - 'readonly': Field is visible but not editable - * - 'disabled': Field interaction is disabled - * - 'required': Field becomes mandatory - * - 'hidden': Field is not displayed - */ - private function registerFieldStateConditions(): void - { - // Example field state conditions would be registered here - // For demonstration purposes, here are some example patterns: - - // Example 1: Make assignedRoomId readonly for participants under 18 - // $this->fieldStateConditions['assignedRoomId'] = [ - // 'readonly' => new AgeRangeCondition(null, 17), - // ]; - - // Example 2: Disable service selection if no room is assigned - // $this->fieldStateConditions['serviceSelection'] = [ - // 'disabled' => FieldValueCondition::isEmpty('assignedRoomId'), - // ]; - - // Example 3: Complex condition with multiple criteria - // $this->fieldStateConditions['advancedOptions'] = [ - // 'hidden' => CompositeCondition::or( - // new AgeRangeCondition(null, 15), - // FieldValueCondition::equals('userType', 'basic') - // ), - // ]; - } } diff --git a/src/Form/Service/FormTraversalTrait.php b/src/Form/Service/Trait/FormTraversalTrait.php similarity index 97% rename from src/Form/Service/FormTraversalTrait.php rename to src/Form/Service/Trait/FormTraversalTrait.php index ae45966..3dd57ff 100644 --- a/src/Form/Service/FormTraversalTrait.php +++ b/src/Form/Service/Trait/FormTraversalTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Form\Service; +namespace App\Form\Service\Trait; use App\Form\Model\BookingDtoInterface; use Symfony\Component\Form\FormInterface; @@ -39,4 +39,4 @@ trait FormTraversalTrait return $data instanceof BookingDtoInterface ? $data : null; } -} +} \ No newline at end of file diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 990763b..2d004e2 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -11,14 +11,51 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class BookingService { + public const BOOKING_CREATE_KEY = 'booking_create'; + public const BOOKING_CREATE_BASELINE_KEY = 'booking_create_baseline_snapshot'; + public function __construct( private readonly TravelDataService $travelDataService, - ) {} + ) { + } + + /** + * Gets or creates the baseline room selection snapshot for change detection. + * + * The baseline snapshot captures the initial room selection state when step 1 + * is first loaded, before any HTMX modifications. This ensures accurate change + * detection for room assignment resets. + */ + public function getOrCreateBaselineSnapshot(Request $request, BookingCreateDto $bookingCreateDto): array + { + $baselineKey = self::BOOKING_CREATE_BASELINE_KEY; + + if (!$request->getSession()->has($baselineKey) || $request->query->has('reset_baseline')) { + $baseline = $this->createRoomSelectionSnapshot($bookingCreateDto); + $request->getSession()->set($baselineKey, $baseline); + + return $baseline; + } + + return $request->getSession()->get($baselineKey); + } + + /** + * Clears the baseline snapshot from the session. + * + * Should be called when moving to the next step or when the baseline + * needs to be refreshed. + */ + public function clearBaselineSnapshot(Request $request): void + { + $request->getSession()->remove('booking_create_baseline_snapshot'); + } public function getOrCreateBookingCreateDto(Request $request): BookingCreateDto { + $bookingCreateKey = self::BOOKING_CREATE_KEY; $bookingUuid = $request->query->get('uid'); - $bookingCreateDto = $request->getSession()->get('booking_create'); + $bookingCreateDto = $request->getSession()->get($bookingCreateKey); // No UID parameter - return existing DTO from session if available if (null === $bookingUuid && null !== $bookingCreateDto) { @@ -41,7 +78,7 @@ class BookingService $availableRooms = $travelData->getAvailableRooms(); $roomSelections = array_map( - fn(Room $room) => $this->createRoomSelection($room, $roomsIdsAndQuantities), + fn (Room $room) => $this->createRoomSelection($room, $roomsIdsAndQuantities), $availableRooms ); @@ -55,7 +92,7 @@ class BookingService public function saveBookingCreateDto(Request $request, BookingCreateDto $bookingCreateDto): void { - $request->getSession()->set('booking_create', $bookingCreateDto); + $request->getSession()->set(self::BOOKING_CREATE_KEY, $bookingCreateDto); } private function createRoomSelection(Room $room, array $roomsIdsAndQuantities): RoomSelectionDto @@ -188,8 +225,8 @@ class BookingService */ public function shouldResetAssignments(BookingCreateDto $oldDto, BookingCreateDto $newDto): bool { - $old = array_map(fn($roomSelectionDto) => [$roomSelectionDto->roomId, $roomSelectionDto->quantity], $oldDto->roomSelections); - $new = array_map(fn($roomSelectionDto) => [$roomSelectionDto->roomId, $roomSelectionDto->quantity], $newDto->roomSelections); + $old = array_map(fn ($roomSelectionDto) => [$roomSelectionDto->roomId, $roomSelectionDto->quantity], $oldDto->roomSelections); + $new = array_map(fn ($roomSelectionDto) => [$roomSelectionDto->roomId, $roomSelectionDto->quantity], $newDto->roomSelections); return $old !== $new; } @@ -212,7 +249,7 @@ class BookingService public function createRoomSelectionSnapshot(BookingCreateDto $dto): array { return array_map( - fn($roomSelection) => [$roomSelection->roomId, $roomSelection->quantity], + fn ($roomSelection) => [(int) $roomSelection->roomId, (int) $roomSelection->quantity], $dto->roomSelections ); } @@ -223,6 +260,7 @@ class BookingService public function hasRoomSelectionChanged(array $oldSnapshot, BookingCreateDto $newDto): bool { $newSnapshot = $this->createRoomSelectionSnapshot($newDto); + return $oldSnapshot !== $newSnapshot; } } diff --git a/templates/booking/_summary.html.twig b/templates/booking/_summary.html.twig index bd767b4..3ab0ec0 100644 --- a/templates/booking/_summary.html.twig +++ b/templates/booking/_summary.html.twig @@ -9,7 +9,7 @@
  • {{ roomSelection.quantity }} x {{ roomSelection.roomLabel }} {% if assignmentCounts is defined and assignmentCounts[roomSelection.roomId] is defined %} - {{ assignmentCounts[roomSelection.roomId] }}/{{ roomSelection.quantity }} belegt + {{ assignmentCounts[roomSelection.roomId] }} belegt {% endif %}
  • {% endfor %} @@ -22,7 +22,7 @@
  • {{ roomSelection.quantity }} x {{ roomSelection.roomLabel }} {% if assignmentCounts is defined and assignmentCounts[roomSelection.roomId] is defined %} - {{ assignmentCounts[roomSelection.roomId] }}/{{ roomSelection.quantity }} belegt + {{ assignmentCounts[roomSelection.roomId] }} belegt {% endif %}
  • {% endfor %} diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 1dc7844..59b248d 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -13,7 +13,7 @@
    {% for participant in form.participants %} {% set participantDataValid = participant.vars.valid %} -
    +
    Teilnehmer:in {{ loop.index }} diff --git a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php index 37ad549..4cbe8c8 100644 --- a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php +++ b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php @@ -36,16 +36,16 @@ class BookingDataProcessorTest extends TestCase public function testCreateUpdateRequestPayloadWithCompleteData(): void { $formData = $this->createCompleteFormData(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertIsArray($result); $this->assertArrayHasKey('idbuchung', $result); $this->assertArrayHasKey('teilnehmerliste', $result); $this->assertArrayHasKey('zusatzleistungen', $result); $this->assertArrayHasKey('beförderungen', $result); $this->assertArrayHasKey('ferienzielunterbringungen', $result); - + $this->assertEquals(123, $result['idbuchung']); $this->assertEquals('ACTIVE', $result['status']); $this->assertCount(2, $result['teilnehmerliste']['teilnehmer']); @@ -54,9 +54,9 @@ class BookingDataProcessorTest extends TestCase public function testCanceledParticipantsAreSkipped(): void { $formData = $this->createFormDataWithCanceledParticipant(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertCount(2, $result['teilnehmerliste']['teilnehmer']); $this->assertEquals(1, $result['teilnehmerliste']['teilnehmer'][0]['@id']); $this->assertEquals(2, $result['teilnehmerliste']['teilnehmer'][1]['@id']); @@ -65,12 +65,12 @@ class BookingDataProcessorTest extends TestCase public function testAdditionalServicesProcessing(): void { $formData = $this->createFormDataWithAdditionalServices(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertNotEmpty($result['zusatzleistungen']['zusatzleistung']); $this->assertCount(2, $result['zusatzleistungen']['zusatzleistung']); - + $service = $result['zusatzleistungen']['zusatzleistung'][0]; $this->assertEquals(1, $service['@idleistung']); $this->assertEquals(1, $service['@anzahl']); @@ -80,9 +80,9 @@ class BookingDataProcessorTest extends TestCase public function testTransportationServicesProcessing(): void { $formData = $this->createFormDataWithTransportation(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertNotEmpty($result['beförderungen']['beförderung']); $this->assertCount(2, $result['beförderungen']['beförderung']); } @@ -90,9 +90,9 @@ class BookingDataProcessorTest extends TestCase public function testBusPickupLocationsProcessing(): void { $formData = $this->createFormDataWithBusPickup(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertArrayHasKey('zustiege', $result); $this->assertNotEmpty($result['zustiege']['zustieg']); $this->assertEquals(1, $result['zustiege']['zustieg'][0]['@idzustieg']); @@ -101,18 +101,18 @@ class BookingDataProcessorTest extends TestCase public function testNonBusTransportationSkipsPickup(): void { $formData = $this->createFormDataWithNonBusTransportation(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertArrayNotHasKey('zustiege', $result); } public function testUnusedServicesAreRemoved(): void { $formData = $this->createFormDataWithUnusedServices(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertEmpty($result['zusatzleistungen']['zusatzleistung']); $this->assertEmpty($result['beförderungen']['beförderung']); } @@ -120,9 +120,9 @@ class BookingDataProcessorTest extends TestCase public function testParticipantPersonalDataUpdate(): void { $formData = $this->createFormDataWithUpdatedPersonalData(); - + $this->processor->createUpdateRequestPayload($formData); - + $participant = $formData->booking->participants[0]; $this->assertEquals('Updated', $participant->firstName); $this->assertEquals('Participant', $participant->name); @@ -133,9 +133,9 @@ class BookingDataProcessorTest extends TestCase public function testInactiveParticipantsPersonalDataNotUpdated(): void { $formData = $this->createFormDataWithInactiveParticipant(); - + $this->processor->createUpdateRequestPayload($formData); - + $participant = $formData->booking->participants[0]; $this->assertEquals('Original', $participant->firstName); $this->assertEquals('Name', $participant->name); @@ -144,12 +144,12 @@ class BookingDataProcessorTest extends TestCase public function testApplicantDataSyncWithFirstParticipant(): void { $formData = $this->createFormDataForApplicantSync(); - + $this->processor->createUpdateRequestPayload($formData); - + $applicant = $formData->booking->applicant; $firstParticipant = $formData->booking->participants[0]; - + $this->assertEquals($firstParticipant->height, $applicant->height); $this->assertEquals($firstParticipant->weight, $applicant->weight); $this->assertEquals($firstParticipant->shoeSize, $applicant->shoeSize); @@ -158,9 +158,9 @@ class BookingDataProcessorTest extends TestCase public function testBankAccountIncludedInPayload(): void { $formData = $this->createFormDataWithBankAccount(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertArrayHasKey('bankverbindung', $result['zahlung']); $this->assertEquals('Test Bank', $result['zahlung']['bankverbindung']['@kreditinstitut']); $this->assertEquals('DE89370400440532013000', $result['zahlung']['bankverbindung']['@iban']); @@ -169,20 +169,20 @@ class BookingDataProcessorTest extends TestCase public function testBankAccountNotIncludedWhenNull(): void { $formData = $this->createFormDataWithoutBankAccount(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertArrayNotHasKey('bankverbindung', $result['zahlung']); } public function testAccommodationRoomsProcessing(): void { $formData = $this->createFormDataWithRooms(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertNotEmpty($result['ferienzielunterbringungen']['ferienzielunterbringung']); - + $room = $result['ferienzielunterbringungen']['ferienzielunterbringung'][0]; $this->assertEquals(1, $room['@idzimmer']); $this->assertEquals('DOUBLE', $room['@kategorie']); @@ -193,9 +193,9 @@ class BookingDataProcessorTest extends TestCase public function testCommunicationObjectCreation(): void { $formData = $this->createFormDataWithoutExistingCommunication(); - + $this->processor->createUpdateRequestPayload($formData); - + $participant = $formData->booking->participants[0]; $this->assertInstanceOf(Communication::class, $participant->communication); $this->assertEquals('new@example.com', $participant->communication->email); @@ -204,150 +204,144 @@ class BookingDataProcessorTest extends TestCase public function testEmptyPickupsToDoesNotCreateZustiegeSection(): void { $formData = $this->createFormDataWithoutPickups(); - + $result = $this->processor->createUpdateRequestPayload($formData); - + $this->assertArrayNotHasKey('zustiege', $result); } private function createCompleteFormData(): BookingEditDto { - $formData = new BookingEditDto(); - - $formData->booking = $this->createMockBooking(); - $formData->travel = $this->createMockTravel(); + $formData = new BookingEditDto($this->createMockBooking(), $this->createMockTravel()); $formData->participants = [ $this->createMockParticipantDto(0, 'F'), $this->createMockParticipantDto(1, 'F'), ]; - + return $formData; } private function createFormDataWithCanceledParticipant(): BookingEditDto { - $formData = new BookingEditDto(); - - $formData->booking = $this->createMockBooking(); - $formData->travel = $this->createMockTravel(); + $formData = new BookingEditDto($this->createMockBooking(), $this->createMockTravel()); $formData->participants = [ $this->createMockParticipantDto(0, 'F'), $this->createMockParticipantDto(1, 'S'), // Canceled ]; - + return $formData; } private function createFormDataWithAdditionalServices(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $participant = $formData->participants[0]; $participant->courses = [$this->createMockService(1)]; $participant->additionalServices = [$this->createMockService(2)]; - + return $formData; } private function createFormDataWithTransportation(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $participant = $formData->participants[0]; $participant->transportationServiceTo = $this->createMockService(1); $participant->transportationServiceFro = $this->createMockService(2); - + return $formData; } private function createFormDataWithBusPickup(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $participant = $formData->participants[0]; $busService = $this->createMockService(1); $busService->subType = 'BUS'; $participant->transportationServiceTo = $busService; $participant->pickup = $this->createMockPickup(1); - + return $formData; } private function createFormDataWithNonBusTransportation(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $participant = $formData->participants[0]; $trainService = $this->createMockService(1); $trainService->subType = 'TRAIN'; $participant->transportationServiceTo = $trainService; - + return $formData; } private function createFormDataWithUnusedServices(): BookingEditDto { $formData = $this->createCompleteFormData(); - + // Remove all participants so services become unused $formData->participants = []; - + return $formData; } private function createFormDataWithUpdatedPersonalData(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $participant = $formData->participants[0]; $participant->firstName = 'Updated'; $participant->lastName = 'Participant'; $participant->email = 'test@example.com'; $participant->mobile = '+49123456789'; - + return $formData; } private function createFormDataWithInactiveParticipant(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $participant = $formData->participants[0]; $participant->status = 'C'; // Inactive status $participant->firstName = 'Updated'; $participant->lastName = 'Participant'; - + // Ensure original data remains unchanged $formData->booking->participants[0]->firstName = 'Original'; $formData->booking->participants[0]->name = 'Name'; - + return $formData; } private function createFormDataForApplicantSync(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $firstParticipant = $formData->booking->participants[0]; $firstParticipant->height = '180'; $firstParticipant->weight = '75'; $firstParticipant->shoeSize = '42'; - + return $formData; } private function createFormDataWithBankAccount(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $bankAccount = new BankAccount(); $bankAccount->bankName = 'Test Bank'; $bankAccount->iban = 'DE89370400440532013000'; $bankAccount->bic = 'COBADEFFXXX'; $bankAccount->holder = 'Test Holder'; - + $formData->booking->bankAccount = $bankAccount; - + return $formData; } @@ -355,14 +349,14 @@ class BookingDataProcessorTest extends TestCase { $formData = $this->createCompleteFormData(); $formData->booking->bankAccount = null; - + return $formData; } private function createFormDataWithRooms(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $room = new Room(); $room->id = 1; $room->category = 'DOUBLE'; @@ -371,22 +365,22 @@ class BookingDataProcessorTest extends TestCase $room->dateTo = new \DateTimeImmutable('2024-01-07'); $room->totalCount = 2; $room->mapping = [0, 1]; - + $formData->booking->rooms = [$room]; - + return $formData; } private function createFormDataWithoutExistingCommunication(): BookingEditDto { $formData = $this->createCompleteFormData(); - + $participant = $formData->participants[0]; $participant->email = 'new@example.com'; $participant->mobile = '+49987654321'; - + $formData->booking->participants[0]->communication = new Communication(); - + return $formData; } @@ -394,7 +388,7 @@ class BookingDataProcessorTest extends TestCase { $formData = $this->createCompleteFormData(); $formData->booking->pickupsTo = []; - + return $formData; } @@ -421,7 +415,7 @@ class BookingDataProcessorTest extends TestCase $booking->applicant = $this->createMockPersonalData('Applicant'); $booking->bankAccount = null; $booking->rooms = []; - + return $booking; } @@ -436,7 +430,7 @@ class BookingDataProcessorTest extends TestCase 1 => $this->createMockService(1), 2 => $this->createMockService(2), ]; - + return $travel; } @@ -463,7 +457,7 @@ class BookingDataProcessorTest extends TestCase $participant->transportationServiceTo = $this->createMockService(1); $participant->transportationServiceFro = $this->createMockService(2); $participant->pickup = null; - + return $participant; } @@ -480,7 +474,7 @@ class BookingDataProcessorTest extends TestCase $personalData->shoeSize = '40'; $personalData->address = new Address(); $personalData->communication = new Communication(); - + return $personalData; } @@ -492,7 +486,7 @@ class BookingDataProcessorTest extends TestCase $service->mapping = []; $service->individualPrice = []; $service->subType = 'STANDARD'; - + return $service; } @@ -501,7 +495,7 @@ class BookingDataProcessorTest extends TestCase $pickup = new Pickup(); $pickup->id = $id; $pickup->mapping = []; - + return $pickup; } -} \ No newline at end of file +}