11 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/)
- FieldStateProviderInterface - Defines the contract for field state management
- FieldOptionsProviderInterface - Defines the contract for field option generation
Abstract Base Classes (src/Form/Service/Abstract/)
- AbstractFieldStateProvider - Common field state functionality
- AbstractFieldOptionsProvider - Common field option functionality
Concrete Implementations (src/Form/Service/)
- CreateFieldStateProvider - Field states for booking creation workflow
- EditFieldStateProvider - Field states for booking edit workflow
- ParticipantFieldOptionsProvider - Dynamic field option generation
Condition System (src/Form/Service/Condition/)
- FieldConditionInterface - Defines the contract for condition evaluation
- Concrete Conditions - Implement specific business logic (age ranges, field values, etc.)
- CompositeCondition - Combines conditions with AND/OR/NOT logic
Form Integration
- BookingCreateParticipantType - Uses CreateFieldStateProvider
- 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 oldnew AgeRangeCondition(null, 17)- Under 18 years oldnew AgeRangeCondition(18, 65)- Between 18 and 65 years old
FieldValueCondition
FieldValueCondition::equals('field', 'value')- Field equals specific valueFieldValueCondition::notEquals('field', 'value')- Field does not equal valueFieldValueCondition::in('field', ['a', 'b'])- Field value is in arrayFieldValueCondition::empty('field')- Field is empty or nullFieldValueCondition::isNotEmpty('field')- Field has a value
CompositeCondition
CompositeCondition::and($cond1, $cond2)- All conditions must be trueCompositeCondition::or($cond1, $cond2)- At least one condition must be trueCompositeCondition::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:
- Field changes trigger dependency re-evaluation
- State modifications are applied via form rebuilding
- HTMX can update field states without full page refresh
- 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 [];
}
}
This system provides a powerful, maintainable foundation for complex conditional field behavior while maintaining clean separation of concerns and extensibility.