fieldry-1.0.x-dev/src/FieldItemRemovalElement.php
src/FieldItemRemovalElement.php
<?php
namespace Drupal\fieldry;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Html;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Field\WidgetInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Utility class for encapsulating a field item removal button.
*/
class FieldItemRemovalElement {
use StringTranslationTrait;
/**
* Create a new instance of the FieldItemRemovalElement helper object.
*
* @param string $fieldName
* The name of the field being worked on.
* @param class-string $widgetClass
* The field widget plugin class for building and processing the field
* edit form elements on entity forms.
*/
public function __construct(protected string $fieldName, protected $widgetClass) {
}
/**
* Determine if the field removal actions should apply to this widget.
*
* @param \Drupal\Core\Field\WidgetInterface $widget
* Field widget for editing the field.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* Field definition of the field being checked if it is compatible.
*
* @return bool
* TRUE if the field item removal functionality is available for this field
* and field widget.
*/
public static function isApplicable(WidgetInterface $widget, FieldDefinitionInterface $field_definition): bool {
$fieldStorage = $field_definition->getFieldStorageDefinition();
if ($fieldStorage->getCardinality() === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) {
// Paragraphs have their own implementations of field item removal.
//
// @todo Create way to detect other instances which implement their own
// field item removal. These can be disabled using the field widget
// configuration, but ideally these would be identified and skipped.
if ($fieldStorage->getSetting('target_type') === 'paragraph' || $field_definition->getTargetEntityTypeId() === 'entity_subqueue') {
return FALSE;
}
return !$field_definition->isComputed() && empty($widget->getPluginDefinition()['multiple_values']);
}
return FALSE;
}
/**
* Get the current settings for the field removal button.
*
* @param \Drupal\Core\Field\WidgetInterface $widget
* Field widget for editing the field.
*
* @return bool
* TRUE if the field item removal functionality is enabled for this widget,
* otherwise return FALSE.
*/
public static function isEnabled(WidgetInterface $widget): bool {
return (bool) ($widget->getThirdPartySetting('fieldry', 'field_item_removal') ?? TRUE);
}
/**
* Build the form settings form for the field item removal buttons.
*
* @param \Drupal\Core\Field\WidgetInterface $widget
* Field widget for editing the field.
*
* @return array
* Form elements for configuring the field removal button settings.
*/
public static function buildSettingsForm(WidgetInterface $widget) {
return [
'field_item_removal' => [
'#type' => 'checkbox',
'#title' => t('Include a button for removing field items'),
'#default_value' => static::isEnabled($widget),
],
];
}
/**
* Get the array of items that are the form element parent keys.
*
* @param array $element
* Array containing the form structure of the field widget form elements.
*
* @return string[]
* An array of array index keys which act as a trail through the field
* field parent structure. Should be similar to the normal form['#parents'].
*/
protected static function getFieldParents(array $element): array {
$parents = [];
if (isset($element['#field_parents'])) {
return $element['#field_parents'];
}
elseif (isset($element['#parents'])) {
return $element['#parents'];
}
else {
foreach (Element::children($element) as $child) {
if (isset($element[$child]['#field_parents'])) {
return $element[$child]['#field_parents'];
}
}
}
return $parents;
}
/**
* Create an ID for the form element wrapper in a consistent way.
*
* @param array $parents
* Either the field parents or element value parents as an array.
*
* @return string
* An HTML cleaned CSS identifier based on the field and its parents.
*/
protected function getElementId(array $parents): string {
$parents[] = $this->fieldName;
return Html::cleanCssIdentifier(implode('-', $parents));
}
/**
* Add remove buttons to each of the field items.
*
* @param \Drupal\Core\Field\FieldItemListInterface $items
* List of field items available for this field.
* @param array $element
* The widget form element for editing these field items.
*/
public function addRemoveButtons(FieldItemListInterface $items, array &$element): void {
$parents = static::getFieldParents($element);
$elementId = $this->getElementId($parents);
// Try to detect the HTML wrapper ID attrribute, otherwise build one.
$wrapperId = preg_match('/\s*id=("|\')([\w_-]+)\1/', $element['#prefix'], $matches)
? $matches[2] : $elementId . '-add-more-wrapper';
// For each of the field items add a button control to remove that item.
foreach ($items as $delta => $item) {
if (empty($element[$delta])) {
continue;
}
$element[$delta]['fieldry_item_removal'] = [
'#theme_wrappers' => ['html_button__fieldry_item_remove'],
'#type' => 'html_button',
'#name' => Html::cleanCssIdentifier("{$elementId}-remove-{$delta}"),
'#executes_submit_callback' => TRUE,
'#field_delta' => $delta,
'#value' => $this->t('Remove'),
'#weight' => 99,
'#limit_validation_errors' => [],
'#submit' => [[$this, 'submitFieldRemoval']],
'#ajax' => [
'callback' => static::class . '::ajaxFieldRemoval',
'wrapper' => $wrapperId,
],
'content' => [
'#markup' => $this->t('Remove'),
],
];
}
}
/**
* Form submit callback for removing a specific field delta.
*
* @param array &$form
* Full structure of the built Drupal form being submitted.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* State and storage for the form values and metadata.
*/
public function submitFieldRemoval(array &$form, FormStateInterface $form_state): void {
$fieldName = $this->fieldName;
$trigger = $form_state->getTriggeringElement();
$delta = $trigger['#field_delta'];
$parents = array_slice($trigger['#array_parents'], 0, -2);
$element = &NestedArray::getValue($form, $parents);
$fieldParents = static::getFieldParents($element);
$widgetState = $this->widgetClass::getWidgetState($fieldParents, $fieldName, $form_state);
$input = &$form_state->getUserInput();
$values = NestedArray::getValue($input, $element['#parents']);
unset($element[$delta], $values[$delta]);
if (!empty($widgetState['items_count'])) {
--$widgetState['items_count'];
/** @var \Drupal\Core\Entity\EntityForm $entityForm */
$entityForm = $form_state->getFormObject();
$items = $entityForm->getEntity()->{$fieldName};
// When going from 1 item to 0, Drupal will try to rebuild this widget
// with the entity field values, which causes the field to get repopulated
// when after removing all items this widget should be empty.
if (empty($widgetState['items_count'])) {
$items->setValue([]);
unset($widgetState['original_deltas']);
}
else {
$items->removeItem($delta);
// Attempt to keep the information about the origin deltas of field
// items as this helps with some complex fields like entity references.
$deltas = [];
foreach ($values as $key => $value) {
$deltas[] = $widgetState['original_deltas'][$key] ?? NULL;
}
$widgetState['original_deltas'] = array_filter($deltas);
}
$this->widgetClass::setWidgetState($fieldParents, $fieldName, $form_state, $widgetState);
}
// Reset the item weights to avoid having an invalid weight value that
// causes items to shift order after widget rebuild. The max allowed
// weight value is equal to the number of items (which we just changed).
//
// EX: 3 items, largest weight is 3 - when 1 item is removed, largest
// allowed becomes 2. So any weight value of 3 is now invalid, and the
// number now overflows to -2 (the new lowest) and is thrown to the top.
$cleaned = [];
$weight = 0;
foreach ($values as $value) {
$value['_weight'] = $weight++;
$cleaned[] = $value;
}
NestedArray::setValue($input, $element['#parents'], $cleaned, TRUE);
$form_state->setRebuild(TRUE);
}
/**
* Form AJAX callback for the removal of a field item.
*
* @param array &$form
* Reference to the built structure of the entity field form being updated.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* Current form state, user input and storage for the processed form.
*
* @return \Drupal\Core\Ajax\AjaxResponse|array
* An AjaxResponse populated with the commands to execute on the
* user client.
*/
public static function ajaxFieldRemoval(array &$form, FormStateInterface $form_state): array|AjaxResponse {
$trigger = $form_state->getTriggeringElement();
$parents = array_slice($trigger['#array_parents'], 0, -2);
$element = &NestedArray::getValue($form, $parents);
return $element;
}
}
