Files
myep/docs/FIELD_STATE_SYSTEM.md
T

13 KiB

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 organized in a clean namespace structure:

Core Interfaces (src/Form/Service/Contract/)

  1. FieldStateProviderInterface - Defines the contract for field state management
  2. FieldOptionsProviderInterface - Defines the contract for field option generation

Abstract Base Classes (src/Form/Service/Abstract/)

  1. AbstractFieldStateProvider - Common field state functionality
  2. AbstractFieldOptionsProvider - Common field option functionality

Concrete Implementations (src/Form/Service/)

  1. CreateFieldStateProvider - Field states for booking creation workflow
  2. EditFieldStateProvider - Field states for booking edit workflow
  3. ParticipantFieldOptionsProvider - Dynamic field option generation

Condition System (src/Form/Service/Condition/)

  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

Form Integration

  1. BookingCreateParticipantType - Uses CreateFieldStateProvider
  2. BookingEditParticipantType - Uses EditFieldStateProvider

Usage Examples

Basic Age-Based Condition

// Make a field readonly for participants under 18
$this->fieldStateConditions['serviceSelection'] = [
    'readonly' => new AgeRangeCondition(null, 17),
];

Field Dependency Condition

// Disable field if room is not assigned
$this->fieldStateConditions['mealPreference'] = [
    'disabled' => FieldValueCondition::empty('assignedRoomId'),
];

Complex Composite Condition

// 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

// 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

For Create Workflow

To register field state conditions for the booking creation workflow, add them to the registerFieldStateConditions() method in CreateFieldStateProvider:

// src/Form/Service/CreateFieldStateProvider.php
protected 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'),
    ];
}

For Edit Workflow

To register field state conditions for the booking edit workflow, add them to the registerFieldStateConditions() method in EditFieldStateProvider:

// src/Form/Service/EditFieldStateProvider.php
protected function registerFieldStateConditions(): void
{
    // Make personal data readonly for applicants or non-mutable fields
    $personalDataFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile'];
    foreach ($personalDataFields as $field) {
        $this->fieldStateConditions[$field] = [
            'readonly' => CompositeCondition::or(
                new ApplicantCondition(),
                new MutabilityCondition()
            ),
        ];
    }
}

Adding Field Options

To register dynamic field options, add them to the registerFieldOptionProviders() method in ParticipantFieldOptionsProvider:

// src/Form/Service/ParticipantFieldOptionsProvider.php
protected function registerFieldOptionProviders(): void
{
    $this->fieldOptionProviders['newField'] = fn(BookingDtoInterface $bookingDto, int $participantIndex) => [
        'label' => 'New Field Label',
        'choices' => $this->generateChoicesFor($bookingDto, $participantIndex),
    ];
}

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

Service Field HTMX Integration Improvements

Critical Fix for Expanded Choice Fields: Service fields (board, skipass, courses, etc.) use expanded choice types (checkboxes/radios) which required special HTMX trigger handling:

Issue Resolved: HTMX attributes were originally placed on container elements (row_attr) which don't capture individual input changes for expanded choice fields.

Solution Implemented: HTMX triggers must be placed directly on each individual checkbox/radio input via the attr configuration:

// Correct implementation for service fields
'attr' => [
    'hx-post' => $this->urlGenerator->generate('booking_create_step_2_refresh'),
    'hx-target' => '#booking-summary', 
    'hx-trigger' => 'change',
],

// Incorrect approach (doesn't work for expanded choices)
// 'row_attr' => ['hx-post' => '...']

Benefits of the Fix:

  • Real-time updates now work reliably for all service field selections
  • Service pricing calculations update immediately when selections change
  • Booking summary reflects service changes without manual form submission
  • Consistent HTMX behavior across all form field types

Affected Field Types:

  • Board/meal selection fields
  • Ski pass selection fields
  • Course selection fields
  • Additional services selection fields
  • Rental equipment selection fields

This improvement ensures that the conditional field state system works seamlessly with HTMX for all field types, providing users with immediate feedback on their selections.

Transportation Services HTMX Integration

Transportation services benefit significantly from the enhanced HTMX integration:

Service-Specific Triggers: Transportation, pickup, and parking fields use individual input-level triggers for immediate state updates:

// Transportation field HTMX configuration
'attr' => [
    'hx-post' => $this->urlGenerator->generate('booking_create_step_2_refresh'),
    'hx-target' => '#booking-summary',
    'hx-trigger' => 'change',
],

Conditional Field Updates: When users change transportation type:

  • Pickup fields automatically show/hide based on bus vs. car selection
  • Parking field visibility toggles for car transport
  • Pricing updates reflect transportation service costs
  • Booking summary updates in real-time

Mutual Exclusivity: The system ensures logical field relationships:

  • Car transport → Parking field visible, pickup fields hidden
  • Bus transport → Pickup fields visible, parking field hidden
  • No transport selected → Both pickup and parking hidden

Extending the System

Custom Conditions

Create new condition classes implementing FieldConditionInterface:

// src/Form/Service/Condition/CustomBusinessRuleCondition.php
use App\Form\Service\Contract\FieldConditionInterface;

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';
    }
}

Custom Field Options Providers

To create more complex field option logic, extend AbstractFieldOptionsProvider:

// src/Form/Service/CustomFieldOptionsProvider.php
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;

class CustomFieldOptionsProvider extends AbstractFieldOptionsProvider
{
    protected function registerFieldOptionProviders(): void
    {
        $this->fieldOptionProviders['customField'] = fn(BookingDtoInterface $bookingDto, int $participantIndex) => [
            'label' => 'Custom Field',
            'choices' => $this->generateCustomChoices($bookingDto, $participantIndex),
        ];
    }
    
    private function generateCustomChoices(BookingDtoInterface $bookingDto, int $participantIndex): array
    {
        // Custom choice generation logic
        return [];
    }
}

Current Implementation Examples

Body Dimensions and Rental Insurance Conditional Fields

The current system implements sophisticated conditional field visibility for body dimensions and rental insurance:

// CreateFieldStateProvider.php
protected function registerFieldStateConditions(): void
{
    $rentalCondition = new RentalSelectionCondition();

    // Hide body dimensions section unless rental services are selected
    $this->fieldStateConditions['bodyDimensions'] = [
        'hidden' => CompositeCondition::not($rentalCondition),
    ];

    // Hide rental insurance unless rental services are selected  
    $this->fieldStateConditions['rentalInsurance'] = [
        'hidden' => CompositeCondition::not($rentalCondition),
    ];

    // Hide parking unless outbound transportation is PKW (car)
    $this->fieldStateConditions['parking'] = [
        'hidden' => CompositeCondition::not(
            ServiceSubTypeCondition::equals('transportationOutbound', DirectionMapper::SUBTYPE_CAR_API)
        ),
    ];
}

Rental Insurance Checkbox Implementation

The rental insurance field demonstrates the checkbox pattern used for service selection:

// Field Options Provider
$this->fieldOptionProviders['rentalInsurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
    'label' => $this->getRentalInsuranceCheckboxLabel($rentalInsuranceServices),
    'required' => false,
    'property_path' => 'rentalInsuranceSelected', // Maps to boolean property
];

// Field Handler Processing
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
    $isRentalInsuranceSelected = (bool) $this->getFieldValue($submittedData, $this->getFieldName());
    
    // Store boolean value for form state
    $participant->rentalInsuranceSelected = $isRentalInsuranceSelected;
    
    // Store Service object for pricing calculations
    if ($isRentalInsuranceSelected) {
        $participant->rentalInsurance = $this->findRentalInsuranceService($bookingDto);
    } else {
        $participant->rentalInsurance = null;
    }
}

Benefits of Current Architecture

  1. Clean Separation: Boolean properties handle form state, Service objects handle business logic
  2. Automatic Pricing Integration: Service objects are automatically included in pricing calculations
  3. Dynamic Visibility: Fields appear/disappear based on related selections
  4. Consistent UX: Checkbox pattern provides intuitive user interface
  5. Validation-Free: Conditional visibility eliminates need for complex validation rules

This system provides a powerful, maintainable foundation for complex conditional field behavior while maintaining clean separation of concerns and extensibility.