diff --git a/src/Form/Service/Abstract/AbstractFieldStateProvider.php b/src/Form/Service/Abstract/AbstractFieldStateProvider.php index 5192a21..90bbc74 100644 --- a/src/Form/Service/Abstract/AbstractFieldStateProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldStateProvider.php @@ -31,9 +31,15 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface /** * Determines whether a field should be included in the form at all. * - * Fields with 'hidden' state conditions should not be added to the form - * rather than being hidden with CSS. This method evaluates the hidden - * condition independently to allow early field exclusion. + * Fields with 'hidden' or 'static_text' state conditions should not be added to the form structure. + * Both states exclude fields from the form to prevent form_rest() from rendering them. + * + * The distinction between 'hidden' and 'static_text': + * - 'hidden': Field completely excluded from form (not rendered at all) + * - 'static_text': Field excluded from form but template renders the value as static text + * + * Templates must check shouldRenderAsStaticText() to determine if they should + * display the field's value as static text when the field is not in the form. * * @param string $fieldName The name of the field to evaluate * @param BookingDto $bookingDto The current booking data for context (create or edit) @@ -44,13 +50,57 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface */ public function shouldIncludeField(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): bool { - if (false === isset($this->fieldStateConditions[$fieldName]['hidden'])) { - return true; // No hidden condition means field should be included + // Check if field has 'hidden' condition + if (isset($this->fieldStateConditions[$fieldName]['hidden'])) { + $hiddenCondition = $this->fieldStateConditions[$fieldName]['hidden']; + if ($hiddenCondition->evaluate($bookingDto, $participantIndex, $formData)) { + return false; // Field is hidden, exclude from form + } } - $hiddenCondition = $this->fieldStateConditions[$fieldName]['hidden']; + // Check if field has 'static_text' condition + if (isset($this->fieldStateConditions[$fieldName]['static_text'])) { + $staticTextCondition = $this->fieldStateConditions[$fieldName]['static_text']; + if ($staticTextCondition->evaluate($bookingDto, $participantIndex, $formData)) { + return false; // Field should render as static text, exclude from form + } + } - return !$hiddenCondition->evaluate($bookingDto, $participantIndex, $formData); + return true; // No conditions met, include field in form + } + + /** + * Determines whether a field should be rendered as static text instead of a form input. + * + * Fields with 'static_text' state are included in the form structure but should be + * displayed as read-only static text by the template. This is used for: + * - Personal data of authenticated users (prevents creating duplicate BPN records) + * - Fields locked by BPN's mutability rules (aenderungmoeglich flag) + * - Any other scenario where data should be visible but not editable + * + * Example usage in templates: + * {% if is_static_text(form.firstName, bookingDto, participantIndex) %} + * {{ render_static_field(form.firstName, participant.firstName) }} + * {% else %} + * {{ form_row(form.firstName) }} + * {% endif %} + * + * @param string $fieldName The name of the field to evaluate + * @param BookingDto $bookingDto The current booking data for context (create or edit) + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return bool True if the field should be rendered as static text, false if it should be a normal form input + */ + public function shouldRenderAsStaticText(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): bool + { + if (false === isset($this->fieldStateConditions[$fieldName]['static_text'])) { + return false; // No static_text condition means field should render normally + } + + $staticTextCondition = $this->fieldStateConditions[$fieldName]['static_text']; + + return $staticTextCondition->evaluate($bookingDto, $participantIndex, $formData); } /** @@ -58,10 +108,17 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface * * 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. + * disabled, required, etc.) and evaluated independently. * - * Note: This method no longer handles 'hidden' state as fields should be - * excluded from the form entirely rather than hidden with CSS. + * Supported state types: + * - 'readonly': Adds readonly HTML attribute to the field + * - 'disabled': Disables the field completely + * - 'required': Makes the field mandatory + * - 'static_text': Field should be rendered as static text (checked separately via shouldRenderAsStaticText()) + * - 'hidden': Field excluded from form entirely (checked via shouldIncludeField()) + * + * Note: 'static_text' and 'hidden' states are not returned by this method as they + * affect form structure/template rendering rather than Symfony form field options. * * @param string $fieldName The name of the field to evaluate * @param BookingDto $bookingDto The current booking data for context (create or edit) @@ -91,6 +148,8 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface case 'required': $stateModifications['required'] = true; break; + // Note: 'static_text' and 'hidden' are intentionally not handled here + // They are checked via shouldRenderAsStaticText() and shouldIncludeField() } } } diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 3a5cf8f..3cbea12 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -23,11 +23,12 @@ use App\Service\ParticipantEligibilityService; /** * Field state provider for the booking create workflow. * - * This service calculates dynamic field states (readonly, disabled, etc.) + * This service calculates dynamic field states (readonly, disabled, static_text, etc.) * for participant fields in the create flow. It extends the common field * state functionality provided by AbstractFieldStateProvider. * * Current field state conditions: + * - Personal data fields render as static text for authenticated users (prevents duplicate BPN records) * - Body dimension fields are hidden unless rental services are selected * - Age-dependent service fields are hidden until birth date is provided * - Transportation pickup fields are hidden by default, shown only when transportation type is BUS @@ -75,58 +76,58 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider $skiPassCondition = new SkiPassSelectionCondition(); // Authenticated user personal data protection - // Hide personal data fields for participants linked to BPN accounts (prevents duplicate records) + // Render personal data fields as static text for participants linked to BPN accounts (prevents duplicate records) $authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition(); $this->fieldStateConditions['firstName'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['lastName'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['gender'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['nationality'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['dateOfBirth'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['email'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; - // Mobile field: hidden for authenticated users, required for guest applicants + // Mobile field: static text for authenticated users, required for guest applicants $this->fieldStateConditions['mobile'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, 'required' => new ApplicantCondition(), ]; - // Address subfields - must be hidden to prevent creating duplicate BPN records + // Address subfields - must render as static text to prevent creating duplicate BPN records $this->fieldStateConditions['address.street'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['address.postCode'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['address.city'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['address.country'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['address.district'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; // Note: Body dimensions (height, weight, shoeSize) are NOT hidden for authenticated users @@ -255,7 +256,9 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider ]; // Make address field required for applicant (participant index 0) + // Also render as static text for authenticated users to prevent duplicate BPN records $this->fieldStateConditions['address'] = [ + 'static_text' => $authenticatedUserCondition, 'required' => new ApplicantCondition(), ]; diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 7573e95..a6001a2 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -45,11 +45,11 @@ class EditFieldStateProvider extends AbstractFieldStateProvider $pickupsMutabilityCondition = new PickupsMutabilityCondition(); // Authenticated user personal data protection - // Hide personal data fields for participants linked to BPN accounts (prevents duplicate records) + // Render personal data fields as static text for participants linked to BPN accounts (prevents duplicate records) $authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition(); // Make all personal data fields readonly if participant not mutable - // OR hidden if participant is linked to BPN account (authenticated user) + // OR static text if participant is linked to BPN account (authenticated user) // Note: First participant is now treated as independent from applicant and can be edited $personalDataFields = [ 'firstName', @@ -62,36 +62,36 @@ class EditFieldStateProvider extends AbstractFieldStateProvider ]; foreach ($personalDataFields as $field) { $this->fieldStateConditions[$field] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, 'readonly' => CompositeCondition::not(new MutabilityCondition()), ]; } - // Address fields - hidden for authenticated users, readonly if participant not mutable + // Address fields - static text for authenticated users, readonly if participant not mutable $this->fieldStateConditions['address'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, 'readonly' => CompositeCondition::not(new MutabilityCondition()), ]; - // Address subfields - must be hidden to prevent creating duplicate BPN records + // Address subfields - must render as static text to prevent creating duplicate BPN records $this->fieldStateConditions['address.street'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['address.postCode'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['address.city'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['address.country'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; $this->fieldStateConditions['address.district'] = [ - 'hidden' => $authenticatedUserCondition, + 'static_text' => $authenticatedUserCondition, ]; // Conditional visibility for service fields (same as create flow) diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index 713775c..b6e70ff 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -27,6 +27,9 @@ class AppExtension extends AbstractExtension new TwigFunction('icon', [AppRuntime::class, 'renderIcon'], ['needs_environment' => true, 'is_safe' => ['html']]), new TwigFunction('is_participant_eligible', [AppRuntime::class, 'isParticipantEligible']), new TwigFunction('qa_attribute', [AppRuntime::class, 'renderQaAttribute'], ['is_safe' => ['html']]), + new TwigFunction('is_static_text', [AppRuntime::class, 'isStaticText']), + new TwigFunction('is_hidden', [AppRuntime::class, 'isHidden']), + new TwigFunction('gravatar_url', [AppRuntime::class, 'getGravatarUrl']), ]; } } diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index 25d35c0..5a2ad3a 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -4,7 +4,10 @@ namespace App\Twig; use App\BusProNet\DataProvider\CountryDataProvider; use App\Form\Model\BookingDto; +use App\Form\Service\CreateFieldStateProvider; +use App\Form\Service\EditFieldStateProvider; use App\Service\ParticipantEligibilityService; +use Symfony\Component\Form\FormView; use Twig\Environment; use Twig\Extension\RuntimeExtensionInterface; use Twig\Extra\Intl\IntlExtension; @@ -15,6 +18,8 @@ class AppRuntime implements RuntimeExtensionInterface private readonly IntlExtension $intlExtension, private readonly CountryDataProvider $countryDataProvider, private readonly ParticipantEligibilityService $participantEligibilityService, + private readonly CreateFieldStateProvider $createFieldStateProvider, + private readonly EditFieldStateProvider $editFieldStateProvider, private readonly string $environment, ) { } @@ -123,4 +128,73 @@ class AppRuntime implements RuntimeExtensionInterface return sprintf(' data-qa-%s="%s"', strtolower($label), $value); } + + /** + * Checks if a form field should be rendered as static text. + * + * Accepts either a FormView (when field is in form structure) or a string field name + * (when field is excluded from form but needs to be checked). + * + * @param FormView|string $field The FormView or field name + * @param BookingDto $bookingDto The booking context (create or edit mode) + * @param int $participantIndex The participant index + * + * @return bool True if field should render as static text + */ + public function isStaticText(FormView|string $field, BookingDto $bookingDto, int $participantIndex): bool + { + $fieldName = $field instanceof FormView ? $field->vars['name'] : $field; + $provider = $this->getProvider($bookingDto); + + return $provider->shouldRenderAsStaticText($fieldName, $bookingDto, $participantIndex); + } + + /** + * Checks if a form field should be completely hidden (not rendered at all). + * + * Accepts either a FormView (when field is in form structure) or a string field name + * (when field is excluded from form but needs to be checked). + * + * @param FormView|string $field The FormView or field name + * @param BookingDto $bookingDto The booking context (create or edit mode) + * @param int $participantIndex The participant index + * + * @return bool True if field should be hidden (not rendered) + */ + public function isHidden(FormView|string $field, BookingDto $bookingDto, int $participantIndex): bool + { + $fieldName = $field instanceof FormView ? $field->vars['name'] : $field; + $provider = $this->getProvider($bookingDto); + + return !$provider->shouldIncludeField($fieldName, $bookingDto, $participantIndex); + } + + /** + * Generates a Gravatar URL for the given email address. + * + * @param string $email The email address + * @param int $size The size of the avatar in pixels (default: 80) + * + * @return string The Gravatar URL + */ + public function getGravatarUrl(string $email, int $size = 80): string + { + $hash = md5(strtolower(trim($email))); + + return sprintf('https://www.gravatar.com/avatar/%s?s=%d&d=404', $hash, $size); + } + + /** + * Gets the appropriate field state provider based on booking mode. + * + * @param BookingDto $bookingDto The booking context + * + * @return CreateFieldStateProvider|EditFieldStateProvider The appropriate provider + */ + private function getProvider(BookingDto $bookingDto): CreateFieldStateProvider|EditFieldStateProvider + { + return BookingDto::MODE_CREATE === $bookingDto->getMode() + ? $this->createFieldStateProvider + : $this->editFieldStateProvider; + } } diff --git a/src/Twig/GravatarExtension.php b/src/Twig/GravatarExtension.php deleted file mode 100644 index aaa46f8..0000000 --- a/src/Twig/GravatarExtension.php +++ /dev/null @@ -1,28 +0,0 @@ - - + {% elseif is_static_text(fieldName, bookingDto, participantIndex) %} + {# Field excluded from form but should render as static text #} +
+
{{ staticValue ?: '-' }}
{% endif %} + {# If neither condition is true, don't render anything (truly hidden) #} {% endmacro %} {# Macro to render a field or placeholder with consistent fieldset structure #} @@ -69,14 +72,16 @@
{# Personal data section #}
- {{ macros.field_or_static(form, 'firstName', 'Vorname', form.vars.data.participant.firstName) }} - {{ macros.field_or_static(form, 'lastName', 'Nachname', form.vars.data.participant.lastName) }} + {{ macros.field_or_static(form, 'firstName', 'Vorname', form.vars.data.participant.firstName, bookingDto, participantIndex) }} + {{ macros.field_or_static(form, 'lastName', 'Nachname', form.vars.data.participant.lastName, bookingDto, participantIndex) }} {{ macros.field_or_static( form, 'dateOfBirth', 'Geburtsdatum', form.vars.data.participant.dateOfBirth ? form.vars.data.participant.dateOfBirth|date('d.m.Y') : null, + bookingDto, + participantIndex, { 'attr': { 'hx-trigger': 'change', @@ -88,35 +93,40 @@ ) }} {% set genderLabel = form.vars.data.participant.gender == 'M' ? 'männlich' : (form.vars.data.participant.gender == 'W' ? 'weiblich' : (form.vars.data.participant.gender == 'D' ? 'divers' : null)) %} - {{ macros.field_or_static(form, 'gender', 'Geschlecht', genderLabel) }} + {{ macros.field_or_static(form, 'gender', 'Geschlecht', genderLabel, bookingDto, participantIndex) }} - {{ macros.field_or_static(form, 'nationality', 'Nationalität', form.vars.data.participant.nationality) }} + {{ macros.field_or_static(form, 'nationality', 'Nationalität', form.vars.data.participant.nationality|map_nationality, bookingDto, participantIndex) }}
{# Contact information #}
- {{ macros.field_or_static(form, 'email', 'E-Mail', form.vars.data.participant.email) }} - {{ macros.field_or_static(form, 'mobile', 'Telefon (mobil)', form.vars.data.participant.mobile) }} + {{ macros.field_or_static(form, 'email', 'E-Mail', form.vars.data.participant.email, bookingDto, participantIndex) }} + {{ macros.field_or_static(form, 'mobile', 'Telefon (mobil)', form.vars.data.participant.mobile, bookingDto, participantIndex) }}
{# Address #} {% if form.address is defined %} + {# Address field is in form - render as editable form fields #}
{{ form_row(form.address.street) }} {{ form_row(form.address.postCode) }} {{ form_row(form.address.city) }} {{ form_row(form.address.country) }}
- {% else %} + {% elseif is_static_text('address', bookingDto, participantIndex) %} + {# Address excluded from form but should render as static text #} + {# For applicant (index 0), use booking.applicant.address which has full data from BPN #} + {# For other participants, use participants[index].address (may only have country in BPN response) #} + {% set addressSource = (0 == participantIndex and bookingDto.booking) ? bookingDto.booking.applicant : bookingDto.participants[participantIndex] %}
- {% if form.vars.data.participant.address %} + {% if addressSource.address %}
- {% if form.vars.data.participant.address.street %}{{ form.vars.data.participant.address.street }}
{% endif %} - {% if form.vars.data.participant.address.postCode or form.vars.data.participant.address.city %} - {{ form.vars.data.participant.address.postCode }} {{ form.vars.data.participant.address.city }}
+ {% if addressSource.address.street %}{{ addressSource.address.street }}
{% endif %} + {% if addressSource.address.postCode or addressSource.address.city %} + {{ addressSource.address.postCode }} {{ addressSource.address.city }}
{% endif %} - {% if form.vars.data.participant.address.country %}{{ form.vars.data.participant.address.country }}{% endif %} + {% if addressSource.address.country %}{{ addressSource.address.country|map_country }}{% endif %}
{% else %}
-
diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 630d7c0..c983d29 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -1,5 +1,13 @@ {% use 'form_div_layout.html.twig' %} +{# Macro to render a static (read-only) field value with consistent formatting #} +{% macro render_static_field(label, value) %} +
+ +
{{ value ?: '-' }}
+
+{% endmacro %} + {# Macro to render info icon tooltip for descriptions #} {% macro info_tooltip(description) %}