Skip to content

Instantly share code, notes, and snippets.

@Eyal-Shalev
Created June 7, 2016 19:41
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save Eyal-Shalev/617813dc859bdc62fe4fb9c631449d9b to your computer and use it in GitHub Desktop.
Save Eyal-Shalev/617813dc859bdc62fe4fb9c631449d9b to your computer and use it in GitHub Desktop.
An example form object (Drupal 8) that combines multiple forms into itself.
<?php
namespace Drupal\sandbox\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\node\Entity\Node;
use Drupal\user\Entity\User;
class ComboForm extends FormBase {
/**
* This constant is used as a key inside the main form state object to gather
* all the inner form state objects.
* @const
* @see getInnerFormState()
*/
const INNER_FORM_STATE_KEY = 'inner_form_state';
const MAIN_SUBMIT_BUTTON = 'submit';
/**
* @var \Drupal\Core\Form\FormInterface[]
*/
protected $innerForms = [];
/**
* The ComboForm constructor needs to initialize the inner form objects
* that this form will later use.
*
* Because both the user and the node forms are entity forms then an entity
* object needs to be assigned to them.
*
* Because the node entity has bundle, the bundle (type) must be defined
* before the form is generated.
*
* @TODO If the node type is customizable then a custom controller is needed.
*
* @TODO should this form allow edits? If so then the form objects should
* be assigned with the edited entities.
*/
public function __construct() {
$this->innerForms['user'] = \Drupal::entityTypeManager()
->getFormObject('user', 'default')
->setEntity(User::create());
$this->innerForms['node'] = \Drupal::entityTypeManager()
->getFormObject('node', 'default')
->setEntity(Node::create([
'type' => 'article'
]));
}
/**
* {@inheritDoc}
*/
public function getFormId() {
return implode('__', [
'combo_form',
$this->innerForms['user']->getFormId(),
$this->innerForms['node']->getFormId()
]);
}
/**
* The build form needs to take care of the following:
* - Creating a custom form state object for each inner form (and keep it
* inside the main form state.
* - Generating a render array for each inner form.
* - Handle compatibility issues such as #process array and action elements.
*
* {@inheritDoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form['#process'] = \Drupal::service('element_info')->getInfoProperty('form', '#process', []);
$form['#process'][] = '::processForm';
foreach ($this->innerForms as $key => $inner_form_object) {
$inner_form_state = static::createInnerFormState($form_state, $inner_form_object, $key);
// By placing the actual inner form inside a container element (such as
// details) we gain the freedom to alter the wrapper of the inner form
// with little damage to the render element attributes of the inner form.
$inner_form = ['#parents' => [$key]];
$inner_form = $inner_form_object->buildForm($inner_form, $inner_form_state);
$form[$key] = [
'#type' => 'details',
'#title' => $this->t('Inner form: %key', ['%key' => $key]),
'form' => $inner_form
];
$form[$key]['form']['#type'] = 'container';
$form[$key]['form']['#theme_wrappers'] = \Drupal::service('element_info')->getInfoProperty('container', '#theme_wrappers', []);
unset($form[$key]['form']['form_token']);
// The process array is called from the FormBuilder::doBuildForm method
// with the form_state object assigned to the this (ComboForm) object.
// This results in a compatibility issues because these methods should
// be called on the inner forms (with their assigned FormStates).
// To resolve this we move the process array in the inner_form_state
// object.
if (!empty($form[$key]['form']['#process'])) {
$inner_form_state->set('#process', $form[$key]['form']['#process']);
unset($form[$key]['form']['#process']);
}
else {
$inner_form_state->set('#process', []);
}
// The actions array causes a UX problem because there should only be a
// single save button and not multiple.
// The current solution is to move the #submit callbacks of the submit
// element to the inner form element root.
if (!empty($form[$key]['form']['actions'])) {
if (isset($form[$key]['form']['actions'][static::MAIN_SUBMIT_BUTTON])) {
$form[$key]['form']['#submit'] = $form[$key]['form']['actions'][static::MAIN_SUBMIT_BUTTON]['#submit'];
}
unset($form[$key]['form']['actions']);
}
}
// Default action elements.
$form['actions'] = [
'#type' => 'actions',
static::MAIN_SUBMIT_BUTTON => [
'#type' => 'submit',
'#value' => $this->t('Save'),
'#validate' => ['::validateForm'],
'#submit' => ['::submitForm']
]
];
return $form;
}
/**
* This method will be called from FormBuilder::doBuildForm during the process
* stage.
* In here we call the #process callbacks that were previously removed.
*
* @param array $element
* The form element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* @param array $complete_form
* @return array
* The altered form element.
*
* @see \Drupal\Core\Form\FormBuilder::doBuildForm()
*/
public function processForm(array &$element, FormStateInterface &$form_state, array &$complete_form) {
foreach ($this->innerForms as $key => $inner_form) {
$inner_form_state = static::getInnerFormState($form_state, $key);
foreach ($inner_form_state->get('#process') as $callback) {
// The callback format was copied from FormBuilder::doBuildForm().
$element[$key]['form'] = call_user_func_array($inner_form_state->prepareCallback($callback), array(&$element[$key]['form'], &$inner_form_state, &$complete_form));
}
}
return $element;
}
/**
* {@inheritDoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Form\FormValidatorInterface $form_validator */
$form_validator = \Drupal::service('form_validator');
foreach ($this->innerForms as $form_key => $inner_form) {
$inner_form_state = static::getInnerFormState($form_state, $form_key);
// Pass through both the form elements validation and the form object
// validation.
$inner_form->validateForm($form[$form_key]['form'], $inner_form_state);
$form_validator->validateForm($inner_form->getFormId(), $form[$form_key]['form'], $inner_form_state);
foreach ($inner_form_state->getErrors() as $error_element_path => $error) {
$form_state->setErrorByName($form_key . '][' . $error_element_path, $error);
}
}
}
/**
* {@inheritDoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Form\FormSubmitterInterface $form_submitter */
$form_submitter = \Drupal::service('form_submitter');
foreach ($this->innerForms as $key => $inner_form) {
$inner_form_state = static::getInnerFormState($form_state, $key);
// The form state needs to be set as submitted before executing the
// doSubmitForm method.
$inner_form_state->setSubmitted();
$form_submitter->doSubmitForm($form[$key]['form'], $inner_form_state);
}
}
/**
* Before returning the innerFormState object, we need to set the
* complete_form, values and user_input properties from the main form state.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The main form state.
* @param string $key
* The key used to store the inner form state.
* @return \Drupal\Core\Form\FormStateInterface
* The inner form state.
*/
protected static function getInnerFormState(FormStateInterface $form_state, $key) {
/** @var \Drupal\Core\Form\FormStateInterface $inner_form_state */
$inner_form_state = $form_state->get([static::INNER_FORM_STATE_KEY, $key]);
$inner_form_state->setCompleteForm($form_state->getCompleteForm());
$inner_form_state->setValues($form_state->getValues() ? : []);
$inner_form_state->setUserInput($form_state->getUserInput() ? : []);
return $inner_form_state;
}
/**
* After the initialization of the inner form state, we need to assign it with
* the inner form object and set it inside the main form state.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The main form state.
* @param \Drupal\Core\Form\FormInterface $form_object
* The inner form object
* @param string $key
* The key used to store the inner form state.
* @return \Drupal\Core\Form\FormStateInterface
* The inner form state.
*/
protected static function createInnerFormState(FormStateInterface $form_state, FormInterface $form_object, $key) {
$inner_form_state = new FormState();
$inner_form_state->setFormObject($form_object);
$form_state->set([static::INNER_FORM_STATE_KEY, $key], $inner_form_state);
return $inner_form_state;
}
}
@heddn
Copy link

heddn commented Mar 5, 2020

Do we know if file uploads or comment forms work with this approach? Any lessons learned from using this?

@welly
Copy link

welly commented Apr 28, 2021

This is great. One thing I've noticed though is it doesn't respect the field weighting that is configured in the node form display manager. I wondered if there is a easy way of applying a particular form view mode to this?

@aytee
Copy link

aytee commented Feb 17, 2022

@heddn - I can confirm that this does not work with file uploads. As written, it will upload the file to the server, but it will not save the field values (field name) to the entity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment