fix: remove form options cache causing issues

This commit is contained in:
Björn Fromme
2025-12-06 15:02:34 +01:00
parent bfb1a04ea0
commit de52907eec
3 changed files with 166 additions and 55 deletions
+9 -2
View File
@@ -63,6 +63,10 @@ class BookingParticipantType extends AbstractType
* Field handlers are executed in PRE_SUBMIT to clean and transform data
* before Symfony binds it to the form. This matches the pattern used in
* the old BookingCreateStep2Type parent form.
*
* The method also synchronizes the submitted data with the DTO state to
* ensure that handler modifications (like discount replacement) are reflected
* in the form binding.
*/
private function processFieldHandlers(FormEvent $event, BookingDto $bookingContext): void
{
@@ -80,12 +84,15 @@ class BookingParticipantType extends AbstractType
return;
}
// Process all field handlers for this participant in dependency order
$this->fieldHandlerRegistry->processFieldsForParticipant(
// Process all field handlers for this participant and sync submitted data
$syncedData = $this->fieldHandlerRegistry->processFieldsForParticipantAndSync(
$submittedData,
$bookingContext,
$data->participant->index
);
// Update the event data with synchronized values
$event->setData($syncedData);
}
/**
@@ -167,6 +167,150 @@ class ParticipantFieldHandlerRegistry
}
}
/**
* Processes field handlers for a single participant and synchronizes submitted data.
*
* This method combines field processing with data synchronization to ensure that
* the submitted form data reflects any changes made by field handlers. This is
* essential for handlers that modify fields (like discount replacement) to have
* their changes reflected in the form binding.
*
* Only fields that are actually modified by handlers (detected by comparing
* submitted ID with DTO value after processing) are synchronized. This prevents
* overwriting partial user input (like incomplete dates).
*
* @param array<string, mixed> $participantData Submitted data for one participant
* @param BookingDto $bookingDto The booking DTO to update
* @param int $participantIndex Index of participant to process
*
* @return array<string, mixed> The synchronized participant data reflecting DTO changes
*/
public function processFieldsForParticipantAndSync(array $participantData, BookingDto $bookingDto, int $participantIndex): array
{
// Process all field handlers for this participant
$this->processFieldsForParticipant($participantData, $bookingDto, $participantIndex);
// Synchronize only modified service fields with the cleaned DTO state
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return $participantData;
}
return $this->syncModifiedServiceFields($participantData, $participant);
}
/**
* Synchronizes only service fields that were modified by handlers.
*
* This method compares the submitted service ID with the DTO value after processing.
* Only fields where the handler assigned a different service are synchronized back.
* This prevents overwriting user input for fields like dateOfBirth where the handler
* just parses the value without changing it.
*
* @param array<string, mixed> $participantData The submitted participant data
* @param ParticipantDto $participant The processed participant DTO
*
* @return array<string, mixed> Updated participant data with modified fields synchronized
*/
private function syncModifiedServiceFields(array $participantData, ParticipantDto $participant): array
{
// Define fields that may be reassigned by handlers and need syncing
$serviceFields = [
'transportationOutbound',
'transportationInbound',
'insurance',
'skiPass',
'rentals',
'courses',
'additionalServices',
'board',
'pickup',
];
foreach ($serviceFields as $fieldName) {
if (false === array_key_exists($fieldName, $participantData)) {
continue;
}
if (false === property_exists($participant, $fieldName)) {
continue;
}
$dtoValue = $participant->{$fieldName};
$submittedValue = $participantData[$fieldName];
// Check if the DTO value differs from submitted value
if ($this->hasFieldValueChanged($submittedValue, $dtoValue)) {
$participantData[$fieldName] = $this->convertDtoValueToSubmittedFormat($dtoValue);
}
}
return $participantData;
}
/**
* Checks if a field value was changed by a handler.
*
* Compares the submitted value (usually an ID string) with the DTO value
* (usually a Service/Insurance object) to detect if a handler reassigned it.
*
* @param mixed $submittedValue The original submitted value (ID or array of IDs)
* @param mixed $dtoValue The DTO value after handler processing
*
* @return bool True if the values differ (handler modified the field)
*/
private function hasFieldValueChanged(mixed $submittedValue, mixed $dtoValue): bool
{
// Handle empty string as null (form submits empty string for unselected)
if ('' === $submittedValue) {
$submittedValue = null;
}
// Handle null cases
if (null === $dtoValue && null === $submittedValue) {
return false;
}
if (null === $dtoValue || null === $submittedValue) {
return true;
}
// Handle arrays (multiple selections like rentals, courses)
if (is_array($submittedValue) && is_array($dtoValue)) {
$submittedIds = array_map('strval', $submittedValue);
$dtoIds = array_map(fn ($item) => $this->extractId($item), $dtoValue);
sort($submittedIds);
sort($dtoIds);
return $submittedIds !== $dtoIds;
}
// Handle single service/insurance objects
$dtoId = $this->extractId($dtoValue);
// Compare as integers for reliable comparison (form may submit string, DTO has int)
return (int) $submittedValue !== (int) $dtoId;
}
/**
* Extracts the ID from a service, insurance, or pickup object.
*
* @param mixed $value The value to extract ID from
*
* @return string|null The extracted ID or null
*/
private function extractId(mixed $value): ?string
{
if ($value instanceof Service || $value instanceof Insurance || $value instanceof Pickup) {
return (string) $value->id;
}
if (is_scalar($value)) {
return (string) $value;
}
return null;
}
/**
* Returns handler names sorted by dependency order using cached results when possible.
*
@@ -385,19 +529,19 @@ class ParticipantFieldHandlerRegistry
*/
private function convertSingleValueToSubmittedFormat(mixed $value): mixed
{
// Handle Service objects -> convert to ID
// Handle Service objects -> convert to ID (as string for form compatibility)
if ($value instanceof Service) {
return $value->id;
return (string) $value->id;
}
// Handle Pickup objects -> convert to ID
// Handle Pickup objects -> convert to ID (as string for form compatibility)
if ($value instanceof Pickup) {
return $value->id;
return (string) $value->id;
}
// Handle Insurance objects -> convert to ID
// Handle Insurance objects -> convert to ID (as string for form compatibility)
if ($value instanceof Insurance) {
return $value->id;
return (string) $value->id;
}
// Handle Address objects -> convert to array of properties
@@ -411,9 +555,13 @@ class ParticipantFieldHandlerRegistry
];
}
// Handle DateTimeInterface -> convert to string format
// Handle DateTimeInterface -> convert to array format for BirthdayType widget
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
return [
'year' => $value->format('Y'),
'month' => $value->format('n'),
'day' => $value->format('j'),
];
}
// Handle numeric values
@@ -36,9 +36,6 @@ use App\Service\ServiceAvailabilityCalculator;
*/
class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
{
/** @var array<string, array<string, mixed>> Request-scoped cache for field options */
private array $optionsCache = [];
/**
* Initializes the provider with required dependencies.
*
@@ -61,39 +58,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
parent::__construct();
}
/**
* Retrieves form field options with request-scoped caching.
*
* Overrides parent to add instance-level caching, preventing redundant
* field option calculations when the same field is accessed multiple times
* during form building (happens ~16 times per participant × 50 participants = 800 calls).
*
* Cache key includes: field name, participant index, mode, and date of birth.
* The cache is automatically cleared between requests since the service is request-scoped.
*
* @param string $fieldName The name of the field to configure
* @param BookingDto $bookingDto The current booking data for context
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
public function getFieldOptions(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $options = []): array
{
// Generate cache key based on factors that affect field options
$participant = $bookingDto->getParticipant($participantIndex);
$cacheKey = sprintf(
'%s_%d_%s_%s',
$fieldName,
$participantIndex,
$bookingDto->getMode(),
$participant?->dateOfBirth?->format('Y-m-d') ?? 'no_dob'
);
// Return cached result if available, otherwise compute and cache
return $this->optionsCache[$cacheKey] ??= parent::getFieldOptions($fieldName, $bookingDto, $participantIndex, $options);
}
/**
* Registers all field option providers during service initialization.
*
@@ -494,15 +458,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead
'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex),
'choice_value' => 'id',
'choice_label' => function (?Insurance $insurance) {
$label = $insurance->label;
if (null !== $insurance->price && $insurance->price > 0) {
$label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.'));
}
return $label;
},
'choice_label' => fn (?Insurance $insurance) => $insurance?->label,
];
// Purchase voucher field provider - redemption code for vouchers that apply to complete booking