Drupal 9: Auto Injecting Paragraph Forms On Node Edit Pages

I tried to do something the other day that I thought would be quite simple, but turned out to be really hard to get my head around. I had a Drupal 9 site with Paragraphs installed and I wanted a user to click a button on the node edit form and inject a particular Paragraph into a Paragraph field.

I found 2 solutions to this problem that solve it in slight different ways.

Piggy Back On Existing Events

After my initial struggles over trying to get this to work I decided to use a piggy back method. This essentially listens for the user interaction and then triggers the Paragraph add event that inserts the Paragraph into the field. The user interaction I was listening for was a user selecting different elements in a select list.

To get this working I added some JavaScript to the page, attached to the select list field called "field_type".

function my_module_form_node_page_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state)
{
  $form['field_type']['widget']['#attached']['library'][] = 'my_module/node';
}

This

node:
  js:
    js/piggy-back.js: {}
  dependencies:
    - core/drupal

This loads in the JavaScript file called piggy-back.js.

This file, seen below, is used to pull together the different events attached to the different Paragraph add buttons. When the user changes the field type select list an event is triggered, which in turn then triggers one of the paragraph events.

(function ($, Drupal, drupalSettings) {

  'use strict';

  Drupal.behaviors.bform = {
    attach : function(context, settings) {
      // Load the existing change event.
      var paragraphDetailEvents = $._data($("#field-paragraph-detail-add-more")[0], "events");
      var paragraphHeaderEvents = $._data($("#field-paragraph-header-add-more")[0], "events");

      $('#edit-field-type').change(function(event) {
        let changeValue = $(this).val();
        if (changeValue == 1) {
          $.each(paragraphDetailEvents.mousedown, function () {
            this.handler(event);
          });
        }
        if (changeValue == 2) {
          $.each(paragraphHeaderEvents.mousedown, function () {
            this.handler(event);
          });
        }
      });
    }
  };

})(jQuery, Drupal);

You can probably adapt this code to suit your needs, but this is a simple solution to the problem that actually produces the intended result.

Inject Paragraph Button Ajax Callback

Whilst the piggy back method worked, what I really wanted to do was have full control over the ajax stream and resulting response. Looking through the Paragraphs module led me to a mix of different code that seemed not to actually produce the needed result. After running through the code a few times I was able to extract the needed components into a single module.

The first thing that is needed here is a form element to control the injection of the Paragraph entity form into the page. This is handled by a submit button that is injected into the node edit form using the hook_form_node_page_form_alter() hook. The button is injected next to a field called "field_type", but I have abstracted this out so that you can move this to anywhere you need. The Paragraph field name has also need abstracted into a variable at the top so you can point this at any Paragraph field you need to. The references to the 'add more' field and class names are to integrate with the Paragraph form itself.

    function my_module_form_node_page_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state)
    {
      $paragraphFieldName = 'field_paragraph';
      $injectParagraphFieldName = 'field_type';
    
      $paragraphField = NestedArray::getValue($form, [$paragraphFieldName]);
    
      $injectParagraphField = NestedArray::getValue($form, [$injectParagraphFieldName]);
    
      $injectParagraphField['inject_paragraph'] = [
        '#type' => 'submit',
        '#name' => 'field_type_my_module',
        '#value' => t('Inject Paragraph'),
        '#attributes' => ['class' => ['field-set-selection-submit']],
        '#limit_validation_errors' => [array_merge($paragraphField['#parents'], [$paragraphFieldName, 'add_more'])],
        '#submit' => [['\Drupal\my_module\InjectParagraph', 'addParagraphSubmit']],
        '#ajax' => [
          'callback' => ['\Drupal\my_module\InjectParagraph', 'addParagraphAjax'],
          'wrapper' => str_replace('_', '-', $paragraphFieldName) . '-add-more-wrapper',
          'effect' => 'fade',
          'progress' => [
            'type' => 'fullscreen',
          ]
        ],
      ];
      NestedArray::setValue($form, [$injectParagraphFieldName], $injectParagraphField);
    }
    

    This button will act as the user input and wrap submit and ajax handlers around the element. In order for this submit button to work correctly it needs three things.

    1. A submit callack, which is called when the button has been submitted. In the field above this has been created as a static method and then put into a class.
    2. A limit_validation_errors. This will, as the name suggests, limit the number of validation errors that the form will produce. In this case we are telling Drupal to only validate the paragraph field for errors and to perform a 'partial submit' on the rest of the form.
    3. An Ajax callback, which is called when the button is submitted, after the submit handler. Again, this has been abstracted into a separate class.

    The class that contains the addParagraphSubmit and addParagraphAjax handlers is where the meat of the action happens. Here is the class in full and I have added lots of comments to show what is happening.

    <?php
    
    namespace Drupal\my_module;
    
    use Drupal\Component\Utility\NestedArray;
    use Drupal\Core\Field\FieldStorageDefinitionInterface;
    use Drupal\Core\Form\FormStateInterface;
    use Drupal\paragraphs\Plugin\Field\FieldWidget\ParagraphsWidget;
    
    class InjectParagraph {
    
      public static function addParagraphSubmit(array $form, FormStateInterface $form_state)
      {
        // Set the paragraph field in question.
        $paragraphFieldName = 'field_paragraph';
    
        // Extract the paragraph field from the form.
        $element = NestedArray::getValue($form, [$paragraphFieldName, 'widget']);
        $field_name = $element['#field_name'];
        $field_parents = $element['#field_parents'];
    
        // Get the widget state.
        $widget_state = static::getWidgetState($field_parents, $field_name, $form_state);
    
        // Inject the new paragraph and increment the items count.
        $widget_state['selected_bundle'][] = 'detail';
        $widget_state['items_count']++;
    
        // Update the widget state.
        static::setWidgetState($field_parents, $field_name, $form_state, $widget_state);
    
        // Rebuild the form.
        $form_state->setRebuild();
      }
    
      public static function addParagraphAjax(array $form, FormStateInterface $form_state)
      {
        // Set the paragraph field in question.
        $paragraphFieldName = 'field_paragraph';
    
        // Extract the paragraph field from the form.
        $element = NestedArray::getValue($form, [$paragraphFieldName, 'widget']);
        
        // Update the field with the needed Ajax formatting.
        $delta = $element['#max_delta'];
        $element[$delta]['#prefix'] = '<div class="ajax-new-content">' . (isset($element[$delta]['#prefix']) ? $element[$delta]['#prefix'] : '');
        $element[$delta]['#suffix'] = (isset($element[$delta]['#suffix']) ? $element[$delta]['#suffix'] : '') . '</div>';
    
        // Clear the add more delta from the paragraph module.
        NestedArray::setValue(
          $element,
          ['add_more', 'add_more_delta', '#value'],
          ''
        );
    
        // Return the paragraph element.
        return $element;
      }
    
      public static function getWidgetState(array $parents, $field_name, FormStateInterface $form_state) {
        return NestedArray::getValue($form_state->getStorage(), array_merge(['field_storage', '#parents'], $parents, ['#fields', $field_name]));
      }
    
      public static function setWidgetState(array $parents, $field_name, FormStateInterface $form_state, array $field_state) {
        NestedArray::setValue($form_state->getStorage(), array_merge(['field_storage', '#parents'], $parents, ['#fields', $field_name]), $field_state);
      }
    }

    If you are familiar with the Paragraph module then you will recognise some of this code as parts have been taken from the Paragraph module. I needed to copy and paste some of the code needed here as they are set to be private or protected methods within the Paragraph widget and are therefore inaccessible. I have left some of the detail out here as the code will only inject one type of Paragraph into the form. This should however, be enough information to get you going if this is what you are wanting to do. The form state is passed to both functions so you should be able to inspect it and see what has been selected and then adapt the code accordingly.

    Essentially the sequence of events is as follows.

    1. The user clicks the new 'Inject Paragraph' button on the node form.
    2. An ajax call is made to Drupal.
    3. The Drupal form processing is started.
    4. The submit handler called addParagraphSubmit is triggered. In this step we inject the placeholder for the Paragraph entity to be added to the form using a placeholder widget with the property of 'selected_bundle'. The Paragraph entity we added is called 'detail'.
    5. The internal Paragraph render functions are called, which then populate the form elements with the blank Paragraph entity forms.
    6. The ajax handler called addParagraphAjax is triggered. In this step the empty Paragraph entity form is present, just are just wrapping it in some HTML so that the form acts in the same way as the normal Paragraph form does.

    As you can see, both the submit handler and the ajax handler are needed here in order to allow the Paragraph entity form to be correctly injected. Without the submit hanlder being called the ajax handler doesn't do anything. Making sure both of these handlers is called is down to the limit_validation_errors setting in form element we injected. If this element is missing then only the ajax handler is called, which is too late in the process to allow the Paragraph entity to be added. I hadn't used this setting before and so wasn't sure what it did so I may even write an entirely separate article just on this setting as it changes how ajax callbacks work quite drastically.

    All of the code above has been put together after hours of debugging, experimentation and tweaking. I hope it is useful to you.

    Comments

    Great article, is it possible to set values on the paragraphs before they're returned in the ajax callback?

    I've been playing around for a few hours and can't seem to make it work.

    Cheers Dan

    Permalink

    Hi Dan, Thanks for commenting.

    I think you might need to use a hook_form_alter on the paragraph items themselves. They go through the normal rendering process and so should pass through the form hooks like other things. I think if you're trying to change them in the above hooks then you might not get the right results.

    Name
    Philip Norton
    Permalink

    Add new comment

    The content of this field is kept private and will not be shown publicly.
    CAPTCHA
    3 + 0 =
    Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
    This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.