wip: dynamic services fields
This commit is contained in:
@@ -74,7 +74,9 @@ services:
|
||||
# All handlers now have no external dependencies and can be instantiated directly
|
||||
- 'App\Form\Service\ParticipantDateOfBirthFieldHandler'
|
||||
- 'App\Form\Service\ParticipantAssignedRoomFieldHandler'
|
||||
- 'App\Form\Service\ParticipantRemarksRoomFieldHandler'
|
||||
- 'App\Form\Service\ParticipantAdditionalServicesFieldHandler'
|
||||
- 'App\Form\Service\ParticipantCoursesFieldHandler'
|
||||
- 'App\Form\Service\ParticipantBoardFieldHandler'
|
||||
- 'App\Form\Service\ParticipantRentalsFieldHandler'
|
||||
- 'App\Form\Service\ParticipantSkiPassFieldHandler'
|
||||
|
||||
@@ -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
|
||||
// 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, ChoiceType::class, $fieldOptions);
|
||||
}
|
||||
$form->add($fieldName, $fieldType, $fieldOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ class ServiceAgeEvaluator
|
||||
{
|
||||
$today = new \DateTimeImmutable();
|
||||
|
||||
return (int) $dateOfBirth->diff($today)->y;
|
||||
return $dateOfBirth->diff($today)->y;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
{{ form_row(participant.bodyDimensions.shoeSize) }}
|
||||
{{ form_row(participant.bodyDimensions.weight) }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{# hx-swap="none" tells HTMX not to do a normal swap, as OOB will handle it #}
|
||||
{{ form_row(participant.assignedRoomId, {
|
||||
'attr': {
|
||||
@@ -54,8 +54,14 @@
|
||||
'hx-swap': 'none'
|
||||
}
|
||||
}) }}
|
||||
{% if participant.remarksRoom is defined %}
|
||||
{{ form_row(participant.remarksRoom) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{% if participant.skiPass is defined %}
|
||||
{{ form_row(participant.skiPass) }}
|
||||
{% endif %}
|
||||
{% if participant.courses is defined %}
|
||||
{{ form_row(participant.courses) }}
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user