wip: major refactoring
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Abstract;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\FieldOptionsProviderInterface;
|
||||
|
||||
/**
|
||||
* Abstract base class for field options providers.
|
||||
*
|
||||
* This class contains common field options generation logic shared between
|
||||
* different field options provider implementations. It provides the core
|
||||
* functionality for managing field option providers and generating dynamic
|
||||
* field configurations.
|
||||
*/
|
||||
abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInterface
|
||||
{
|
||||
/** @var array<string, callable> Field option providers indexed by field name */
|
||||
protected array $fieldOptionProviders = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->registerFieldOptionProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves form field options for a specified dynamic field.
|
||||
*
|
||||
* This method looks up the appropriate option provider for the field
|
||||
* and executes it with the current booking and participant context to
|
||||
* generate dynamic field options.
|
||||
*
|
||||
* The returned array contains Symfony form field options that will be
|
||||
* used when building the form field. These options are merged with any
|
||||
* static options defined in the form type.
|
||||
*
|
||||
* @param string $fieldName The name of the field to configure
|
||||
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
|
||||
* @param int $participantIndex The index of the participant being configured
|
||||
*
|
||||
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
|
||||
*/
|
||||
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array
|
||||
{
|
||||
// Check if we have a provider for this field
|
||||
if (!isset($this->fieldOptionProviders[$fieldName])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Execute the provider with current context to generate dynamic options
|
||||
return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a field has option provider support.
|
||||
*
|
||||
* This method allows form builders to determine if a field can be
|
||||
* dynamically configured by this service. It's useful for deciding
|
||||
* whether to use static field options or dynamic configuration.
|
||||
*
|
||||
* @param string $fieldName The name of the field to check
|
||||
*
|
||||
* @return bool True if the field has registered option providers, false otherwise
|
||||
*/
|
||||
public function hasFieldOptions(string $fieldName): bool
|
||||
{
|
||||
return isset($this->fieldOptionProviders[$fieldName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers field option providers during service initialization.
|
||||
*
|
||||
* This method must be implemented by concrete classes to define their
|
||||
* specific field option providers. Each provider is a callable that
|
||||
* receives the booking DTO and participant index and returns appropriate
|
||||
* Symfony form field options.
|
||||
*
|
||||
* Example implementation:
|
||||
*
|
||||
* protected function registerFieldOptionProviders(): void
|
||||
* {
|
||||
* $this->fieldOptionProviders['fieldName'] = fn($bookingDto, $participantIndex) => [
|
||||
* 'label' => 'Field Label',
|
||||
* 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex),
|
||||
* ];
|
||||
* }
|
||||
*/
|
||||
abstract protected function registerFieldOptionProviders(): void;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Abstract;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
use App\Form\Service\Contract\FieldStateProviderInterface;
|
||||
use App\Form\Service\Trait\FormTraversalTrait;
|
||||
|
||||
/**
|
||||
* Abstract base class for field state providers.
|
||||
*
|
||||
* This class contains common field state evaluation logic shared between
|
||||
* different field state provider implementations. It provides the core
|
||||
* functionality for evaluating field conditions and applying state modifications.
|
||||
*/
|
||||
abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
|
||||
{
|
||||
use FormTraversalTrait;
|
||||
|
||||
/** @var array<string, array<string, FieldConditionInterface>> Field state conditions indexed by field name and state type */
|
||||
protected array $fieldStateConditions = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->registerFieldStateConditions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 BookingDtoInterface $bookingDto The current booking data for context (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data for condition evaluation
|
||||
*
|
||||
* @return array<string, mixed> Symfony form field options for state modifications
|
||||
*/
|
||||
public function getFieldState(string $fieldName, BookingDtoInterface $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 BookingDtoInterface $bookingDto The current booking data for context (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data for condition evaluation
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Field states indexed by field name
|
||||
*/
|
||||
public function getAllFieldStates(BookingDtoInterface $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 must be implemented by concrete classes to define their
|
||||
* specific field state conditions.
|
||||
*/
|
||||
abstract protected function registerFieldStateConditions(): void;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Abstract;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
|
||||
|
||||
/**
|
||||
* Abstract base class providing common functionality for participant field handlers.
|
||||
*
|
||||
* This class implements common patterns used across participant field handlers,
|
||||
* including default implementations for field state modification methods.
|
||||
* Reduces boilerplate code in concrete implementations.
|
||||
*/
|
||||
abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandlerInterface
|
||||
{
|
||||
/**
|
||||
* Returns the field names this handler depends on.
|
||||
*
|
||||
* Default implementation returns an empty array, meaning no dependencies.
|
||||
* Override this method in concrete handlers that depend on other fields
|
||||
* being processed first (e.g., a meal preference handler might depend on
|
||||
* the room assignment being processed first).
|
||||
*
|
||||
* @return string[] Array of field names that must be processed before this handler
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if this handler should process the field based on submitted data.
|
||||
*
|
||||
* Default implementation checks if the field exists in the submitted data.
|
||||
* Override this method for more complex processing conditions (e.g., only
|
||||
* process if certain other conditions are met).
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The submitted participant form data
|
||||
* @param int $participantIndex The index of the participant being processed
|
||||
*
|
||||
* @return bool True if the handler should process this field, false otherwise
|
||||
*/
|
||||
public function shouldProcess(array $submittedData, int $participantIndex): bool
|
||||
{
|
||||
return isset($submittedData[$this->getFieldName()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely retrieves a participant object from the booking DTO.
|
||||
*
|
||||
* This helper method provides safe access to participant data by checking
|
||||
* if the participant exists at the given index. Returns null if the
|
||||
* participant doesn't exist, preventing array access errors.
|
||||
*
|
||||
* @param BookingDtoInterface $bookingDto The booking DTO containing participants (create or edit)
|
||||
* @param int $participantIndex The index of the participant to retrieve
|
||||
*
|
||||
* @return object|null The participant object, or null if not found
|
||||
*/
|
||||
protected function getParticipant(BookingDtoInterface $bookingDto, int $participantIndex): ?object
|
||||
{
|
||||
$participants = $bookingDto->getParticipants();
|
||||
return $participants[$participantIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts a field value from submitted participant data.
|
||||
*
|
||||
* This helper method provides safe access to form field values using
|
||||
* the null coalescing operator. Useful for extracting field values
|
||||
* without worrying about undefined array keys.
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The submitted participant form data
|
||||
* @param string $fieldName The name of the field to retrieve
|
||||
* @param mixed $default The default value to return if field is not set
|
||||
*
|
||||
* @return mixed The field value, or the default value if not found
|
||||
*/
|
||||
protected function getFieldValue(array $submittedData, string $fieldName, mixed $default = null): mixed
|
||||
{
|
||||
return $submittedData[$fieldName] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes empty string values to null.
|
||||
*
|
||||
* Form inputs often submit empty strings for unselected/empty fields.
|
||||
* This helper converts those empty strings to null values, which is
|
||||
* typically more appropriate for database storage and business logic.
|
||||
*
|
||||
* @param mixed $value The value to normalize
|
||||
*
|
||||
* @return mixed The normalized value (null if empty, original value otherwise)
|
||||
*/
|
||||
protected function normalizeEmptyValue(mixed $value): mixed
|
||||
{
|
||||
return empty($value) ? null : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts and normalizes string values to integers.
|
||||
*
|
||||
* Form inputs submit all values as strings. This helper safely converts
|
||||
* string values to integers while handling empty strings and non-numeric
|
||||
* values gracefully by returning null.
|
||||
*
|
||||
* @param mixed $value The value to convert to integer
|
||||
*
|
||||
* @return int|null The integer value, or null if empty/non-numeric
|
||||
*/
|
||||
protected function normalizeIntValue(mixed $value): ?int
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 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 BookingDtoInterface $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, BookingDtoInterface $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,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* 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 BookingDtoInterface $bookingDto The current booking data (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (unused for age conditions)
|
||||
*
|
||||
* @return bool True if the participant's age meets the criteria, false otherwise
|
||||
*/
|
||||
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
|
||||
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,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* Condition that checks if the participant is the applicant.
|
||||
*
|
||||
* This is used to apply field state logic (e.g., enable/disable fields)
|
||||
* specifically for the applicant participant in the booking.
|
||||
*
|
||||
* The default logic assumes the applicant is the first participant (index 0),
|
||||
* but this can be adjusted if your domain uses a different rule or property.
|
||||
*/
|
||||
class ApplicantCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* Evaluates if the participant is the applicant.
|
||||
*
|
||||
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (unused)
|
||||
*
|
||||
* @return bool True if the participant is the applicant, false otherwise
|
||||
*/
|
||||
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
// Default: applicant is the first participant (index 0)
|
||||
return $participantIndex === 0;
|
||||
}
|
||||
|
||||
public function getDependentFields(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Checks if the participant is the applicant (index 0)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* 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 BookingDtoInterface $bookingDto The current booking data (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data for condition evaluation
|
||||
*
|
||||
* @return bool True if the composite condition is met, false otherwise
|
||||
*/
|
||||
public function evaluate(BookingDtoInterface $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(BookingDtoInterface $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(BookingDtoInterface $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(BookingDtoInterface $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,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* 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 BookingDtoInterface $bookingDto The current booking data (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data for condition evaluation
|
||||
*
|
||||
* @return bool True if the field value meets the condition criteria, false otherwise
|
||||
*/
|
||||
public function evaluate(BookingDtoInterface $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, BookingDtoInterface $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->getParticipant($participantIndex);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Condition;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\FieldConditionInterface;
|
||||
|
||||
/**
|
||||
* Condition that checks if a participant's personal data is mutable in the edit flow.
|
||||
*
|
||||
* This is used to set fields as readonly or disabled if the booking or participant
|
||||
* is not allowed to be modified (e.g., after a certain workflow step or status).
|
||||
*
|
||||
* The logic assumes the BookingDtoInterface or its participant DTOs expose a
|
||||
* 'personalDataMutable' property or method. Adjust as needed for your domain.
|
||||
*/
|
||||
class MutabilityCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* Evaluates if the participant's personal data is mutable.
|
||||
*
|
||||
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (unused)
|
||||
*
|
||||
* @return bool True if the participant's data is mutable, false otherwise
|
||||
*/
|
||||
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
if (null === $participant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $participant->mutable;
|
||||
}
|
||||
|
||||
public function getDependentFields(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Checks if the participant\'s personal data is mutable (edit flow)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Contract;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
|
||||
/**
|
||||
* 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 BookingDtoInterface $bookingDto The current booking data (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data (may include partial submissions)
|
||||
*
|
||||
* @return bool True if the condition is met, false otherwise
|
||||
*/
|
||||
public function evaluate(BookingDtoInterface $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,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Contract;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
|
||||
/**
|
||||
* Interface for providing dynamic field options based on context.
|
||||
*
|
||||
* Field option providers generate context-aware Symfony form field options
|
||||
* for dynamic fields that require their configuration to be calculated based
|
||||
* on the current booking state, participant data, and business logic.
|
||||
*
|
||||
* Key Responsibilities:
|
||||
* - Generate dynamic field options based on booking context
|
||||
* - Support context-aware field configurations
|
||||
* - Enable extensible field option generation
|
||||
* - Provide lazy evaluation of field options
|
||||
*
|
||||
* Field options typically include:
|
||||
* - 'label': The field label text
|
||||
* - 'placeholder': Placeholder text for input fields
|
||||
* - 'choices': Available choices for choice fields
|
||||
* - 'choice_loader': Dynamic choice loader for complex choices
|
||||
* - 'disabled': Whether the field should be disabled
|
||||
* - 'required': Whether the field is required
|
||||
* - 'attr': HTML attributes for the field
|
||||
*/
|
||||
interface FieldOptionsProviderInterface
|
||||
{
|
||||
/**
|
||||
* Retrieves form field options for a specified dynamic field.
|
||||
*
|
||||
* This method looks up the appropriate option provider for the field
|
||||
* and executes it with the current booking and participant context to
|
||||
* generate dynamic field options.
|
||||
*
|
||||
* The returned array contains Symfony form field options that will be
|
||||
* used when building the form field. These options are merged with any
|
||||
* static options defined in the form type.
|
||||
*
|
||||
* @param string $fieldName The name of the field to configure
|
||||
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
|
||||
* @param int $participantIndex The index of the participant being configured
|
||||
*
|
||||
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
|
||||
*/
|
||||
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array;
|
||||
|
||||
/**
|
||||
* Checks whether a field has option provider support.
|
||||
*
|
||||
* This method allows form builders to determine if a field can be
|
||||
* dynamically configured by this service. It's useful for deciding
|
||||
* whether to use static field options or dynamic configuration.
|
||||
*
|
||||
* @param string $fieldName The name of the field to check
|
||||
*
|
||||
* @return bool True if the field has registered option providers, false otherwise
|
||||
*/
|
||||
public function hasFieldOptions(string $fieldName): bool;
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service;
|
||||
namespace App\Form\Service\Contract;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service\Contract;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
{
|
||||
/**
|
||||
* Returns the field name this handler processes.
|
||||
*/
|
||||
public function getFieldName(): string;
|
||||
|
||||
/**
|
||||
* Returns field names this handler depends on.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getDependencies(): array;
|
||||
|
||||
/**
|
||||
* Processes the participant field data from submitted form data and updates the DTO.
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The submitted participant form data
|
||||
* @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit)
|
||||
* @param int $participantIndex The participant index being processed
|
||||
*/
|
||||
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void;
|
||||
|
||||
/**
|
||||
* 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<string, mixed> $submittedData The submitted participant form data
|
||||
* @param BookingDtoInterface $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, BookingDtoInterface $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,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service;
|
||||
|
||||
use App\Form\Service\Abstract\AbstractFieldStateProvider;
|
||||
|
||||
/**
|
||||
* Field state provider for the booking create workflow.
|
||||
*
|
||||
* This service calculates dynamic field states (readonly, disabled, etc.)
|
||||
* for participant fields in the create flow. It extends the common field
|
||||
* state functionality provided by AbstractFieldStateProvider.
|
||||
*
|
||||
* Currently, no specific field state conditions are implemented for the
|
||||
* create workflow, but the infrastructure is ready for future additions.
|
||||
*/
|
||||
class CreateFieldStateProvider extends AbstractFieldStateProvider
|
||||
{
|
||||
/**
|
||||
* Registers field state conditions for the create workflow.
|
||||
*
|
||||
* This method defines the conditional logic for field states in the
|
||||
* booking creation process. 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
|
||||
*/
|
||||
protected 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')
|
||||
// ),
|
||||
// ];
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\ParticipantFieldHandler\Condition\ApplicantCondition;
|
||||
use App\Form\ParticipantFieldHandler\Condition\CompositeCondition;
|
||||
use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface;
|
||||
use App\Form\ParticipantFieldHandler\Condition\MutabilityCondition;
|
||||
use App\Form\Service\Condition\ApplicantCondition;
|
||||
use App\Form\Service\Condition\CompositeCondition;
|
||||
use App\Form\Service\Condition\MutabilityCondition;
|
||||
use App\Form\Service\Abstract\AbstractFieldStateProvider;
|
||||
|
||||
/**
|
||||
* Field state provider for the booking edit workflow.
|
||||
@@ -16,27 +15,19 @@ use App\Form\ParticipantFieldHandler\Condition\MutabilityCondition;
|
||||
* This service calculates dynamic field states (readonly, disabled, etc.)
|
||||
* for participant fields in the edit flow, using edit-specific conditions.
|
||||
*
|
||||
* It is designed to be extensible and composable, allowing reuse of
|
||||
* existing condition classes and easy registration of new logic.
|
||||
* It extends the common field state functionality provided by
|
||||
* AbstractFieldStateProvider and adds edit-specific field state logic.
|
||||
*/
|
||||
class EditFieldStateProvider implements FieldStateProviderInterface
|
||||
class EditFieldStateProvider extends AbstractFieldStateProvider
|
||||
{
|
||||
use FormTraversalTrait;
|
||||
|
||||
/** @var array<string, array<string, FieldConditionInterface>> */
|
||||
private array $fieldStateConditions = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->registerFieldStateConditions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers field state conditions for the edit workflow.
|
||||
*
|
||||
* Add or modify conditions as needed for your domain.
|
||||
* This method defines the conditional logic for field states in the
|
||||
* booking edit process. It makes personal data fields readonly when
|
||||
* the participant is the applicant or when the field is not mutable.
|
||||
*/
|
||||
private function registerFieldStateConditions(): void
|
||||
protected function registerFieldStateConditions(): void
|
||||
{
|
||||
// Make all personal data fields readonly if not mutable OR if applicant
|
||||
$personalDataFields = [
|
||||
@@ -56,75 +47,5 @@ class EditFieldStateProvider implements FieldStateProviderInterface
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Example: Only applicant can edit email
|
||||
$this->fieldStateConditions['email']['readonly'] = new ApplicantCondition();
|
||||
}
|
||||
|
||||
public function getFieldState(string $fieldName, BookingDtoInterface $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;
|
||||
}
|
||||
|
||||
public function hasStateConditions(string $fieldName): bool
|
||||
{
|
||||
return isset($this->fieldStateConditions[$fieldName]) && !empty($this->fieldStateConditions[$fieldName]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function getAllFieldStates(BookingDtoInterface $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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service;
|
||||
namespace App\Form\Service\Factory;
|
||||
|
||||
use App\Form\ChoiceLoader\ParticipantRoomChoiceLoader;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
|
||||
|
||||
/**
|
||||
* Handles processing of the assignedRoomId field for booking participants.
|
||||
*
|
||||
* This handler manages room assignments for participants in the booking creation
|
||||
* process. It processes the assignedRoomId field from form submissions and updates
|
||||
* the participant DTO with the selected room. The handler properly handles empty
|
||||
* selections (converting them to null) and validates numeric room IDs.
|
||||
*
|
||||
* Field Processing:
|
||||
* - Extracts room ID from submitted form data
|
||||
* - Converts empty strings to null (unselected choice)
|
||||
* - Normalizes string values to integers
|
||||
* - Updates the participant's assignedRoomId property
|
||||
*
|
||||
* Dependencies: None (this is a base field that other handlers may depend on)
|
||||
*/
|
||||
class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandler
|
||||
{
|
||||
/**
|
||||
* Returns the form field name this handler processes.
|
||||
*
|
||||
* This handler is responsible for the 'assignedRoomId' field, which contains
|
||||
* the selected room ID for each participant in the booking form.
|
||||
*
|
||||
* @return string The field name 'assignedRoomId'
|
||||
*/
|
||||
public function getFieldName(): string
|
||||
{
|
||||
return 'assignedRoomId';
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the assignedRoomId field for a specific participant.
|
||||
*
|
||||
* This method extracts the room assignment from the submitted form data and
|
||||
* updates the corresponding participant in the booking DTO. It handles the
|
||||
* common form processing pattern where empty selections are submitted as
|
||||
* empty strings but should be stored as null values.
|
||||
*
|
||||
* Processing steps:
|
||||
* 1. Safely retrieves the participant object from the DTO
|
||||
* 2. Extracts the assignedRoomId value from submitted data
|
||||
* 3. Normalizes the value (empty string → null, numeric string → integer)
|
||||
* 4. Updates the participant's assignedRoomId property
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The submitted participant form data
|
||||
* @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit)
|
||||
* @param int $participantIndex The index of the participant being processed
|
||||
*/
|
||||
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
|
||||
{
|
||||
// Safely get the participant object, returning early if not found
|
||||
$participant = $this->getParticipant($bookingDto, $participantIndex);
|
||||
|
||||
if (null === $participant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the room ID from form data (defaults to null if not present)
|
||||
$roomId = $this->getFieldValue($submittedData, $this->getFieldName());
|
||||
|
||||
// Convert form string to integer, handling empty selections as null
|
||||
$participant->assignedRoomId = $this->normalizeIntValue($roomId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
|
||||
|
||||
/**
|
||||
* Registry for managing and executing participant field handlers in dependency order.
|
||||
*
|
||||
* This class ensures that participant field handlers are executed in the correct order
|
||||
* based on their dependencies, preventing issues where a field depends on
|
||||
* another field that hasn't been processed yet.
|
||||
*/
|
||||
class ParticipantFieldHandlerRegistry
|
||||
{
|
||||
/** @var ParticipantFieldHandlerInterface[] Registered handlers indexed by field name */
|
||||
private array $handlers = [];
|
||||
|
||||
/** @var string[]|null Cached array of handler names sorted by dependency order */
|
||||
private ?array $sortedHandlers = null;
|
||||
|
||||
/**
|
||||
* Initializes the registry with a hybrid array of handlers.
|
||||
*
|
||||
* This constructor supports both simple handlers (passed as class names) and
|
||||
* complex handlers (passed as instantiated service objects). Simple handlers
|
||||
* with no dependencies can be passed as strings and will be instantiated
|
||||
* automatically. Complex handlers with dependencies should be passed as
|
||||
* already-instantiated objects via dependency injection.
|
||||
*
|
||||
* @param array<ParticipantFieldHandlerInterface|string> $handlers Array of handler instances or class names
|
||||
*
|
||||
* @throws \Exception If a class name cannot be instantiated
|
||||
*/
|
||||
public function __construct(array $handlers)
|
||||
{
|
||||
foreach ($handlers as $handler) {
|
||||
if (is_string($handler)) {
|
||||
// It's a class name - instantiate it (for simple handlers without dependencies)
|
||||
$handler = new $handler();
|
||||
}
|
||||
// If it's already an object (service), use as-is (for complex handlers with dependencies)
|
||||
$this->addHandler($handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a participant field handler with the registry.
|
||||
*
|
||||
* Handlers are indexed by their field name to ensure uniqueness and enable
|
||||
* fast lookups. Adding a handler invalidates the dependency sort cache,
|
||||
* forcing a re-sort on the next processing request.
|
||||
*
|
||||
* @param ParticipantFieldHandlerInterface $handler The handler to register
|
||||
*/
|
||||
public function addHandler(ParticipantFieldHandlerInterface $handler): void
|
||||
{
|
||||
$this->handlers[$handler->getFieldName()] = $handler;
|
||||
$this->sortedHandlers = null; // Reset cache to force re-sorting with new handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes all participant fields from submitted form data using registered handlers.
|
||||
*
|
||||
* This is the main entry point for field processing. It iterates through all
|
||||
* participants in the submitted data and applies the appropriate handlers in
|
||||
* dependency order. Each handler determines whether it should process the
|
||||
* participant's data and updates the booking DTO accordingly.
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The submitted form data containing participants array
|
||||
* @param BookingDtoInterface $bookingDto The booking DTO to update with processed field values (create or edit)
|
||||
*/
|
||||
public function processFields(array $submittedData, BookingDtoInterface $bookingDto): void
|
||||
{
|
||||
// Early return if no participant data exists in submission
|
||||
if (!isset($submittedData['participants']) || !is_array($submittedData['participants'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get handlers sorted by dependency order (uses cache if available)
|
||||
$sortedHandlerNames = $this->getSortedHandlers();
|
||||
|
||||
// Process each participant's data
|
||||
foreach ($submittedData['participants'] as $participantIndex => $participantData) {
|
||||
// Skip invalid participant data
|
||||
if (!is_array($participantData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply each handler in dependency order
|
||||
foreach ($sortedHandlerNames as $handlerName) {
|
||||
$handler = $this->handlers[$handlerName];
|
||||
|
||||
// Let each handler decide if it should process this participant's data
|
||||
if ($handler->shouldProcess($participantData, (int) $participantIndex)) {
|
||||
$handler->processField($participantData, $bookingDto, (int) $participantIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns handler names sorted by dependency order using cached results when possible.
|
||||
*
|
||||
* This method implements lazy loading with caching for performance. The dependency
|
||||
* sort is only performed once and the result is cached until handlers are added
|
||||
* or modified.
|
||||
*
|
||||
* @return string[] Array of handler field names in dependency execution order
|
||||
*/
|
||||
private function getSortedHandlers(): array
|
||||
{
|
||||
// Return cached result if available
|
||||
if (null !== $this->sortedHandlers) {
|
||||
return $this->sortedHandlers;
|
||||
}
|
||||
|
||||
// Perform topological sort and cache the result
|
||||
$this->sortedHandlers = $this->topologicalSort();
|
||||
|
||||
return $this->sortedHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs topological sort to determine safe handler execution order.
|
||||
*
|
||||
* This method uses Kahn's algorithm to sort handlers based on their declared
|
||||
* dependencies. It ensures that no handler is executed before its dependencies
|
||||
* have been processed, preventing data consistency issues.
|
||||
*
|
||||
* The algorithm works by:
|
||||
* 1. Building a dependency graph of handlers
|
||||
* 2. Finding handlers with no dependencies (in-degree = 0)
|
||||
* 3. Iteratively removing handlers and updating dependencies
|
||||
* 4. Detecting circular dependencies (deadlock prevention)
|
||||
*
|
||||
* @return string[] Array of handler field names in safe execution order
|
||||
*
|
||||
* @throws \InvalidArgumentException When a handler depends on a non-existent handler
|
||||
* @throws \InvalidArgumentException When circular dependencies are detected
|
||||
*/
|
||||
private function topologicalSort(): array
|
||||
{
|
||||
$inDegree = []; // Count of dependencies for each handler
|
||||
$graph = []; // Adjacency list of handler dependencies
|
||||
$handlerNames = array_keys($this->handlers);
|
||||
|
||||
// Initialize all handlers with zero dependencies
|
||||
foreach ($handlerNames as $handlerName) {
|
||||
$inDegree[$handlerName] = 0;
|
||||
$graph[$handlerName] = [];
|
||||
}
|
||||
|
||||
// Build dependency graph by examining each handler's dependencies
|
||||
foreach ($this->handlers as $handlerName => $handler) {
|
||||
foreach ($handler->getDependencies() as $dependency) {
|
||||
// Validate that the dependency exists
|
||||
if (!isset($this->handlers[$dependency])) {
|
||||
throw new \InvalidArgumentException(sprintf('Handler "%s" depends on unknown handler "%s"', $handlerName, $dependency));
|
||||
}
|
||||
|
||||
// Add edge from dependency to dependent handler
|
||||
$graph[$dependency][] = $handlerName;
|
||||
++$inDegree[$handlerName];
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's topological sort algorithm
|
||||
$queue = []; // Handlers ready to be processed (no remaining dependencies)
|
||||
$result = []; // Final sorted order
|
||||
|
||||
// Start with handlers that have no dependencies
|
||||
foreach ($inDegree as $handlerName => $degree) {
|
||||
if (0 === $degree) {
|
||||
$queue[] = $handlerName;
|
||||
}
|
||||
}
|
||||
|
||||
// Process handlers in dependency order
|
||||
while (!empty($queue)) {
|
||||
$current = array_shift($queue);
|
||||
$result[] = $current;
|
||||
|
||||
// Remove this handler's dependencies from dependent handlers
|
||||
foreach ($graph[$current] as $dependent) {
|
||||
--$inDegree[$dependent];
|
||||
// If dependent now has no remaining dependencies, add to queue
|
||||
if (0 === $inDegree[$dependent]) {
|
||||
$queue[] = $dependent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies (if not all handlers were processed)
|
||||
if (count($result) !== count($handlerNames)) {
|
||||
throw new \InvalidArgumentException('Circular dependency detected in participant field handlers');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -6,98 +6,42 @@ namespace App\Form\Service;
|
||||
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use App\Form\ParticipantFieldHandler\Condition\FieldConditionInterface;
|
||||
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
|
||||
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
|
||||
|
||||
/**
|
||||
* Provides dynamic field options and state for participant form fields.
|
||||
* Provides dynamic field options for participant form fields.
|
||||
*
|
||||
* 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.
|
||||
* This service generates context-aware Symfony form field options for
|
||||
* dynamic fields in the participant form system. It handles fields that
|
||||
* require options to be calculated based on the current booking context,
|
||||
* participant data, and business 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 and state generation
|
||||
* - Supports extensible field option generation
|
||||
*
|
||||
* The provider uses a callable pattern for field options and a condition-based
|
||||
* system for field states, enabling complex conditional field behavior.
|
||||
* The provider uses a callable pattern for field options, enabling
|
||||
* lazy evaluation and complex conditional field behavior.
|
||||
*/
|
||||
class ParticipantFieldOptionsProvider implements FieldStateProviderInterface
|
||||
class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
{
|
||||
use FormTraversalTrait;
|
||||
|
||||
/** @var array<string, callable> Field option providers indexed by field name */
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
|
||||
) {
|
||||
$this->registerFieldOptionProviders();
|
||||
$this->registerFieldStateConditions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves form field options for a specified dynamic field.
|
||||
*
|
||||
* This is the main entry point for getting field configurations. It looks up
|
||||
* the appropriate option provider for the field and executes it with the
|
||||
* current booking and participant context to generate dynamic field options.
|
||||
*
|
||||
* The returned array contains Symfony form field options such as:
|
||||
* - 'label' - The field label
|
||||
* - 'placeholder' - Placeholder text
|
||||
* - 'choices' - Available choices for choice fields
|
||||
* - 'choice_loader' - Dynamic choice loader for complex choices
|
||||
* - 'disabled' - Whether the field should be disabled
|
||||
* - 'required' - Whether the field is required
|
||||
*
|
||||
* @param string $fieldName The name of the field to configure
|
||||
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
|
||||
* @param int $participantIndex The index of the participant being configured
|
||||
*
|
||||
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
|
||||
*/
|
||||
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array
|
||||
{
|
||||
// Check if we have a provider for this field
|
||||
if (!isset($this->fieldOptionProviders[$fieldName])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Execute the provider with current context to generate dynamic options
|
||||
return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a field has option provider support.
|
||||
*
|
||||
* This method allows form builders to determine if a field can be
|
||||
* dynamically configured by this service. It's useful for deciding
|
||||
* whether to use static field options or dynamic configuration.
|
||||
*
|
||||
* @param string $fieldName The name of the field to check
|
||||
*
|
||||
* @return bool True if the field has registered option providers, false otherwise
|
||||
*/
|
||||
public function hasFieldOptions(string $fieldName): bool
|
||||
{
|
||||
return isset($this->fieldOptionProviders[$fieldName]);
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +65,7 @@ class ParticipantFieldOptionsProvider implements FieldStateProviderInterface
|
||||
* - Easy to test individual field logic
|
||||
* - Supports complex interdependencies
|
||||
*/
|
||||
private function registerFieldOptionProviders(): void
|
||||
protected function registerFieldOptionProviders(): void
|
||||
{
|
||||
// Room assignment field provider (only available for create workflow)
|
||||
$this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
|
||||
@@ -151,158 +95,4 @@ class ParticipantFieldOptionsProvider implements FieldStateProviderInterface
|
||||
// ],
|
||||
// ];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 BookingDtoInterface $bookingDto The current booking data for context (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data for condition evaluation
|
||||
*
|
||||
* @return array<string, mixed> Symfony form field options for state modifications
|
||||
*/
|
||||
public function getFieldState(string $fieldName, BookingDtoInterface $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 BookingDtoInterface $bookingDto The current booking data for context (create or edit)
|
||||
* @param int $participantIndex The index of the participant being evaluated
|
||||
* @param array<string, mixed> $formData Current form data for condition evaluation
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Field states indexed by field name
|
||||
*/
|
||||
public function getAllFieldStates(BookingDtoInterface $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')
|
||||
// ),
|
||||
// ];
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Service;
|
||||
namespace App\Form\Service\Trait;
|
||||
|
||||
use App\Form\Model\BookingDtoInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -39,4 +39,4 @@ trait FormTraversalTrait
|
||||
|
||||
return $data instanceof BookingDtoInterface ? $data : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user