Commit 671d81e9 authored by Pierre Dureau's avatar Pierre Dureau
Browse files

feat: #3508641 Define form elements from SDC

By: pdureau
By: grimreaper
By: d34dman
By: nod_
By: mglaman
By: catch
parent 7410b513
Loading
Loading
Loading
Loading
Loading
+29 −3
Original line number Diff line number Diff line
@@ -2,8 +2,9 @@

namespace Drupal\Core\Render\Element;

use Drupal\Core\Render\Attribute\RenderElement;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Component\Exception\InvalidComponentDataException;
use Drupal\Core\Render\Attribute\FormElement;
use Drupal\Core\Render\Element;
use Drupal\Core\Security\DoTrustedCallbackTrait;
use Drupal\Core\Template\Attribute;
@@ -33,9 +34,16 @@
 * @endcode
 *
 * @see \Drupal\Core\Render\Element\Textarea
 *
 * Implements FormElementInterface so that ElementInfoManager recognizes this
 * as a form element (sets #input and #value_callback), enabling it to
 * participate in form processing without inheriting the additional static
 * methods from FormElementBase that are not applicable to components.
 *
 * @see \Drupal\Core\Form\FormBuilder::handleInputElement()
 */
#[RenderElement('component')]
class ComponentElement extends RenderElementBase {
#[FormElement('component')]
class ComponentElement extends RenderElementBase implements FormElementInterface {

  use DoTrustedCallbackTrait;

@@ -78,6 +86,13 @@ public function preRenderComponent(array $element): array {
      unset($element[$key]);
    }

    // This component is a form component.
    // @see \Drupal\Core\Form\FormBuilder::handleInputElement().
    if (!empty($element['#name'])) {
      $props['form_state']['value']['name'] = $element['#name'];
      $props['form_state']['value']['required'] = $element['#required'] ?? FALSE;
    }

    $inline_template = $this->generateComponentTemplate(
      $element['#component'],
      $element['#slots'],
@@ -180,6 +195,17 @@ private function mergeElementAttributesToPropAttributes(array &$element): void {
    $element['#props']['attributes'] = $element_attributes->merge($prop_attributes);
  }

  /**
   * {@inheritdoc}
   *
   * Returns NULL to let the Form API fall back to #default_value or #value.
   * Components delegate actual value rendering to the Twig template via the
   * form_state prop, so no server-side value transformation is needed here.
   */
  public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
    return NULL;
  }

  /**
   * {@inheritdoc}
   */
+12 −0
Original line number Diff line number Diff line
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: "Test textfield"
status: experimental
slots:
  label:
    title: "Label"
props:
  type: object
  properties:
    id:
      title: ID
      type: string
+18 −0
Original line number Diff line number Diff line
{% set input_attributes = create_attribute({
  type: 'text',
  name: form_state.value.name,
  id: id|default('test-sdc-text-field-' ~ random()),
  value: form_state.value.value,
}) %}

{% if form_state.value.required %}
  {% set input_attributes = input_attributes.setAttribute('required', true) %}
{% endif %}

<div{{ attributes }}>
  <label class="form-label" for="{{ input_attributes.id }}">
    {{ label }}
  </label>
  <input{{ input_attributes }}>
</div>
+297 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\KernelTests\Components;

use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Template\Attribute;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests the correct rendering of components in form.
 */
#[RunTestsInSeparateProcesses]
#[Group('sdc')]
class ComponentAsFormElementTest extends ComponentKernelTestBase implements FormInterface {

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'system',
    'sdc_test',
  ];

  /**
   * {@inheritdoc}
   */
  protected static $themes = ['sdc_theme_test'];

  /**
   * {@inheritdoc}
   */
  public function getFormId(): string {
    return 'component_as_form_element_in_form_test';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state): array {

    $form['sdc_input'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
    ];

    $form['sdc_input_basic'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#default_value' => 'test_data_default_value_basic',
    ];

    $form['sdc_input_with_label'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#slots' => [
        'label' => [
          '#type' => 'html_tag',
          '#tag' => 'span',
          '#attributes' => [
            'id' => 'test_data_label_container',
          ],
          'content' => [
            '#markup' => 'test_data_label',
          ],
        ],
      ],
    ];

    $form['sdc_input_with_default_value'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#default_value' => 'test_data_default_value',
    ];

    $form['sdc_input_with_value'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#value' => 'test_data_value',
    ];

    $form['sdc_input_with_value_and_default_value'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#default_value' => 'test_data_default_value',
      '#value' => 'test_data_value',
    ];

    $form['sdc_input_with_required'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#required' => TRUE,
    ];

    $form['sdc_input_with_id_as_prop'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#props' => [
        'id' => 'test_sdc_input_prop_id',
      ],
    ];

    $form['sdc_input_with_id_as_prop_attributes'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#props' => [
        'attributes' => new Attribute(
          [
            'id' => 'test_sdc_input_prop_attributes_id',
          ]
        ),
      ],
    ];

    $form['sdc_input_with_validation'] = [
      '#type' => 'component',
      '#component' => 'sdc_theme_test:input',
      '#default_value' => 'test_data_valid_value',
      '#element_validate' => [
        [
          $this,
          'customValidator',
        ],
      ],
    ];

    $form['actions'] = [
      '#type' => 'actions',
      'submit' => [
        '#type' => 'submit',
        '#value' => 'Submit',
      ],
    ];

    return $form;
  }

  /**
   * Validation callback for a datetime element.
   *
   * If the date is valid, the date object created from the user input is set in
   * the form for use by the caller. The work of compiling the user input back
   * into a date object is handled by the value callback, so we can use it here.
   * We also have the raw input available for validation testing.
   *
   * @param array $element
   *   The form element whose value is being validated.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current state of the form.
   * @param array $complete_form
   *   The complete form structure.
   */
  public static function customValidator(&$element, FormStateInterface $form_state, &$complete_form): void {
    $input_exists = FALSE;
    $input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists);

    // Example: Only allow 'test_data_valid_value' as valid.
    if ($input !== "test_data_valid_value") {
      $form_state->setError($element, "Invalid value provided.");
    }
  }

  /**
   * {@inheritdoc}
   */
  public function validateForm(array &$form, FormStateInterface $form_state): void {
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state): void {
    // Check that submitted data are present (set with #default_value).
    $data = [
      'sdc_input' => '',
      'sdc_input_basic' => 'test_data_default_value_basic',
      'sdc_input_with_label' => '',
      'sdc_input_with_default_value' => 'test_data_default_value',
      'sdc_input_with_value' => 'test_data_value',
      'sdc_input_with_value_and_default_value' => 'test_data_value',
      'sdc_input_with_id_as_prop' => '',
      'sdc_input_with_id_as_prop_attributes' => '',
    ];
    foreach ($data as $key => $value) {
      $this->assertSame($value, $form_state->getValue($key));
    }
  }

  /**
   * Tests that fields validation messages are sorted in the fields order.
   */
  public function testFormRenderingAndSubmission(): void {
    /** @var \Drupal\Core\Form\FormBuilderInterface $form_builder */
    $form_builder = \Drupal::service('form_builder');
    /** @var \Drupal\Core\Render\RendererInterface $renderer */
    $renderer = \Drupal::service('renderer');
    $form = $form_builder->getForm($this);

    // Test form rendering.
    $markup = $renderer->renderRoot($form);
    $this->setRawContent($markup);

    // Ensure form elements are rendered once.
    $this->assertCount(1, $this->cssSelect('input[name="sdc_input"]'), 'The sdc_input textfield should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name="sdc_input_basic"]'), 'The sdc_input_basic textfield should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name="sdc_input_with_label"]'), 'The sdc_input_with_label textfield should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('span[id="test_data_label_container"]'), 'The span with id "test_data_label_container" should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name="sdc_input_with_default_value"]'), 'The sdc_input_with_default_value textfield should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name="sdc_input_with_value"]'), 'The sdc_input_with_value textfield should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name="sdc_input_with_value_and_default_value"]'), 'The sdc_input_with_value_and_default_value textfield should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name="sdc_input_with_required"]'), 'The sdc_input_with_required textfield should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name="sdc_input_with_id_as_prop"]'), 'The sdc_input_with_id_as_prop textfield should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[id=test_sdc_input_prop_id]'), 'A textfield with id "test_sdc_input_prop_id" should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name=sdc_input_with_id_as_prop]'), 'A sdc_input with id "sdc_input_with_id_as_prop" should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('input[name=sdc_input_with_id_as_prop_attributes]'), 'A sdc_input with id "sdc_input_with_id_as_prop_attributes" should have been rendered once.');
    $this->assertCount(1, $this->cssSelect('div[id=test_sdc_input_prop_attributes_id]'), 'A div wrapper with id "test_sdc_input_prop_attributes_id" should have been rendered once.');

    // Check the position of the form elements in the DOM.
    $paths = [
      '//form/div[1]/input[@name="sdc_input"]',
      '//form/div[2]/input[@name="sdc_input_basic"]',
      '//form/div[3]/input[@name="sdc_input_with_label"]',
      '//form/div[4]/input[@name="sdc_input_with_default_value"]',
      '//form/div[5]/input[@name="sdc_input_with_value"]',
      '//form/div[6]/input[@name="sdc_input_with_value_and_default_value"]',
      '//form/div[7]/input[@name="sdc_input_with_required"]',
      '//form/div[8]/input[@name="sdc_input_with_id_as_prop"]',
      '//form/div[9]/input[@name="sdc_input_with_id_as_prop_attributes"]',
    ];

    foreach ($paths as $path) {
      $this->assertNotEmpty($this->xpath($path), 'There should be a result with the path: ' . $path . '.');
    }

    // Test form submission. Assertions are in submitForm().
    $form_state = new FormState();

    $form_builder->submitForm($this, $form_state);
  }

  /**
   * Tests that #element_validate works as expected.
   */
  public function testElementValidateCallback(): void {
    /** @var \Drupal\Core\Form\FormBuilderInterface $form_builder */
    $form_builder = \Drupal::service('form_builder');

    // Build the form.
    $form_builder->getForm($this);

    // Simulate form submission with a value that should pass validation.
    $form_state = new FormState();
    $form_state->setValues([
      'sdc_input_with_required' => 'test_data_required_value',
      'sdc_input_with_validation' => 'test_data_valid_value',
    ]);
    $form_builder->submitForm($this, $form_state);

    // There should be no errors for valid value.
    $this->assertFalse($form_state->hasAnyErrors(), "No errors should be set for valid value.");

    // Simulate form submission with a value that should fail validation because
    // an invalid value is provided.
    $form_state = new FormState();
    $form_state->setValues([
      'sdc_input_with_required' => 'test_data_required_value',
      'sdc_input_with_validation' => 'invalid_value',
    ]);
    // You may need to adjust your customValidator to actually set
    // an error for this value.
    $form_builder->submitForm($this, $form_state);

    // There should be an error for invalid value.
    $this->assertTrue($form_state->hasAnyErrors(), "An error should be set for invalid value.");
    $this->assertArrayHasKey('sdc_input_with_validation', $form_state->getErrors(), "An error should be set for invalid value on sdc_input_with_validation.");

    // Simulate form submission with a value that should fail
    // validation because an invalid value is provided.
    $form_state = new FormState();
    $form_state->setValues([
      'sdc_input_with_validation' => 'test_data_valid_value',
    ]);
    // You may need to adjust your customValidator
    // to actually set an error for this value.
    $form_builder->submitForm($this, $form_state);

    // There should be an error for invalid value.
    $this->assertTrue($form_state->hasAnyErrors(), "An error should be set when required value is not provided.");
    $this->assertArrayHasKey('sdc_input_with_required', $form_state->getErrors(), "An error should be set for required field sdc_input_with_required.");
  }

}