diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index 200ee29a03..76f0779eea 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -2,10 +2,11 @@ CHANGELOG ========= 5.3 ------ +--- * Add a new `markAsPublic` method on `NotificationEmail` to change the `importance` context option to null after creation * Add a new `fragment_uri()` helper to generate the URI of a fragment +* Add support of Bootstrap 5 for form theming 5.3.0 ----- diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig index 9c8a0cc14f..7316abf0dd 100644 --- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_3_layout.html.twig @@ -54,6 +54,11 @@ {%- endif -%} {%- endblock radio_widget %} +{% block choice_widget_collapsed -%} + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%} + {{- parent() -}} +{%- endblock choice_widget_collapsed %} + {# Labels #} {% block form_label -%} diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig index c990d81370..3fbaa052c7 100644 --- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_4_layout.html.twig @@ -202,6 +202,11 @@ {%- endif -%} {%- endblock radio_widget %} +{% block choice_widget_collapsed -%} + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%} + {{- parent() -}} +{%- endblock choice_widget_collapsed %} + {% block choice_widget_expanded -%}
{%- for child in form %} diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_horizontal_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_horizontal_layout.html.twig new file mode 100644 index 0000000000..3c24166d48 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_horizontal_layout.html.twig @@ -0,0 +1,130 @@ +{% use "bootstrap_5_layout.html.twig" %} + +{# Labels #} + +{% block form_label -%} + {%- if label is same as(false) -%} +
+ {%- else -%} + {%- set row_class = row_class|default(row_attr.class|default('')) -%} + {%- if 'form-floating' not in row_class and 'input-group' not in row_class -%} + {%- if expanded is not defined or not expanded -%} + {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' col-form-label')|trim}) -%} + {%- endif -%} + {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ block('form_label_class'))|trim}) -%} + {%- endif -%} + {{- parent() -}} + {%- endif -%} +{%- endblock form_label %} + +{% block form_label_class -%} + col-sm-2 +{%- endblock form_label_class %} + +{# Rows #} + +{% block form_row -%} + {%- if expanded is defined and expanded -%} + {{ block('fieldset_form_row') }} + {%- else -%} + {%- set widget_attr = {} -%} + {%- if help is not empty -%} + {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%} + {%- endif -%} + {%- set row_class = row_class|default(row_attr.class|default('mb-3')) -%} + {%- set is_form_floating = is_form_floating|default('form-floating' in row_class) -%} + {%- set is_input_group = is_input_group|default('input-group' in row_class) -%} + {#- Remove behavior class from the main container -#} + {%- set row_class = row_class|replace({'form-floating': '', 'input-group': ''}) -%} + + {%- if is_form_floating or is_input_group -%} +
+
+ {%- if is_form_floating -%} +
+ {{- form_widget(form, widget_attr) -}} + {{- form_label(form) -}} +
+ {%- elseif is_input_group -%} +
+ {{- form_label(form) -}} + {{- form_widget(form, widget_attr) -}} + {#- Hack to properly display help with input group -#} + {{- form_help(form) -}} +
+ {%- endif -%} + {%- if not is_input_group -%} + {{- form_help(form) -}} + {%- endif -%} + {{- form_errors(form) -}} +
+ {%- else -%} + {{- form_label(form) -}} +
+ {{- form_widget(form, widget_attr) -}} + {{- form_help(form) -}} + {{- form_errors(form) -}} +
+ {%- endif -%} + {##}
+ {%- endif -%} +{%- endblock form_row %} + +{% block fieldset_form_row -%} + {%- set widget_attr = {} -%} + {%- if help is not empty -%} + {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%} + {%- endif -%} + +
+ {{- form_label(form) -}} +
+ {{- form_widget(form, widget_attr) -}} + {{- form_help(form) -}} + {{- form_errors(form) -}} +
+
+ +{%- endblock fieldset_form_row %} + +{% block submit_row -%} + {#--#} +
{#--#} +
+ {{- form_widget(form) -}} +
{#--#} + +{%- endblock submit_row %} + +{% block reset_row -%} + {#--#} +
{#--#} +
+ {{- form_widget(form) -}} +
{#--#} + +{%- endblock reset_row %} + +{% block button_row -%} + {#--#} +
{#--#} +
+ {{- form_widget(form) -}} +
{#--#} + +{%- endblock button_row %} + +{% block checkbox_row -%} + {#--#} +
{#--#} +
+ {{- form_widget(form) -}} + {{- form_help(form) -}} + {{- form_errors(form) -}} +
{#--#} + +{%- endblock checkbox_row %} + +{% block form_group_class -%} + col-sm-10 +{%- endblock form_group_class %} diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_layout.html.twig new file mode 100644 index 0000000000..eef6f606ed --- /dev/null +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_5_layout.html.twig @@ -0,0 +1,374 @@ +{% use "bootstrap_base_layout.html.twig" %} + +{# Widgets #} + +{% block money_widget -%} + {%- set prepend = not (money_pattern starts with '{{') -%} + {%- set append = not (money_pattern ends with '}}') -%} + {%- if prepend or append -%} +
+ {%- if prepend -%} + {{ money_pattern|form_encode_currency }} + {%- endif -%} + {{- block('form_widget_simple') -}} + {%- if append -%} + {{ money_pattern|form_encode_currency }} + {%- endif -%} +
+ {%- else -%} + {{- block('form_widget_simple') -}} + {%- endif -%} +{%- endblock money_widget %} + +{% block date_widget -%} + {%- if widget == 'single_text' -%} + {{- block('form_widget_simple') -}} + {%- else -%} + {% if not valid %} + {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%} + {% set valid = true %} + {% endif %} + {%- if datetime is not defined or not datetime -%} +
+ {%- endif %} + {%- if label is not same as(false) -%} +
+ {{- form_label(form.year) -}} + {{- form_label(form.month) -}} + {{- form_label(form.day) -}} +
+ {%- endif -%} +
+ {{- date_pattern|replace({ + '{{ year }}': form_widget(form.year), + '{{ month }}': form_widget(form.month), + '{{ day }}': form_widget(form.day), + })|raw -}} +
+ {%- if datetime is not defined or not datetime -%} +
+ {%- endif -%} + {%- endif -%} +{%- endblock date_widget %} + +{% block time_widget -%} + {%- if widget == 'single_text' -%} + {{- block('form_widget_simple') -}} + {%- else -%} + {% if not valid %} + {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%} + {% set valid = true %} + {% endif %} + {%- if datetime is not defined or false == datetime -%} +
+ {%- endif -%} + {%- if label is not same as(false) -%} +
+ {{- form_label(form.hour) -}} + {%- if with_minutes -%}{{ form_label(form.minute) }}{%- endif -%} + {%- if with_seconds -%}{{ form_label(form.second) }}{%- endif -%} +
+ {%- endif -%} + {% if with_minutes or with_seconds %} +
+ {% endif %} + {{- form_widget(form.hour) -}} + {%- if with_minutes -%} + : + {{- form_widget(form.minute) -}} + {%- endif -%} + {%- if with_seconds -%} + : + {{- form_widget(form.second) -}} + {%- endif -%} + {% if with_minutes or with_seconds %} +
+ {% endif %} + {%- if datetime is not defined or false == datetime -%} +
+ {%- endif -%} + {%- endif -%} +{%- endblock time_widget %} + +{% block datetime_widget -%} + {%- if widget == 'single_text' -%} + {{- block('form_widget_simple') -}} + {%- else -%} + {% if not valid %} + {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%} + {% set valid = true %} + {% endif %} +
+ {{- form_widget(form.date, { datetime: true } ) -}} + {{- form_errors(form.date) -}} + {{- form_widget(form.time, { datetime: true } ) -}} + {{- form_errors(form.time) -}} +
+ {%- endif -%} +{%- endblock datetime_widget %} + +{% block dateinterval_widget -%} + {%- if widget == 'single_text' -%} + {{- block('form_widget_simple') -}} + {%- else -%} + {% if not valid %} + {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) -%} + {% set valid = true %} + {% endif %} +
+ {%- if with_years -%} +
+ {{ form_label(form.years) }} + {{ form_widget(form.years) }} +
+ {%- endif -%} + {%- if with_months -%} +
+ {{ form_label(form.months) }} + {{ form_widget(form.months) }} +
+ {%- endif -%} + {%- if with_weeks -%} +
+ {{ form_label(form.weeks) }} + {{ form_widget(form.weeks) }} +
+ {%- endif -%} + {%- if with_days -%} +
+ {{ form_label(form.days) }} + {{ form_widget(form.days) }} +
+ {%- endif -%} + {%- if with_hours -%} +
+ {{ form_label(form.hours) }} + {{ form_widget(form.hours) }} +
+ {%- endif -%} + {%- if with_minutes -%} +
+ {{ form_label(form.minutes) }} + {{ form_widget(form.minutes) }} +
+ {%- endif -%} + {%- if with_seconds -%} +
+ {{ form_label(form.seconds) }} + {{ form_widget(form.seconds) }} +
+ {%- endif -%} + {%- if with_invert %}{{ form_widget(form.invert) }}{% endif -%} +
+ {%- endif -%} +{%- endblock dateinterval_widget %} + +{% block percent_widget -%} + {%- if symbol -%} +
+ {{- block('form_widget_simple') -}} + {{ symbol|default('%') }} +
+ {%- else -%} + {{- block('form_widget_simple') -}} + {%- endif -%} +{%- endblock percent_widget %} + +{% block form_widget_simple -%} + {%- if type is not defined or type != 'hidden' %} + {%- set widget_class = ' form-control' %} + {%- if type|default('') == 'color' -%} + {%- set widget_class = widget_class ~ ' form-control-color' -%} + {%- elseif type|default('') == 'range' -%} + {%- set widget_class = ' form-range' -%} + {%- endif -%} + {%- set attr = attr|merge({class: (attr.class|default('') ~ widget_class)|trim}) -%} + {% endif -%} + {%- if type is defined and type in ['range', 'color'] %} + {# Attribute "required" is not supported #} + {% set required = false %} + {% endif -%} + {{- parent() -}} +{%- endblock form_widget_simple %} + +{%- block widget_attributes -%} + {%- if not valid %} + {% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) %} + {% endif -%} + {{ parent() }} +{%- endblock widget_attributes -%} + +{%- block button_widget -%} + {%- set attr = attr|merge({class: (attr.class|default('btn-secondary') ~ ' btn')|trim}) -%} + {{- parent() -}} +{%- endblock button_widget %} + +{%- block submit_widget -%} + {%- set attr = attr|merge({class: (attr.class|default('btn-primary'))|trim}) -%} + {{- parent() -}} +{%- endblock submit_widget %} + +{%- block checkbox_widget -%} + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-check-input')|trim}) -%} + {%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%} + {%- set row_class = 'form-check' -%} + {%- if 'checkbox-inline' in parent_label_class %} + {% set row_class = row_class ~ ' form-check-inline' %} + {% endif -%} + {%- if 'checkbox-switch' in parent_label_class %} + {% set row_class = row_class ~ ' form-switch' %} + {% endif -%} +
+ {{- form_label(form, null, { widget: parent() }) -}} +
+{%- endblock checkbox_widget %} + +{%- block radio_widget -%} + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-check-input')|trim}) -%} + {%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%} + {%- set row_class = 'form-check' -%} + {%- if 'radio-inline' in parent_label_class -%} + {%- set row_class = row_class ~ ' form-check-inline' -%} + {%- endif -%} +
+ {{- form_label(form, null, { widget: parent() }) -}} +
+{%- endblock radio_widget %} + +{%- block choice_widget_collapsed -%} + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%} + {{- parent() -}} +{%- endblock choice_widget_collapsed -%} + +{%- block choice_widget_expanded -%} +
+ {%- for child in form %} + {{- form_widget(child, { + parent_label_class: label_attr.class|default(''), + translation_domain: choice_translation_domain, + valid: valid, + }) -}} + {% endfor -%} +
+{%- endblock choice_widget_expanded %} + +{# Labels #} + +{%- block form_label -%} + {% if label is not same as(false) -%} + {%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%} + {%- if compound is defined and compound -%} + {%- set element = 'legend' -%} + {%- if 'col-form-label' not in parent_label_class -%} + {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' col-form-label' )|trim}) -%} + {%- endif -%} + {%- else -%} + {%- set row_class = row_class|default(row_attr.class|default('')) -%} + {%- set label_attr = label_attr|merge({for: id}) -%} + {%- if 'col-form-label' not in parent_label_class -%} + {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ('input-group' in row_class ? ' input-group-text' : ' form-label') )|trim}) -%} + {%- endif -%} + {%- endif -%} + {%- endif -%} + {{- parent() -}} +{%- endblock form_label %} + +{%- block checkbox_radio_label -%} + {#- Do not display the label if widget is not defined in order to prevent double label rendering -#} + {%- if widget is defined -%} + {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' form-check-label')|trim}) -%} + {%- if not compound -%} + {% set label_attr = label_attr|merge({'for': id}) %} + {%- endif -%} + {%- if required -%} + {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' required')|trim}) -%} + {%- endif -%} + {%- if parent_label_class is defined -%} + {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ parent_label_class)|replace({'checkbox-inline': '', 'radio-inline': ''})|trim}) -%} + {%- endif -%} + {%- if label is not same as(false) and label is empty -%} + {%- if label_format is not empty -%} + {%- set label = label_format|replace({ + '%name%': name, + '%id%': id, + }) -%} + {%- else -%} + {%- set label = name|humanize -%} + {%- endif -%} + {%- endif -%} + + {{ widget|raw }} + + {%- if label is not same as(false) -%} + {%- if translation_domain is same as(false) -%} + {%- if label_html is same as(false) -%} + {{- label -}} + {%- else -%} + {{- label|raw -}} + {%- endif -%} + {%- else -%} + {%- if label_html is same as(false) -%} + {{- label|trans(label_translation_parameters, translation_domain) -}} + {%- else -%} + {{- label|trans(label_translation_parameters, translation_domain)|raw -}} + {%- endif -%} + {%- endif -%} + {%- endif -%} + + {%- endif -%} +{%- endblock checkbox_radio_label %} + +{# Rows #} + +{%- block form_row -%} + {%- if compound is defined and compound -%} + {%- set element = 'fieldset' -%} + {%- endif -%} + {%- set widget_attr = {} -%} + {%- if help is not empty -%} + {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%} + {%- endif -%} + {%- set row_class = row_class|default(row_attr.class|default('mb-3')|trim) -%} + <{{ element|default('div') }}{% with {attr: row_attr|merge({class: row_class})} %}{{ block('attributes') }}{% endwith %}> + {%- if 'form-floating' in row_class -%} + {{- form_widget(form, widget_attr) -}} + {{- form_label(form) -}} + {%- else -%} + {{- form_label(form) -}} + {{- form_widget(form, widget_attr) -}} + {%- endif -%} + {{- form_help(form) -}} + {{- form_errors(form) -}} + +{%- endblock form_row %} + +{%- block button_row -%} + + {{- form_widget(form) -}} + +{%- endblock button_row %} + +{# Errors #} + +{%- block form_errors -%} + {%- if errors|length > 0 -%} + {%- for error in errors -%} +
{{ error.message }}
+ {%- endfor -%} + {%- endif %} +{%- endblock form_errors %} + +{# Help #} + +{%- block form_help -%} + {% set row_class = row_attr.class|default('') %} + {% set help_class = ' form-text' %} + {% if 'input-group' in row_class %} + {#- Hack to properly display help with input group -#} + {% set help_class = ' input-group-text' %} + {% endif %} + {%- if help is not empty -%} + {%- set help_attr = help_attr|merge({class: (help_attr.class|default('') ~ help_class ~ ' mb-0')|trim}) -%} + {%- endif -%} + {{- parent() -}} +{%- endblock form_help %} diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_base_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_base_layout.html.twig index 1b0092859d..b8cb8c44aa 100644 --- a/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_base_layout.html.twig +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/bootstrap_base_layout.html.twig @@ -143,11 +143,6 @@ {%- endif -%} {%- endblock dateinterval_widget -%} -{% block choice_widget_collapsed -%} - {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%} - {{- parent() -}} -{%- endblock choice_widget_collapsed %} - {% block choice_widget_expanded -%} {%- if '-inline' in label_attr.class|default('') -%} {%- for child in form %} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5HorizontalLayoutTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5HorizontalLayoutTest.php new file mode 100644 index 0000000000..9fe231bfa1 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5HorizontalLayoutTest.php @@ -0,0 +1,353 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\RadioType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormError; + +/** + * Abstract class providing test cases for the Bootstrap 5 horizontal Twig form theme. + * + * @author Romain Monteil + */ +abstract class AbstractBootstrap5HorizontalLayoutTest extends AbstractBootstrap5LayoutTest +{ + public function testRow() + { + $form = $this->factory->createNamed('name', TextType::class); + $form->addError(new FormError('[trans]Error![/trans]')); + $html = $this->renderRow($form->createView()); + + $this->assertMatchesXpath($html, + '/div + [@class="mb-3 row"] + [ + ./label + [@for="name"] + [@class="col-form-label col-sm-2 required"] + /following-sibling::div + [@class="col-sm-10"] + [ + ./input[@id="name"] + /following-sibling::div + [@class="invalid-feedback d-block"] + [.="[trans]Error![/trans]"] + ] + [count(./div)=1] + ] +' + ); + } + + public function testRowWithCustomClass() + { + $form = $this->factory->createNamed('name', TextType::class); + $form->addError(new FormError('[trans]Error![/trans]')); + $html = $this->renderRow($form->createView(), [ + 'row_attr' => [ + 'class' => 'mb-5', + ], + ]); + + $this->assertMatchesXpath($html, + '/div + [@class="mb-5 row"] + [ + ./label + [@for="name"] + [@class="col-form-label col-sm-2 required"] + /following-sibling::div + [@class="col-sm-10"] + [ + ./input[@id="name"] + /following-sibling::div + [@class="invalid-feedback d-block"] + [.="[trans]Error![/trans]"] + ] + [count(./div)=1] + ] +' + ); + } + + public function testLabelOnForm() + { + $form = $this->factory->createNamed('name', DateType::class); + $view = $form->createView(); + $this->renderWidget($view, ['label' => 'foo']); + $html = $this->renderLabel($view); + + $this->assertMatchesXpath($html, + '/legend + [@class="col-form-label col-sm-2 required"] + [.="[trans]Name[/trans]"] +' + ); + } + + public function testLabelDoesNotRenderFieldAttributes() + { + $form = $this->factory->createNamed('name', TextType::class); + $html = $this->renderLabel($form->createView(), null, [ + 'attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, +'/label + [@for="name"] + [@class="col-form-label col-sm-2 required"] +' + ); + } + + public function testLabelWithCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', TextType::class); + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, +'/label + [@for="name"] + [@class="my&class col-form-label col-sm-2 required"] +' + ); + } + + public function testLabelWithCustomTextAndCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', TextType::class); + $html = $this->renderLabel($form->createView(), 'Custom label', [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, +'/label + [@for="name"] + [@class="my&class col-form-label col-sm-2 required"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + public function testLabelWithCustomTextAsOptionAndCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'label' => 'Custom label', + ]); + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, +'/label + [@for="name"] + [@class="my&class col-form-label col-sm-2 required"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + public function testLabelHtmlDefaultIsFalse() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'label' => 'Bolded label', + ]); + + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class col-form-label col-sm-2 required"][.="[trans]Bolded label[/trans]"]'); + $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class col-form-label col-sm-2 required"]/b[.="Bolded label"]', 0); + } + + public function testLabelHtmlIsTrue() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'label' => 'Bolded label', + 'label_html' => true, + ]); + + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class col-form-label col-sm-2 required"][.="[trans]Bolded label[/trans]"]', 0); + $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class col-form-label col-sm-2 required"]/b[.="Bolded label"]'); + } + + public function testLegendOnExpandedType() + { + $form = $this->factory->createNamed('name', ChoiceType::class, null, [ + 'label' => 'Custom label', + 'expanded' => true, + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + ]); + $view = $form->createView(); + $this->renderWidget($view); + $html = $this->renderLabel($view); + + $this->assertMatchesXpath($html, +'/legend + [@class="col-sm-2 col-form-label required"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + public function testCheckboxRow() + { + $form = $this->factory->createNamed('name', CheckboxType::class); + $view = $form->createView(); + $html = $this->renderRow($view, ['label' => 'foo']); + + $this->assertMatchesXpath($html, '/div[@class="mb-3 row"]/div[@class="col-sm-2" or @class="col-sm-10"]', 2); + } + + public function testCheckboxRowWithHelp() + { + $form = $this->factory->createNamed('name', CheckboxType::class); + $view = $form->createView(); + $html = $this->renderRow($view, ['label' => 'foo', 'help' => 'really helpful text']); + + $this->assertMatchesXpath($html, +'/div + [@class="mb-3 row"] + [ + ./div[@class="col-sm-2" or @class="col-sm-10"] + /following-sibling::div[@class="col-sm-2" or @class="col-sm-10"] + [ + ./p + [@class="form-text mb-0 help-text"] + [.="[trans]really helpful text[/trans]"] + ] + ] +' + ); + } + + public function testRadioRowWithHelp() + { + $form = $this->factory->createNamed('name', RadioType::class, false); + $html = $this->renderRow($form->createView(), ['label' => 'foo', 'help' => 'really helpful text']); + + $this->assertMatchesXpath($html, +'/div + [@class="mb-3 row"] + [ + ./div[@class="col-sm-2" or @class="col-sm-10"] + /following-sibling::div[@class="col-sm-2" or @class="col-sm-10"] + [ + ./p + [@class="form-text mb-0 help-text"] + [.="[trans]really helpful text[/trans]"] + ] + ] +' + ); + } + + public function testFileWithGroup() + { + $form = $this->factory->createNamed('name', FileType::class); + $html = $this->renderRow($form->createView(), [ + 'id' => 'n/a', + 'attr' => [ + 'class' => 'my&class', + ], + 'row_attr' => [ + 'class' => 'input-group mb-3', + ], + ]); + + $this->assertMatchesXpath($html, + '/div + [@class="mb-3 row"] + [ + ./div + [@class="col-sm-2"] + /following-sibling::div + [@class="col-sm-10"] + [ + ./div + [@class="input-group"] + [ + ./label + [@class="input-group-text required"] + [.="[trans]Name[/trans]"] + /following-sibling::input + [@type="file"] + [@name="name"] + [@class="my&class form-control"] + ] + ] + ] +' + ); + } + + public function testFloatingLabel() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'attr' => [ + 'placeholder' => 'name', + ], + 'row_attr' => [ + 'class' => 'form-floating mb-3', + ], + ]); + + $html = $this->renderRow($form->createView()); + + $this->assertMatchesXpath($html, + '/div + [@class="mb-3 row"] + [ + ./div + [@class="col-sm-2"] + /following-sibling::div + [@class="col-sm-10"] + [ + ./div + [@class="form-floating"] + [ + ./input + [@id="name"] + [@placeholder="[trans]name[/trans]"] + /following-sibling::label + [@for="name"] + ] + ] + ] +' + ); + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTest.php new file mode 100644 index 0000000000..c6b9e3baaa --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTest.php @@ -0,0 +1,1819 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use Symfony\Component\Form\Extension\Core\Type\BirthdayType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\ColorType; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Form\Extension\Core\Type\LocaleType; +use Symfony\Component\Form\Extension\Core\Type\MoneyType; +use Symfony\Component\Form\Extension\Core\Type\PercentType; +use Symfony\Component\Form\Extension\Core\Type\RadioType; +use Symfony\Component\Form\Extension\Core\Type\RangeType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\TimeType; +use Symfony\Component\Form\Extension\Core\Type\TimezoneType; +use Symfony\Component\Form\Extension\Core\Type\WeekType; +use Symfony\Component\Form\FormError; + +/** + * Abstract class providing test cases for the Bootstrap 5 Twig form theme. + * + * @author Romain Monteil + */ +abstract class AbstractBootstrap5LayoutTest extends AbstractBootstrap4LayoutTest +{ + public function testRow() + { + $form = $this->factory->createNamed('name', TextType::class); + $form->addError(new FormError('[trans]Error![/trans]')); + $html = $this->renderRow($form->createView()); + + $this->assertMatchesXpath($html, + '/div + [@class="mb-3"] + [ + ./label[@for="name"] + /following-sibling::input[@id="name"] + /following-sibling::div + [@class="invalid-feedback d-block"] + [.="[trans]Error![/trans]"] + ] + [count(./div)=1] +' + ); + } + + public function testRowWithCustomClass() + { + $form = $this->factory->createNamed('name', TextType::class); + $form->addError(new FormError('[trans]Error![/trans]')); + $html = $this->renderRow($form->createView(), [ + 'row_attr' => [ + 'class' => 'mb-5', + ], + ]); + + $this->assertMatchesXpath($html, + '/div + [@class="mb-5"] + [ + ./label[@for="name"] + /following-sibling::input[@id="name"] + /following-sibling::div + [@class="invalid-feedback d-block"] + [.="[trans]Error![/trans]"] + ] + [count(./div)=1] +' + ); + } + + public function testLabelDoesNotRenderFieldAttributes() + { + $form = $this->factory->createNamed('name', TextType::class); + $html = $this->renderLabel($form->createView(), null, [ + 'attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [@class="form-label required"] +' + ); + } + + public function testLabelWithCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', TextType::class); + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [@class="my&class form-label required"] +' + ); + } + + public function testLabelWithCustomTextAndCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', TextType::class); + $html = $this->renderLabel($form->createView(), 'Custom label', [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [@class="my&class form-label required"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + public function testLabelWithCustomTextAsOptionAndCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'label' => 'Custom label', + ]); + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [@class="my&class form-label required"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + public function testLabelHtmlDefaultIsFalse() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'label' => 'Bolded label', + ]); + + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class form-label required"][.="[trans]Bolded label[/trans]"]'); + $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class form-label required"]/b[.="Bolded label"]', 0); + } + + public function testLabelHtmlIsTrue() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'label' => 'Bolded label', + 'label_html' => true, + ]); + + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class form-label required"][.="[trans]Bolded label[/trans]"]', 0); + $this->assertMatchesXpath($html, '/label[@for="name"][@class="my&class form-label required"]/b[.="Bolded label"]'); + } + + public function testHelp() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'help' => 'Help text test!', + ]); + $view = $form->createView(); + $html = $this->renderHelp($view); + + $this->assertMatchesXpath($html, + '/p + [@id="name_help"] + [@class="form-text mb-0 help-text"] + [.="[trans]Help text test![/trans]"] +' + ); + } + + public function testHelpAttr() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'help' => 'Help text test!', + 'help_attr' => [ + 'class' => 'class-test', + ], + ]); + $view = $form->createView(); + $html = $this->renderHelp($view); + + $this->assertMatchesXpath($html, + '/p + [@id="name_help"] + [@class="class-test form-text mb-0 help-text"] + [.="[trans]Help text test![/trans]"] +' + ); + } + + public function testHelpHtmlDefaultIsFalse() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'help' => 'Help text test!', + ]); + + $view = $form->createView(); + $html = $this->renderHelp($view); + + $this->assertMatchesXpath($html, + '/p + [@id="name_help"] + [@class="form-text mb-0 help-text"] + [.="[trans]Help text test![/trans]"] +' + ); + + $this->assertMatchesXpath($html, + '/p + [@id="name_help"] + [@class="form-text mb-0 help-text"] + /b + [.="text"] +', 0 + ); + } + + public function testHelpHtmlIsFalse() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'help' => 'Help text test!', + 'help_html' => false, + ]); + + $view = $form->createView(); + $html = $this->renderHelp($view); + + $this->assertMatchesXpath($html, + '/p + [@id="name_help"] + [@class="form-text mb-0 help-text"] + [.="[trans]Help text test![/trans]"] +' + ); + + $this->assertMatchesXpath($html, + '/p + [@id="name_help"] + [@class="form-text mb-0 help-text"] + /b + [.="text"] +', 0 + ); + } + + public function testHelpHtmlIsTrue() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'help' => 'Help text test!', + 'help_html' => true, + ]); + $html = $this->renderHelp($form->createView()); + + $this->assertMatchesXpath($html, + '/p + [@id="name_help"] + [@class="form-text mb-0 help-text"] + [.="[trans]Help text test![/trans]"] +', 0 + ); + + $this->assertMatchesXpath($html, + '/p + [@id="name_help"] + [@class="form-text mb-0 help-text"] + /b + [.="text"] +' + ); + } + + public function testErrors() + { + $form = $this->factory->createNamed('name', TextType::class); + $form->addError(new FormError('[trans]Error 1[/trans]')); + $form->addError(new FormError('[trans]Error 2[/trans]')); + $html = $this->renderErrors($form->createView()); + + $this->assertMatchesXpath($html, + '/div + [@class="invalid-feedback d-block"] + [.="[trans]Error 1[/trans]"] + /following-sibling::div + [@class="invalid-feedback d-block"] + [.="[trans]Error 2[/trans]"] +' + ); + } + + public function testErrorWithNoLabel() + { + self::markTestSkipped('Errors are no longer rendered inside label with Bootstrap 5.'); + } + + public function testSingleChoiceAttributesWithMainAttributes() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + 'attr' => ['class' => 'bar&baz'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'bar&baz']], + '/select + [@name="name"] + [@class="bar&baz form-select"] + [not(@required)] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testCheckboxRowWithHelp() + { + $form = $this->factory->createNamed('name', CheckboxType::class); + $html = $this->renderRow($form->createView(), ['label' => 'foo', 'help' => 'really helpful text']); + + $this->assertMatchesXpath($html, + '/div + [@class="mb-3"] + [ + ./div[@class="form-check"] + [ + ./input + [@type="checkbox"] + [@id="name"] + [@name="name"] + [@required="required"] + [@aria-describedby="name_help"] + [@class="form-check-input"] + [@value="1"] + /following-sibling::label + [@class="form-check-label required"] + [@for="name"] + [.="[trans]foo[/trans]"] + ] + /following-sibling::p + [@class="form-text mb-0 help-text"] + [.="[trans]really helpful text[/trans]"] + ] +' + ); + } + + public function testCheckboxSwitchWithValue() + { + $form = $this->factory->createNamed('name', CheckboxType::class, false, [ + 'value' => 'foo&bar', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'my&class'], 'label_attr' => ['class' => 'checkbox-switch']], + '/div + [@class="form-check form-switch"] + [ + ./input[@type="checkbox"][@name="name"][@id="my&id"][@class="my&class form-check-input"][@value="foo&bar"] + /following-sibling::label + [@class="checkbox-switch form-check-label required"] + [.="[trans]Name[/trans]"] + ] +' + ); + } + + public function testMultipleChoiceSkipsPlaceholder() + { + $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => true, + 'expanded' => false, + 'placeholder' => 'Test&Me', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name[]"] + [@class="my&class form-select"] + [@multiple="multiple"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testSingleChoice() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testSingleChoiceWithoutTranslation() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + 'choice_translation_domain' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&a"][@selected="selected"][.="Choice&A"] + /following-sibling::option[@value="&b"][not(@selected)][.="Choice&B"] + ] + [count(./option)=2] +' + ); + } + + public function testSingleChoiceWithPlaceholderWithoutTranslation() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + 'required' => false, + 'translation_domain' => false, + 'placeholder' => 'Placeholder&Not&Translated', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value=""][not(@selected)][not(@disabled)][.="Placeholder&Not&Translated"] + /following-sibling::option[@value="&a"][@selected="selected"][.="Choice&A"] + /following-sibling::option[@value="&b"][not(@selected)][.="Choice&B"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceAttributes() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_attr' => ['Choice&B' => ['class' => 'foo&bar']], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][@class="foo&bar"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testSingleChoiceWithPreferred() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --', 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=4] +' + ); + } + + public function testSingleChoiceWithSelectedPreferred() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&a'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --', 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&a"][not(@selected)][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=4] +' + ); + } + + public function testSingleChoiceWithPreferredAndNoSeparator() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => null, 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceWithPreferredAndBlankSeparator() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '', 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.=""] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=4] +' + ); + } + + public function testChoiceWithOnlyPreferred() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&a', '&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@class="my&class form-select"] + [count(./option)=5] +' + ); + } + + public function testSingleChoiceNonRequired() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value=""][.=""] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceNonRequiredNoneSelected() + { + $form = $this->factory->createNamed('name', ChoiceType::class, null, [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value=""][.=""] + /following-sibling::option[@value="&a"][not(@selected)][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceNonRequiredWithPlaceholder() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + 'required' => false, + 'placeholder' => 'Select&Anything&Not&Me', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Anything&Not&Me[/trans]"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceRequiredWithPlaceholder() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => true, + 'multiple' => false, + 'expanded' => false, + 'placeholder' => 'Test&Me', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [@required="required"] + [ + ./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Test&Me[/trans]"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceRequiredWithPlaceholderViaView() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => true, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['placeholder' => '', 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [@required="required"] + [ + ./option[@value=""][not(@selected)][not(@disabled)][.=""] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceGrouped() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => [ + 'Group&1' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'Group&2' => ['Choice&C' => '&c'], + ], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [./optgroup[@label="[trans]Group&1[/trans]"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] + ] + [./optgroup[@label="[trans]Group&2[/trans]"] + [./option[@value="&c"][not(@selected)][.="[trans]Choice&C[/trans]"]] + [count(./option)=1] + ] + [count(./optgroup)=2] +' + ); + } + + public function testMultipleChoice() + { + $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => true, + 'multiple' => true, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name[]"] + [@class="my&class form-select"] + [@required="required"] + [@multiple="multiple"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testMultipleChoiceAttributes() + { + $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_attr' => ['Choice&B' => ['class' => 'foo&bar']], + 'required' => true, + 'multiple' => true, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name[]"] + [@class="my&class form-select"] + [@required="required"] + [@multiple="multiple"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][@class="foo&bar"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testMultipleChoiceNonRequired() + { + $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => false, + 'multiple' => true, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name[]"] + [@class="my&class form-select"] + [@multiple="multiple"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testRadioRowWithHelp() + { + $form = $this->factory->createNamed('name', RadioType::class, false); + $html = $this->renderRow($form->createView(), ['label' => 'foo', 'help' => 'really helpful text']); + + $this->assertMatchesXpath($html, + '/div + [@class="mb-3"] + [ + ./p + [@class="form-text mb-0 help-text"] + [.="[trans]really helpful text[/trans]"] + ] +' + ); + } + + public function testFile() + { + $form = $this->factory->createNamed('name', FileType::class); + + $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'n/a', 'attr' => ['class' => 'my&class']], + '/input + [@type="file"] + [@name="name"] + [@class="my&class form-control"] +' + ); + } + + public function testFileWithGroup() + { + $form = $this->factory->createNamed('name', FileType::class); + $html = $this->renderRow($form->createView(), [ + 'id' => 'n/a', + 'attr' => [ + 'class' => 'my&class', + ], + 'row_attr' => [ + 'class' => 'input-group mb-3', + ], + ]); + + $this->assertMatchesXpath($html, + '/div + [@class="input-group mb-3"] + [ + ./label + [@class="input-group-text required"] + [.="[trans]Name[/trans]"] + /following-sibling::input + [@type="file"] + [@name="name"] + [@class="my&class form-control"] + ] +' + ); + } + + public function testFileWithPlaceholder() + { + self::markTestSkipped('Placeholder does not apply on input file.'); + } + + public function testCountry() + { + $form = $this->factory->createNamed('name', CountryType::class, 'AT'); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [./option[@value="AT"][@selected="selected"][.="Austria"]] + [count(./option)>200] +' + ); + } + + public function testCountryWithPlaceholder() + { + $form = $this->factory->createNamed('name', CountryType::class, 'AT', [ + 'placeholder' => 'Select&Country', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Country[/trans]"]] + [./option[@value="AT"][@selected="selected"][.="Austria"]] + [count(./option)>201] +' + ); + } + + public function testDateTime() + { + $form = $this->factory->createNamed('name', DateTimeType::class, date('Y').'-02-03 04:05:06', [ + 'input' => 'string', + 'with_seconds' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="visually-hidden"] + /following-sibling::div + [@class="input-group"] + [ + + ./select + [@id="name_date_month"] + [@class="form-select"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_date_day"] + [@class="form-select"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_date_year"] + [@class="form-select"] + [./option[@value="'.date('Y').'"][@selected="selected"]] + ] + /following-sibling::div + [@class="visually-hidden"] + /following-sibling::div + [@class="input-group"] + [ + ./select + [@id="name_time_hour"] + [@class="form-select"] + [./option[@value="4"][@selected="selected"]] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_time_minute"] + [@class="form-select"] + [./option[@value="5"][@selected="selected"]] + ] + ] + [count(.//select)=5] +' + ); + } + + public function testDateTimeWithPlaceholderGlobal() + { + $form = $this->factory->createNamed('name', DateTimeType::class, null, [ + 'input' => 'string', + 'placeholder' => 'Change&Me', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="visually-hidden"] + /following-sibling::div + [@class="input-group"] + [ + ./select + [@id="name_date_month"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_date_day"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_date_year"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + ] + /following-sibling::div + [@class="visually-hidden"] + /following-sibling::div + [@class="input-group"] + [ + ./select + [@id="name_time_hour"] + [@class="form-select"] + [./option[@value=""][.="[trans]Change&Me[/trans]"]] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_time_minute"] + [@class="form-select"] + [./option[@value=""][.="[trans]Change&Me[/trans]"]] + ] + ] + [count(.//select)=5] +' + ); + } + + public function testDateTimeWithHourAndMinute() + { + $data = ['year' => date('Y'), 'month' => '2', 'day' => '3', 'hour' => '4', 'minute' => '5']; + + $form = $this->factory->createNamed('name', DateTimeType::class, $data, [ + 'input' => 'array', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="visually-hidden"] + /following-sibling::div + [@class="input-group"] + [ + ./select + [@id="name_date_month"] + [@class="form-select"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_date_day"] + [@class="form-select"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_date_year"] + [@class="form-select"] + [./option[@value="'.date('Y').'"][@selected="selected"]] + ] + /following-sibling::div + [@class="visually-hidden"] + /following-sibling::div + [@class="input-group"] + [ + ./select + [@id="name_time_hour"] + [@class="form-select"] + [./option[@value="4"][@selected="selected"]] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_time_minute"] + [@class="form-select"] + [./option[@value="5"][@selected="selected"]] + ] + ] + [count(.//select)=5] +' + ); + } + + public function testDateTimeWithSeconds() + { + $form = $this->factory->createNamed('name', DateTimeType::class, date('Y').'-02-03 04:05:06', [ + 'input' => 'string', + 'with_seconds' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="visually-hidden"] + /following-sibling::div + [@class="input-group"] + [ + ./select + [@id="name_date_month"] + [@class="form-select"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_date_day"] + [@class="form-select"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_date_year"] + [@class="form-select"] + [./option[@value="'.date('Y').'"][@selected="selected"]] + ] + /following-sibling::div + [@class="visually-hidden"] + /following-sibling::div + [@class="input-group"] + [ + ./select + [@id="name_time_hour"] + [@class="form-select"] + [./option[@value="4"][@selected="selected"]] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_time_minute"] + [@class="form-select"] + [./option[@value="5"][@selected="selected"]] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_time_second"] + [@class="form-select"] + [./option[@value="6"][@selected="selected"]] + ] + ] + [count(.//select)=6] +' + ); + } + + public function testDateTimeSingleText() + { + $form = $this->factory->createNamed('name', DateTimeType::class, '2011-02-03 04:05:06', [ + 'input' => 'string', + 'date_widget' => 'single_text', + 'time_widget' => 'single_text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./input + [@type="date"] + [@id="name_date"] + [@name="name[date]"] + [@class="form-control"] + [@value="2011-02-03"] + /following-sibling::input + [@type="time"] + [@id="name_time"] + [@name="name[time]"] + [@class="form-control"] + [@value="04:05"] + ] +' + ); + } + + public function testDateChoice() + { + $form = $this->factory->createNamed('name', DateType::class, date('Y').'-02-03', [ + 'input' => 'string', + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_month"] + [@class="form-select"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_day"] + [@class="form-select"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_year"] + [@class="form-select"] + [./option[@value="'.date('Y').'"][@selected="selected"]] + ] + [count(./select)=3] + ] +' + ); + } + + public function testDateChoiceWithPlaceholderGlobal() + { + $form = $this->factory->createNamed('name', DateType::class, null, [ + 'input' => 'string', + 'widget' => 'choice', + 'placeholder' => 'Change&Me', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_month"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_day"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_year"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + ] + [count(./select)=3] + ] +' + ); + } + + public function testDateChoiceWithPlaceholderOnYear() + { + $form = $this->factory->createNamed('name', DateType::class, null, [ + 'input' => 'string', + 'widget' => 'choice', + 'required' => false, + 'placeholder' => ['year' => 'Change&Me'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_month"] + [@class="form-select"] + [./option[@value="1"]] + /following-sibling::select + [@id="name_day"] + [@class="form-select"] + [./option[@value="1"]] + /following-sibling::select + [@id="name_year"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + ] + [count(./select)=3] + ] +' + ); + } + + public function testDateText() + { + $form = $this->factory->createNamed('name', DateType::class, '2011-02-03', [ + 'input' => 'string', + 'widget' => 'text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./input + [@id="name_month"] + [@type="text"] + [@class="form-control"] + [@value="2"] + /following-sibling::input + [@id="name_day"] + [@type="text"] + [@class="form-control"] + [@value="3"] + /following-sibling::input + [@id="name_year"] + [@type="text"] + [@class="form-control"] + [@value="2011"] + ] + [count(./input)=3] + ] +' + ); + } + + public function testBirthDay() + { + $form = $this->factory->createNamed('name', BirthdayType::class, '2000-02-03', [ + 'input' => 'string', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_month"] + [@class="form-select"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_day"] + [@class="form-select"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_year"] + [@class="form-select"] + [./option[@value="2000"][@selected="selected"]] + ] + [count(./select)=3] + ] +' + ); + } + + public function testBirthDayWithPlaceholder() + { + $form = $this->factory->createNamed('name', BirthdayType::class, '1950-01-01', [ + 'input' => 'string', + 'placeholder' => '', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_month"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.=""]] + [./option[@value="1"][@selected="selected"]] + /following-sibling::select + [@id="name_day"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.=""]] + [./option[@value="1"][@selected="selected"]] + /following-sibling::select + [@id="name_year"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.=""]] + [./option[@value="1950"][@selected="selected"]] + ] + [count(./select)=3] + ] +' + ); + } + + public function testLanguage() + { + $form = $this->factory->createNamed('name', LanguageType::class, 'de'); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [./option[@value="de"][@selected="selected"][.="German"]] + [count(./option)>200] +' + ); + } + + public function testLocale() + { + $form = $this->factory->createNamed('name', LocaleType::class, 'de_AT'); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [./option[@value="de_AT"][@selected="selected"][.="German (Austria)"]] + [count(./option)>200] +' + ); + } + + public function testMoney() + { + $form = $this->factory->createNamed('name', MoneyType::class, 1234.56, [ + 'currency' => 'EUR', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'my&class']], + '/div + [@class="input-group"] + [ + ./span + [@class="input-group-text"] + [contains(.., "€")] + /following-sibling::input + [@id="my&id"] + [@type="text"] + [@name="name"] + [@class="my&class form-control"] + [@value="1234.56"] + ] +' + ); + } + + public function testPercent() + { + $form = $this->factory->createNamed('name', PercentType::class, 0.1, ['rounding_mode' => \NumberFormatter::ROUND_CEILING]); + + $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'my&class']], + '/div + [@class="input-group"] + [ + ./input + [@id="my&id"] + [@type="text"] + [@name="name"] + [@class="my&class form-control"] + [@value="10"] + /following-sibling::span + [@class="input-group-text"] + [contains(.., "%")] + ] +' + ); + } + + public function testPercentCustomSymbol() + { + $form = $this->factory->createNamed('name', PercentType::class, 0.1, ['symbol' => '‱', 'rounding_mode' => \NumberFormatter::ROUND_CEILING]); + $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'my&class']], + '/div + [@class="input-group"] + [ + ./input + [@id="my&id"] + [@type="text"] + [@name="name"] + [@class="my&class form-control"] + [@value="10"] + /following-sibling::span + [@class="input-group-text"] + [contains(.., "‱")] + ] +' + ); + } + + public function testRange() + { + $form = $this->factory->createNamed('name', RangeType::class, 42, ['attr' => ['min' => 5]]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/input + [@type="range"] + [@name="name"] + [@value="42"] + [@min="5"] + [@class="my&class form-range"] +' + ); + } + + public function testRangeWithMinMaxValues() + { + $form = $this->factory->createNamed('name', RangeType::class, 42, ['attr' => ['min' => 5, 'max' => 57]]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/input + [@type="range"] + [@name="name"] + [@value="42"] + [@min="5"] + [@max="57"] + [@class="my&class form-range"] +' + ); + } + + public function testColor() + { + $color = '#0000ff'; + $form = $this->factory->createNamed('name', ColorType::class, $color); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/input + [@type="color"] + [@name="name"] + [@class="my&class form-control form-control-color"] + [@value="#0000ff"] +' + ); + } + + public function testTime() + { + $form = $this->factory->createNamed('name', TimeType::class, '04:05:06', [ + 'input' => 'string', + 'with_seconds' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_hour"] + [@class="form-select"] + [not(@size)] + [./option[@value="4"][@selected="selected"]] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_minute"] + [@class="form-select"] + [not(@size)] + [./option[@value="5"][@selected="selected"]] + ] + [count(./select)=2] + ] +' + ); + } + + public function testTimeWithSeconds() + { + $form = $this->factory->createNamed('name', TimeType::class, '04:05:06', [ + 'input' => 'string', + 'with_seconds' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_hour"] + [@class="form-select"] + [not(@size)] + [./option[@value="4"][@selected="selected"]] + [count(./option)>23] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_minute"] + [@class="form-select"] + [not(@size)] + [./option[@value="5"][@selected="selected"]] + [count(./option)>59] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_second"] + [@class="form-select"] + [not(@size)] + [./option[@value="6"][@selected="selected"]] + [count(./option)>59] + ] + [count(./select)=3] + ] +' + ); + } + + public function testTimeText() + { + $form = $this->factory->createNamed('name', TimeType::class, '04:05:06', [ + 'input' => 'string', + 'widget' => 'text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./input + [@type="text"] + [@id="name_hour"] + [@name="name[hour]"] + [@class="form-control"] + [@value="04"] + [@required="required"] + [not(@size)] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::input + [@type="text"] + [@id="name_minute"] + [@name="name[minute]"] + [@class="form-control"] + [@value="05"] + [@required="required"] + [not(@size)] + ] + [count(./input)=2] + ] +' + ); + } + + public function testTimeWithPlaceholderGlobal() + { + $form = $this->factory->createNamed('name', TimeType::class, null, [ + 'input' => 'string', + 'placeholder' => 'Change&Me', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_hour"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + [count(./option)>24] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_minute"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + [count(./option)>60] + ] + [count(./select)=2] + ] +' + ); + } + + public function testTimeWithPlaceholderOnYear() + { + $form = $this->factory->createNamed('name', TimeType::class, null, [ + 'input' => 'string', + 'required' => false, + 'placeholder' => ['hour' => 'Change&Me'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./div + [@class="input-group"] + [ + ./select + [@id="name_hour"] + [@class="form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + [count(./option)>24] + /following-sibling::span + [@class="input-group-text"] + /following-sibling::select + [@id="name_minute"] + [./option[@value="1"]] + [count(./option)>59] + ] + [count(./select)=2] + ] +' + ); + } + + public function testTimezone() + { + $form = $this->factory->createNamed('name', TimezoneType::class, 'Europe/Vienna'); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [./option[@value="Europe/Vienna"][@selected="selected"][.="Europe / Vienna"]] + [count(.//option)>200] +' + ); + } + + public function testTimezoneWithPlaceholder() + { + $form = $this->factory->createNamed('name', TimezoneType::class, null, [ + 'placeholder' => 'Select&Timezone', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/select + [@class="my&class form-select"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Timezone[/trans]"]] + [count(.//option)>201] +' + ); + } + + public function testWeekChoices() + { + $this->requiresFeatureSet(404); + + $data = ['year' => (int) date('Y'), 'week' => 1]; + + $form = $this->factory->createNamed('name', WeekType::class, $data, [ + 'input' => 'array', + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./select + [@id="name_year"] + [@class="form-select"] + [./option[@value="'.$data['year'].'"][@selected="selected"]] + /following-sibling::select + [@id="name_week"] + [@class="form-select"] + [./option[@value="'.$data['week'].'"][@selected="selected"]] + ] + [count(.//select)=2]' + ); + } + + public function testFloatingLabel() + { + $form = $this->factory->createNamed('name', TextType::class, null, [ + 'attr' => [ + 'placeholder' => 'name', + ], + 'row_attr' => [ + 'class' => 'form-floating mb-3', + ], + ]); + + $html = $this->renderRow($form->createView()); + + $this->assertMatchesXpath($html, + '/div + [@class="form-floating mb-3"] + [ + ./input + [@id="name"] + [@placeholder="[trans]name[/trans]"] + /following-sibling::label + [@for="name"] + ] +' + ); + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5HorizontalLayoutTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5HorizontalLayoutTest.php new file mode 100644 index 0000000000..042cbf0e40 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5HorizontalLayoutTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use Symfony\Bridge\Twig\Extension\FormExtension; +use Symfony\Bridge\Twig\Extension\TranslationExtension; +use Symfony\Bridge\Twig\Form\TwigRendererEngine; +use Symfony\Bridge\Twig\Tests\Extension\Fixtures\StubTranslator; +use Symfony\Component\Form\FormRenderer; +use Symfony\Component\Form\FormView; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Twig\Environment; +use Twig\Loader\FilesystemLoader; + +/** + * Class providing test cases for the Bootstrap 5 horizontal Twig form theme. + * + * @author Romain Monteil + */ +class FormExtensionBootstrap5HorizontalLayoutTest extends AbstractBootstrap5HorizontalLayoutTest +{ + use RuntimeLoaderProvider; + + protected $testableFeatures = [ + 'choice_attr', + ]; + + private $renderer; + + protected function setUp(): void + { + parent::setUp(); + + $loader = new FilesystemLoader([ + __DIR__.'/../../Resources/views/Form', + __DIR__.'/Fixtures/templates/form', + ]); + + $environment = new Environment($loader, ['strict_variables' => true]); + $environment->addExtension(new TranslationExtension(new StubTranslator())); + $environment->addExtension(new FormExtension()); + + $rendererEngine = new TwigRendererEngine([ + 'bootstrap_5_horizontal_layout.html.twig', + 'custom_widgets.html.twig', + ], $environment); + $this->renderer = new FormRenderer($rendererEngine, $this->getMockBuilder(CsrfTokenManagerInterface::class)->getMock()); + $this->registerTwigRuntimeLoader($environment, $this->renderer); + } + + protected function renderForm(FormView $view, array $vars = []): string + { + return (string) $this->renderer->renderBlock($view, 'form', $vars); + } + + protected function renderLabel(FormView $view, $label = null, array $vars = []): string + { + if (null !== $label) { + $vars += ['label' => $label]; + } + + return (string) $this->renderer->searchAndRenderBlock($view, 'label', $vars); + } + + protected function renderHelp(FormView $view): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'help'); + } + + protected function renderErrors(FormView $view): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'errors'); + } + + protected function renderWidget(FormView $view, array $vars = []): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'widget', $vars); + } + + protected function renderRow(FormView $view, array $vars = []): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'row', $vars); + } + + protected function renderRest(FormView $view, array $vars = []): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'rest', $vars); + } + + protected function renderStart(FormView $view, array $vars = []): string + { + return (string) $this->renderer->renderBlock($view, 'form_start', $vars); + } + + protected function renderEnd(FormView $view, array $vars = []): string + { + return (string) $this->renderer->renderBlock($view, 'form_end', $vars); + } + + protected function setTheme(FormView $view, array $themes, $useDefaultThemes = true): void + { + $this->renderer->setTheme($view, $themes, $useDefaultThemes); + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5LayoutTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5LayoutTest.php new file mode 100644 index 0000000000..1bf4a315fa --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionBootstrap5LayoutTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use Symfony\Bridge\Twig\Extension\FormExtension; +use Symfony\Bridge\Twig\Extension\TranslationExtension; +use Symfony\Bridge\Twig\Form\TwigRendererEngine; +use Symfony\Bridge\Twig\Tests\Extension\Fixtures\StubTranslator; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\MoneyType; +use Symfony\Component\Form\FormRenderer; +use Symfony\Component\Form\FormView; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Twig\Environment; +use Twig\Loader\FilesystemLoader; + +/** + * Class providing test cases for the Bootstrap 5 Twig form theme. + * + * @author Romain Monteil + */ +class FormExtensionBootstrap5LayoutTest extends AbstractBootstrap5LayoutTest +{ + use RuntimeLoaderProvider; + + /** + * @var FormRenderer + */ + private $renderer; + + protected function setUp(): void + { + parent::setUp(); + + $loader = new FilesystemLoader([ + __DIR__.'/../../Resources/views/Form', + __DIR__.'/Fixtures/templates/form', + ]); + + $environment = new Environment($loader, ['strict_variables' => true]); + $environment->addExtension(new TranslationExtension(new StubTranslator())); + $environment->addExtension(new FormExtension()); + + $rendererEngine = new TwigRendererEngine([ + 'bootstrap_5_layout.html.twig', + 'custom_widgets.html.twig', + ], $environment); + $this->renderer = new FormRenderer($rendererEngine, $this->getMockBuilder(CsrfTokenManagerInterface::class)->getMock()); + $this->registerTwigRuntimeLoader($environment, $this->renderer); + } + + public function testStartTagHasNoActionAttributeWhenActionIsEmpty() + { + $form = $this->factory->create(FormType::class, null, [ + 'method' => 'get', + 'action' => '', + ]); + + $html = $this->renderStart($form->createView()); + + self::assertSame('
', $html); + } + + public function testStartTagHasActionAttributeWhenActionIsZero() + { + $form = $this->factory->create(FormType::class, null, [ + 'method' => 'get', + 'action' => '0', + ]); + + $html = $this->renderStart($form->createView()); + + self::assertSame('', $html); + } + + public function testMoneyWidgetInIso() + { + $environment = new Environment(new FilesystemLoader([ + __DIR__.'/../../Resources/views/Form', + __DIR__.'/Fixtures/templates/form', + ]), ['strict_variables' => true]); + $environment->addExtension(new TranslationExtension(new StubTranslator())); + $environment->addExtension(new FormExtension()); + $environment->setCharset('ISO-8859-1'); + + $rendererEngine = new TwigRendererEngine([ + 'bootstrap_5_layout.html.twig', + 'custom_widgets.html.twig', + ], $environment); + $this->renderer = new FormRenderer($rendererEngine, $this->getMockBuilder(CsrfTokenManagerInterface::class)->getMock()); + $this->registerTwigRuntimeLoader($environment, $this->renderer); + + $view = $this->factory + ->createNamed('name', MoneyType::class) + ->createView(); + + self::assertSame(<<<'HTML' +
+HTML + , trim($this->renderWidget($view))); + } + + protected function renderForm(FormView $view, array $vars = []): string + { + return (string) $this->renderer->renderBlock($view, 'form', $vars); + } + + protected function renderLabel(FormView $view, $label = null, array $vars = []): string + { + if (null !== $label) { + $vars += ['label' => $label]; + } + + return (string) $this->renderer->searchAndRenderBlock($view, 'label', $vars); + } + + protected function renderHelp(FormView $view): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'help'); + } + + protected function renderErrors(FormView $view): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'errors'); + } + + protected function renderWidget(FormView $view, array $vars = []): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'widget', $vars); + } + + protected function renderRow(FormView $view, array $vars = []): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'row', $vars); + } + + protected function renderRest(FormView $view, array $vars = []): string + { + return (string) $this->renderer->searchAndRenderBlock($view, 'rest', $vars); + } + + protected function renderStart(FormView $view, array $vars = []): string + { + return (string) $this->renderer->renderBlock($view, 'form_start', $vars); + } + + protected function renderEnd(FormView $view, array $vars = []): string + { + return (string) $this->renderer->renderBlock($view, 'form_end', $vars); + } + + protected function setTheme(FormView $view, array $themes, $useDefaultThemes = true): void + { + $this->renderer->setTheme($view, $themes, $useDefaultThemes); + } +}