diff --git a/config/services.yaml b/config/services.yaml index fd5b81c..a229569 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -66,3 +66,12 @@ services: App\Form\Service\ParticipantRoomChoiceLoaderFactory: arguments: $choiceListFactory: '@form.choice_list_factory.default' + + # Participant Field Handler Registry with hybrid handler configuration + App\Form\ParticipantFieldHandler\ParticipantFieldHandlerRegistry: + arguments: + $handlers: + # Simple handlers (no dependencies) - use class names + - 'App\Form\ParticipantFieldHandler\ParticipantAssignedRoomFieldHandler' + # Complex handlers (with dependencies) would use service references like: + # - '@participant.complex.handler' diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index 0446a11..7ab5d46 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -5,7 +5,7 @@ namespace App\Form; use App\BusProNet\Form\CountryType; use App\Form\Model\BookingCreateDto; use App\Form\Model\ParticipantDto; -use App\Form\Service\ParticipantFormConfigurator; +use App\Form\Service\ParticipantFieldOptionsProvider; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\BirthdayType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -14,12 +14,13 @@ use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class BookingCreateParticipantType extends AbstractType { public function __construct( - private readonly ParticipantFormConfigurator $formConfigurator, + private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider, ) { } @@ -66,27 +67,47 @@ class BookingCreateParticipantType extends AbstractType 'clean_xss' => true, ]) ->add('bodyDimensions', BodyDimensionsType::class) - ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { - /** @var ParticipantDto|null $participantData */ - $participantData = $event->getData(); - $form = $event->getForm(); + ->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData']); + } - if (null === $participantData) { - return; - } + /** + * Adds dynamic fields to the form based on participant data. + */ + public function onPreSetData(FormEvent $event): void + { + /** @var ParticipantDto|null $participantData */ + $participantData = $event->getData(); + $form = $event->getForm(); - // Traverse up the form tree to get the root form's data. - $rootForm = $form; - while ($rootForm->getParent()) { - $rootForm = $rootForm->getParent(); - } + if (null === $participantData) { + return; + } - /** @var BookingCreateDto $bookingCreateDto */ - $bookingCreateDto = $rootForm->getData(); + // Traverse up the form tree to get the root form's data. + $rootForm = $form; + while ($rootForm->getParent()) { + $rootForm = $rootForm->getParent(); + } - $roomOptions = $this->formConfigurator->getRoomFieldOptions($bookingCreateDto, $participantData->index); - $form->add('assignedRoomId', ChoiceType::class, $roomOptions); - }); + /** @var BookingCreateDto $bookingCreateDto */ + $bookingCreateDto = $rootForm->getData(); + + $this->addDynamicFields($form, $bookingCreateDto, $participantData->index); + } + + /** + * Adds all configured dynamic fields to the form. + */ + private function addDynamicFields(FormInterface $form, BookingCreateDto $bookingCreateDto, int $participantIndex): void + { + $dynamicFields = ['assignedRoomId']; // List of fields that need dynamic configuration + + foreach ($dynamicFields as $fieldName) { + if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) { + $fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingCreateDto, $participantIndex); + $form->add($fieldName, ChoiceType::class, $fieldOptions); + } + } } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index 86d9495..705dd45 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -3,6 +3,7 @@ namespace App\Form; use App\Form\Model\BookingCreateDto; +use App\Form\ParticipantFieldHandler\ParticipantFieldHandlerRegistry; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; @@ -13,6 +14,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class BookingCreateStep2Type extends AbstractType { + public function __construct( + private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry, + ) { + } + public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -35,9 +41,9 @@ class BookingCreateStep2Type extends AbstractType } /** - * Handles dynamic updates on POST requests (e.g., from HTMX). + * Handles dynamic participant form field updates on POST requests (e.g., from HTMX). * - * This listener synchronizes the DTO with the submitted data *before* + * This listener synchronizes the BookingCreateDto with the submitted participant data *before* * the form's children are processed. It then rebuilds the participants * field to ensure choice loaders are created with the fresh state. */ @@ -49,21 +55,10 @@ class BookingCreateStep2Type extends AbstractType /** @var BookingCreateDto $bookingDto */ $bookingDto = $form->getData(); - // If participant data isn't in the submission, we can't do anything. - if (!isset($submittedData['participants']) || !is_array($submittedData['participants'])) { - return; - } + // Process all registered participant field handlers + $this->participantFieldHandlerRegistry->processFields($submittedData, $bookingDto); - // Manually update the DTO with the submitted room assignments. - foreach ($submittedData['participants'] as $index => $participantData) { - if (isset($participantData['assignedRoomId']) && isset($bookingDto->participants[$index])) { - $roomId = $participantData['assignedRoomId']; - // An unselected choice submits an empty string. - $bookingDto->participants[$index]->assignedRoomId = empty($roomId) ? null : (int)$roomId; - } - } - - // Now, rebuild the 'participants' field with the updated DTO. + // Rebuild the 'participants' field with the updated DTO. $this->addParticipantsField($form); } diff --git a/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php b/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php new file mode 100644 index 0000000..d7a2ff9 --- /dev/null +++ b/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php @@ -0,0 +1,119 @@ + $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool True if the handler should process this field, false otherwise + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return isset($submittedData[$this->getFieldName()]); + } + + /** + * Safely retrieves a participant object from the booking DTO. + * + * This helper method provides safe access to participant data by checking + * if the participant exists at the given index. Returns null if the + * participant doesn't exist, preventing array access errors. + * + * @param BookingCreateDto $bookingDto The booking DTO containing participants + * @param int $participantIndex The index of the participant to retrieve + * + * @return object|null The participant object, or null if not found + */ + protected function getParticipant(BookingCreateDto $bookingDto, int $participantIndex): ?object + { + return $bookingDto->participants[$participantIndex] ?? null; + } + + /** + * Safely extracts a field value from submitted participant data. + * + * This helper method provides safe access to form field values using + * the null coalescing operator. Useful for extracting field values + * without worrying about undefined array keys. + * + * @param array $submittedData The submitted participant form data + * @param string $fieldName The name of the field to retrieve + * @param mixed $default The default value to return if field is not set + * + * @return mixed The field value, or the default value if not found + */ + protected function getFieldValue(array $submittedData, string $fieldName, mixed $default = null): mixed + { + return $submittedData[$fieldName] ?? $default; + } + + /** + * Normalizes empty string values to null. + * + * Form inputs often submit empty strings for unselected/empty fields. + * This helper converts those empty strings to null values, which is + * typically more appropriate for database storage and business logic. + * + * @param mixed $value The value to normalize + * + * @return mixed The normalized value (null if empty, original value otherwise) + */ + protected function normalizeEmptyValue(mixed $value): mixed + { + return empty($value) ? null : $value; + } + + /** + * Converts and normalizes string values to integers. + * + * Form inputs submit all values as strings. This helper safely converts + * string values to integers while handling empty strings and non-numeric + * values gracefully by returning null. + * + * @param mixed $value The value to convert to integer + * + * @return int|null The integer value, or null if empty/non-numeric + */ + protected function normalizeIntValue(mixed $value): ?int + { + if (empty($value)) { + return null; + } + + return is_numeric($value) ? (int) $value : null; + } +} diff --git a/src/Form/ParticipantFieldHandler/ParticipantAssignedRoomFieldHandler.php b/src/Form/ParticipantFieldHandler/ParticipantAssignedRoomFieldHandler.php new file mode 100644 index 0000000..6dfc88a --- /dev/null +++ b/src/Form/ParticipantFieldHandler/ParticipantAssignedRoomFieldHandler.php @@ -0,0 +1,73 @@ + $submittedData The submitted participant form data + * @param BookingCreateDto $bookingDto The booking DTO to update + * @param int $participantIndex The index of the participant being processed + */ + public function processField(array $submittedData, BookingCreateDto $bookingDto, int $participantIndex): void + { + // Safely get the participant object, returning early if not found + $participant = $this->getParticipant($bookingDto, $participantIndex); + + if (null === $participant) { + return; + } + + // Extract the room ID from form data (defaults to null if not present) + $roomId = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Convert form string to integer, handling empty selections as null + $participant->assignedRoomId = $this->normalizeIntValue($roomId); + } +} diff --git a/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php b/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php new file mode 100644 index 0000000..a64fdc3 --- /dev/null +++ b/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php @@ -0,0 +1,43 @@ + $submittedData The submitted participant form data + * @param BookingCreateDto $bookingDto The booking DTO to update + * @param int $participantIndex The participant index being processed + */ + public function processField(array $submittedData, BookingCreateDto $bookingDto, int $participantIndex): void; + + /** + * Determines if this handler should process the field based on submitted participant data. + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool; +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerRegistry.php b/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerRegistry.php new file mode 100644 index 0000000..75e43bc --- /dev/null +++ b/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerRegistry.php @@ -0,0 +1,203 @@ + $handlers Array of handler instances or class names + * + * @throws \Exception If a class name cannot be instantiated + */ + public function __construct(array $handlers) + { + foreach ($handlers as $handler) { + if (is_string($handler)) { + // It's a class name - instantiate it (for simple handlers without dependencies) + $handler = new $handler(); + } + // If it's already an object (service), use as-is (for complex handlers with dependencies) + $this->addHandler($handler); + } + } + + /** + * Registers a participant field handler with the registry. + * + * Handlers are indexed by their field name to ensure uniqueness and enable + * fast lookups. Adding a handler invalidates the dependency sort cache, + * forcing a re-sort on the next processing request. + * + * @param ParticipantFieldHandlerInterface $handler The handler to register + */ + public function addHandler(ParticipantFieldHandlerInterface $handler): void + { + $this->handlers[$handler->getFieldName()] = $handler; + $this->sortedHandlers = null; // Reset cache to force re-sorting with new handler + } + + /** + * Processes all participant fields from submitted form data using registered handlers. + * + * This is the main entry point for field processing. It iterates through all + * participants in the submitted data and applies the appropriate handlers in + * dependency order. Each handler determines whether it should process the + * participant's data and updates the booking DTO accordingly. + * + * @param array $submittedData The submitted form data containing participants array + * @param BookingCreateDto $bookingDto The booking DTO to update with processed field values + */ + public function processFields(array $submittedData, BookingCreateDto $bookingDto): void + { + // Early return if no participant data exists in submission + if (!isset($submittedData['participants']) || !is_array($submittedData['participants'])) { + return; + } + + // Get handlers sorted by dependency order (uses cache if available) + $sortedHandlerNames = $this->getSortedHandlers(); + + // Process each participant's data + foreach ($submittedData['participants'] as $participantIndex => $participantData) { + // Skip invalid participant data + if (!is_array($participantData)) { + continue; + } + + // Apply each handler in dependency order + foreach ($sortedHandlerNames as $handlerName) { + $handler = $this->handlers[$handlerName]; + + // Let each handler decide if it should process this participant's data + if ($handler->shouldProcess($participantData, (int) $participantIndex)) { + $handler->processField($participantData, $bookingDto, (int) $participantIndex); + } + } + } + } + + /** + * Returns handler names sorted by dependency order using cached results when possible. + * + * This method implements lazy loading with caching for performance. The dependency + * sort is only performed once and the result is cached until handlers are added + * or modified. + * + * @return string[] Array of handler field names in dependency execution order + */ + private function getSortedHandlers(): array + { + // Return cached result if available + if (null !== $this->sortedHandlers) { + return $this->sortedHandlers; + } + + // Perform topological sort and cache the result + $this->sortedHandlers = $this->topologicalSort(); + + return $this->sortedHandlers; + } + + /** + * Performs topological sort to determine safe handler execution order. + * + * This method uses Kahn's algorithm to sort handlers based on their declared + * dependencies. It ensures that no handler is executed before its dependencies + * have been processed, preventing data consistency issues. + * + * The algorithm works by: + * 1. Building a dependency graph of handlers + * 2. Finding handlers with no dependencies (in-degree = 0) + * 3. Iteratively removing handlers and updating dependencies + * 4. Detecting circular dependencies (deadlock prevention) + * + * @return string[] Array of handler field names in safe execution order + * + * @throws \InvalidArgumentException When a handler depends on a non-existent handler + * @throws \InvalidArgumentException When circular dependencies are detected + */ + private function topologicalSort(): array + { + $inDegree = []; // Count of dependencies for each handler + $graph = []; // Adjacency list of handler dependencies + $handlerNames = array_keys($this->handlers); + + // Initialize all handlers with zero dependencies + foreach ($handlerNames as $handlerName) { + $inDegree[$handlerName] = 0; + $graph[$handlerName] = []; + } + + // Build dependency graph by examining each handler's dependencies + foreach ($this->handlers as $handlerName => $handler) { + foreach ($handler->getDependencies() as $dependency) { + // Validate that the dependency exists + if (!isset($this->handlers[$dependency])) { + throw new \InvalidArgumentException(sprintf('Handler "%s" depends on unknown handler "%s"', $handlerName, $dependency)); + } + + // Add edge from dependency to dependent handler + $graph[$dependency][] = $handlerName; + ++$inDegree[$handlerName]; + } + } + + // Kahn's topological sort algorithm + $queue = []; // Handlers ready to be processed (no remaining dependencies) + $result = []; // Final sorted order + + // Start with handlers that have no dependencies + foreach ($inDegree as $handlerName => $degree) { + if (0 === $degree) { + $queue[] = $handlerName; + } + } + + // Process handlers in dependency order + while (!empty($queue)) { + $current = array_shift($queue); + $result[] = $current; + + // Remove this handler's dependencies from dependent handlers + foreach ($graph[$current] as $dependent) { + --$inDegree[$dependent]; + // If dependent now has no remaining dependencies, add to queue + if (0 === $inDegree[$dependent]) { + $queue[] = $dependent; + } + } + } + + // Detect circular dependencies (if not all handlers were processed) + if (count($result) !== count($handlerNames)) { + throw new \InvalidArgumentException('Circular dependency detected in participant field handlers'); + } + + return $result; + } +} diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php new file mode 100644 index 0000000..7e54f28 --- /dev/null +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -0,0 +1,146 @@ + Field option providers indexed by field name */ + private array $fieldOptionProviders = []; + + /** + * Initializes the provider with required dependencies. + * + * 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(); + } + + /** + * 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 BookingCreateDto $bookingDto The current booking data for context + * @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, BookingCreateDto $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 all field option providers during service initialization. + * + * This method defines the option generation logic for each supported dynamic field. + * Each provider is a callable that receives the booking DTO and participant + * index and returns appropriate Symfony form field options. + * + * Adding new fields: + * To add support for a new dynamic field, simply add a new provider here: + * + * $this->fieldOptionProviders['newField'] = fn($bookingDto, $participantIndex) => [ + * 'label' => 'New Field Label', + * 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex), + * ]; + * + * Provider Pattern Benefits: + * - Lazy evaluation (options only generated when needed) + * - Context-aware configuration + * - Easy to test individual field logic + * - Supports complex interdependencies + */ + private function registerFieldOptionProviders(): void + { + // Room assignment field provider + $this->fieldOptionProviders['assignedRoomId'] = fn (BookingCreateDto $bookingDto, int $participantIndex) => [ + 'label' => 'Zimmer', + 'placeholder' => 'Bitte wählen', + // Use factory to create context-aware choice loader that: + // - Shows only available rooms for this participant + // - Excludes rooms already assigned to other participants + // - Respects room capacity and booking constraints + 'choice_loader' => $this->roomChoiceLoaderFactory->create( + $bookingDto->participants, + $bookingDto->getSelectedRooms(), + $participantIndex + ), + ]; + + // Future field providers would be added here, for example: + // + // $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [ + // 'label' => 'Meal Preference', + // 'choices' => [ + // 'Standard' => 'standard', + // 'Vegetarian' => 'vegetarian', + // 'Vegan' => 'vegan', + // ], + // // Could depend on room assignment or other factors + // 'disabled' => !$this->hasMealOptions($bookingDto, $participantIndex), + // ]; + } +} \ No newline at end of file diff --git a/src/Form/Service/ParticipantFormConfigurator.php b/src/Form/Service/ParticipantFormConfigurator.php deleted file mode 100644 index 9c280be..0000000 --- a/src/Form/Service/ParticipantFormConfigurator.php +++ /dev/null @@ -1,41 +0,0 @@ -roomChoiceLoaderFactory->create( - $bookingDto->participants, - $bookingDto->getSelectedRooms(), - $participantIndex - ); - - return [ - 'label' => 'Zimmer', - 'placeholder' => 'Bitte wählen', - 'choice_loader' => $choiceLoader, - ]; - } -}