I'm making a form element plugin which has compound elements, but which should return a single string value. This is the same idea as core's password confirm form element (https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Render%21Element%21PasswordConfirm.php/10).
I've imitated what password confirm does: an #element_validate callback gets the value from the inner elements, and sets it as the value for the element as a whole:
// Password field must be converted from a two-element array into a single
// string regardless of validation results.
$form_state->setValueForElement($element['pass1'], NULL);
$form_state->setValueForElement($element['pass2'], NULL);
$form_state->setValueForElement($element, $pass1);
This works fine in a plain form.
However, when I then try to use this form element in a config entity's form to select a plugin, it crashes on submit.
This is because when the form is rebuilt on submission, EntityForm::copyFormValuesToEntity() is called and calls $entity->getPluginCollections(), and at that point, the value of the main element is an array, which causes a crash in the plugin collection system because it tries to use that as an array key.
The value is an array because after main element is processed and the #element_validate callback sets sets its value to a string, the nested element is processed, and sets its value, and that causes NestedArray to create an array in the form values:
Step 1, form values are:
[
main_element => value,
]
Step 2, inner element's value sets set on form state values and we get:
[
main_element => [
inner_element => value,
],
]
I don't understand why during rebuild we get this effect, but in the final submission, the value of the element is a string as expected.
Am I doing something wrong, or is this just not going to work?
Snippet of code for the form element:
/**
* Process callback.
*/
public static function processPlugin(&$element, FormStateInterface $form_state, &$complete_form) {
$element['#tree'] = TRUE;
// This needs to be a nested element so the radio or select element
// processing and theming takes place.
$element['plugin_id'] = [
'#type' => $element['#options_element_type'],
'#title' => $element['#title'],
'#options' => $options, // SNIP defined elsewhere
'#empty_value' => '',
'#required' => $element['#required'],
'#default_value' => $element['#default_value'] ?? '',
];
$element['#element_validate'] = [[static::class, 'validatePlugin']];
return $element;
}
/**
* {@inheritdoc}
*/
public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
if ($input === FALSE) {
return $element['#default_value'] ?? '';
}
elseif (is_null($input)) {
return $element['#default_value'] ?? '';
}
else {
return $input['plugin_id'];
}
}
public static function validatePlugin(&$element, FormStateInterface $form_state, &$complete_form) {
$plugin_id = $element['plugin_id']['#value'];
// Set the value at the top level of the element.
$form_state->setValueForElement($element['plugin_id'], NULL);
$form_state->setValueForElement($element, $plugin_id);
return $element;
}
(Full thing is in this MR https://git.drupalcode.org/project/plugin/-/merge_requests/3#note_144104 for this issue https://www.drupal.org/project/plugin/issues/3197304.)
Snippet of code for the form that uses it:
$form['link_style'] = [
'#type' => 'action_link_plugin',
'#title' => $this->t('Link style'),
'#required' => TRUE,
'#default_value' => $action_link->get('link_style'),
'#plugin_type' => 'action_link.link_style',
'#options_element_type' => 'radios',
];
And in the config entity class:
public function getPluginCollections() {
$collections = [];
if ($this->getActionLinkPluginCollection()) {
$collections['plugin_config'] = $this->getActionLinkPluginCollection();
}
if ($this->getLinkStylePluginCollection()) {
$collections['link_style_collection'] = $this->getLinkStylePluginCollection();
}
return $collections;
}
protected function getActionLinkPluginCollection() {
if (!$this->actionLinkPluginCollection && $this->plugin_id) {
$this->actionLinkPluginCollection = new DefaultSingleLazyPluginCollection(
\Drupal::service('plugin.manager.action_link_state_action'),
$this->plugin_id, $this->plugin_config
);
}
return $this->actionLinkPluginCollection;
}
protected function getLinkStylePluginCollection() {
// Horrible workaround for the form element's inner element's value getting
// set and then the resulting value *array* for the outer element being used
// by copyFormValuesToEntity().
if (is_array($this->link_style)) {
return NULL;
}
if (!$this->linkStylePluginCollection && $this->link_style) {
$this->linkStylePluginCollection = new DefaultSingleLazyPluginCollection(
\Drupal::service('plugin.manager.action_link_style'),
$this->link_style,
[]
);
}
return $this->linkStylePluginCollection;
}