wip: dynamic services fields

This commit is contained in:
Björn Fromme
2025-08-27 17:49:49 +02:00
parent 369d660e24
commit 60b655a195
21 changed files with 634 additions and 39 deletions
+64 -14
View File
@@ -11,6 +11,7 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
@@ -131,6 +132,7 @@ class BookingCreateParticipantType extends AbstractType
*
* This method checks each form field for state conditions and applies
* the appropriate state modifications (readonly, disabled, etc.).
* Fields that should be hidden are removed from the form entirely.
*
* @param FormInterface $form The form to modify
* @param BookingDtoInterface $bookingDto The booking data for context (create or edit)
@@ -139,7 +141,10 @@ class BookingCreateParticipantType extends AbstractType
*/
private function applyFieldStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void
{
// Get all fields that have state conditions
// First, remove fields that should be excluded entirely
$this->removeExcludedFields($form, $bookingDto, $participantIndex, $formData);
// Get all fields that have state conditions (excluding hidden fields)
$allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex, $formData);
// Handle body dimension fields separately as they are nested in bodyDimensions form
@@ -153,7 +158,7 @@ class BookingCreateParticipantType extends AbstractType
continue;
}
if (true === $form->has($fieldName)) {
if ($form->has($fieldName)) {
$field = $form->get($fieldName);
$currentOptions = $field->getConfig()->getOptions();
@@ -168,11 +173,41 @@ class BookingCreateParticipantType extends AbstractType
}
// Apply body dimension field states to the nested bodyDimensions form
if (false === empty($bodyDimensionStates) && true === $form->has('bodyDimensions')) {
if (!empty($bodyDimensionStates) && $form->has('bodyDimensions')) {
$this->applyBodyDimensionStates($form, $bodyDimensionStates);
}
}
/**
* Removes fields that should be excluded from the form entirely.
*
* This method handles dynamic field exclusion during form submission when
* field states change based on submitted data.
*
* @param FormInterface $form The form to modify
* @param BookingDtoInterface $bookingDto The booking data for context
* @param int $participantIndex The participant index
* @param array<string, mixed> $formData Submitted form data for state calculation
*/
private function removeExcludedFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void
{
$dynamicFields = [
'assignedRoomId',
'remarksRoom',
'courses',
'additionalServices',
'board',
'rentals',
'skiPass',
];
foreach ($dynamicFields as $fieldName) {
if ($form->has($fieldName) && !$this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex, $formData)) {
$form->remove($fieldName);
}
}
}
/**
* Applies field states to body dimension fields in the nested bodyDimensions form.
*
@@ -205,23 +240,38 @@ class BookingCreateParticipantType extends AbstractType
*/
private function addDynamicFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$dynamicFields = ['assignedRoomId', 'courses', 'additionalServices', 'board', 'rentals']; // List of fields that need dynamic configuration
$dynamicFields = [
'assignedRoomId' => ChoiceType::class,
'remarksRoom' => TextareaType::class,
'courses' => ChoiceType::class,
'additionalServices' => ChoiceType::class,
'board' => ChoiceType::class,
'rentals' => ChoiceType::class,
'skiPass' => ChoiceType::class,
];
foreach ($dynamicFields as $fieldName) {
foreach ($dynamicFields as $fieldName => $fieldType) {
if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) {
// Check if field should be included in the form at all
if (false === $this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex)) {
continue;
}
// Get base field options
$fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex);
// Only add the field if there are actually choices/options available
if (true === $this->hasValidFieldOptions($fieldOptions)) {
// Apply dynamic field state if conditions exist
if (true === $this->fieldStateProvider->hasStateConditions($fieldName)) {
$fieldState = $this->fieldStateProvider->getFieldState($fieldName, $bookingDto, $participantIndex);
$fieldOptions = $this->mergeFieldState($fieldOptions, $fieldState);
}
$form->add($fieldName, ChoiceType::class, $fieldOptions);
// Skip choice fields without any choices
if ($fieldType === ChoiceType::class && false === $this->hasValidFieldOptions($fieldOptions)) {
continue;
}
// Apply non-hidden field states (readonly, disabled, required)
if (true === $this->fieldStateProvider->hasStateConditions($fieldName)) {
$fieldState = $this->fieldStateProvider->getFieldState($fieldName, $bookingDto, $participantIndex);
$fieldOptions = $this->mergeFieldState($fieldOptions, $fieldState);
}
$form->add($fieldName, $fieldType, $fieldOptions);
}
}
}
+5 -1
View File
@@ -34,8 +34,12 @@ class BookingEditDto implements BookingDtoInterface
$participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
$participantData->skiPass = $booking
// Skipass is single selection - take first item from array or null
$skipasses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_SKI_PASS);
$participantData->skiPass = !empty($skipasses) ? $skipasses[0] : null;
$participantData->additionalServices = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_ADDITIONAL);
$participantData->board = $booking
+3 -1
View File
@@ -44,9 +44,11 @@ class ParticipantDto
#[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create_step_2'])]
public ?int $assignedRoomId = null;
public ?string $remarksRoom = null;
public array $courses = [];
public array $additionalServices = [];
public array $skiPass = [];
public ?Service $skiPass = null;
public array $board = [];
public array $rentals = [];
public ?Service $transportationServiceTo = null;
@@ -45,7 +45,7 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array
{
// Check if we have a provider for this field
if (!isset($this->fieldOptionProviders[$fieldName])) {
if (false === isset($this->fieldOptionProviders[$fieldName])) {
return [];
}
@@ -28,6 +28,30 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
$this->registerFieldStateConditions();
}
/**
* Determines whether a field should be included in the form at all.
*
* Fields with 'hidden' state conditions should not be added to the form
* rather than being hidden with CSS. This method evaluates the hidden
* condition independently to allow early field exclusion.
*
* @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<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if the field should be included in the form, false if it should be excluded
*/
public function shouldIncludeField(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): bool
{
if (false === isset($this->fieldStateConditions[$fieldName]['hidden'])) {
return true; // No hidden condition means field should be included
}
$hiddenCondition = $this->fieldStateConditions[$fieldName]['hidden'];
return !$hiddenCondition->evaluate($bookingDto, $participantIndex, $formData);
}
/**
* Calculates the dynamic state for a specified field.
*
@@ -35,6 +59,9 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
* state modifications. State conditions are organized by state type (readonly,
* disabled, etc.) and evaluated independently.
*
* Note: This method no longer handles 'hidden' state as fields should be
* excluded from the form entirely rather than hidden with CSS.
*
* @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
@@ -44,7 +71,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
*/
public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array
{
if (!isset($this->fieldStateConditions[$fieldName])) {
if (false === isset($this->fieldStateConditions[$fieldName])) {
return [];
}
@@ -63,14 +90,11 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
case 'required':
$stateModifications['required'] = true;
break;
case 'hidden':
$attributes['style'] = ($attributes['style'] ?? '').' display: none;';
break;
}
}
}
if (!empty($attributes)) {
if (false === empty($attributes)) {
$stateModifications['attr'] = $attributes;
}
@@ -126,7 +126,7 @@ class AgeRangeCondition implements FieldConditionInterface
*
* Uses DateTimeImmutable to ensure immutable date calculations and
* handles the calculation accurately accounting for leap years and
* exact birth date anniversaries.
* exact birthdate anniversaries.
*
* @param \DateTimeImmutable $dateOfBirth The participant's date of birth
*
@@ -136,6 +136,6 @@ class AgeRangeCondition implements FieldConditionInterface
{
$today = new \DateTimeImmutable();
return (int) $dateOfBirth->diff($today)->y;
return $dateOfBirth->diff($today)->y;
}
}
@@ -211,7 +211,7 @@ class CompositeCondition implements FieldConditionInterface
{
$validOperators = [self::OPERATOR_AND, self::OPERATOR_OR, self::OPERATOR_NOT];
if (!in_array($operator, $validOperators, true)) {
if (false === in_array($operator, $validOperators, true)) {
throw new \InvalidArgumentException(sprintf('Invalid operator "%s". Valid operators: %s', $operator, implode(', ', $validOperators)));
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\BusProNet\Model\Room;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that evaluates whether a participant has selected a room with specific code(s).
*
* This condition checks if a participant has selected a room that matches any of the
* configured room codes. This allows for flexible room-based field visibility logic
* where different fields can be shown based on the selected room type.
*/
class RoomSelectionCondition implements FieldConditionInterface
{
private array $requiredRoomCodes;
/**
* Creates a new room selection condition for specified room codes.
*
* @param array<string> $requiredRoomCodes Room codes that should trigger this condition
*/
public function __construct(array $requiredRoomCodes)
{
$this->requiredRoomCodes = array_map('strtolower', $requiredRoomCodes);
}
/**
* Evaluates whether the participant has selected a room with one of the required codes.
*
* Checks both submitted form data and participant DTO data to determine
* if the selected room matches any of the configured room codes.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if room with matching code is selected, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
// First check submitted form data for room selection
if (isset($formData['participants'][$participantIndex]['assignedRoomId'])) {
$selectedRoomId = $formData['participants'][$participantIndex]['assignedRoomId'];
if (true === is_numeric($selectedRoomId)) {
$room = $this->findRoomById((int) $selectedRoomId, $bookingDto);
if (null !== $room && in_array(strtolower($room->code ?? ''), $this->requiredRoomCodes, true)) {
return true;
}
}
}
// Then check participant DTO data for existing room selection
$participant = $bookingDto->getParticipant($participantIndex);
if (null !== $participant && null !== $participant->assignedRoomId) {
$room = $this->findRoomById($participant->assignedRoomId, $bookingDto);
if (null !== $room && in_array(strtolower($room->code ?? ''), $this->requiredRoomCodes, true)) {
return true;
}
}
return false;
}
/**
* Returns field names that trigger re-evaluation of this condition.
*
* This condition depends on the assignedRoomId field, so any changes to room
* selection should trigger re-evaluation of dependent fields.
*
* @return string[] Array containing the field names that affect this condition
*/
public function getDependentFields(): array
{
return ['assignedRoomId'];
}
/**
* Returns a human-readable description of this condition.
*
* @return string A brief description of the condition logic
*/
public function getDescription(): string
{
$codes = implode(', ', $this->requiredRoomCodes);
return "Field visible when room with code(s) [{$codes}] is selected";
}
/**
* Finds a room by ID in the available travel rooms.
*
* @param int $roomId The room ID to find
* @param BookingDtoInterface $bookingDto The booking DTO containing travel data
*
* @return Room|null The found room or null if not found
*/
private function findRoomById(int $roomId, BookingDtoInterface $bookingDto): ?Room
{
foreach ($bookingDto->travel->rooms as $room) {
if ($room->id === $roomId) {
return $room;
}
}
return null;
}
}
@@ -30,6 +30,22 @@ use App\Form\Model\BookingDtoInterface;
*/
interface FieldStateProviderInterface
{
/**
* Determines whether a field should be included in the form at all.
*
* Fields with 'hidden' state conditions should not be added to the form
* rather than being hidden with CSS. This method evaluates the hidden
* condition independently to allow early field exclusion during form building.
*
* @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<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if the field should be included in the form, false if it should be excluded
*/
public function shouldIncludeField(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): bool;
/**
* Calculates the dynamic state for a specified field.
*
@@ -38,11 +54,14 @@ interface FieldStateProviderInterface
* The returned array contains Symfony form field attributes that control
* field behavior and appearance.
*
* Note: This method no longer handles 'hidden' state as fields should be
* excluded from the form entirely using shouldIncludeField() rather than
* hidden with CSS.
*
* State attributes may include:
* - 'attr' => ['readonly' => true] - Make field readonly
* - 'disabled' => true - Disable field interaction
* - 'required' => false - Override field requirement
* - 'attr' => ['style' => 'display: none'] - Hide field
* - 'attr' => ['class' => 'conditional-field'] - Add CSS classes
*
* @param string $fieldName The name of the field to evaluate
@@ -8,6 +8,7 @@ use App\Form\Service\Abstract\AbstractFieldStateProvider;
use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
/**
* Field state provider for the booking create workflow.
@@ -83,6 +84,14 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
// Room-specific field conditions
$mbzRoomCondition = new RoomSelectionCondition(['mbz']);
// Show remarks room field only when room with code 'mbz' is selected
$this->fieldStateConditions['remarksRoom'] = [
'hidden' => CompositeCondition::not($mbzRoomCondition),
];
// Example field state conditions would be registered here
// For demonstration purposes, here are some example patterns:
@@ -50,6 +50,24 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
return ['dateOfBirth'];
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For service selection fields like additional services, we always need to process
* to handle cases where all selections are cleared (field not present in data).
* This ensures the participant DTO is updated with an empty array when no
* services are selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
/**
* Processes the additionalServices field for a specific participant.
*
@@ -154,7 +172,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
// Check if service has age constraints
$ageEvaluator = new ServiceAgeEvaluator();
if (!$ageEvaluator->canEvaluate($service)) {
if (false === $ageEvaluator->canEvaluate($service)) {
return true; // No age restrictions, service is valid
}
@@ -31,6 +31,24 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
return ['dateOfBirth'];
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For service selection fields like board options, we always need to process
* to handle cases where all selections are cleared (field not present in data).
* This ensures the participant DTO is updated with an empty array when no
* services are selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -81,7 +99,7 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
}
$ageEvaluator = new ServiceAgeEvaluator();
if (!$ageEvaluator->canEvaluate($service)) {
if (false === $ageEvaluator->canEvaluate($service)) {
return true;
}
@@ -50,6 +50,24 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
return ['dateOfBirth'];
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For service selection fields like courses, we always need to process
* to handle cases where all selections are cleared (field not present in data).
* This ensures the participant DTO is updated with an empty array when no
* services are selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
/**
* Processes the courses field for a specific participant.
*
@@ -140,7 +158,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
// Check if service has age constraints
$ageEvaluator = new ServiceAgeEvaluator();
if (!$ageEvaluator->canEvaluate($service)) {
if (false === $ageEvaluator->canEvaluate($service)) {
return true; // No age restrictions, service is valid
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
@@ -98,7 +99,7 @@ class ParticipantFieldHandlerRegistry
public function processFields(array $submittedData, BookingDtoInterface $bookingDto): void
{
// Early return if no participant data exists in submission
if (!isset($submittedData['participants']) || !is_array($submittedData['participants'])) {
if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) {
return;
}
@@ -108,7 +109,7 @@ class ParticipantFieldHandlerRegistry
// Process each participant's data
foreach ($submittedData['participants'] as $participantIndex => $participantData) {
// Skip invalid participant data
if (!is_array($participantData)) {
if (false === is_array($participantData)) {
continue;
}
@@ -180,7 +181,7 @@ class ParticipantFieldHandlerRegistry
foreach ($this->handlers as $handlerName => $handler) {
foreach ($handler->getDependencies() as $dependency) {
// Validate that the dependency exists
if (!isset($this->handlers[$dependency])) {
if (false === isset($this->handlers[$dependency])) {
throw new \InvalidArgumentException(sprintf('Handler "%s" depends on unknown handler "%s"', $handlerName, $dependency));
}
@@ -202,7 +203,7 @@ class ParticipantFieldHandlerRegistry
}
// Process handlers in dependency order
while (!empty($queue)) {
while (false === empty($queue)) {
$current = array_shift($queue);
$result[] = $current;
@@ -243,13 +244,13 @@ class ParticipantFieldHandlerRegistry
private function syncSubmittedDataWithDto(array $submittedData, BookingDtoInterface $bookingDto): array
{
// Ensure participants array exists in submitted data
if (!isset($submittedData['participants']) || !is_array($submittedData['participants'])) {
if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) {
return $submittedData;
}
// Sync each participant's data with the cleaned DTO
foreach ($submittedData['participants'] as $index => $participantData) {
if (!is_array($participantData)) {
if (false === is_array($participantData)) {
continue;
}
@@ -333,7 +334,7 @@ class ParticipantFieldHandlerRegistry
private function convertSingleValueToSubmittedFormat(mixed $value): mixed
{
// Handle Service objects -> convert to ID
if ($value instanceof \App\BusProNet\Model\Service) {
if ($value instanceof Service) {
return $value->id;
}
@@ -96,6 +96,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$bookingDto,
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
];
@@ -142,6 +143,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$bookingDto,
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
];
@@ -156,9 +158,35 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$bookingDto,
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
];
// Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range
$this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Skipass',
'multiple' => false,
'expanded' => true,
'required' => true,
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true),
$bookingDto,
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
];
// Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms)
$this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Wünsche oder Anmerkungen zum Zimmer',
'required' => false,
'clean_xss' => true,
'attr' => [
'rows' => 2,
],
];
// Future field providers would be added here, for example:
//
// $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [
@@ -190,7 +218,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participant = $bookingDto->getParticipant($participantIndex);
// If no birth date provided, return empty array (handled by field visibility conditions)
// If no birthdate provided, return empty array (handled by field visibility conditions)
if (null === $participant || null === $participant->dateOfBirth) {
return [];
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles processing of the remarksRoom field for booking participants.
*
* This handler manages room-specific remarks for participants in the booking
* creation process. It processes the remarksRoom field from form submissions
* and updates the participant DTO with the provided remarks.
*
* Key responsibilities:
* - Processes room remarks text input
* - Handles empty text normalization (empty string → null)
* - Updates participant DTO with normalized remarks
*
* Dependencies: None (this is a simple text field)
*/
class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler
{
/**
* Returns the form field name this handler processes.
*
* @return string The field name 'remarksRoom'
*/
public function getFieldName(): string
{
return 'remarksRoom';
}
/**
* Processes the remarksRoom field for a specific participant.
*
* This method extracts room remarks from submitted form data and
* updates the corresponding participant in the booking DTO. It handles
* the normalization of empty strings to null values.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDtoInterface $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 remarks from form data
$remarksRoom = $this->getFieldValue($submittedData, $this->getFieldName());
// Normalize empty string to null and update participant
$participant->remarksRoom = $this->normalizeEmptyValue($remarksRoom);
}
}
@@ -31,6 +31,24 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
return ['dateOfBirth'];
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For service selection fields like rentals, we always need to process
* to handle cases where all selections are cleared (field not present in data).
* This ensures the participant DTO is updated with an empty array when no
* services are selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles processing of the skiPass field for booking participants.
*
* This handler manages skipass selection for participants in the booking
* creation process. It processes the skiPass field from form submissions,
* validates age-appropriate and date-valid skipasses, and updates the
* participant DTO with the valid selection.
*
* Key responsibilities:
* - Validates single skipass selection against age constraints
* - Validates skipass date ranges against travel dates
* - Clears skipass if no longer available due to age or date changes
* - Maintains data consistency during HTMX form updates
* - Prevents form validation errors from stale skipass selections
*
* Dependencies: dateOfBirth (must be processed first for age evaluation)
*/
class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
{
/**
* Returns the form field name this handler processes.
*
* @return string The field name 'skiPass'
*/
public function getFieldName(): string
{
return 'skiPass';
}
/**
* Returns the field dependencies for proper processing order.
*
* This handler depends on dateOfBirth being processed first because
* age evaluation requires the participant's birth date to be available.
*
* @return string[] Array containing 'dateOfBirth' dependency
*/
public function getDependencies(): array
{
return ['dateOfBirth'];
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For service selection fields like skipasses, we always need to process
* to handle cases where the selection is cleared (field not present in data).
* This ensures the participant DTO is updated with null when no
* skipass is selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for service selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
/**
* Processes the skiPass field for a specific participant.
*
* This method extracts the skipass selection from submitted form data,
* validates the selection against the participant's age constraints and
* travel date constraints, and updates the participant DTO with the valid
* selection. If the skipass is no longer appropriate for the participant's
* age or exceeds the travel date range, it is automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
// Extract current skipass selection from submitted data
$selectedSkiPass = $this->getFieldValue($submittedData, $this->getFieldName());
// Get available skipasses from travel data (with date filtering)
$availableSkipasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true);
// For single selection, validate the selected skipass and convert ID to Service object
$validSelection = null;
if (null !== $selectedSkiPass) {
if ($this->isServiceValidForParticipant($selectedSkiPass, $availableSkipasses, $bookingDto, $participantIndex)) {
// Convert the submitted ID back to the Service object
$validSelection = $this->findServiceInAvailableServices($selectedSkiPass, $availableSkipasses);
}
}
// Update participant with validated selection
$participant->skiPass = $validSelection;
}
/**
* Validates if a selected skipass is still valid for the participant.
*
* This method checks both age constraints (via birth year ranges) and
* date constraints (skipass dates must be within travel dates).
*
* @param mixed $selectedService The selected skipass to validate
* @param array $availableServices Array of available skipasses
* @param BookingDtoInterface $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
*
* @return bool True if the skipass is valid for the participant, false otherwise
*/
private function isServiceValidForParticipant(
mixed $selectedService,
array $availableServices,
BookingDtoInterface $bookingDto,
int $participantIndex,
): bool {
// Find the service in available services
$service = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null === $service) {
return false; // Service not found in available services
}
// Check if service has age constraints
$ageEvaluator = new ServiceAgeEvaluator();
if ($ageEvaluator->canEvaluate($service)) {
// Validate service against participant's age
if (false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) {
return false; // Age constraints not met
}
}
// Date constraints are already handled by the travel->getAdditionalServicesBySubTypes
// method with date filtering enabled (true, true parameters), so if the service
// is in the available services list, it already passed date validation.
return true; // Service passed both age and date validation
}
/**
* Finds a selected skipass in the list of available skipasses.
*
* @param mixed $selectedService The selected skipass to find
* @param array $availableServices Array of available Service objects
*
* @return Service|null The found service or null if not found
*/
private function findServiceInAvailableServices(mixed $selectedService, array $availableServices): ?Service
{
foreach ($availableServices as $availableService) {
if ($this->servicesMatch($selectedService, $availableService)) {
return $availableService;
}
}
return null;
}
/**
* Determines if a selected skipass matches an available skipass.
*
* @param mixed $selectedService The selected skipass from form data
* @param Service $availableService The available skipass to compare against
*
* @return bool True if the skipasses match, false otherwise
*/
private function servicesMatch(mixed $selectedService, Service $availableService): bool
{
// Direct object comparison
if ($selectedService === $availableService) {
return true;
}
// ID comparison for Service objects
if ($selectedService instanceof Service) {
return $selectedService->id === $availableService->id;
}
// ID comparison for numeric values
if (is_numeric($selectedService)) {
return (int) $selectedService === $availableService->id;
}
// String ID comparison
if (is_string($selectedService)) {
return $selectedService === (string) $availableService->id;
}
return false;
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ class ServiceAgeEvaluator
{
$today = new \DateTimeImmutable();
return (int) $dateOfBirth->diff($today)->y;
return $dateOfBirth->diff($today)->y;
}
/**