module_builder-8.x-3.x-dev/src/Form/ComponentSectionForm.php
src/Form/ComponentSectionForm.php
<?php
namespace Drupal\module_builder\Form;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Security\Attribute\TrustedCallback;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\Core\Utility\Error;
use MutableTypedData\Data\DataItem;
use MutableTypedData\Exception\InvalidInputException;
use Symfony\Component\HttpFoundation\Request;
/**
* Generic form for entering a section of data for a component.
*
* This determines which properties of the component to show from the values of
* the entity type's code_builder annotation.
*
* @see \Drupal\module_builder\EntityHandler\ComponentSectionFormHandler
*/
class ComponentSectionForm extends ComponentFormBase {
/**
* Address suffixes for properties which override to use core autocomplete.
*/
protected const OVERRIDE_CORE_AUTOCOMPLETE = [
':decorates',
];
/**
* Gets the names of properties this form should show.
*
* @return string[]
* An array of property names.
*/
protected function getFormComponentProperties(DataItem $data) {
// Get the list of component properties this section form uses from the
// handler, which gets them from the entity type annotation.
$component_entity_type_id = $this->entity->getEntityTypeId();
$component_sections_handler = $this->entityTypeManager->getHandler($component_entity_type_id, 'component_sections');
$operation = $this->getOperation();
$component_properties_to_use = $component_sections_handler->getSectionFormComponentProperties($operation);
return $component_properties_to_use;
}
/**
* Title callback.
*
* @see \Drupal\module_builder\Routing\ComponentRouteProvider
*/
public function title(Request $request, $entity_type, $op, $title) {
// Get the entity request parameter. We can't use it as a function parameter
// because we want this to work with any entity type.
$entity = $request->attributes->get($entity_type);
return $this->t($title, [
'%label' => $entity->label(),
]);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
$this->prepareFormStateData($form, $form_state);
$form = $this->componentPropertiesForm($form, $form_state);
$form['#attached']['library'][] = 'module_builder/component_form';
$form['#attached']['library'][] = 'module_builder/typed_data_defaults';
$form['#attached']['drupalSettings']['moduleBuilder']['typedDataDefaults']['defaults'] = [];
return $form;
}
/**
* Sets the data object for the form's entity in the form state.
*
* This can be obtained with:
* @code
* $component_data = $form_state->get('data');
* @endcode
*
* Storing it in the form state ensures it is available during AJAX rebuilds.
*
* @param $form
* The form array.
* @param FormStateInterface $form_state
* The form state object.
*/
protected function prepareFormStateData($form, FormStateInterface $form_state): void {
if (!$form_state->has('data')) {
// The first time we show this form, create the typed data object.
$component_data = $this->getComponentDataObject();
$form_state->set('data', $component_data);
}
}
/**
* Add form elements for the specified component properties.
*
* @param $form
* The form array.
* @param FormStateInterface $form_state
* The form state object.
*
* @return
* The form array.
*/
protected function componentPropertiesForm($form, FormStateInterface $form_state) {
$component_data = $form_state->get('data');
// Get the properties that this form section should show.
$component_properties_to_use = $this->getFormComponentProperties($component_data);
// WTF during an AJAX call this is no longer set?!?
if (!$form_state->has('component_properties_checked')) {
// Warn about properties in the entity annotation that are not in the
// data.
$undefined_properties = array_diff($component_properties_to_use, array_keys($component_data->getProperties()));
foreach ($undefined_properties as $property_name) {
$this->messenger()->addError(t("The property '@name' is not defined in Drupal Code Builder. You should ensure you are using an up-to-date version.", [
'@name' => $property_name,
]));
}
$form_state->set('component_properties_checked', TRUE);
}
$component_properties_to_use = array_intersect($component_properties_to_use, array_keys($component_data->getProperties()));
// Set #tree on the data element.
$component_type = $this->entity->getComponentType();
$form[$component_type]['#tree'] = TRUE;
// We need to set our own version of #parents on our form elements,
// because some of our buttons need it for #limit_validation_errors. Setting
// #limit_validation_errors in a #process callback doesn't work because it
// breaks in form submit. We use our own attribute, even though its value
// should be identical to #parents, just in case it's not, so it
// doesn't get overwritten by FormBuilder.
$form[$component_type]['#mb_parents'] = [$component_type];
foreach ($component_properties_to_use as $property_name) {
$this->buildDataItemFormElement($form[$component_type], $form_state, $component_data->{$property_name});
}
// Put the data back into the form state, as the building of the form
// elements may have caused changes.
$form_state->set('data', $component_data);
// Developer trapdoor: disable AJAX for easier debugging of the form.
if (FALSE) {
// TODO: AAAAAARGH why can't this be done with Iterator classes???
static::removeAjax($form);
}
return $form;
}
/**
* Helper to remove all ajax from the form.
*
* Use this when debugging, as if an ajax request is crashing, it's best to
* turn ajax off and use normal submission to see the error messages
* immediately rather than pick them out of the log.
*/
protected static function removeAjax(&$element) {
foreach ($element as $key => &$value) {
if (is_array($value) && isset($value['#ajax'])) {
unset($value['#ajax']);
}
if (is_array($value)) {
static::removeAjax($value);
}
}
}
/**
* Adds the form element for a data item into the form array.
*
* This is called recursively for complex and multi-valued data items.
*
* @param array &$form
* The parent form element (or the entire form), passed by reference. The
* data item's element is placed with an array key that is its machine
* name. This is expected to have the '#mb_parents' attribute set.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param \MutableTypedData\Data\DataItem $data
* The data item.
*/
protected function buildDataItemFormElement(&$form, FormStateInterface $form_state, DataItem $data): void {
$element = [];
$element['#tree'] = TRUE;
$form_key = $data->getName();
$element['#mb_parents'] = $form['#mb_parents'];
$element['#mb_parents'][] = $form_key;
// Determine whether to handle multiple data as a single element or a set
// of deltas.
if ($data->isMultiple() && $data->isComplex()) {
// Complex multiple-valued data gets a details wrapper. This contains
// either:
// - A table showing all delta items
// - A list of delta items which are built as components.
// In both cases, items can be added and removed with AJAX buttons.
if (
!$data->isMutable()
// Need brackets around the assignment so that $delta_properties is
// defined for the subsequent conditions.
&& ($delta_properties = array_filter($data->getDefinition()->getProperties(), fn ($property) => !$property->isInternal()))
&& count($delta_properties) <= 3
&& array_product(array_map(
fn ($property) => !$property->isComplex() && !$property->isMultiple(),
$delta_properties
))
) {
// If the data is not mutable, and all the child properties are simple
// and single-valued, and there aren't too many of them, show a table
// with a row for each delta item.
$element = $this->buildMultipleDeltaTableFormElement($element, $form_state, $data);
}
else {
// Otherwise, show each delta item using recursion into this method.
$element = $this->buildMultipleDeltaFormElement($element, $form_state, $data);
}
}
elseif (!$data->isMultiple() && $data->isComplex()) {
// Complex single-valued element.
$element = $this->buildComplexFormElement($element, $form_state, $data);
}
else {
// Single, simple form element. This covers:
// - Single-valued simple data
// - Multiple-valued simple data, which uses either a textarea element or
// a select element.
$element = $this->buildSingleFormElement($element, $form_state, $data);
}
// Special case for hooks in a test module, as otherwise they're totally
// unwieldy.
if ($data->getName() == 'hooks') {
$element_inner = $element;
$element = [
'#type' => 'details',
'#title' => $data->getLabel(),
];
$element['inner'] = $element_inner;
// Force the #parents so the inner element data is saved correctly, as
// the element overall gets #tree set to TRUE.
$element['inner']['#parents'] = explode(':', $data->getAddress());
}
$form[$form_key] = $element;
}
protected function getFormElementNameFromData($data_item) {
$pieces = explode(':', $data_item->getAddress());
$name = array_shift($pieces);
foreach ($pieces as $piece) {
$name .= "[$piece]";
}
return $name;
}
/**
* Builds a form element for data that uses a single element.
*
* Helper for buildDataItemFormElement().
*
* Note that buildDataItemFormElement() is responsible for some attributes of the
* element.
*/
protected function buildSingleFormElement(array $element, FormStateInterface $form_state, DataItem $data): array {
// Case 3A: element with options.
if ($data->hasOptions()) {
$options = [];
$options_have_descriptions = FALSE;
foreach ($data->getOptions() as $value => $option) {
$options[$value] = $option->getLabel();
if ($description = $option->getDescription()) {
// Set the description on each value. This is a not-terribly-well
// documented feature in FormAPI. This relies on us not clobbering
// $element further on!
// TODO: Do this later, not always used.
$element[$value]['#description'] = $description;
$options_have_descriptions = TRUE;
}
}
$options_count = count($options);
if ($data->isMultiple()) {
$element_type = 'checkboxes';
// Build up a default value array for the checkboxes from the data's
// delta items.
$default_value = [];
foreach ($data as $delta => $delta_item) {
$default_value[$delta_item->value] = $delta_item->value;
}
}
else {
if ($options_count > 8 && !$options_have_descriptions) {
$element_type = 'select';
$default_value = $data->value;
}
else {
$element_type = 'radios';
$default_value = $data->value;
// Add a 'None' option to non-required radios, as otherwise it's
// impossible to unselect something.
if (!$data->isRequired()) {
$options = [
'' => $this->t('None'),
] + $options;
// The incoming value will be NULL; set it to an empty string so
// the 'None' radio is selected.
if (empty($default_value)) {
$default_value = '';
}
}
}
}
$element += [
'#type' => $element_type,
'#title' => $data->getLabel(),
'#description' => $data->getDescription(),
'#default_value' => $default_value,
'#options' => $options,
// ARGH why isn't this happening automatically like it's supposed to?
'#empty_option' => $data->isRequired() ? $this->t('- Select -') : $this->t('- None -'),
'#empty_value' => NULL,
'#description_display' => 'before',
];
if (count($options) > 20) {
$element = $this->buildAutocompleteFormElement($element, $form_state, $data);
}
// Need to account for orphan data, coming from the 'add new variant'
// form element which uses dummy data.
if ($data->isVariantProperty() && $data->getParent()) {
// Put this above the 'Update variant properties' button; compare
// with the weight set on that.
$element['#weight'] = -20;
$wrapper_id = Html::getId($data->getParent()->getAddress() . '-mutable-wrapper');
$variant_property_form_address = explode(':', $data->getAddress());
$variant_property_address = array_merge(
$variant_property_form_address
);
$values = $form_state->getValues();
$variant_value = NestedArray::getValue($values, $variant_property_address);
if (isset($variant_value)) {
$data->set($variant_value);
}
}
}
// Case 3B: boolean element.
elseif ($data->getType() == 'boolean') {
$element += [
'#type' => 'checkbox',
'#title' => $data->getLabel(),
'#description' => $data->getDescription(),
'#default_value' => $data->value,
];
}
// Case 3C: multi-valued plain data.
elseif ($data->isMultiple()) {
$element += [
'#type' => 'textarea',
'#title' => $data->getLabel(),
'#description' => $data->getDescription() . ' ' . t("Enter one item per line."),
'#default_value' => implode("\n", $data->export()),
];
}
// Case 3D: everything else!
else {
$element += [
'#type' => 'textfield',
'#title' => $data->getLabel(),
'#description' => $data->getDescription(),
'#default_value' => $data->value,
];
}
// Make form elements required if the data is required, unless there is
// a default, in which case, either the JS will set it, or data validation
// will set it on submission, so there's no need to force the user to
// enter something.
if ($data->isRequired() && !$data->getDefault()) {
$element['#required'] = TRUE;
}
if ($data->getDefault()) {
$element['#description'] .= ' ' . t("Leave blank for a default value.");
}
$element['#attributes']['data-typed-data-address'] = $data->getAddress();
// dsm($data->getDefault());
// Note need parentheses around the assignment because of precedence
// relative to &&.
if (($default = $data->getDefault()) && $default->getType() == 'expression') {
$expression = $default->getExpressionWithAbsoluteAddresses($data);
// Prefix custom EL functions with the JS namespace.
// TODO: Would be nice to get the names from the EL rather than
// hardcode them!
$expression = str_replace('get(', 'DataAddressExpressionLanguage.get(', $expression);
$expression = str_replace('machineToClass(', 'DataAddressExpressionLanguage.machineToClass(', $expression);
$expression = str_replace('machineToLabel(', 'DataAddressExpressionLanguage.machineToLabel(', $expression);
$expression = str_replace('stripBefore(', 'DataAddressExpressionLanguage.stripBefore(', $expression);
$dependencies = $default->getDependencies();
if (!empty($dependencies)) {
// CHEAT; for now only ever one dependency!
// TODO: this only works for addresses that go up only one level!
$dependencies[0] = str_replace('..:', $data->getParent()->getAddress() . ':', $dependencies[0]);
}
$element['#attached']['drupalSettings']['moduleBuilder']['typedDataDefaults']['defaults'][$data->getAddress()] = [
'dependencies' => $dependencies,
'expression' => $expression,
];
foreach ($dependencies as $dependency) {
$element['#attached']['drupalSettings']['moduleBuilder']['typedDataDefaults']['reactions'][$dependency] = $data->getAddress();
}
}
return $element;
}
/**
* Adds autocomplete to a single form element.
*
* This is used for elements with large options sets. We either use the core
* autocomplete, or our custom requestless autocomplete.
*
* Helper for static::buildSingleFormElement().
*/
protected function buildAutocompleteFormElement(array $element, FormStateInterface $form_state, DataItem $data): array {
$default_value = $element['#default_value'];
$options = $element['#options'];
$use_core_autocomplete = $data->isMultiple();
// Some special cases for core autocomplete. Only check for these if we've
// not already decided on using it.
if (
!$use_core_autocomplete &&
array_sum(
array_map(
fn ($potential) => str_ends_with($data->getAddress(), $potential),
static::OVERRIDE_CORE_AUTOCOMPLETE
)
)
) {
$use_core_autocomplete = TRUE;
}
if ($use_core_autocomplete) {
// Use Drupal core autocomplete for multi-valued elements. This is
// typically injected services.
$element['#type'] = 'textfield';
// This needs to be massive to allow lots of services!
$element['#maxlength'] = 512;
if ($data->isMultiple()) {
$element['#description'] .= ' ' . $this->t("Enter a comma-separated list of names.");
}
$element['#description'] .= ' ' . $this->t("The '_' and '.' characters are treated as interchangeable by the autocomplete search.");
$element['#default_value'] = $data->isMultiple()
? implode(', ', $default_value)
: $default_value;
$element['#autocomplete_route_name'] = 'module_builder.autocomplete';
$element['#autocomplete_route_parameters'] = [
'property_address' => $data->getAddress(),
];
// Remove the options, as it makes FormAPI think the value must be
// compared against them.
unset($element['#options']);
}
elseif (count($options) > 20 && !$data->isMultiple()) {
// Use our custom requestless autocomplete for single-valued elements.
$element['#attributes']['placeholder'] = $this->t('Enter text to filter');
$element['#type'] = 'textfield';
unset($element['#options']);
// Assume that all data with the same name has the same option set!
$name = $data->getName();
// Only build the options for this set the first time; multiple elements
// may use the same option set.
if (!$form_state->has(['autocomplete_option_sets', $name])) {
$options = [];
foreach ($data->getOptions() as $value => $option) {
$option_label = $option->getLabel();
$options[$value] = [
($value == $option_label ? $value : $value . ' - ' . $option_label),
$option->getDescription(),
$option->apiUrl,
];
}
$element['#attached']['drupalSettings']['moduleBuilder']['options'][$name] = $options;
$form_state->set(['autocomplete_option_sets', $name], TRUE);
}
$element['#attributes']['data-option-set'] = $name;
$element['#attributes']['class'][] = 'module-builder-autocomplete';
}
return $element;
}
/**
* Builds a table form element for a multi-valued data item.
*/
protected function buildMultipleDeltaTableFormElement(array $element, FormStateInterface $form_state, DataItem $data): array {
$delta_properties = array_filter($data->getDefinition()->getProperties(), fn ($property) => !$property->isInternal());
// Set up a wrapper for AJAX.
$wrapper_id = Html::getId($data->getAddress() . '-add-more-wrapper');
$element += [
'#type' => 'table',
'#caption' => [
'#markup' =>
'<span class="form-item__label">' . $data->getLabel() . '</span>' .
'<span class="form-item__description">' . $data->getDescription() . '</span>',
],
'#attributes' => [
'id' => $wrapper_id,
'class' => ['hook_methods'],
],
];
if (count($data)) {
foreach ($delta_properties as $delta_property_name => $delta_property) {
$element['#header'][$delta_property_name] = [
'data' => [
'#markup' => $delta_property->getLabel() .
'<div class="form-item__description">' . $delta_property->getDescription() . '</div>'
]]
;
}
$element['#header']['_actions'] = $this->t('Actions');
}
foreach ($data as $delta => $delta_item) {
$element[$delta] = [];
foreach ($delta_item as $delta_property_name => $delta_property) {
$delta_property_element = [];
$delta_property_element = $this->buildSingleFormElement($delta_property_element, $form_state, $delta_item->{$delta_property_name});
unset($delta_property_element['#title']);
unset($delta_property_element['#description']);
$element[$delta][$delta_property_name] = $delta_property_element;
}
$element[$delta][':remove_button'] = [
'#type' => 'submit',
// Needs to be full address for uniquess in the whole form.
'#name' => $delta_item->getAddress() . '_remove_item',
'#data_address' => $delta_item->getAddress(),
'#ajax_parent_slice' => 2,
'#value' => $this->t('Remove @label item @human-index', [
'@label' => $data->getLabel(),
'@human-index' => $delta + 1,
]),
'#limit_validation_errors' => [],
'#submit' => ['::removeItemSubmit'],
'#ajax' => [
'callback' => '::itemButtonAjax',
'wrapper' => $wrapper_id,
'effect' => 'fade',
],
];
}
if ($data->mayAddItem()) {
$element = $this->buildAddItemButton($element, $form_state, $data);
// Move the button into a table row, and adjust its AJAX slice.
$element[':delta_add'][':add_button'] = $element[':add_button'];
unset($element[':add_button']);
$element[':delta_add'][':add_button']['#ajax_parent_slice'] = 2;
if (isset($element['#header'])) {
$element[':delta_add'][':add_button']['#wrapper_attributes'] = ['colspan' => count($element['#header'])];
}
}
return $element;
}
/**
* Builds a multi-valued form element.
*
* Helper for buildDataItemFormElement().
*
* Note that buildDataItemFormElement() is responsible for some attributes of the
* element.
*/
protected function buildMultipleDeltaFormElement(array $element, FormStateInterface $form_state, DataItem $data): array {
// Set up a wrapper for AJAX.
$wrapper_id = Html::getId($data->getAddress() . '-add-more-wrapper');
// Use 'details' rather than 'container' so there's a visual indicator
// of the multi-valued property.
$element += [
'#type' => 'details',
'#title' => $data->getLabel(),
'#description' => $data->getDescription(),
'#open' => TRUE,
'#attributes' => [
'id' => $wrapper_id,
],
];
foreach ($data as $delta => $delta_item) {
$this->buildDataItemFormElement($element, $form_state, $delta_item);
// Set the label on each delta item to differentiate it from the overall
// element label.
$element[$delta]['#title'] = $delta_item->getLabel();
// Add a pre-render and CSS class to move the remove delta button into
// the details element summary.
$element[$delta]['#attributes']['class'][] = 'module-builder-delta-item';
// Adding a pre_render zaps the default ones, so we have to explicitly
// set them for the element to work.
// Core WTF: https://www.drupal.org/project/drupal/issues/1300290
$pre_render = \Drupal::service('plugin.manager.element_info')->getInfoProperty('details', '#pre_render');
$pre_render[] = [static::class, 'preRenderMultipleDeltaFormElement'];
$element[$delta]['#pre_render'] = $pre_render;
$element[$delta][':remove_button'] = [
'#type' => 'submit',
// Needs to be full address for uniquess in the whole form.
'#name' => $delta_item->getAddress() . '_remove_item',
'#data_address' => $delta_item->getAddress(),
'#ajax_parent_slice' => 2,
'#value' => t('Remove @label', [
'@label' => $delta_item->getLabel(),
]),
'#limit_validation_errors' => [],
'#submit' => ['::removeItemSubmit'],
'#ajax' => [
'callback' => '::itemButtonAjax',
'wrapper' => $wrapper_id,
'effect' => 'fade',
],
];
}
if ($data->mayAddItem()) {
$element = $this->buildAddItemButton($element, $form_state, $data);
}
return $element;
}
/**
* Creates a button to add another item to a multiple delta element.
*/
protected function buildAddItemButton(array $element, FormStateInterface $form_state, DataItem $data): array {
$wrapper_id = $element['#attributes']['id'];
if (count($data)) {
$button_label = $this->t('Add another @label item', [
'@label' => $data->getLabel(),
]);
}
else {
$button_label = $this->t('Add a @label item', [
'@label' => $data->getLabel(),
]);
}
if ($data->getDefinition()->isMutable()) {
$properties = $data->getDefinition()->getProperties();
$type_property = reset($properties);
$options = [];
$options[''] = $this->t('- Select @label -', [
'@label' => $type_property->getLabel(),
]);
foreach ($type_property->getOptions() as $value => $option) {
$options[$value] = $option->getLabel();
}
$element[':variant_add'] = [
'#type' => 'fieldset',
'#title' => $this->t('Add a @label item', [
'@label' => $data->getLabel(),
]),
'#attributes' => [
'class' => ['module-builder-variant-add'],
],
];
// Build dummy data for the type property, to create a standalone
// form element for it.
$type_property_dummy_data = \DrupalCodeBuilder\MutableTypedData\DrupalCodeBuilderDataItemFactory::createFromDefinition($type_property);
$type_property_element = [];
$type_property_element = $this->buildSingleFormElement($type_property_element, $form_state, $type_property_dummy_data);
// Remove the required attribute, as when the form is saved this can be
// empty, unlike the type property on actual data.
$type_property_element['#required'] = FALSE;
$type_property_element['#element_validate'] = ['::addVariantItemValidate'];
$element[':variant_add'][':type_property'] = $type_property_element;
$element[':variant_add'][':add_button'] = [
'#type' => 'submit',
'#name' => $data->getAddress() . '_add_more_variant',
'#value' => $button_label,
'#limit_validation_errors' => [
array_merge($element['#mb_parents'], [':variant_add', ':type_property']),
],
'#submit' => ['::addVariantItemSubmit'],
'#data_address' => $data->getAddress(),
'#ajax_parent_slice' => 2,
'#ajax' => [
'callback' => '::itemButtonAjax',
'wrapper' => $wrapper_id,
'effect' => 'fade',
],
'#prefix' => '<div>',
'#suffix' => '</div>',
];
}
else {
$element[':add_button'] = [
'#type' => 'submit',
// This allows FormAPI to figure out which button is the triggering
// element. The name must be unique across all buttons in the form,
// otherwise, the first matching name will be taken by FormAPI as being
// the button that was clicked, with unexpected results.
// See \Drupal\Core\Form\FormBuilder::elementTriggeredScriptedSubmission().
'#name' => $data->getAddress() . '_add_more',
'#value' => $button_label,
'#limit_validation_errors' => [],
'#submit' => ['::addItemSubmit'],
'#data_address' => $data->getAddress(),
'#ajax_parent_slice' => 1,
'#ajax' => [
'callback' => '::itemButtonAjax',
'wrapper' => $wrapper_id,
'effect' => 'fade',
],
'#prefix' => '<div>',
'#suffix' => '</div>',
];
}
return $element;
}
/**
* Pre-render callback for a delta item details element.
*/
#[TrustedCallback]
public static function preRenderMultipleDeltaFormElement (array $element) {
// Move the remove delta button into the details summary. The #title can be
// a render array as well as a string: see template_preprocess_details().
$element['#title'] = [
'title' => [
// Wrap the actual title in a SPAN for the CSS.
'#markup' => '<span>' . $element['#title'] . '</span>',
],
':remove_button' => $element[':remove_button'],
];
unset($element[':remove_button']);
return $element;
}
/**
* Builds a form element with multiple child elements.
*
* Helper for buildDataItemFormElement().
*
* Note that buildDataItemFormElement() is responsible for some attributes of the
* element.
*/
protected function buildComplexFormElement(array $element, FormStateInterface $form_state, DataItem $data): array {
// Set up a wrapper for AJAX.
$wrapper_id = Html::getId($data->getAddress() . '-complex-wrapper');
$element += [
'#type' => 'details',
'#title' => $data->getLabel(),
'#open' => TRUE,
'#attributes' => [
'id' => $wrapper_id,
],
];
// Don't show an optional and single-valued complex element until the user
// requests it and it gets set. This is to keep the form clear, and to
// prevent validation errors of the complex element has required properties
// but the user doesn't want it.
if (!$data->isRequired() && !$data->isSet() && !$data->isDelta()) {
$element[':add_button'] = [
'#type' => 'submit',
// This allows FormAPI to figure out which button is the triggering
// element. The name must be unique across all buttons in the form,
// otherwise, the first matching name will be taken by FormAPI as being
// the button that was clicked, with unexpected results.
// See \Drupal\Core\Form\FormBuilder::elementTriggeredScriptedSubmission().
'#name' => $data->getAddress() . '_add',
'#value' => $this->t('Add @component', [
'@component' => $data->getLabel(),
]),
'#limit_validation_errors' => [],
'#submit' => ['::addComplexDataSubmit'],
'#data_address' => $data->getAddress(),
'#ajax' => [
'callback' => '::complexButtonAjax',
'wrapper' => $wrapper_id,
'effect' => 'fade',
],
'#prefix' => '<div>',
'#suffix' => '</div>',
];
return $element;
}
foreach ($data as $data_item) {
$this->buildDataItemFormElement($element, $form_state, $data_item);
}
if (!$data->isRequired() && !$data->isEmpty() && !$data->isDelta()) {
$element[':remove_button'] = [
'#type' => 'submit',
// This allows FormAPI to figure out which button is the triggering
// element. The name must be unique across all buttons in the form,
// otherwise, the first matching name will be taken by FormAPI as being
// the button that was clicked, with unexpected results.
// See \Drupal\Core\Form\FormBuilder::elementTriggeredScriptedSubmission().
'#name' => $data->getAddress() . '_remove',
'#value' => $this->t('Remove @component', [
'@component' => $data->getLabel(),
]),
'#limit_validation_errors' => [],
'#submit' => ['::removeComplexDataSubmit'],
'#data_address' => $data->getAddress(),
'#ajax' => [
'callback' => '::complexButtonAjax',
'wrapper' => $wrapper_id,
'effect' => 'fade',
],
'#prefix' => '<div>',
'#suffix' => '</div>',
];
}
// NO! has to go immediately after the variant property!
if ($data->isMutable()) {
/** @var \MutableTypedData\Data\MutableData $data */
if (count($data->getProperties()) == 1 && $data->getVariantData()->value) {
$element['count_notice'] = [
'#type' => 'container',
'notice' => [
'#plain_text' => $this->t("The '@value' @variant variant has no additional properties.", [
// TODO: add a better way of getting the option label to MTD.
'@value' => $data->getVariantData()->getOptions()[$data->getVariantData()->value]->getLabel(),
'@variant' => $data->getVariantData()->getLabel(),
]),
],
];
}
// Set up a wrapper for AJAX.
// Note that we don't to use Html::getUniqueId() because the data's
// address is already unique, and furthermore, we don't WANT uniqueness
// because we want the same data item to produce the same HTML ID when
// we're looking at the variant property form element further on.
// TODO: this assumes only one root data item in the form, as another
// data item could have the same addresses!
$wrapper_id = Html::getId($data->getAddress() . '-mutable-wrapper');
$element['#attributes'] = [
'id' => $wrapper_id,
];
// WARNING: assumes the form structure!
$mutable_property_form_address = explode(':', $data->getAddress());
$variant_property_address = array_merge(
$mutable_property_form_address,
[$data->getVariantData()->getName()]
);
// The text for the 'update variant' button depends on whether data has
// been set yet, and whether there is data that a change of variant would
// delete.
if ($data->isEmpty()) {
$update_variant_button_label = $this->t('Set @variant variant', [
'@variant' => $data->getVariantData()->getLabel(),
]);
}
else {
if (count($data->getProperties()) == 1) {
$update_variant_button_label = $this->t('Change @variant variant', [
'@variant' => $data->getVariantData()->getLabel(),
]);
}
else {
$update_variant_button_label = $this->t("Change @variant variant and delete '@value' data for this item", [
'@variant' => $data->getVariantData()->getLabel(),
'@value' => $data->getVariantData()->getOptions()[$data->getVariantData()->value]->getLabel(),
]);
}
}
$element[':update_variant'] = [
'#type' => 'submit',
// Needs to be full address for uniquess in the whole form.
'#name' => $data->getAddress() . '_update_variant',
'#value' => $update_variant_button_label,
// We need to validate the variant property so we get its value in the
// submit handler for this button.
'#limit_validation_errors' => [
$variant_property_address,
],
'#element_validate' => ['::updateVariantValidate'],
'#submit' => ['::updateVariantSubmit'],
'#data_address' => $data->getVariantData()->getAddress(),
'#variant_data_name' => $data->getVariantData()->getName(),
'#ajax' => [
'callback' => '::variantButtonAjax',
'wrapper' => $wrapper_id,
'effect' => 'fade',
],
'#prefix' => '<div>',
'#suffix' => '</div>',
'#weight' => -10,
];
// Also tweak the weight of the variant property so it goes above the
// button to change the variant.
$variant_property_name = $data->getVariantData()->getName();
$element[$variant_property_name]['#weight'] = -20;
}
return $element;
}
/**
* Submission handler for the "Add another item" buttons.
*/
public static function addItemSubmit(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
// Get the data item, using the address set in the button.
$data = $form_state->get('data');
$data_item = $data->getItem($button['#data_address']);
// Add a new delta item.
$data_item->createItem();
$form_state->set('data', $data);
$form_state->setRebuild();
}
/**
* Element validate callback for the variant type add select element.
*/
public static function addVariantItemValidate(&$elements, FormStateInterface &$form_state, &$complete_form) {
$button = $form_state->getTriggeringElement();
// Only validate the variant add select element if a variant add submit
// button was clicked. Otherwise, it's fine to be empty.
if ($button['#submit'][0] != '::addVariantItemSubmit') {
return;
}
// Get the value of the variant property, using the button's
// #limit_validation_errors which point to it in the form.
$variant_property_address = $button['#limit_validation_errors'][0];
$variant_value = $form_state->getValue($variant_property_address);
if (empty($variant_value)) {
$form_state->setError($elements, t('Select a value for the @label to add an item.', [
'@label' => $elements['#title'],
]));
}
}
/**
* Submission handler for the "Add another item" buttons for variants.
*/
public static function addVariantItemSubmit(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
// Get the value of the variant property, using the button's
// #limit_validation_errors which point to it in the form.
$variant_property_address = $button['#limit_validation_errors'][0];
$variant_value = $form_state->getValue($variant_property_address);
// Get the data item, using the address set in the button.
$data = $form_state->get('data');
$data_item = $data->getItem($button['#data_address']);
// Add a new delta item.
$delta_item = $data_item->createItem();
$variant_property_name = array_key_first($delta_item->getProperties());
$delta_item->{$variant_property_name} = $variant_value;
$form_state->set('data', $data);
// Hit it with a big stick so this form element is empty after the AJAX
// update, because it looks silly to the user if it sticks to the value they
// put in for the 'Add another' button. The docs for this method say not to
// touch it, but I've not found another way to clear this value: setValue()
// for example doesn't work.
$input = &$form_state->getUserInput();
NestedArray::setValue($input, $variant_property_address, '');
$form_state->setRebuild();
}
/**
* Submission handler for the "Remove item" buttons.
*/
public static function removeItemSubmit(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
$delta_item_data_address = $button['#data_address'];
// $delta = $button['#input'];
$delta_item_data_form_address = explode(':', $delta_item_data_address);
$multiple_data_form_address = array_slice($delta_item_data_form_address, 0, -1);
// $delta_item_data_form_address[] = $delta;
// Get the data item, using the address set in the button.
$data = $form_state->get('data');
$delta_item = $data->getItem($delta_item_data_address);
$multiple_data_item = $delta_item->getParent();
$delta = $delta_item->getName();
// dsm($multiple_data_item);
unset($multiple_data_item[$delta]);
// Remove the form input for the removed item. The docs for this method say
// not to touch form input, but I've not found another way to clear this
// value: $form_state->setValue() for example doesn't work.
$input = &$form_state->getUserInput();
// wrong, one up!!!!
$multiple_data_input = NestedArray::getValue($input, $multiple_data_form_address);
unset($multiple_data_input[$delta]);
// Re-key the array numerically to remove the gap.
$multiple_data_input = array_values($multiple_data_input);
NestedArray::setValue($input, $multiple_data_form_address, $multiple_data_input);
// must ALSO update form state values, beause
// copyFormValuesToEntity() will read thm.
$multiple_data_form_value = $form_state->getValue($multiple_data_form_address);
unset($multiple_data_form_value[$delta]);
// Re-key the array numerically to remove the gap.
$multiple_data_form_value = array_values($multiple_data_form_value);
$form_state->setValue($multiple_data_form_address, $multiple_data_form_value);
// We could remove any of the items here, but the problem is then that
// FormAPI appears to put the currently entered values back into the form
// elements whose deltas have closed the gap, which makes it look like it
// was the last one that was removed anyway.
// $last_delta = count($multiple_data_item) - 1;
// unset($multiple_data_item[$last_delta]);
$form_state->set('data', $data);
$form_state->setRebuild();
}
/**
* Ajax callback for the item count buttons.
*
* This returns the new page content to replace the page content made obsolete
* by the form submission.
*/
public static function itemButtonAjax(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
if (!isset($button['#ajax_parent_slice'])) {
throw new \Exception("Buttons using the 'itemButtonAjax' callback need the '#ajax_parent_slice' property.");
}
// Go up in the form from the button to get the element to return. How far
// up we go is given by the '#ajax_parent_slice' property.
$button_array_parents = $button['#array_parents'];
$parent_slice = $button['#ajax_parent_slice'];
$widgets_container_parents = array_slice($button_array_parents, 0, -$parent_slice);
$element = NestedArray::getValue($form, $widgets_container_parents);
return $element;
}
public static function addComplexDataSubmit(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
$data = $form_state->get('data');
$complex_data_item = $data->getItem($button['#data_address']);
// Set the data to an empty array.
// Don't use access to instantiate, as that causes problems with recursion:
// see https://github.com/joachim-n/mutable-typed-data/issues/5.
$complex_data_item->set([]);
$form_state->set('data', $data);
$form_state->setRebuild();
}
public static function removeComplexDataSubmit(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
$data = $form_state->get('data');
$complex_data_item = $data->getItem($button['#data_address']);
$complex_data_item->unset();
$form_state->set('data', $data);
$form_state->setRebuild();
}
public static function complexButtonAjax(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
// Go up in the form, to the widgets container.
$button_array_parents = $button['#array_parents'];
$widgets_container_parents = array_slice($button_array_parents, 0, -1);
$element = NestedArray::getValue($form, $widgets_container_parents);
return $element;
}
/**
* Validate handler for the update variant button.
*
* This removes variant-dependent values if the variant has changed.
*/
public static function updateVariantValidate(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
// WTF FormAPI?
// Why does this
if (array_pop($button['#parents']) != ':update_variant') {
return;
}
// Get the value of the mutable data variant property.
$values_address = $button['#parents'];
$values_address[] = $button['#variant_data_name'];
$values = $form_state->getValues();
$submitted_variant_value = NestedArray::getValue($values, $values_address);
// Checking the variant property form element is #required hasn't happened
// yet at this point. So bail and let FormAPI set a form error.
if (empty($submitted_variant_value)) {
return;
}
// Get the containing variant data item, using the address set in the
// button.
$data = $form_state->get('data');
$variant_data_item = $data->getItem($button['#data_address']);
// Clean up the values if the current submission is changing the variant
// property.
// We can't determine whether this is happening by comparing the form
// state's variant value with the data item's variant value, because
// validateForm()'s call to CleanUpValues() has already set the values on
// the data item, and then set that back in the form state. Which is ugly,
// because it means a button validator such as this one doesn't have a
// proper picture of what is going on. TODO: look at core entity forms and
// see whether they set the entity back on the form after building it in
// validation. A quick look suggests that they don't.
$variant_properties = $variant_data_item->getParent()->getProperties();
$complex_values_address = $button['#parents'];
$complex_values = NestedArray::getValue($values, $complex_values_address);
$cleaned_complex_values = array_intersect_key($complex_values, $variant_properties);
NestedArray::setValue($values, $complex_values_address, $cleaned_complex_values);
$form_state->setValues($values);
// TODO: this bit WOULD work if validateForm() weren't updating the data
// item stored in the form state. As it stands, it does nothing because
// the two values will be equal even when the variant is being changed.
if ($submitted_variant_value != $variant_data_item->value) {
$complex_values_address = $button['#parents'];
$complex_values = NestedArray::getValue($values, $complex_values_address);
$complex_values = array_intersect_key($complex_values, [$button['#variant_data_name'] => TRUE]);
NestedArray::setValue($values, $complex_values_address, $complex_values);
$form_state->setValues($values);
}
}
/**
* Submission handler for the "Update variants" buttons.
*/
public static function updateVariantSubmit(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
// dsm($button);
// Get the value of the mutable data variant property.
$values_address = array_slice($button['#parents'], 0, -1);
$values_address[] = $button['#variant_data_name'];
// Get the containing variant data item, using the address set in the
// button.
$data = $form_state->get('data');
$variant_data_item = $data->getItem($button['#data_address']);
$values = $form_state->getValues();
$variant_value = NestedArray::getValue($values, $values_address);
$variant_data_item->value = $variant_value;
$form_state->set('data', $data);
// dsm($data);
$form_state->setRebuild();
}
/**
* Ajax callback for the variant buttons.
*
* This returns the new page content to replace the page content made obsolete
* by the form submission.
*/
public static function variantButtonAjax(array $form, FormStateInterface $form_state) {
$button = $form_state->getTriggeringElement();
// Go up in the form, to the widgets container.
$button_array_parents = $button['#array_parents'];
// Get the address of the containing multiple data item.
// WARNING: this assumes the 'data' form element is at the top in the
// form structure!
$widgets_container_parents = array_slice($button_array_parents, 0, -1);
$element = NestedArray::getValue($form, $widgets_container_parents);
return $element;
}
/**
* Ajax callback for the variant elements.
*
* This returns the new page content to replace the page content made obsolete
* by the form submission.
*/
public static function variantElementAjax(array $form, FormStateInterface $form_state) {
$variant_element = $form_state->getTriggeringElement();
// Go up in the form, to the widgets container.
$variant_element_array_parents = $variant_element['#array_parents'];
// Get the address of the containing multiple data item.
// WARNING: this assumes the 'data' form element is at the top in the
// form structure!
if ($variant_element['#type'] == 'radios') {
$widgets_container_parents = array_slice($variant_element_array_parents, 0, -2);
}
elseif ($variant_element['#type'] == 'select') {
$widgets_container_parents = array_slice($variant_element_array_parents, 0, -1);
}
$element = NestedArray::getValue($form, $widgets_container_parents);
return $element;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
parent::validateForm($form, $form_state);
$data = $form_state->get('data');
// EntityForm::submitForm() has already called $form_state->cleanValues().
$data_values = $form_state->getValue($data->getName());
// ARGH. Because FormValidator does general validation before element or
// button validation, we call this BEFORE button-specific validation can do
// things to clean up things specific to the button's action, such as
// removing data for a changed variant.
$this->cleanUpValues($data_values, $data);
// Clear the properties we use in this form, so we don't merge with what's
// already there. Note that $data is initially loaded from the component
// entity when the form is built.
$component_properties_to_use = $this->getFormComponentProperties($data);
foreach ($component_properties_to_use as $property_name) {
// dsm("CLEAR $property_name");
$data->removeItem($property_name);
}
try {
$data->set($data_values);
}
catch (InvalidInputException $e) {
$form_state->setError($form, $this->t("There was a problem with the form data."));
}
// Validate the data and set any violations as form errors.
// TODO: we've validating the whole data, some of which doesn't appear on
// the form -- but there shouldn't be violations outside of the form, since
// those would have been caught when their form page was saved! In theory!
$violations = $data->validate();
foreach ($violations as $address => $violation_messages) {
$form_address = explode(':', $address);
// Special case for the component root name, as that uses a dedicated
// form element.
if (isset($form_address[1]) && $form_address[1] == 'root_name') {
$form_address = ['id'];
}
$key_exists = NULL;
$form_element = NestedArray::getValue($form, $form_address, $key_exists);
// Some form elements group all the deltas of a data item together, such
// as injected services and textareas. In that case, there is no element
// for the actual delta, and the error should be set on the parent
// address.
if (!$key_exists) {
array_pop($form_address);
// dsm($form_address);
$form_element = NestedArray::getValue($form, $form_address, $key_exists);
}
// Filter the violations to those elements shown on this form section.
// The 2nd element of the address corresponds to the names in the
// $component_properties_to_use array (since the 1st element is 'module).
if (count($form_address) > 1 && !in_array($form_address[1], $component_properties_to_use)) {
continue;
}
if (empty($form_element)) {
$form_element = $form;
}
foreach ($violation_messages as $violation_message) {
// Special handling for an unrecognised value, as this is often caused
// by analysis data being out of date. Hacky sniffing of the error
// message!
if (str_contains($violation_message, 'not one of the options')) {
// Putting the string inside the TranslatableMarkup is bad, but the
// alternative of concatenating stringifies the TranslatableMarkup and
// then causes the HTML of the link to be escaped.
$violation_message = new TranslatableMarkup($violation_message . ' ' . 'Perhaps you need to <a href=":url">re-run code analysis</a>?', [
':url' => Url::fromRoute('module_builder.analyse')->toString(),
]);
}
$form_state->setError($form_element, $violation_message);
}
}
// TODO: not sure we should do this here! it means that element and button
// validators have an incorrect picture of what is going on!
// TODO: figure out why values filled in by validation don't make it to the
// form at this point!
$form_state->set('data', $data);
}
/**
* Copies top-level form values to entity properties
*
* This should not change existing entity properties that are not being edited
* by this form.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity the current form should operate upon.
* @param array $form
* A nested array of form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state) {
$data = $form_state->get('data');
// EntityForm::submitForm() has already called $form_state->cleanValues().
$data_values = $form_state->getValue($data->getName());
$this->cleanUpValues($data_values, $data);
// We need to preserve any data for properties not on this form, but
// completely clear properties that are on this form so we don't merge into
// any existing data.
// Note that $data is initially loaded from the component entity when the
// form is built.
$component_properties_to_use = $this->getFormComponentProperties($data);
foreach ($component_properties_to_use as $property_name) {
$data->removeItem($property_name);
}
try {
$data->set($data_values);
}
catch (InvalidInputException $e) {
\Drupal\Component\Utility\DeprecationHelper::backwardsCompatibleCall(
\Drupal::VERSION,
'10.1.0',
fn() => Error::logException(\Drupal::logger('module_builder'), $e),
fn() => watchdog_exception('module_builder', $e)
);
$this->messenger()->addError($this->t("There was a problem with the form data. The component was not saved."));
return;
}
// Set the name and ID, which use form elements outside of MTD.
// This only applies to the 'name' component section form.
if ($form_state->getValue('id')) {
$data->root_name = $form_state->getValue('id');
$data->readable_name = $form_state->getValue('name');
}
// Let validation fill in required values with defaults, which we didn't
// mark as required in the form.
$data->validate();
$data_export = $data->export();
$entity->set('data', $data_export);
}
/**
* Recursively clean up the submitted form values.
*
* Note that this is called by validateForm(), and so is run BEFORE any
* button-specific validators. This means that it can't rely on any action-
* specific clean up of values.
*
* @param array &$array
* An array of form values, passed by reference. This will be altered in
* place.
* @param \MutableTypedData\Data\DataItem $data
* The data item that corresponds to the form values array.
*/
protected function cleanUpValues(&$array, DataItem $data) {
// Clean up the data for the old variant if mutable data is having its
// variant changed.
if ($data->isMutable() && !$data->isMultiple()) {
$variant_property_name = $data->getVariantData()->getName();
if ($array[$variant_property_name] != $data->getVariantData()->value) {
// The variant property has been changed. Remove everything from the
// array except for the variant property.
$array = [
$variant_property_name => $array[$variant_property_name],
];
}
}
foreach ($array as $key => &$value) {
// Remove buttons.
if (substr($key, 0, 1) == ':') {
unset($array[$key]);
continue;
}
// Single checkbox.
// TODO ARRRGH can't figure out how to safely get a child item in all
// circumstances.
// See https://github.com/joachim-n/mutable-typed-data/issues/3
if (!is_numeric($key) && $data->{$key}->getType() == 'boolean') {
$array[$key] = (bool) $array[$key];
continue;
}
// Remove empty values, so for example, empty checkboxes in a set don't
// try to set an empty value on the data item.
if (is_null($value)) {
unset($array[$key]);
continue;
}
// Remove empty mutable data. This means that we remove the empty option
// during an AJAX call to add a delta to a multi-valued mutable item,
// which can't be set on the data as a variant can't be set to an empty
// value.
// TODO: needs test coverage.
if ($data->isMutable()) {
if (empty($value)) {
unset($array[$key]);
continue;
}
}
if (is_array($value)) {
if (is_numeric($key)) {
$this->cleanUpValues($value, $data[$key]);
}
elseif ($data->{$key}->hasOptions()) {
// We're dealing with checkboxes. Convert the keyed array to an numeric
// array.
$value = array_values(array_filter($value));
}
else {
$this->cleanUpValues($value, $data->{$key});
}
// If the value array is now empty (because recursively cleaning it has
// removed all its keys), remove it.
if (empty($value)) {
unset($array[$key]);
}
// For FKW reasons, the form state values get order mixed up when
// the variant type is set on an additional variant. The new value is
// first in the array, which MTD won't accept because it expects deltas
// to be in the correct order. Values are in the right order in
// updateVariantSubmit(), but in the wrong order when
// when copyFormValuesToEntity() is called.
// I've given up figuring out why FormAPI does this so here is a hack
// to fix the values.
// See https://www.drupal.org/project/module_builder/issues/3173604
if (!empty($value) && is_numeric(array_keys($value)[0])) {
ksort($value);
}
}
else {
// Some single values need special handling too.
if ($data->{$key}->isMultiple() && !$data->{$key}->isComplex() && !$data->{$key}->hasOptions()) {
// Handle a textarea.
// Text area line breaks are weird, apparently.
$values = preg_split("@[\r\n]+@", $value);
$values = array_filter($values);
$array[$key] = $values;
}
elseif ($data->{$key}->isMultiple() && !$data->{$key}->isComplex() && $data->{$key}->hasOptions() && is_string($value)) {
// Form elements that use an autocomplete will produce a single string
// value that needs to be split if the data is multi-valued.
// Autocomplete is used if the data is non-complex and has options.
$value = preg_split("@[,]+@", $value);
$value = array_filter($value);
$array[$key] = array_map('trim', $value);
}
elseif (!is_numeric($key) && $data->{$key}->hasOptions() && !$data->{$key}->isRequired()) {
// Options elements that are not required should be cleaned up, so we
// don't set an empty string as the data value.
if ($value == '' || $value == NULL) {
unset($array[$key]);
}
}
}
}
}
/**
* Builds the form element for a component.
*
* This builds the root level form element, or an element for any part of
* the property info array that is an array of properties. This is recursed
* into by elementCompound().
*
* @param array $property_address
* The property address for the component. This is an array that gives the
* location of this component's properties list in the complete property info array
* in static::$componentDataInfo. For the root, this will be an empty array;
* for a child compound property this will be an address of the form
* parent->properties->child->properties.
* @param array $value_address
* The value address for the form element to be created. This is similar to
* the property address, but will include items for compound property deltas.
* This ensures that buttons and item counts in form storage are unique for
* compound elements which are themselves children of multi-valued compound
* elements.
* @param $form_value_address
* The form values address for the component. This is used to set the
* #parents property on the form element we create, so that the form values
* structure matches the original data structure. This is different again
* from the other two addresses, as it does not include a level for the
* 'properties' array, but does include deltas.
*
* @return array
* The form array for the component's element.
*/
// TODO: mine this and helpers for old code.
private function getCompomentElement($form_state, $property_address, $value_address, $form_value_address) {
$component_element = [];
$properties = NestedArray::getValue(static::$componentDataInfo, $property_address);
// TODO: should this be carried through? Check whether preparing a compound
// property can set values in the array.
$component_data = [];
foreach ($properties as $property_name => &$property_info) {
// Prepare the single property: get options, default value, etc.
$this->codeBuilderTaskHandlerGenerate->prepareComponentDataProperty($property_name, $property_info, $component_data);
// Skip the properties that we're not showing on this form section.
if (!empty($property_info['hidden'])) {
continue;
}
// Add the name of the current property to the address arrays.
$property_component_address = $property_address;
$property_component_address[] = $property_name;
$property_value_address = $value_address;
$property_value_address[] = $property_name;
$property_form_value_address = $form_value_address;
$property_form_value_address[] = $property_name;
// Create a basic form element for the property.
$property_element = [
'#title' => $property_info['label'],
'#required' => $property_info['required'],
'#mb_property_address' => $property_component_address,
'#mb_value_address' => $property_value_address,
// Explicitly set this so we control the structure of the form
// submission values. In particular, we don't want to have to pick data
// out from the the structure the 'table' element would create.
'#parents' => $property_form_value_address,
];
if (isset($property_info['description'])) {
$property_element['#description'] = $property_info['description'];
}
// Add description to properties that can get defaults filled in by
// DCB in processing.
if (!empty($property_info['process_default'])) {
$property_element['#required'] = FALSE;
$property_element['#description'] = (isset($property_element['#description']) ? $property_element['#description'] . ' ' : '')
. t("Leave blank for a default value.");
}
// Determine the default value to present in the form element.
// (Compound elements don't have a default value as they are just
// containers, but we use the count of the array we get to determine how
// many deltas to show.)
$key_exists = NULL;
$form_default_value = NestedArray::getValue($this->moduleEntityData, array_slice($property_form_value_address, 1), $key_exists);
// If there is no value set in the module entity data, take the default
// value that prepareComponentDataProperty() set.
if (!$key_exists) {
$form_default_value = $component_data[$property_name];
if ($property_info['format'] == 'compound') {
// Bit of a hack: for compound properties, zap the prepared default.
// The problem is that this will cause a child element to appear in
// the form, rather than starting with a zero delta.
// This happens for example with the PHPUnit test component, where
// the prepared default for the test_modules property tries to set a
// module name derived from the test class name.
// This will be fixed in DCB 3.3.x when we get the ability to do
// defaults in JS.
$form_default_value = [];
}
}
// The type of the form element depends on the format of the component data
// property.
$format = $property_info['format'];
$format_method = 'element' . ucfirst($format);
if (!method_exists($this, $format_method)) {
throw new \Exception("No method '$format_method' exists to handle property '$property_name' with format '$format'.");
continue;
}
$handling = $this->{$format_method}($property_element, $form_state, $property_info, $form_default_value);
$property_form_value_address_key = implode(':', $property_form_value_address);
$form_state->set(['element_handling', $property_form_value_address_key], $handling);
$component_element[$property_name] = $property_element;
}
return $component_element;
}
/**
* Set form element properties specific to array component properties.
*
* @param &$element
* The form element for the component property.
* @param FormStateInterface $form_state
* The form state.
* @param $property_info
* The info array for the component property.
* @param $form_default_value
* The default value for the form element.
*
* @return string
* The handling type to be applied to this element's value on submit.
*/
protected function XelementArray(&$element, FormStateInterface $form_state, $property_info, $form_default_value) {
if (isset($property_info['options'])) {
if (isset($property_info['options_extra'])) {
// Show an autocomplete textfield.
// TODO: use Select or Other module for this when it has a stable
// release.
$element['#type'] = 'textfield';
$element['#maxlength'] = 512;
$element['#description'] = (isset($element['#description']) ? $element['#description'] . ' ' : '')
. t("Enter multiple values separated with a comma.");
$element['#autocomplete_route_name'] = 'module_builder.autocomplete';
$element['#autocomplete_route_parameters'] = [
'property_address' => implode(':', $element['#mb_property_address']),
];
if ($form_default_value) {
$form_default_value = implode(', ', $form_default_value);
}
$handling = 'autocomplete';
}
else {
$element['#type'] = 'checkboxes';
$element['#options'] = $property_info['options'];
if (is_null($form_default_value)) {
$form_default_value = [];
}
else {
$form_default_value = array_combine($form_default_value, $form_default_value);
}
$handling = 'checkboxes';
}
}
else {
$element['#type'] = 'textarea';
if (isset($element['#description'])) {
$element['#description'] .= ' ';
}
else {
$element['#description'] = '';
}
$element['#description'] .= t("Enter one item per line.");
// Handle a property that DCB has added since the component was saved.
if (empty($form_default_value) && !is_array($form_default_value)) {
$form_default_value = [];
}
$form_default_value = implode("\n", $form_default_value);
$handling = 'textarea';
}
$element['#default_value'] = $form_default_value;
return $handling;
}
/**
* Set form element properties specific to boolean component properties.
*
* @param &$element
* The form element for the component property.
* @param FormStateInterface $form_state
* The form state.
* @param $property_info
* The info array for the component property.
* @param $form_default_value
* The default value for the form element.
*
* @return string
* The handling type to be applied to this element's value on submit.
*/
protected function XelementBoolean(&$element, FormStateInterface $form_state, $property_info, $form_default_value) {
$element['#type'] = 'checkbox';
$element['#default_value'] = $form_default_value;
return 'checkbox';
}
/**
* Set form element properties specific to compound component properties.
*
* @param &$element
* The form element for the component property.
* @param FormStateInterface $form_state
* The form state.
* @param $property_info
* The info array for the component property.
* @param $form_default_value
* The default value for the form element.
*
* @return string
* The handling type to be applied to this element's value on submit.
*/
protected function XelementCompound(&$element, FormStateInterface $form_state, $property_info, $form_default_value) {
// A compound property shows a details element, for which we recurse and
// show another component.
$element['#type'] = 'details';
$element['#open'] = TRUE;
// Figure out how many items to show.
// If we're reloading the form in response to the 'add more' button, then
// form storage dictates the item count.
// If there's nothing set in form storage yet, it's the first time we're
// here and the number of items in the entity tells us how many items to
// show in the form.
// Finally, if that's empty, then show no items, just a button to add one.
$item_count = static::getCompoundPropertyItemCount($form_state, $element['#mb_value_address']);
if (is_null($item_count)) {
$item_count = count($form_default_value);
static::setCompoundPropertyItemCount($form_state, $element['#mb_value_address'], $item_count);
}
if (empty($item_count)) {
$item_count = 0;
static::setCompoundPropertyItemCount($form_state, $element['#mb_value_address'], $item_count);
}
// Property cardinality overrides anything else.
if (isset($property_info['cardinality'])) {
$item_count = min($item_count, $property_info['cardinality']);
if ($item_count == $property_info['cardinality']) {
// We're at the maximum item count.
$add_more = FALSE;
}
else {
// We're not yet at the cardinality: we can add more.
$add_more = TRUE;
}
}
else {
// Unlimited cardinality: can always add more.
$add_more = TRUE;
}
// Set up a wrapper for AJAX.
$wrapper_id = Html::getUniqueId(implode('-', $element['#mb_value_address']) . '-add-more-wrapper');
// TODO - use '#type' => 'container',?
$element['#prefix'] = '<div id="' . $wrapper_id . '">';
$element['#suffix'] = '</div>';
// Show the items in a table. This is single-column, with all child
// properties in the one cell, but we just want the striping for visual
// clarity.
$element['table'] = array(
'#type' => 'table',
);
// The address in the properties array to find this component's properties
// list.
$component_properties_address = $element['#mb_property_address'];
$component_properties_address[] = 'properties';
$component_value_address = $element['#mb_value_address'];
$component_value_address[] = 'properties';
$property_form_value_address = $element['#parents'];
for ($delta = 0; $delta < $item_count; $delta++) {
$row = [];
$delta_value_address = $component_value_address;
$delta_value_address[] = $delta;
$delta_form_value_address = $property_form_value_address;
$delta_form_value_address[] = $delta;
// Put all the properties into a single cell so it's a 1-column table.
// TODO: WTF NO STRIPING IN SEVEN THEME???
$delta_component_element = $this->getCompomentElement($form_state, $component_properties_address, $delta_value_address, $delta_form_value_address, []);
$row['row'] = $delta_component_element;
$element['table'][$delta] = $row;
}
if ($add_more) {
// Show a button to add items, if they can be added.
$button_text = ($item_count == 0)
? t('Add a @label item', [
'@label' => strtolower($property_info['label']),
])
: t('Add another @label item', [
'@label' => strtolower($property_info['label']),
]);
$element['actions']['add'] = array(
'#type' => 'submit',
// This allows FormAPI to figure out which button is the triggering
// element. The name must be unique across all buttons in the form,
// otherwise, the first matching name will be taken by FormAPI as being
// the button that was clicked, with unexpected results.
// See \Drupal\Core\Form\FormBuilder::elementTriggeredScriptedSubmission().
'#name' => implode(':', $element['#mb_value_address']) . '_add_more',
'#ajax_parent_slice' => 1,
'#value' => $button_text,
'#limit_validation_errors' => [],
'#submit' => array(array(get_class($this), 'addItemSubmit')),
'#ajax' => array(
'callback' => array(get_class($this), 'itemButtonAjax'),
'wrapper' => $wrapper_id,
'effect' => 'fade',
),
);
}
if ($item_count > 0) {
$element['actions']['remove'] = [
'#type' => 'submit',
'#name' => implode(':', $element['#mb_value_address']) . '_remove_item',
'#ajax_parent_slice' => 1,
'#value' => t('Remove last item'),
'#limit_validation_errors' => [],
'#submit' => array(array(get_class($this), 'removeItemSubmit')),
'#ajax' => array(
'callback' => array(get_class($this), 'itemButtonAjax'),
'wrapper' => $wrapper_id,
'effect' => 'fade',
),
];
}
return 'compound';
}
/**
* Set form element properties specific to array component properties.
*
* @param &$element
* The form element for the component property.
* @param FormStateInterface $form_state
* The form state.
* @param $property_info
* The info array for the component property.
* @param $form_default_value
* The default value for the form element.
*
* @return string
* The handling type to be applied to this element's value on submit.
*/
protected function XelementString(&$element, FormStateInterface $form_state, $property_info, $form_default_value) {
if (isset($property_info['options'])) {
$element['#type'] = 'select';
$options = [];
$element['#options'] = $property_info['options'];
$element['#empty_value'] = '';
if (empty($form_default_value)) {
$form_default_value = '';
}
$handling = 'select';
}
else {
$element['#type'] = 'textfield';
$handling = 'textfield';
}
$element['#default_value'] = $form_default_value;
return $handling;
}
/**
* Returns an array of supported actions for the current entity form.
*/
protected function actions(array $form, FormStateInterface $form_state) {
// TODO: remove #mb_action, use #name instead.
$actions['submit'] = array(
'#type' => 'submit',
'#value' => $this->t('Save'),
'#dropbutton' => 'mb',
// Still no way to get a button's name, apparently?
'#mb_action' => 'submit',
'#submit' => array('::submitForm', '::save'),
);
if ($this->getNextLink() != 'generate-form') {
$actions['submit_next'] = array(
'#type' => 'submit',
'#value' => $this->t('Save and go to next page'),
'#dropbutton' => 'mb',
'#mb_action' => 'submit_next',
'#submit' => array('::submitForm', '::save'),
);
}
$actions['submit_generate'] = array(
'#type' => 'submit',
'#value' => $this->t('Save and generate code'),
'#dropbutton' => 'mb',
'#mb_action' => 'submit_generate',
'#submit' => array('::submitForm', '::save'),
);
return $actions;
}
/**
* Get the value for a property from the form values.
*
* This performs various processing depending on the form element type and the
* property format:
* - explode textarea values
* - filter checkboxes and store only the keys
* - recurse into compound properties
* The form build process leaves instructions for how to handle each value in
* the 'element_handling' form state setting, so that here we don't need to
* repeat the logic based on property info. Furthermore, we can't put this
* a property info array into form state storage, because it contains closures,
* which don't survive the serialization process in the database, and so the
* property info would need to be run through DCB's preparation process all
* over again.
*
* @param array $value_address
* The address array of the value in the form state values array. The final
* element of this is name of the property and the form element.
* @param $value
* The incoming form value from the form element for this property.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return
* The processed value.
*/
// TODO: check for stuff here to move to cleanUpValues().
protected function XgetFormElementValue($value_address, $value, FormStateInterface $form_state) {
// Retrieve the handling type from the form state.
$property_form_value_address_key = implode(':', $value_address);
$handling = $form_state->get(['element_handling', $property_form_value_address_key]);
switch ($handling) {
case 'textarea':
// Array format, without options: textarea.
if (empty($value)) {
$value = [];
}
else {
// Can't split on just "\n" because for FKW reasons, linebreaks come
// back through POST as Windows-style "\r\n".
$value = preg_split("@[\r\n]+@", $value);
}
break;
case 'autocomplete':
// Array format, with extra options: textfield with autocomplete.
// Only explode a non-empty string, as explode() will turn '' into an
// array!
if (!empty($value)) {
// Textfield with autocomplete.
$value = preg_split("@,\s*@", $value);
}
break;
case 'checkboxes':
// Array format, with options: checkboxes.
// Filter out empty values. (FormAPI *still* doesn't do this???)
$value = array_filter($value);
// Don't store values also in the keys, as some of these have dots in
// them, which ConfigAPI doesn't allow.
$value = array_keys($value);
break;
case 'compound':
// Remove the item count buttons from the values.
unset($value['actions']);
unset($value['table']);
foreach ($value as $delta => $item_value) {
$delta_value_address = $value_address;
$delta_value_address[] = $delta;
// Recurse into the child property values.
foreach ($item_value as $child_key => $child_value) {
$delta_child_value_address = $delta_value_address;
$delta_child_value_address[] = $child_key;
$value[$delta][$child_key] = $this->getFormElementValue($delta_child_value_address, $child_value, $form_state);
}
}
break;
case 'checkbox':
case 'select':
case 'textfield':
// Nothing to do in these cases: $value is fine as it is.
break;
default:
throw new \Exception("Unknown handling type: {$handling}.");
}
return $value;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$is_new = $this->entity->isNew();
$module = $this->entity;
// dsm($module);
$status = $module->save();
if ($status) {
// Setting the success message.
$this->messenger()->addStatus($this->t('Saved the module: @name.', array(
'@name' => $module->name,
)));
}
else {
$this->messenger()->addStatus($this->t('The @name module was not saved.', array(
'@name' => $module->name,
)));
}
// Optionally advance to next tab or go to the generate page.
$element = $form_state->getTriggeringElement();
switch ($element['#mb_action']) {
case 'submit':
$operation = $this->getOperation();
// For a new module, we need to redirect to its edit form, as staying
// put would leave on the add form.
if ($operation == 'add') {
$operation = 'edit';
}
// For an existing module, we also redirect so that changing the machine
// name of the module goes to the new URL.
$url = $module->toUrl($operation . '-form');
$form_state->setRedirectUrl($url);
break;
case 'submit_next':
$next_link = $this->getNextLink();
$url = $module->toUrl($next_link);
$form_state->setRedirectUrl($url);
break;
case 'submit_generate':
$url = $module->toUrl('generate-form');
$form_state->setRedirectUrl($url);
break;
}
}
/**
* Get the next entity link after the one for the current form.
*
* @return
* The name of an entity link.
*/
protected function getNextLink() {
// Probably a more elegant way of figuring out where we currently are
// with routes maybe?
$operation = $this->getOperation();
// Special case for add and edit forms.
if ($operation == 'default' || $operation == 'edit') {
$operation = 'name';
}
$handler_class = $this->entityTypeManager->getHandler('module_builder_module', 'component_sections');
$form_ops = $handler_class->getFormOperations();
// Add in the 'name' operation, as the handler doesn't return it.
$form_ops = array_merge(['name'], $form_ops);
$index = array_search($operation, $form_ops);
return $form_ops[$index + 1] . '-form';
}
}
