wip: field states and dependencies
This commit is contained in:
@@ -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.
|
||||||
@@ -67,7 +67,8 @@ class BookingCreateParticipantType extends AbstractType
|
|||||||
'clean_xss' => true,
|
'clean_xss' => true,
|
||||||
])
|
])
|
||||||
->add('bodyDimensions', BodyDimensionsType::class)
|
->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();
|
$bookingCreateDto = $rootForm->getData();
|
||||||
|
|
||||||
$this->addDynamicFields($form, $bookingCreateDto, $participantData->index);
|
$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<string, mixed> $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
|
private function addDynamicFields(FormInterface $form, BookingCreateDto $bookingCreateDto, int $participantIndex): void
|
||||||
{
|
{
|
||||||
@@ -104,12 +173,46 @@ class BookingCreateParticipantType extends AbstractType
|
|||||||
|
|
||||||
foreach ($dynamicFields as $fieldName) {
|
foreach ($dynamicFields as $fieldName) {
|
||||||
if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) {
|
if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) {
|
||||||
|
// Get base field options
|
||||||
$fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingCreateDto, $participantIndex);
|
$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);
|
$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<string, mixed> $fieldOptions The base field options
|
||||||
|
* @param array<string, mixed> $fieldState The dynamic state modifications
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> 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
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ use App\Form\Model\BookingCreateDto;
|
|||||||
* Abstract base class providing common functionality for participant field handlers.
|
* Abstract base class providing common functionality for participant field handlers.
|
||||||
*
|
*
|
||||||
* This class implements common patterns used across 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
|
abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandlerInterface
|
||||||
{
|
{
|
||||||
@@ -116,4 +117,36 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
|
|||||||
|
|
||||||
return is_numeric($value) ? (int) $value : null;
|
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<string, mixed> $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<string, array<string, mixed>> 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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\ParticipantFieldHandler\Condition;
|
||||||
|
|
||||||
|
use App\Form\Model\BookingCreateDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condition that evaluates participant age against specified range criteria.
|
||||||
|
*
|
||||||
|
* This condition checks if a participant's age (calculated from their date of birth)
|
||||||
|
* falls within, outside, or meets specific age criteria. It's commonly used to
|
||||||
|
* enable/disable fields based on age restrictions for services, legal requirements,
|
||||||
|
* or business rules.
|
||||||
|
*
|
||||||
|
* Age Calculation:
|
||||||
|
* - Uses participant's dateOfBirth property to calculate current age
|
||||||
|
* - Handles null dates gracefully (condition fails if no birth date)
|
||||||
|
* - Age is calculated as of today's date when condition is evaluated
|
||||||
|
*
|
||||||
|
* Range Types:
|
||||||
|
* - Minimum age: participant must be at least X years old
|
||||||
|
* - Maximum age: participant must be under X years old
|
||||||
|
* - Age range: participant must be between X and Y years old
|
||||||
|
* - Exact age: participant must be exactly X years old
|
||||||
|
*/
|
||||||
|
class AgeRangeCondition implements FieldConditionInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Creates a new age range condition.
|
||||||
|
*
|
||||||
|
* @param int|null $minAge Minimum required age (inclusive), null for no minimum
|
||||||
|
* @param int|null $maxAge Maximum allowed age (inclusive), null for no maximum
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly ?int $minAge = null,
|
||||||
|
private readonly ?int $maxAge = null,
|
||||||
|
) {
|
||||||
|
if (null === $minAge && null === $maxAge) {
|
||||||
|
throw new \InvalidArgumentException('At least one of minAge or maxAge must be specified');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $minAge && null !== $maxAge && $minAge > $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<string, mixed> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\ParticipantFieldHandler\Condition;
|
||||||
|
|
||||||
|
use App\Form\Model\BookingCreateDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite condition that combines multiple conditions with logical operators.
|
||||||
|
*
|
||||||
|
* This condition enables complex field state logic by combining multiple
|
||||||
|
* conditions using AND, OR, and NOT operators. It supports nested composition
|
||||||
|
* for arbitrarily complex conditional logic while maintaining readability
|
||||||
|
* and performance.
|
||||||
|
*
|
||||||
|
* Logical Operations:
|
||||||
|
* - AND: All conditions must be true
|
||||||
|
* - OR: At least one condition must be true
|
||||||
|
* - NOT: Inverts the result of the wrapped condition
|
||||||
|
*
|
||||||
|
* Common Use Cases:
|
||||||
|
* - Field requires both age restriction AND room assignment
|
||||||
|
* - Field is enabled if service A OR service B is selected
|
||||||
|
* - Field is disabled unless (condition A AND NOT condition B)
|
||||||
|
* - Complex business rules combining multiple criteria
|
||||||
|
*
|
||||||
|
* Performance Optimization:
|
||||||
|
* - Short-circuit evaluation for AND/OR operations
|
||||||
|
* - Dependency tracking aggregates all child condition dependencies
|
||||||
|
* - Lazy evaluation prevents unnecessary condition checks
|
||||||
|
*/
|
||||||
|
class CompositeCondition implements FieldConditionInterface
|
||||||
|
{
|
||||||
|
private const OPERATOR_AND = 'AND';
|
||||||
|
private const OPERATOR_OR = 'OR';
|
||||||
|
private const OPERATOR_NOT = 'NOT';
|
||||||
|
|
||||||
|
/** @var FieldConditionInterface[] */
|
||||||
|
private readonly array $conditions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new composite condition.
|
||||||
|
*
|
||||||
|
* @param string $operator The logical operator (AND, OR, NOT)
|
||||||
|
* @param FieldConditionInterface $condition Primary condition (required for all operators)
|
||||||
|
* @param FieldConditionInterface ...$conditions Additional conditions (for AND/OR operators)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $operator,
|
||||||
|
FieldConditionInterface $condition,
|
||||||
|
FieldConditionInterface ...$conditions,
|
||||||
|
) {
|
||||||
|
$this->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<string, mixed> $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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\ParticipantFieldHandler\Condition;
|
||||||
|
|
||||||
|
use App\Form\Model\BookingCreateDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for evaluating field state conditions.
|
||||||
|
*
|
||||||
|
* Field conditions determine whether a form field should be in a specific state
|
||||||
|
* (readonly, disabled, hidden) based on participant data, other field values,
|
||||||
|
* or business logic. Conditions are reusable components that can be combined
|
||||||
|
* to create complex field state rules.
|
||||||
|
*
|
||||||
|
* Key Responsibilities:
|
||||||
|
* - Evaluate condition logic based on current booking and participant context
|
||||||
|
* - Declare field dependencies that trigger re-evaluation
|
||||||
|
* - Support composition for complex conditional logic
|
||||||
|
* - Provide efficient condition evaluation with minimal performance impact
|
||||||
|
*/
|
||||||
|
interface FieldConditionInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Evaluates the condition based on current booking and form context.
|
||||||
|
*
|
||||||
|
* This method determines whether the condition is met given the current
|
||||||
|
* state of the booking, participant data, and submitted form values.
|
||||||
|
* The result is used to determine field state (enabled/disabled/readonly).
|
||||||
|
*
|
||||||
|
* @param BookingCreateDto $bookingDto The current booking data
|
||||||
|
* @param int $participantIndex The index of the participant being evaluated
|
||||||
|
* @param array<string, mixed> $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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\ParticipantFieldHandler\Condition;
|
||||||
|
|
||||||
|
use App\Form\Model\BookingCreateDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condition that evaluates field states based on other field values.
|
||||||
|
*
|
||||||
|
* This condition enables complex field interdependencies by checking the current
|
||||||
|
* value of one or more form fields against specified criteria. It supports various
|
||||||
|
* comparison operations and can handle both simple value checks and complex
|
||||||
|
* multi-field dependencies.
|
||||||
|
*
|
||||||
|
* Common Use Cases:
|
||||||
|
* - Enable/disable fields based on checkbox selections
|
||||||
|
* - Show/hide fields based on dropdown selections
|
||||||
|
* - Make fields readonly when certain conditions are met
|
||||||
|
* - Create wizard-like forms with conditional steps
|
||||||
|
*
|
||||||
|
* Supported Comparisons:
|
||||||
|
* - Equality: field equals specific value
|
||||||
|
* - Non-equality: field does not equal specific value
|
||||||
|
* - Inclusion: field value is in a set of allowed values
|
||||||
|
* - Exclusion: field value is not in a set of values
|
||||||
|
* - Empty/null checks: field is empty or has value
|
||||||
|
*/
|
||||||
|
class FieldValueCondition implements FieldConditionInterface
|
||||||
|
{
|
||||||
|
private const OPERATOR_EQUALS = 'equals';
|
||||||
|
private const OPERATOR_NOT_EQUALS = 'not_equals';
|
||||||
|
private const OPERATOR_IN = 'in';
|
||||||
|
private const OPERATOR_NOT_IN = 'not_in';
|
||||||
|
private const OPERATOR_EMPTY = 'empty';
|
||||||
|
private const OPERATOR_NOT_EMPTY = 'not_empty';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new field value condition.
|
||||||
|
*
|
||||||
|
* @param string $fieldName The name of the field to check
|
||||||
|
* @param string $operator The comparison operator to use
|
||||||
|
* @param mixed $expectedValue The expected value(s) for comparison
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $fieldName,
|
||||||
|
private readonly string $operator,
|
||||||
|
private readonly mixed $expectedValue = null,
|
||||||
|
) {
|
||||||
|
$this->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<string, mixed> $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<string, mixed> $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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,12 @@ namespace App\Form\ParticipantFieldHandler;
|
|||||||
use App\Form\Model\BookingCreateDto;
|
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
|
* Participant field handlers are responsible for processing submitted form data
|
||||||
* for booking participants and updating the BookingCreateDto accordingly.
|
* for booking participants and updating the BookingCreateDto accordingly. They also
|
||||||
* They support dependency management to ensure fields are processed in the correct order.
|
* 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
|
interface ParticipantFieldHandlerInterface
|
||||||
{
|
{
|
||||||
@@ -40,4 +41,30 @@ interface ParticipantFieldHandlerInterface
|
|||||||
* Determines if this handler should process the field based on submitted participant data.
|
* Determines if this handler should process the field based on submitted participant data.
|
||||||
*/
|
*/
|
||||||
public function shouldProcess(array $submittedData, int $participantIndex): bool;
|
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<string, mixed> $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<string, array<string, mixed>> 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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Service;
|
||||||
|
|
||||||
|
use App\Form\Model\BookingCreateDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for providing dynamic field state based on conditions.
|
||||||
|
*
|
||||||
|
* Field state providers determine the runtime state of form fields (readonly,
|
||||||
|
* disabled, hidden) based on participant data, other field values, and business
|
||||||
|
* logic. This interface enables centralized field state management with support
|
||||||
|
* for complex conditional logic.
|
||||||
|
*
|
||||||
|
* Key Responsibilities:
|
||||||
|
* - Calculate field states based on current booking and form context
|
||||||
|
* - Support multiple state types (readonly, disabled, hidden, etc.)
|
||||||
|
* - Handle field interdependencies and conditional logic
|
||||||
|
* - Provide efficient state calculation with caching support
|
||||||
|
* - Enable dynamic field state updates during form processing
|
||||||
|
*
|
||||||
|
* State Types:
|
||||||
|
* - readonly: Field is visible but not editable
|
||||||
|
* - disabled: Field is visible but interaction is disabled
|
||||||
|
* - hidden: Field is not displayed in the form
|
||||||
|
* - required: Field becomes mandatory based on conditions
|
||||||
|
* - attr: Custom HTML attributes for advanced styling/behavior
|
||||||
|
*/
|
||||||
|
interface FieldStateProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Calculates the dynamic state for a specified field.
|
||||||
|
*
|
||||||
|
* This method evaluates all configured conditions for a field and returns
|
||||||
|
* the appropriate state modifications that should be applied to the field.
|
||||||
|
* The returned array contains Symfony form field attributes that control
|
||||||
|
* field behavior and appearance.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* @param BookingCreateDto $bookingDto The current booking data for context
|
||||||
|
* @param int $participantIndex The index of the participant being evaluated
|
||||||
|
* @param array<string, mixed> $formData Current form data (may include partial submissions)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> 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<string, mixed> $formData Current form data for condition evaluation
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, mixed>> Field states indexed by field name
|
||||||
|
*/
|
||||||
|
public function getAllFieldStates(BookingCreateDto $bookingDto, int $participantIndex, array $formData = []): array;
|
||||||
|
}
|
||||||
@@ -5,36 +5,40 @@ declare(strict_types=1);
|
|||||||
namespace App\Form\Service;
|
namespace App\Form\Service;
|
||||||
|
|
||||||
use App\Form\Model\BookingCreateDto;
|
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
|
* This service acts as both a field option provider and field state provider
|
||||||
* system. It generates context-aware Symfony form field options based on the
|
* for the participant form system. It generates context-aware Symfony form
|
||||||
* overall booking context and individual participant data.
|
* field options and calculates dynamic field states based on conditional logic.
|
||||||
*
|
*
|
||||||
* Key Responsibilities:
|
* Key Responsibilities:
|
||||||
* - Manages field option providers for dynamic fields
|
* - Manages field option providers for dynamic fields
|
||||||
|
* - Calculates field states based on conditional logic
|
||||||
* - Provides context-aware field configurations
|
* - Provides context-aware field configurations
|
||||||
* - Handles interdependent field relationships
|
* - 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
|
* The provider uses a callable pattern for field options and a condition-based
|
||||||
* that generates the appropriate Symfony form options based on the current
|
* system for field states, enabling complex conditional field behavior.
|
||||||
* booking and participant state.
|
|
||||||
*/
|
*/
|
||||||
class ParticipantFieldOptionsProvider
|
class ParticipantFieldOptionsProvider implements FieldStateProviderInterface
|
||||||
{
|
{
|
||||||
/** @var array<string, callable> Field option providers indexed by field name */
|
/** @var array<string, callable> Field option providers indexed by field name */
|
||||||
private array $fieldOptionProviders = [];
|
private array $fieldOptionProviders = [];
|
||||||
|
|
||||||
|
/** @var array<string, array<string, FieldConditionInterface>> Field state conditions indexed by field name and state type */
|
||||||
|
private array $fieldStateConditions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the provider with required dependencies.
|
* Initializes the provider with required dependencies.
|
||||||
*
|
*
|
||||||
* The provider automatically registers all field option providers
|
* The provider automatically registers all field option providers and
|
||||||
* during construction to ensure they're available for form building.
|
* field state conditions during construction to ensure they're available
|
||||||
* This approach keeps all field configuration logic centralized and
|
* for form building. This approach keeps all field configuration logic
|
||||||
* makes it easy to add new dynamic fields.
|
* centralized and makes it easy to add new dynamic fields.
|
||||||
*
|
*
|
||||||
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
|
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
|
||||||
*/
|
*/
|
||||||
@@ -42,6 +46,7 @@ class ParticipantFieldOptionsProvider
|
|||||||
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
|
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
|
||||||
) {
|
) {
|
||||||
$this->registerFieldOptionProviders();
|
$this->registerFieldOptionProviders();
|
||||||
|
$this->registerFieldStateConditions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,8 +144,160 @@ class ParticipantFieldOptionsProvider
|
|||||||
// 'Vegetarian' => 'vegetarian',
|
// 'Vegetarian' => 'vegetarian',
|
||||||
// 'Vegan' => 'vegan',
|
// 'Vegan' => 'vegan',
|
||||||
// ],
|
// ],
|
||||||
// // Could depend on room assignment or other factors
|
|
||||||
// 'disabled' => !$this->hasMealOptions($bookingDto, $participantIndex),
|
|
||||||
// ];
|
// ];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 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<string, mixed> $formData Current form data for condition evaluation
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> 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<string, mixed> $formData Current form data for condition evaluation
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, mixed>> 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')
|
||||||
|
// ),
|
||||||
|
// ];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user