eca-1.0.x-dev/modules/form/src/Plugin/Action/FormAddAjax.php
modules/form/src/Plugin/Action/FormAddAjax.php
<?php
namespace Drupal\eca_form\Plugin\Action;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\eca\Attribute\EcaAction;
use Drupal\eca\Plugin\DataType\DataTransferObject;
use Drupal\eca_form\Hook\FormHooks;
/**
* Adds an Ajax handler to a form.
*/
#[Action(
id: 'eca_form_add_ajax',
label: new TranslatableMarkup('Form: add Ajax handler'),
type: 'form',
)]
#[EcaAction(
description: new TranslatableMarkup('Enhances an existing form field element with an Ajax handler for refreshing parts of a form without refreshing the whole page.'),
version_introduced: '1.0.0',
)]
class FormAddAjax extends FormFieldActionBase {
/**
* Whether to use form field value filters or not.
*
* @var bool
*/
protected bool $useFilters = FALSE;
/**
* Temporarily holds the most recent form array build, if provided externally.
*
* @var array|null
*/
protected ?array $currentForm = NULL;
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return [
'disable_validation_errors' => FALSE,
'validate_fields' => '',
'target' => '',
] + parent::defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
$form['disable_validation_errors'] = [
'#type' => 'checkbox',
'#title' => $this->t('Disable validation errors'),
'#description' => $this->t('Enable this option to completely suppress validation errors.'),
'#default_value' => $this->configuration['disable_validation_errors'],
'#weight' => -10,
];
$form['validate_fields'] = [
'#type' => 'textfield',
'#title' => $this->t('Validate form fields'),
'#description' => $this->t('Machine names of form fields that should be validated. Define multiple values separated with commas. Example: <em>first_name,last_name</em>. When no fields are defined at all and validation is not disabled above, then the whole form will be validated.'),
'#weight' => -9,
'#default_value' => $this->configuration['validate_fields'],
];
$form['target'] = [
'#type' => 'textfield',
'#title' => $this->t('Target'),
'#description' => $this->t('The machine name of the form element target to refresh via Ajax. When empty, then the whole form will be refreshed.'),
'#weight' => -8,
'#default_value' => $this->configuration['target'],
];
$form = parent::buildConfigurationForm($form, $form_state);
$form['field_name']['#description'] .= ' ' . $this->t('When this form element got Ajax handling attached, using this element will automatically submit the form. Therefore, you can react upon that with regular form events like <em>Build form</em>, <em>Submit form</em> and when validation is enabled, also <em>Validate form</em>.');
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
$this->configuration['disable_validation_errors'] = !empty($form_state->getValue('disable_validation_errors'));
$this->configuration['validate_fields'] = $form_state->getValue('validate_fields');
$this->configuration['target'] = $form_state->getValue('target');
parent::submitConfigurationForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = parent::access($object, $account, TRUE);
if (trim($this->configuration['target']) !== '') {
$original_field_name = $this->configuration['field_name'];
$this->configuration['field_name'] = $this->tokenService->replace($this->configuration['target']);
$result = $result->andIf(AccessResult::allowedIf(!is_null($this->getTargetElement())));
$this->configuration['field_name'] = $original_field_name;
}
return $return_as_object ? $result : $result->isAllowed();
}
/**
* {@inheritdoc}
*/
protected function doExecute(): void {
$element = &$this->getTargetElement();
$target_name = '';
if (isset($element['widget'])) {
// Automatically jump to the widget form element, as it's being build
// by \Drupal\Core\Field\WidgetBase::form().
$element = &$element['widget'];
}
if (trim($this->configuration['target']) !== '') {
$original_field_name = $this->configuration['field_name'];
$target_name = $this->tokenService->replace($this->configuration['target']);
$this->configuration['field_name'] = $target_name;
$target_element = &$this->getTargetElement();
$this->configuration['field_name'] = $original_field_name;
}
else {
$form_state = $this->getCurrentFormState();
if ($form_state && ($target_name = $form_state->getFormObject()->getFormId())) {
$target_element = &$this->getCurrentForm();
}
else {
$target_element = NULL;
}
}
if (!$element || !$target_element) {
return;
}
$wrapper_id = Html::getUniqueId($target_name . '-ajax-wrapper');
$target_element['#prefix'] = '<div id="' . $wrapper_id . '">' . ($target_element['#prefix'] ?? '');
$target_element['#suffix'] = ($target_element['#suffix'] ?? '') . '</div>';
$element['#ajax'] = [
'callback' => [$this, 'ajax'],
'wrapper' => $wrapper_id,
'method' => 'html',
];
$element['#executes_submit_callback'] = TRUE;
if ($this->configuration['disable_validation_errors']) {
$element['#limit_validation_errors'] = [];
}
// Re-focus on fields could mean that you will never get away
// from them. To avoid this, use the option to disable refocus.
$element['#ajax']['disable-refocus'] = TRUE;
$validate_fields = trim($this->configuration['validate_fields']) !== '' ? DataTransferObject::buildArrayFromUserInput((string) $this->tokenService->replace($this->configuration['validate_fields'])) : [];
if (!empty($validate_fields)) {
// These are the supported separators. The first one is the official one,
// the others are unofficially supported.
// @see \Drupal\eca\Plugin\FormFieldPluginTrait::getTargetElement()
$separators = ['][', ':', '.'];
foreach ($validate_fields as $validate_field) {
foreach ($separators as $separator) {
if (mb_strpos($validate_field, $separator) !== FALSE) {
$validate_field = explode($separator, $validate_field);
break;
}
}
if (!is_array($validate_field)) {
$validate_field = [$validate_field];
}
$element['#limit_validation_errors'][] = $validate_field;
}
}
$submit_handler = [FormHooks::class, 'submit'];
if (empty($element['#submit']) || !in_array($submit_handler, $element['#submit'], TRUE)) {
$element['#submit'][] = $submit_handler;
}
$element['#submit'][] = [static::class, 'ajaxSubmit'];
}
/**
* Ajax callback coming from added handlers.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return array
* The form element to refresh via Ajax.
*/
public function ajax(array $form, FormStateInterface $form_state): array {
if ($triggering_element = &$form_state->getTriggeringElement()) {
$array_parents = $triggering_element['#array_parents'] ?? [];
while ($array_parents) {
$parent_element = &NestedArray::getValue($form, $array_parents);
if ($parent_element && isset($parent_element['#group']) && isset($form[$parent_element['#group']]['#open'])) {
// When the triggering element is placed inside a grouping element,
// the generally expected state of it is to be opened. Otherwise
// it would not be visible to the user.
// @todo Find a common solution to guarantee not losing any opened
// and closed states of form elements when refreshed via Ajax.
$form[$parent_element['#group']]['#open'] = TRUE;
}
array_pop($array_parents);
}
}
if (trim($this->configuration['target']) !== '') {
$original_field_name = $this->configuration['field_name'];
$target_name = $this->tokenService->replace($this->configuration['target']);
$this->configuration['field_name'] = $target_name;
// Use the provided form array build as current form, because this holds
// the most recent state. According events may hold an outdated state.
// Not relevant for the else-block below, this is only relevant when
// requesting for a specific target element.
$this->currentForm = &$form;
$target_element = &$this->getTargetElement();
unset($this->currentForm);
$this->currentForm = NULL;
$this->configuration['field_name'] = $original_field_name;
if ($target_element) {
if (!empty($target_element['#array_parents'])) {
// When provided, use the updated render array build.
return NestedArray::getValue($form, $target_element['#array_parents']) ?? [];
}
return $target_element;
}
}
else {
$element = &$this->getCurrentForm();
if ($element && !empty($element['#array_parents'])) {
// Provided form of the event is a subform, so return only this part.
return NestedArray::getValue($form, $element['#array_parents']) ?? [];
}
return $form;
}
return [];
}
/**
* Ajax submit handler that sets the form to rebuild.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*/
public static function ajaxSubmit(array $form, FormStateInterface $form_state): void {
$form_state->setRebuild();
}
/**
* {@inheritdoc}
*/
protected function &getCurrentForm(): ?array {
if (isset($this->currentForm)) {
return $this->currentForm;
}
$current_form = &parent::getCurrentForm();
return $current_form;
}
}
