feat: improved logic to control field visibility
addresses #869btdphv
This commit is contained in:
@@ -363,9 +363,9 @@ class BookingParticipantType extends AbstractType
|
||||
// Get base field options
|
||||
$fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex);
|
||||
|
||||
// Skip choice fields without any choices
|
||||
$isChoiceField = ChoiceType::class === $fieldType || RoomAssignmentType::class === $fieldType;
|
||||
if ($isChoiceField && false === $this->hasValidFieldOptions($fieldOptions)) {
|
||||
// Skip fields where provider returns empty options
|
||||
// Convention: providers return [] when field has no valid data (no choices, no services, etc.)
|
||||
if (true === empty($fieldOptions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -386,37 +386,6 @@ class BookingParticipantType extends AbstractType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if field options contain valid choices for rendering.
|
||||
*
|
||||
* This method validates that the field options contain either choices array,
|
||||
* choice_loader, or other valid choice sources. Empty choice arrays or
|
||||
* null choice loaders indicate the field should not be rendered.
|
||||
*
|
||||
* @param array<string, mixed> $fieldOptions The field options to validate
|
||||
*
|
||||
* @return bool True if the field has valid options for rendering, false otherwise
|
||||
*/
|
||||
private function hasValidFieldOptions(array $fieldOptions): bool
|
||||
{
|
||||
// Check if choices array exists and is not empty
|
||||
if (false === empty($fieldOptions['choices'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if choice_loader exists and is not null
|
||||
if (isset($fieldOptions['choice_loader'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for other valid choice sources
|
||||
if (isset($fieldOptions['choice_list'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges field state modifications into existing field options.
|
||||
*
|
||||
|
||||
@@ -53,24 +53,39 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
* 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:
|
||||
* Empty Array Convention:
|
||||
* Providers MUST return an empty array when the field has no valid data to display.
|
||||
* This signals to the form builder that the field should be skipped entirely.
|
||||
* Examples: no choices available, no underlying services exist, etc.
|
||||
*
|
||||
* $this->fieldOptionProviders['newField'] = fn($bookingDto, $participantIndex) => [
|
||||
* Adding new fields:
|
||||
* To add support for a new dynamic field, add a provider following this pattern:
|
||||
*
|
||||
* $this->fieldOptionProviders['newField'] = function (BookingDto $bookingDto, int $participantIndex): array {
|
||||
* $choices = $this->getChoicesFor($bookingDto, $participantIndex);
|
||||
*
|
||||
* if (true === empty($choices)) {
|
||||
* return [];
|
||||
* }
|
||||
*
|
||||
* return [
|
||||
* 'label' => 'New Field Label',
|
||||
* 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex),
|
||||
* 'choices' => $choices,
|
||||
* ];
|
||||
* };
|
||||
*
|
||||
* Provider Pattern Benefits:
|
||||
* - Lazy evaluation (options only generated when needed)
|
||||
* - Context-aware configuration
|
||||
* - Easy to test individual field logic
|
||||
* - Supports complex interdependencies
|
||||
* - Unified empty-check pattern for all field types
|
||||
*/
|
||||
protected function registerFieldOptionProviders(): void
|
||||
{
|
||||
// Room assignment field provider (available for both create and edit workflows)
|
||||
$this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []) {
|
||||
// Returns empty array when no room choices available
|
||||
$this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = [];
|
||||
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
@@ -85,6 +100,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
$choices = $this->convertBookedRoomsToSelectionDtos($bookingDto->booking->rooms);
|
||||
}
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$singleChoice = 1 === count($choices);
|
||||
|
||||
return [
|
||||
@@ -100,16 +119,24 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
};
|
||||
|
||||
// Courses field provider - provides age-appropriate courses from travel data
|
||||
$this->fieldOptionProviders['courses'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
// Returns empty array when no courses available
|
||||
$this->fieldOptionProviders['courses'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Kurse',
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
'choices' => $choices,
|
||||
'choice_value' => 'id',
|
||||
'choice_label' => fn (?Service $service) => $service?->label,
|
||||
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
|
||||
@@ -133,18 +160,27 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $attributes;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Additional services field provider - provides age-appropriate additional services with mandatory pre-selection
|
||||
$this->fieldOptionProviders['additionalServices'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
// Returns empty array when no additional services available
|
||||
$this->fieldOptionProviders['additionalServices'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Zusatzleistungen',
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
'choices' => $choices,
|
||||
'choice_value' => 'id',
|
||||
'choice_label' => fn (?Service $service) => $service?->label,
|
||||
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
|
||||
@@ -175,18 +211,27 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $attributes;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Board field provider - provides age-appropriate board options from travel data
|
||||
$this->fieldOptionProviders['board'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
// Returns empty array when no board options available
|
||||
$this->fieldOptionProviders['board'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Verpflegung',
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
'choices' => $choices,
|
||||
'choice_value' => 'id',
|
||||
'choice_label' => fn (?Service $service) => $service?->label,
|
||||
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
|
||||
@@ -210,18 +255,27 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $attributes;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Veg (vegetarian/vegan) field provider - provides dietary preference options as radio buttons (mutually exclusive)
|
||||
$this->fieldOptionProviders['veg'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
// Returns empty array when no dietary options available
|
||||
$this->fieldOptionProviders['veg'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_VEG, true),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Verpflegungswunsch',
|
||||
'multiple' => false,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_VEG, true),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
'choices' => $choices,
|
||||
'choice_value' => 'id',
|
||||
'choice_label' => fn (?Service $service) => $service?->label,
|
||||
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
|
||||
@@ -255,14 +309,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $attributes;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration
|
||||
$this->fieldOptionProviders['rentals'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
'label' => 'Leihmaterial',
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
// Returns empty array when no rental options available
|
||||
$this->fieldOptionProviders['rentals'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $this->filterServicesByAgeConstraints(
|
||||
$this->filterRentalsBySkiPassDuration(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true),
|
||||
$bookingDto,
|
||||
@@ -270,7 +322,18 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Leihmaterial',
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $choices,
|
||||
'choice_value' => 'id',
|
||||
'choice_label' => fn (?Service $service) => $service?->label,
|
||||
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
|
||||
@@ -294,6 +357,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $attributes;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Rental insurance field provider - provides rental insurance options when rental services are selected
|
||||
$this->fieldOptionProviders['rentalInsurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
@@ -319,16 +383,24 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
];
|
||||
|
||||
// Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range
|
||||
$this->fieldOptionProviders['skiPass'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
// Returns empty array when no skipass options available
|
||||
$this->fieldOptionProviders['skiPass'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $this->filterSkiPassChoices(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Skipass',
|
||||
'multiple' => false,
|
||||
'expanded' => true,
|
||||
'required' => true,
|
||||
'choices' => $this->filterSkiPassChoices(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
'choices' => $choices,
|
||||
'choice_value' => 'id',
|
||||
'choice_label' => fn (?Service $service) => $service?->label,
|
||||
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
|
||||
@@ -362,6 +434,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $attributes;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms)
|
||||
$this->fieldOptionProviders['remarksRoom'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
@@ -376,13 +449,21 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
// Transportation field providers - handles outbound/inbound transportation and pickup selection
|
||||
|
||||
// Outbound Transportation
|
||||
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
'label' => 'Hinfahrt',
|
||||
'choices' => $this->filterTransportationChoices(
|
||||
// Returns empty array when no transportation options available
|
||||
$this->fieldOptionProviders['transportationOutbound'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $this->filterTransportationChoices(
|
||||
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Hinfahrt',
|
||||
'choices' => $choices,
|
||||
'choice_label' => fn (Service $service) => $service?->label,
|
||||
'choice_value' => 'id',
|
||||
'expanded' => true,
|
||||
@@ -409,11 +490,20 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $attributes;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Inbound Transportation
|
||||
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
// Returns empty array when no transportation options available
|
||||
$this->fieldOptionProviders['transportationInbound'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Rückfahrt',
|
||||
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL),
|
||||
'choices' => $choices,
|
||||
'choice_label' => fn (Service $service) => $service?->label,
|
||||
'choice_value' => 'id',
|
||||
'expanded' => true,
|
||||
@@ -440,25 +530,43 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $attributes;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Pickup (conditional - only shown when either transportation direction is bus)
|
||||
// Uses outbound pickups list, applies to both directions
|
||||
$this->fieldOptionProviders['pickup'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
// Returns empty array when no pickup options available
|
||||
$this->fieldOptionProviders['pickup'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $bookingDto->travel->pickupsOutbound;
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Zu- und Ausstieg',
|
||||
'choices' => $bookingDto->travel->pickupsOutbound,
|
||||
'choices' => $choices,
|
||||
'choice_label' => fn (?Pickup $pickup) => $pickup?->getLabel(),
|
||||
'choice_value' => 'id',
|
||||
'expanded' => true, // Radio buttons in table layout like other services
|
||||
'multiple' => false,
|
||||
'required' => true,
|
||||
];
|
||||
};
|
||||
|
||||
// Parking (conditional - only shown when outbound transportation is PKW)
|
||||
// Simple checkbox since there's only ever one parking type
|
||||
$this->fieldOptionProviders['parking'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING)),
|
||||
// Returns empty array when no parking services exist to prevent field from rendering
|
||||
$this->fieldOptionProviders['parking'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$parkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING);
|
||||
if (true === empty($parkingServices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $this->getParkingCheckboxLabel($parkingServices),
|
||||
'required' => false,
|
||||
];
|
||||
};
|
||||
|
||||
// Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants)
|
||||
// Only registered in create mode - insurance cannot be modified in edit mode due to API limitation
|
||||
@@ -475,16 +583,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
|
||||
// Insurance field provider - provides age and eligibility filtered insurances for participants
|
||||
// Only registered in create mode - insurance cannot be modified in edit mode due to API limitation
|
||||
$this->fieldOptionProviders['insurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
// Returns empty array when no insurance options available
|
||||
$this->fieldOptionProviders['insurance'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
|
||||
$choices = $this->getEligibleInsurances($bookingDto, $participantIndex);
|
||||
|
||||
if (true === empty($choices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Reiseversicherung',
|
||||
'multiple' => false,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead
|
||||
'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex),
|
||||
'choices' => $choices,
|
||||
'choice_value' => 'id',
|
||||
'choice_label' => fn (?Insurance $insurance) => $insurance?->label,
|
||||
];
|
||||
};
|
||||
|
||||
// Purchase voucher field provider - redemption code for vouchers that apply to complete booking
|
||||
// Collected from all participants and aggregated into single <gutscheine> collection in booking payload
|
||||
|
||||
Reference in New Issue
Block a user