eca-1.0.x-dev/src/Plugin/FormFieldPluginTrait.php
src/Plugin/FormFieldPluginTrait.php
<?php namespace Drupal\eca\Plugin; use Drupal\Component\Utility\NestedArray; use Drupal\Component\Utility\Xss; use Drupal\Core\Entity\EntityFormInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Element; use Drupal\Core\TypedData\ComplexDataDefinitionInterface; /** * Trait for ECA plugins making use of a form field. * * Plugins must have a "field_name" configuration key. */ trait FormFieldPluginTrait { use FormPluginTrait; /** * Whether the lookup automatically jumps to a real field element as target. * * @var bool */ protected bool $automaticJumpToFieldElement = TRUE; /** * The lookup keys to use, respecting their occurring order. * * Values are either one of "parents" or "array_parents". * * @var string[] */ protected array $lookupKeys = ['parents', 'array_parents']; /** * Whether to use form field value filters or not. * * Mostly only relevant when working with submitted input values. * * @var bool */ protected bool $useFilters = TRUE; /** * Get a default configuration array regarding a form field. * * @return array * The array of default configuration. */ protected function defaultFormFieldConfiguration(): array { $default = ['field_name' => '']; if ($this->useFilters) { $default += [ 'strip_tags' => TRUE, 'trim' => TRUE, 'xss_filter' => TRUE, ]; } return $default; } /** * Builds the configuration form regarding a form field. * * @param array $form * An associative array containing the initial structure of the plugin form. * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form. Calling code should pass on a subform * state created through \Drupal\Core\Form\SubformState::createForSubform(). * * @return array * The form structure. */ protected function buildFormFieldConfigurationForm(array $form, FormStateInterface $form_state): array { $form['field_name'] = [ '#type' => 'textfield', '#title' => $this->t('Field name'), '#description' => $this->t('The input name of the form field. This is mostly found in the "name" attribute of an <input> form element. <em>For submit buttons within content forms:</em> Use "submit" for the labeled "Save" button, and "preview" for the labeled "Preview" button.'), '#default_value' => $this->configuration['field_name'], '#required' => TRUE, '#weight' => -50, '#eca_token_replacement' => TRUE, ]; if ($this->useFilters) { $form['strip_tags'] = [ '#type' => 'checkbox', '#title' => $this->t('Strip tags'), '#description' => $this->t('Whether stripping all <em>HTML</em> and <em>PHP</em> tags or not.'), '#default_value' => $this->configuration['strip_tags'], '#weight' => -10, ]; $form['trim'] = [ '#type' => 'checkbox', '#title' => $this->t('Trim'), '#description' => $this->t('Whether stripping all whitespaces at the beginning and end or not.'), '#default_value' => $this->configuration['trim'], '#weight' => -9, ]; $form['xss_filter'] = [ '#type' => 'checkbox', '#title' => $this->t('Filter XSS'), '#description' => $this->t('Additionally filters out possible cross-site scripting (XSS) text.'), '#default_value' => $this->configuration['xss_filter'], '#weight' => -8, ]; } return $form; } /** * Validation handler regarding form field configuration. * * @param array $form * The form array. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state. */ public function validateFormFieldConfigurationForm(array &$form, FormStateInterface $form_state): void { } /** * Submit handler regarding form field configuration. * * @param array $form * The form array. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state. */ protected function submitFormFieldConfigurationForm(array &$form, FormStateInterface $form_state): void { $this->configuration['field_name'] = $form_state->getValue('field_name'); if ($this->useFilters) { $this->configuration['strip_tags'] = !empty($form_state->getValue('strip_tags')); $this->configuration['trim'] = !empty($form_state->getValue('trim')); $this->configuration['xss_filter'] = !empty($form_state->getValue('xss_filter')); } } /** * Filters the given form field value with enabled filter methods. * * @param mixed &$value * The value to apply filtering on. */ protected function filterFormFieldValue(mixed &$value): void { $config = &$this->configuration; if (!$config['trim'] && !$config['strip_tags'] && !$config['xss_filter']) { return; } if (is_array($value)) { array_walk_recursive($value, function (&$v) { $this->filterFormFieldValue($v); }); } elseif (is_scalar($value) || is_null($value) || (is_object($value) && method_exists($value, '__toString'))) { $value = (string) $value; if ($config['trim']) { $value = trim($value); } if ($config['strip_tags']) { $value = strip_tags($value); } if ($config['xss_filter']) { $value = Xss::filter($value); } } } /** * Get a single field name as normalized array for accessing form components. * * @return array * The normalized array. */ protected function getFieldNameAsArray(): array { return array_filter(explode('[', str_replace(']', '[', $this->configuration['field_name'])), static function ($value) { return $value !== ''; }); } /** * Get the targeted form element specified by the configured form field name. * * @return array|null * The target element, or NULL if not found. */ protected function &getTargetElement(): ?array { $nothing = NULL; if (!($form = &$this->getCurrentForm()) || !($name_array = $this->getFieldNameAsArray())) { return $nothing; } $key = array_pop($name_array); foreach ($this->lookupFormElements($form, $key) as &$element) { if (empty($name_array) || (isset($element['#parents']) && array_intersect($name_array, $element['#parents']) === $name_array) || (isset($element['#array_parents']) && array_intersect($name_array, $element['#array_parents']) === $name_array)) { // Found an element due to defined parents or array_parents. if (!isset($element['#type']) && $this->automaticJumpToFieldElement && Element::children($element)) { // Some field widgets are additionally nested. And since we need // a form field element here, catch the first defined child element. $element = &$this->jumpToFirstFieldChild($element); } return $element; } // For early form builds, parents and array_parents may not be available. // For such a case, have another deep look into the render array. $lookup = NULL; $parents = []; $lookup = static function (array &$elements, array &$name_array) use (&$lookup, &$parents) { if ($parent = &NestedArray::getValue($elements, $name_array)) { if (isset($parent['widget'])) { // Automatically jump to the widget form element, as it's being // build by \Drupal\Core\Field\WidgetBase::form(). $parents[] = &$parent['widget']; } else { $parents[] = &$parent; } } else { $top_name = (string) reset($name_array); foreach (Element::children($elements) as $c_key) { if ($top_name === (string) $c_key) { $sub_name_array = $name_array; array_shift($sub_name_array); $lookup($elements[$c_key], $sub_name_array); break; } else { $lookup($elements[$c_key], $name_array); } } } }; $lookup($form, $name_array); foreach ($parents as &$parent) { if (isset($parent[$key]) && ($parent[$key] === $element)) { return $element; } } unset($parent); } unset($element); // Although not officially supported, try to get a target element using // either "." or ":" as a separator for nested form elements. The official // separator format is "][", which will be used for another try here. // Not replacing "." and ":" at once, because there may be nested forms // making use of both (e.g. "configuration.plugin.type:id"). $field_name = $this->configuration['field_name']; if (mb_strpos($field_name, '.')) { $this->configuration['field_name'] = str_replace('.', '][', $field_name); return $this->getTargetElement(); } if (mb_strpos($field_name, ':')) { $this->configuration['field_name'] = str_replace(':', '][', $field_name); return $this->getTargetElement(); } return $nothing; } /** * Helper function to jump to the first child of an entity field in a form. * * @param array &$element * The form element that may contain the child. This variable will be * changed as it is being passed as reference. * * @return array * The child element as reference. */ protected function &jumpToFirstFieldChild(array &$element): array { 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 (isset($element[0])) { // Automatically jump to the first element. $element = &$element[0]; } // Try to get the main property name and address it if not specified // otherwise. $main_property = 'value'; $form_object = $this->getCurrentFormState() ? $this->getCurrentFormState()->getFormObject() : NULL; if ($form_object instanceof EntityFormInterface) { $entity = $form_object->getEntity(); if ($entity instanceof FieldableEntityInterface) { $name_array = $this->getFieldNameAsArray(); $field_name = array_shift($name_array); if ($entity->hasField($field_name)) { $item_definition = $entity->get($field_name)->getFieldDefinition()->getItemDefinition(); if ($item_definition instanceof ComplexDataDefinitionInterface) { $main_property = $item_definition->getMainPropertyName() ?? 'value'; } } } } if (isset($element[$main_property])) { // Automatically jump to the main property key. $element = &$element[$main_property]; } return $element; } /** * Helper method for ::getTargetElement() to get form element candidates. * * @param mixed &$element * The current element in scope. * @param mixed $key * The key to lookup. * @param bool $is_root_call * (optional) This is a recursive function, and this flag indicates whether * the invocation is the root one. * * @return array * The found element candidates. */ protected function lookupFormElements(mixed &$element, mixed $key, bool $is_root_call = TRUE): array { $found = []; $lookup_keys = $this->lookupKeys; foreach ($lookup_keys as $lookup_key) { switch ($lookup_key) { case 'parents': $this->lookupKeys = ['parents']; foreach (Element::children($element) as $child_key) { if ((isset($element[$child_key]['#name']) && $element[$child_key]['#name'] === $key) || (isset($element[$child_key]['#parents']) && in_array($key, $element[$child_key]['#parents'], TRUE))) { $found[] = &$element[$child_key]; } else { /* @noinspection SlowArrayOperationsInLoopInspection */ $found = array_merge($found, $this->lookupFormElements($element[$child_key], $key, FALSE)); } } break; case 'array_parents': $this->lookupKeys = ['array_parents']; // Alternatively, traverse along the keys of the form build array. foreach (Element::children($element) as $child_key) { if (((string) $child_key === (string) $key) || (isset($element[$child_key]['#array_parents']) && in_array($key, $element[$child_key]['#array_parents'], TRUE))) { $found[] = &$element[$child_key]; } else { /* @noinspection SlowArrayOperationsInLoopInspection */ $found = array_merge($found, $this->lookupFormElements($element[$child_key], $key, FALSE)); } } break; } if ($found) { break; } } $this->lookupKeys = $lookup_keys; if ($is_root_call) { // Sort the found elements from the smallest number of parents to the // highest number of parents. When a specified form element key defines // a subset of parent keys, then this sorting makes sure, that the element // with the highest probability of exact match will be used. uasort($found, function ($a, $b) { return count($a['#parents'] ?? []) - count($b['#parents'] ?? []); }); } return $found; } /** * Get the submitted value specified by the configured form field name. * * @param mixed|null &$found * (Optional) Stores a boolean whether a value was found. * * @return mixed * The submitted value. May return NULL if no submitted value exists. */ protected function &getSubmittedValue(mixed &$found = NULL): mixed { // Initialize the value and found state. $value = NULL; if ($found === NULL) { $found = FALSE; } if (!($form_state = $this->getCurrentFormState())) { return $value; } $field_name_array = $this->getFieldNameAsArray(); $values = &$form_state->getValues(); $user_input = &$form_state->getUserInput(); if (!$found && $values) { $value = &$this->getFirstNestedOccurrence($field_name_array, $values, $found); } if (!$found && $user_input) { $value = &$this->getFirstNestedOccurrence($field_name_array, $user_input, $found); } if (!$found) { // Although not officially supported, try to get a submitted value using // either "." or ":" as a separator for nested form elements. The official // separator format is "][", which will be used for another try here. // Not replacing "." and ":" at once, because there may be nested forms // making use of both (e.g. "configuration.plugin.type:id"). $field_name = $this->configuration['field_name']; if (mb_strpos($field_name, '.')) { $this->configuration['field_name'] = str_replace('.', '][', $field_name); return $this->getSubmittedValue($found); } if (mb_strpos($field_name, ':')) { $this->configuration['field_name'] = str_replace(':', '][', $field_name); return $this->getSubmittedValue($found); } } return $value; } /** * Helper method to get the first occurrence of $key in the given array. * * @param array &$keys * The nested keys to lookup. * @param array &$array * The array to look into. * @param mixed|null &$found * (Optional) Stores a boolean whether a value was found. * * @return mixed * The found element as reference. Returns NULL if not found. */ protected function &getFirstNestedOccurrence(array &$keys, array &$array, mixed &$found = NULL): mixed { $value = &NestedArray::getValue($array, $keys, $found); if ($found) { return $value; } foreach ($array as &$v) { if (is_array($v)) { $value = &$this->getFirstNestedOccurrence($keys, $v, $found); if ($found) { return $value; } } } $nothing = NULL; return $nothing; } }