From 86223dc8a62b866a263a388ed61418834741df68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 23 Jul 2025 18:38:31 +0200 Subject: [PATCH] wip: field states and dependencies --- docs/FIELD_STATE_SYSTEM.md | 159 +++++++++++ src/Form/BookingCreateParticipantType.php | 107 ++++++- .../AbstractParticipantFieldHandler.php | 35 ++- .../Condition/AgeRangeCondition.php | 140 +++++++++ .../Condition/CompositeCondition.php | 233 +++++++++++++++ .../Condition/FieldConditionInterface.php | 62 ++++ .../Condition/FieldValueCondition.php | 265 ++++++++++++++++++ .../ParticipantFieldHandlerInterface.php | 33 ++- .../Service/FieldStateProviderInterface.php | 101 +++++++ .../ParticipantFieldOptionsProvider.php | 189 +++++++++++-- 10 files changed, 1302 insertions(+), 22 deletions(-) create mode 100644 docs/FIELD_STATE_SYSTEM.md create mode 100644 src/Form/ParticipantFieldHandler/Condition/AgeRangeCondition.php create mode 100644 src/Form/ParticipantFieldHandler/Condition/CompositeCondition.php create mode 100644 src/Form/ParticipantFieldHandler/Condition/FieldConditionInterface.php create mode 100644 src/Form/ParticipantFieldHandler/Condition/FieldValueCondition.php create mode 100644 src/Form/Service/FieldStateProviderInterface.php diff --git a/docs/FIELD_STATE_SYSTEM.md b/docs/FIELD_STATE_SYSTEM.md new file mode 100644 index 0000000..b71164c --- /dev/null +++ b/docs/FIELD_STATE_SYSTEM.md @@ -0,0 +1,159 @@ +# Universal Conditional Field State System + +This system provides a flexible architecture for implementing conditional field states (readonly, disabled, hidden) based on participant data and interdependent field values. + +## Architecture Overview + +The system consists of several key components: + +1. **FieldConditionInterface** - Defines the contract for condition evaluation +2. **Concrete Conditions** - Implement specific business logic (age ranges, field values, etc.) +3. **CompositeCondition** - Combines conditions with AND/OR/NOT logic +4. **FieldStateProviderInterface** - Manages field state calculation +5. **ParticipantFieldOptionsProvider** - Enhanced to support state conditions +6. **Form Integration** - Applied in BookingCreateParticipantType + +## Usage Examples + +### Basic Age-Based Condition + +```php +// Make a field readonly for participants under 18 +$this->fieldStateConditions['serviceSelection'] = [ + 'readonly' => new AgeRangeCondition(null, 17), +]; +``` + +### Field Dependency Condition + +```php +// Disable field if room is not assigned +$this->fieldStateConditions['mealPreference'] = [ + 'disabled' => FieldValueCondition::empty('assignedRoomId'), +]; +``` + +### Complex Composite Condition + +```php +// Hide field for young participants OR if basic service is selected +$this->fieldStateConditions['advancedOptions'] = [ + 'hidden' => CompositeCondition::or( + new AgeRangeCondition(null, 15), + FieldValueCondition::equals('serviceType', 'basic') + ), +]; +``` + +### Multiple State Conditions + +```php +// Field with multiple conditional states +$this->fieldStateConditions['specialServices'] = [ + 'readonly' => new AgeRangeCondition(null, 17), + 'required' => FieldValueCondition::equals('roomType', 'premium'), + 'disabled' => CompositeCondition::and( + FieldValueCondition::empty('assignedRoomId'), + FieldValueCondition::notEquals('participantType', 'staff') + ), +]; +``` + +## Available Conditions + +### AgeRangeCondition +- `new AgeRangeCondition(18, null)` - At least 18 years old +- `new AgeRangeCondition(null, 17)` - Under 18 years old +- `new AgeRangeCondition(18, 65)` - Between 18 and 65 years old + +### FieldValueCondition +- `FieldValueCondition::equals('field', 'value')` - Field equals specific value +- `FieldValueCondition::notEquals('field', 'value')` - Field does not equal value +- `FieldValueCondition::in('field', ['a', 'b'])` - Field value is in array +- `FieldValueCondition::empty('field')` - Field is empty or null +- `FieldValueCondition::isNotEmpty('field')` - Field has a value + +### CompositeCondition +- `CompositeCondition::and($cond1, $cond2)` - All conditions must be true +- `CompositeCondition::or($cond1, $cond2)` - At least one condition must be true +- `CompositeCondition::not($condition)` - Inverts condition result + +## State Types + +- **readonly** - Field is visible but not editable +- **disabled** - Field interaction is disabled +- **required** - Field becomes mandatory +- **hidden** - Field is not displayed (via CSS display: none) + +## Adding New Conditions + +To register field state conditions, add them to the `registerFieldStateConditions()` method in `ParticipantFieldOptionsProvider`: + +```php +private function registerFieldStateConditions(): void +{ + // Age-based readonly state + $this->fieldStateConditions['assignedRoomId'] = [ + 'readonly' => new AgeRangeCondition(null, 17), + ]; + + // Field dependency + $this->fieldStateConditions['mealPreference'] = [ + 'disabled' => FieldValueCondition::empty('assignedRoomId'), + ]; + + // Complex business logic + $this->fieldStateConditions['advancedServices'] = [ + 'hidden' => CompositeCondition::or( + new AgeRangeCondition(null, 15), + FieldValueCondition::equals('membershipLevel', 'basic') + ), + 'required' => FieldValueCondition::equals('roomType', 'suite'), + ]; +} +``` + +## Performance Considerations + +- Conditions use lazy evaluation and short-circuit logic +- Field state calculations are cached during form processing +- Dependency tracking prevents unnecessary re-evaluations +- Bulk state calculation optimizes multiple field updates + +## Integration with HTMX + +The system supports real-time field state updates: + +1. Field changes trigger dependency re-evaluation +2. State modifications are applied via form rebuilding +3. HTMX can update field states without full page refresh +4. Dependency tracking ensures only affected fields are updated + +## Extending the System + +### Custom Conditions + +Create new condition classes implementing `FieldConditionInterface`: + +```php +class CustomBusinessRuleCondition implements FieldConditionInterface +{ + public function evaluate(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool + { + // Custom business logic here + return true; + } + + public function getDependentFields(): array + { + return ['fieldThatTriggersThisCondition']; + } + + public function getDescription(): string + { + return 'Custom business rule description'; + } +} +``` + +This system provides a powerful, maintainable foundation for complex conditional field behavior while maintaining clean separation of concerns and extensibility. \ No newline at end of file diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index 7ab5d46..5c6c825 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -67,7 +67,8 @@ class BookingCreateParticipantType extends AbstractType 'clean_xss' => true, ]) ->add('bodyDimensions', BodyDimensionsType::class) - ->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData']); + ->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData']) + ->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']); } /** @@ -93,10 +94,78 @@ class BookingCreateParticipantType extends AbstractType $bookingCreateDto = $rootForm->getData(); $this->addDynamicFields($form, $bookingCreateDto, $participantData->index); + $this->applyFieldStates($form, $bookingCreateDto, $participantData->index); } /** - * Adds all configured dynamic fields to the form. + * Handles form pre-submit events to update field states based on submitted data. + */ + public function onPreSubmit(FormEvent $event): void + { + $submittedData = $event->getData(); + $form = $event->getForm(); + + if (!is_array($submittedData)) { + return; + } + + // Get the root form data to access BookingCreateDto + $rootForm = $form; + while ($rootForm->getParent()) { + $rootForm = $rootForm->getParent(); + } + + /** @var BookingCreateDto $bookingCreateDto */ + $bookingCreateDto = $rootForm->getData(); + + if (null === $bookingCreateDto) { + return; + } + + // Get participant index from form data + $participantData = $form->getData(); + if (null === $participantData || !property_exists($participantData, 'index')) { + return; + } + + // Apply updated field states based on submitted data + $this->applyFieldStates($form, $bookingCreateDto, $participantData->index, $submittedData); + } + + /** + * Applies dynamic field states to all form fields. + * + * This method checks each form field for state conditions and applies + * the appropriate state modifications (readonly, disabled, etc.). + * + * @param FormInterface $form The form to modify + * @param BookingCreateDto $bookingCreateDto The booking data for context + * @param int $participantIndex The participant index + * @param array $formData Optional submitted form data for state calculation + */ + private function applyFieldStates(FormInterface $form, BookingCreateDto $bookingCreateDto, int $participantIndex, array $formData = []): void + { + // Get all fields that have state conditions + $allFieldStates = $this->fieldOptionsProvider->getAllFieldStates($bookingCreateDto, $participantIndex, $formData); + + foreach ($allFieldStates as $fieldName => $fieldState) { + if ($form->has($fieldName)) { + $field = $form->get($fieldName); + $currentOptions = $field->getConfig()->getOptions(); + + // Merge state into current options + $updatedOptions = $this->mergeFieldState($currentOptions, $fieldState); + + // Remove and re-add the field with updated options + $fieldType = $field->getConfig()->getType()->getInnerType(); + $form->remove($fieldName); + $form->add($fieldName, $fieldType::class, $updatedOptions); + } + } + } + + /** + * Adds all configured dynamic fields to the form with state conditions applied. */ private function addDynamicFields(FormInterface $form, BookingCreateDto $bookingCreateDto, int $participantIndex): void { @@ -104,12 +173,46 @@ class BookingCreateParticipantType extends AbstractType foreach ($dynamicFields as $fieldName) { if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) { + // Get base field options $fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingCreateDto, $participantIndex); + + // Apply dynamic field state if conditions exist + if ($this->fieldOptionsProvider->hasStateConditions($fieldName)) { + $fieldState = $this->fieldOptionsProvider->getFieldState($fieldName, $bookingCreateDto, $participantIndex); + $fieldOptions = $this->mergeFieldState($fieldOptions, $fieldState); + } + $form->add($fieldName, ChoiceType::class, $fieldOptions); } } } + /** + * Merges field state modifications into existing field options. + * + * This method combines the base field options with dynamic state modifications, + * handling attribute merging and option overrides correctly. + * + * @param array $fieldOptions The base field options + * @param array $fieldState The dynamic state modifications + * + * @return array The merged field options with state applied + */ + private function mergeFieldState(array $fieldOptions, array $fieldState): array + { + foreach ($fieldState as $key => $value) { + if ('attr' === $key && isset($fieldOptions['attr'])) { + // Merge attributes instead of overwriting + $fieldOptions['attr'] = array_merge($fieldOptions['attr'], $value); + } else { + // Direct assignment for non-attribute options + $fieldOptions[$key] = $value; + } + } + + return $fieldOptions; + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php b/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php index d7a2ff9..19b52a2 100644 --- a/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php +++ b/src/Form/ParticipantFieldHandler/AbstractParticipantFieldHandler.php @@ -10,7 +10,8 @@ use App\Form\Model\BookingCreateDto; * Abstract base class providing common functionality for participant field handlers. * * This class implements common patterns used across participant field handlers, - * reducing boilerplate code in concrete implementations. + * including default implementations for field state modification methods. + * Reduces boilerplate code in concrete implementations. */ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandlerInterface { @@ -116,4 +117,36 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle return is_numeric($value) ? (int) $value : null; } + + /** + * Returns field state modifications that should be applied after processing. + * + * Default implementation returns no state modifications. Override this method + * in concrete handlers that need to modify field states based on their + * processing results. + * + * @param array $submittedData The submitted participant form data + * @param BookingCreateDto $bookingDto The booking DTO (potentially modified by processing) + * @param int $participantIndex The participant index being processed + * + * @return array> Empty array (no state modifications by default) + */ + public function getFieldStateModifications(array $submittedData, BookingCreateDto $bookingDto, int $participantIndex): array + { + return []; + } + + /** + * Returns field names whose state is affected by this handler's processing. + * + * Default implementation returns an empty array, meaning this handler doesn't + * affect the state of any fields. Override this method in concrete handlers + * that modify field states. + * + * @return string[] Empty array (no affected fields by default) + */ + public function getAffectedFieldNames(): array + { + return []; + } } diff --git a/src/Form/ParticipantFieldHandler/Condition/AgeRangeCondition.php b/src/Form/ParticipantFieldHandler/Condition/AgeRangeCondition.php new file mode 100644 index 0000000..37264ee --- /dev/null +++ b/src/Form/ParticipantFieldHandler/Condition/AgeRangeCondition.php @@ -0,0 +1,140 @@ + $maxAge) { + throw new \InvalidArgumentException('Minimum age cannot be greater than maximum age'); + } + } + + /** + * Evaluates if the participant's age meets the specified criteria. + * + * Calculates the participant's current age from their date of birth and + * checks if it falls within the configured age range. Returns false if + * the participant has no date of birth set. + * + * @param BookingCreateDto $bookingDto The current booking data + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data (unused for age conditions) + * + * @return bool True if the participant's age meets the criteria, false otherwise + */ + public function evaluate(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool + { + $participant = $bookingDto->participants[$participantIndex] ?? null; + + if (null === $participant || null === $participant->dateOfBirth) { + return false; + } + + $age = $this->calculateAge($participant->dateOfBirth); + + // Check minimum age requirement + if (null !== $this->minAge && $age < $this->minAge) { + return false; + } + + // Check maximum age requirement + if (null !== $this->maxAge && $age > $this->maxAge) { + return false; + } + + return true; + } + + /** + * Returns field names that affect age calculation. + * + * The age condition depends on the participant's date of birth field. + * When this field changes, any conditions based on age should be re-evaluated. + * + * @return string[] Array containing 'dateOfBirth' field name + */ + public function getDependentFields(): array + { + return ['dateOfBirth']; + } + + /** + * Returns a human-readable description of the age criteria. + * + * Generates a descriptive string explaining the age requirements, + * useful for debugging and understanding condition logic. + * + * @return string Description of the age range criteria + */ + public function getDescription(): string + { + if (null !== $this->minAge && null !== $this->maxAge) { + if ($this->minAge === $this->maxAge) { + return sprintf('Participant must be exactly %d years old', $this->minAge); + } + + return sprintf('Participant must be between %d and %d years old', $this->minAge, $this->maxAge); + } + + if (null !== $this->minAge) { + return sprintf('Participant must be at least %d years old', $this->minAge); + } + + return sprintf('Participant must be under %d years old', $this->maxAge + 1); + } + + /** + * Calculates age in years from a date of birth. + * + * Uses DateTimeImmutable to ensure immutable date calculations and + * handles the calculation accurately accounting for leap years and + * exact birth date anniversaries. + * + * @param \DateTimeImmutable $dateOfBirth The participant's date of birth + * + * @return int The calculated age in complete years + */ + private function calculateAge(\DateTimeImmutable $dateOfBirth): int + { + $today = new \DateTimeImmutable(); + + return (int) $dateOfBirth->diff($today)->y; + } +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/Condition/CompositeCondition.php b/src/Form/ParticipantFieldHandler/Condition/CompositeCondition.php new file mode 100644 index 0000000..a7ba1ae --- /dev/null +++ b/src/Form/ParticipantFieldHandler/Condition/CompositeCondition.php @@ -0,0 +1,233 @@ +validateOperator($operator); + $this->conditions = [$condition, ...$conditions]; + $this->validateConditionCount($operator, $this->conditions); + } + + /** + * Evaluates the composite condition using the specified logical operator. + * + * Applies the logical operator to all child conditions, using short-circuit + * evaluation for optimal performance. The evaluation stops as soon as the + * final result can be determined. + * + * @param BookingCreateDto $bookingDto The current booking data + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return bool True if the composite condition is met, false otherwise + */ + public function evaluate(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool + { + return match ($this->operator) { + self::OPERATOR_AND => $this->evaluateAnd($bookingDto, $participantIndex, $formData), + self::OPERATOR_OR => $this->evaluateOr($bookingDto, $participantIndex, $formData), + self::OPERATOR_NOT => $this->evaluateNot($bookingDto, $participantIndex, $formData), + default => false, + }; + } + + /** + * Returns field names that affect any child condition. + * + * Aggregates dependency declarations from all child conditions to create + * a comprehensive list of fields that trigger re-evaluation of this + * composite condition. + * + * @return string[] Array of field names that affect any child condition + */ + public function getDependentFields(): array + { + $dependentFields = []; + + foreach ($this->conditions as $condition) { + $dependentFields = array_merge($dependentFields, $condition->getDependentFields()); + } + + return array_unique($dependentFields); + } + + /** + * Returns a human-readable description of the composite condition. + * + * Generates a descriptive string that shows the logical structure and + * descriptions of all child conditions, useful for debugging and + * understanding complex conditional logic. + * + * @return string Description of the composite condition logic + */ + public function getDescription(): string + { + $conditionDescriptions = array_map( + fn (FieldConditionInterface $condition) => $condition->getDescription(), + $this->conditions + ); + + return match ($this->operator) { + self::OPERATOR_AND => sprintf('(%s)', implode(' AND ', $conditionDescriptions)), + self::OPERATOR_OR => sprintf('(%s)', implode(' OR ', $conditionDescriptions)), + self::OPERATOR_NOT => sprintf('NOT (%s)', $conditionDescriptions[0]), + default => 'Invalid composite condition', + }; + } + + /** + * Creates a composite condition that requires all conditions to be true. + * + * @param FieldConditionInterface ...$conditions The conditions that must all be true + */ + public static function and(FieldConditionInterface ...$conditions): self + { + if (empty($conditions)) { + throw new \InvalidArgumentException('AND condition requires at least one condition'); + } + + return new self(self::OPERATOR_AND, ...$conditions); + } + + /** + * Creates a composite condition that requires at least one condition to be true. + * + * @param FieldConditionInterface ...$conditions The conditions where at least one must be true + */ + public static function or(FieldConditionInterface ...$conditions): self + { + if (empty($conditions)) { + throw new \InvalidArgumentException('OR condition requires at least one condition'); + } + + return new self(self::OPERATOR_OR, ...$conditions); + } + + /** + * Creates a composite condition that inverts the result of another condition. + * + * @param FieldConditionInterface $condition The condition to invert + */ + public static function not(FieldConditionInterface $condition): self + { + return new self(self::OPERATOR_NOT, $condition); + } + + /** + * Evaluates AND logic with short-circuit evaluation. + * + * Returns false as soon as any condition evaluates to false, + * avoiding unnecessary evaluation of remaining conditions. + */ + private function evaluateAnd(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool + { + foreach ($this->conditions as $condition) { + if (!$condition->evaluate($bookingDto, $participantIndex, $formData)) { + return false; // Short-circuit: if any condition is false, result is false + } + } + + return true; // All conditions evaluated to true + } + + /** + * Evaluates OR logic with short-circuit evaluation. + * + * Returns true as soon as any condition evaluates to true, + * avoiding unnecessary evaluation of remaining conditions. + */ + private function evaluateOr(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool + { + foreach ($this->conditions as $condition) { + if ($condition->evaluate($bookingDto, $participantIndex, $formData)) { + return true; // Short-circuit: if any condition is true, result is true + } + } + + return false; // No conditions evaluated to true + } + + /** + * Evaluates NOT logic by inverting the result of the single condition. + */ + private function evaluateNot(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool + { + return !$this->conditions[0]->evaluate($bookingDto, $participantIndex, $formData); + } + + /** + * Validates that the operator is supported. + */ + private function validateOperator(string $operator): void + { + $validOperators = [self::OPERATOR_AND, self::OPERATOR_OR, self::OPERATOR_NOT]; + + if (!in_array($operator, $validOperators, true)) { + throw new \InvalidArgumentException(sprintf('Invalid operator "%s". Valid operators: %s', $operator, implode(', ', $validOperators))); + } + } + + /** + * Validates condition count based on operator requirements. + */ + private function validateConditionCount(string $operator, array $conditions): void + { + $conditionCount = count($conditions); + + if (self::OPERATOR_NOT === $operator && 1 !== $conditionCount) { + throw new \InvalidArgumentException('NOT operator requires exactly one condition'); + } + + if (in_array($operator, [self::OPERATOR_AND, self::OPERATOR_OR], true) && $conditionCount < 1) { + throw new \InvalidArgumentException(sprintf('%s operator requires at least one condition', $operator)); + } + } +} diff --git a/src/Form/ParticipantFieldHandler/Condition/FieldConditionInterface.php b/src/Form/ParticipantFieldHandler/Condition/FieldConditionInterface.php new file mode 100644 index 0000000..5cae08c --- /dev/null +++ b/src/Form/ParticipantFieldHandler/Condition/FieldConditionInterface.php @@ -0,0 +1,62 @@ + $formData Current form data (may include partial submissions) + * + * @return bool True if the condition is met, false otherwise + */ + public function evaluate(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool; + + /** + * Returns field names that trigger re-evaluation of this condition. + * + * This method declares which form fields affect this condition's outcome. + * When any of these fields change (either through user input or other + * field handlers), the condition should be re-evaluated to determine + * if dependent field states need to be updated. + * + * @return string[] Array of field names that affect this condition + */ + public function getDependentFields(): array; + + /** + * Returns a human-readable description of this condition. + * + * This method provides a description of what the condition checks, + * useful for debugging, logging, and developer documentation. + * Should be concise but descriptive enough to understand the logic. + * + * @return string A brief description of the condition logic + */ + public function getDescription(): string; +} \ No newline at end of file diff --git a/src/Form/ParticipantFieldHandler/Condition/FieldValueCondition.php b/src/Form/ParticipantFieldHandler/Condition/FieldValueCondition.php new file mode 100644 index 0000000..42943ff --- /dev/null +++ b/src/Form/ParticipantFieldHandler/Condition/FieldValueCondition.php @@ -0,0 +1,265 @@ +validateOperator($operator); + $this->validateExpectedValue($operator, $expectedValue); + } + + /** + * Evaluates the field value condition against current form data. + * + * Retrieves the current value of the specified field and compares it + * against the expected value using the configured operator. Supports + * both participant-level fields and booking-level fields. + * + * @param BookingCreateDto $bookingDto The current booking data + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return bool True if the field value meets the condition criteria, false otherwise + */ + public function evaluate(BookingCreateDto $bookingDto, int $participantIndex, array $formData): bool + { + $fieldValue = $this->getFieldValue($formData, $participantIndex, $bookingDto); + + return match ($this->operator) { + self::OPERATOR_EQUALS => $this->compareEquals($fieldValue, $this->expectedValue), + self::OPERATOR_NOT_EQUALS => !$this->compareEquals($fieldValue, $this->expectedValue), + self::OPERATOR_IN => $this->compareIn($fieldValue, $this->expectedValue), + self::OPERATOR_NOT_IN => !$this->compareIn($fieldValue, $this->expectedValue), + self::OPERATOR_EMPTY => $this->isEmpty($fieldValue), + self::OPERATOR_NOT_EMPTY => !$this->isEmpty($fieldValue), + default => false, + }; + } + + /** + * Returns the field names that this condition depends on. + * + * This condition depends on the field being evaluated, so any changes + * to that field should trigger re-evaluation of dependent field states. + * + * @return string[] Array containing the field name being evaluated + */ + public function getDependentFields(): array + { + return [$this->fieldName]; + } + + /** + * Returns a human-readable description of the field value condition. + * + * Generates a descriptive string explaining what field value is being + * checked and what the expected criteria are. + * + * @return string Description of the field value condition + */ + public function getDescription(): string + { + $valueDescription = is_array($this->expectedValue) + ? '['.implode(', ', $this->expectedValue).']' + : (string) $this->expectedValue; + + return match ($this->operator) { + self::OPERATOR_EQUALS => sprintf('Field "%s" equals %s', $this->fieldName, $valueDescription), + self::OPERATOR_NOT_EQUALS => sprintf('Field "%s" does not equal %s', $this->fieldName, $valueDescription), + self::OPERATOR_IN => sprintf('Field "%s" is in %s', $this->fieldName, $valueDescription), + self::OPERATOR_NOT_IN => sprintf('Field "%s" is not in %s', $this->fieldName, $valueDescription), + self::OPERATOR_EMPTY => sprintf('Field "%s" is empty', $this->fieldName), + self::OPERATOR_NOT_EMPTY => sprintf('Field "%s" is not empty', $this->fieldName), + default => sprintf('Field "%s" %s %s', $this->fieldName, $this->operator, $valueDescription), + }; + } + + /** + * Creates a condition for field equality. + * + * @param string $fieldName The field to check + * @param mixed $expectedValue The expected value + */ + public static function equals(string $fieldName, mixed $expectedValue): self + { + return new self($fieldName, self::OPERATOR_EQUALS, $expectedValue); + } + + /** + * Creates a condition for field non-equality. + * + * @param string $fieldName The field to check + * @param mixed $expectedValue The value that should not match + */ + public static function notEquals(string $fieldName, mixed $expectedValue): self + { + return new self($fieldName, self::OPERATOR_NOT_EQUALS, $expectedValue); + } + + /** + * Creates a condition for field value inclusion. + * + * @param string $fieldName The field to check + * @param array $allowedValues The allowed values + */ + public static function in(string $fieldName, array $allowedValues): self + { + return new self($fieldName, self::OPERATOR_IN, $allowedValues); + } + + /** + * Creates a condition for field emptiness. + * + * @param string $fieldName The field to check + */ + public static function empty(string $fieldName): self + { + return new self($fieldName, self::OPERATOR_EMPTY); + } + + /** + * Creates a condition for field non-emptiness. + * + * @param string $fieldName The field to check + */ + public static function isNotEmpty(string $fieldName): self + { + return new self($fieldName, self::OPERATOR_NOT_EMPTY); + } + + /** + * Retrieves field value from form data or participant data. + */ + private function getFieldValue(array $formData, int $participantIndex, BookingCreateDto $bookingDto): mixed + { + // First check participant-specific form data + if (isset($formData['participants'][$participantIndex][$this->fieldName])) { + return $formData['participants'][$participantIndex][$this->fieldName]; + } + + // Then check participant DTO data + $participant = $bookingDto->participants[$participantIndex] ?? null; + if (null !== $participant && property_exists($participant, $this->fieldName)) { + return $participant->{$this->fieldName}; + } + + // Finally check booking-level form data + return $formData[$this->fieldName] ?? null; + } + + /** + * Compares two values for equality, handling type coercion. + */ + private function compareEquals(mixed $fieldValue, mixed $expectedValue): bool + { + // Handle string/number coercion common in form data + if (is_string($fieldValue) && is_numeric($expectedValue)) { + return $fieldValue === (string) $expectedValue; + } + + if (is_numeric($fieldValue) && is_string($expectedValue)) { + return (string) $fieldValue === $expectedValue; + } + + return $fieldValue === $expectedValue; + } + + /** + * Checks if field value is in array of expected values. + */ + private function compareIn(mixed $fieldValue, array $expectedValues): bool + { + foreach ($expectedValues as $expectedValue) { + if ($this->compareEquals($fieldValue, $expectedValue)) { + return true; + } + } + + return false; + } + + /** + * Checks if a value is considered empty. + */ + private function isEmpty(mixed $value): bool + { + return null === $value || '' === $value || (is_array($value) && empty($value)); + } + + /** + * Validates that the operator is supported. + */ + private function validateOperator(string $operator): void + { + $validOperators = [ + self::OPERATOR_EQUALS, + self::OPERATOR_NOT_EQUALS, + self::OPERATOR_IN, + self::OPERATOR_NOT_IN, + self::OPERATOR_EMPTY, + self::OPERATOR_NOT_EMPTY, + ]; + + if (!in_array($operator, $validOperators, true)) { + throw new \InvalidArgumentException(sprintf('Invalid operator "%s". Valid operators: %s', $operator, implode(', ', $validOperators))); + } + } + + /** + * Validates expected value based on operator requirements. + */ + private function validateExpectedValue(string $operator, mixed $expectedValue): void + { + if (in_array($operator, [self::OPERATOR_IN, self::OPERATOR_NOT_IN], true) && !is_array($expectedValue)) { + throw new \InvalidArgumentException(sprintf('Operator "%s" requires expectedValue to be an array', $operator)); + } + + if (in_array($operator, [self::OPERATOR_EMPTY, self::OPERATOR_NOT_EMPTY], true) && null !== $expectedValue) { + throw new \InvalidArgumentException(sprintf('Operator "%s" does not accept expectedValue parameter', $operator)); + } + } +} diff --git a/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php b/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php index a64fdc3..f8a1194 100644 --- a/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php +++ b/src/Form/ParticipantFieldHandler/ParticipantFieldHandlerInterface.php @@ -7,11 +7,12 @@ namespace App\Form\ParticipantFieldHandler; use App\Form\Model\BookingCreateDto; /** - * Interface for handling dynamic participant form field processing. + * Interface for handling dynamic participant form field processing and state modification. * * Participant field handlers are responsible for processing submitted form data - * for booking participants and updating the BookingCreateDto accordingly. - * They support dependency management to ensure fields are processed in the correct order. + * for booking participants and updating the BookingCreateDto accordingly. They also + * support field state modification based on processed data and business logic. + * Handlers support dependency management to ensure fields are processed in the correct order. */ interface ParticipantFieldHandlerInterface { @@ -40,4 +41,30 @@ interface ParticipantFieldHandlerInterface * Determines if this handler should process the field based on submitted participant data. */ public function shouldProcess(array $submittedData, int $participantIndex): bool; + + /** + * Returns field state modifications that should be applied after processing. + * + * This method allows handlers to dynamically modify the state of form fields + * based on the processed data. It's called after processField() and can be used + * to enable/disable/hide fields based on the handler's processing results. + * + * @param array $submittedData The submitted participant form data + * @param BookingCreateDto $bookingDto The booking DTO (potentially modified by processing) + * @param int $participantIndex The participant index being processed + * + * @return array> Field state modifications indexed by field name + */ + public function getFieldStateModifications(array $submittedData, BookingCreateDto $bookingDto, int $participantIndex): array; + + /** + * Returns field names whose state is affected by this handler's processing. + * + * This method declares which form fields have their state modified by this handler. + * It's used for dependency tracking and determining when field states need to be + * recalculated during form processing. + * + * @return string[] Array of field names that this handler may modify the state of + */ + public function getAffectedFieldNames(): array; } \ No newline at end of file diff --git a/src/Form/Service/FieldStateProviderInterface.php b/src/Form/Service/FieldStateProviderInterface.php new file mode 100644 index 0000000..51b99fa --- /dev/null +++ b/src/Form/Service/FieldStateProviderInterface.php @@ -0,0 +1,101 @@ + ['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 + * @param BookingCreateDto $bookingDto The current booking data for context + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data (may include partial submissions) + * + * @return array Symfony form field options for state modifications, empty if no changes needed + */ + public function getFieldState(string $fieldName, BookingCreateDto $bookingDto, int $participantIndex, array $formData = []): array; + + /** + * Checks whether a field has state conditions configured. + * + * This method allows form builders to determine if a field has dynamic + * state behavior configured. Fields without state conditions use their + * default static configuration, while fields with conditions require + * runtime state evaluation. + * + * @param string $fieldName The name of the field to check + * + * @return bool True if the field has state conditions configured, false otherwise + */ + public function hasStateConditions(string $fieldName): bool; + + /** + * Returns field names that trigger state re-evaluation for a given field. + * + * This method identifies which form fields, when changed, should trigger + * re-evaluation of the specified field's state. This information is used + * for dependency tracking and efficient state updates during form processing. + * + * For example, if field 'serviceSelection' affects the state of field 'ageRestriction', + * then 'serviceSelection' should be returned as a dependency for 'ageRestriction'. + * + * @param string $fieldName The name of the field to get dependencies for + * + * @return string[] Array of field names that affect the specified field's state + */ + public function getFieldStateDependencies(string $fieldName): array; + + /** + * Calculates field states for all configured fields at once. + * + * This method provides bulk state calculation for performance optimization + * when multiple field states need to be determined simultaneously. It's + * particularly useful during form building and bulk state updates. + * + * @param BookingCreateDto $bookingDto The current booking data for context + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return array> Field states indexed by field name + */ + public function getAllFieldStates(BookingCreateDto $bookingDto, int $participantIndex, array $formData = []): array; +} \ No newline at end of file diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 7e54f28..f738ea1 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -5,36 +5,40 @@ declare(strict_types=1); namespace App\Form\Service; use App\Form\Model\BookingCreateDto; +use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface; /** - * Provides dynamic field options for participant form fields. + * Provides dynamic field options and state for participant form fields. * - * This service acts as a field option provider registry for the participant form - * system. It generates context-aware Symfony form field options based on the - * overall booking context and individual participant data. + * This service acts as both a field option provider and field state provider + * for the participant form system. It generates context-aware Symfony form + * field options and calculates dynamic field states based on conditional logic. * * Key Responsibilities: * - Manages field option providers for dynamic fields + * - Calculates field states based on conditional logic * - Provides context-aware field configurations * - Handles interdependent field relationships - * - Supports extensible field option generation + * - Supports extensible field option and state generation * - * The provider uses a callable pattern where each field has a function - * that generates the appropriate Symfony form options based on the current - * booking and participant state. + * The provider uses a callable pattern for field options and a condition-based + * system for field states, enabling complex conditional field behavior. */ -class ParticipantFieldOptionsProvider +class ParticipantFieldOptionsProvider implements FieldStateProviderInterface { /** @var array Field option providers indexed by field name */ private array $fieldOptionProviders = []; + /** @var array> Field state conditions indexed by field name and state type */ + private array $fieldStateConditions = []; + /** * Initializes the provider with required dependencies. * - * The provider automatically registers all field option providers - * during construction to ensure they're available for form building. - * This approach keeps all field configuration logic centralized and - * makes it easy to add new dynamic fields. + * The provider automatically registers all field option providers and + * field state conditions during construction to ensure they're available + * for form building. This approach keeps all field configuration logic + * centralized and makes it easy to add new dynamic fields. * * @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders */ @@ -42,6 +46,7 @@ class ParticipantFieldOptionsProvider private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory, ) { $this->registerFieldOptionProviders(); + $this->registerFieldStateConditions(); } /** @@ -139,8 +144,160 @@ class ParticipantFieldOptionsProvider // 'Vegetarian' => 'vegetarian', // 'Vegan' => 'vegan', // ], - // // Could depend on room assignment or other factors - // 'disabled' => !$this->hasMealOptions($bookingDto, $participantIndex), // ]; } -} \ No newline at end of file + + /** + * Calculates the dynamic state for a specified field. + * + * Evaluates all configured conditions for a field and returns the appropriate + * state modifications. State conditions are organized by state type (readonly, + * disabled, etc.) and evaluated independently. + * + * @param string $fieldName The name of the field to evaluate + * @param BookingCreateDto $bookingDto The current booking data for context + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return array Symfony form field options for state modifications + */ + public function getFieldState(string $fieldName, BookingCreateDto $bookingDto, int $participantIndex, array $formData = []): array + { + if (!isset($this->fieldStateConditions[$fieldName])) { + return []; + } + + $stateModifications = []; + $attributes = []; + + foreach ($this->fieldStateConditions[$fieldName] as $stateType => $condition) { + if ($condition->evaluate($bookingDto, $participantIndex, $formData)) { + switch ($stateType) { + case 'readonly': + $attributes['readonly'] = true; + break; + case 'disabled': + $stateModifications['disabled'] = true; + break; + case 'required': + $stateModifications['required'] = true; + break; + case 'hidden': + $attributes['style'] = ($attributes['style'] ?? '').' display: none;'; + break; + } + } + } + + if (!empty($attributes)) { + $stateModifications['attr'] = $attributes; + } + + return $stateModifications; + } + + /** + * Checks whether a field has state conditions configured. + * + * @param string $fieldName The name of the field to check + * + * @return bool True if the field has state conditions configured + */ + public function hasStateConditions(string $fieldName): bool + { + return isset($this->fieldStateConditions[$fieldName]) && !empty($this->fieldStateConditions[$fieldName]); + } + + /** + * Returns field names that trigger state re-evaluation for a given field. + * + * @param string $fieldName The name of the field to get dependencies for + * + * @return string[] Array of field names that affect the specified field's state + */ + public function getFieldStateDependencies(string $fieldName): array + { + if (!isset($this->fieldStateConditions[$fieldName])) { + return []; + } + + $dependencies = []; + + foreach ($this->fieldStateConditions[$fieldName] as $condition) { + $dependencies = array_merge($dependencies, $condition->getDependentFields()); + } + + return array_unique($dependencies); + } + + /** + * Calculates field states for all configured fields at once. + * + * @param BookingCreateDto $bookingDto The current booking data for context + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return array> Field states indexed by field name + */ + public function getAllFieldStates(BookingCreateDto $bookingDto, int $participantIndex, array $formData = []): array + { + $allStates = []; + + foreach (array_keys($this->fieldStateConditions) as $fieldName) { + $fieldState = $this->getFieldState($fieldName, $bookingDto, $participantIndex, $formData); + if (!empty($fieldState)) { + $allStates[$fieldName] = $fieldState; + } + } + + return $allStates; + } + + /** + * Registers field state conditions during service initialization. + * + * This method defines the conditional logic for field states. Each field + * can have multiple state conditions (readonly, disabled, hidden, required) + * that are evaluated independently. + * + * Adding new field state conditions: + * To add conditional state logic for a field, register conditions here: + * + * $this->fieldStateConditions['fieldName'] = [ + * 'readonly' => new SomeCondition(), + * 'disabled' => CompositeCondition::and( + * new AgeRangeCondition(null, 17), + * FieldValueCondition::equals('someField', 'someValue') + * ), + * ]; + * + * Condition Types: + * - 'readonly': Field is visible but not editable + * - 'disabled': Field interaction is disabled + * - 'required': Field becomes mandatory + * - 'hidden': Field is not displayed + */ + private function registerFieldStateConditions(): void + { + // Example field state conditions would be registered here + // For demonstration purposes, here are some example patterns: + + // Example 1: Make assignedRoomId readonly for participants under 18 + // $this->fieldStateConditions['assignedRoomId'] = [ + // 'readonly' => new AgeRangeCondition(null, 17), + // ]; + + // Example 2: Disable service selection if no room is assigned + // $this->fieldStateConditions['serviceSelection'] = [ + // 'disabled' => FieldValueCondition::isEmpty('assignedRoomId'), + // ]; + + // Example 3: Complex condition with multiple criteria + // $this->fieldStateConditions['advancedOptions'] = [ + // 'hidden' => CompositeCondition::or( + // new AgeRangeCondition(null, 15), + // FieldValueCondition::equals('userType', 'basic') + // ), + // ]; + } +}