AJAX without the Drupal FormBase class

AJAX Close button
AJAX Open button

AJAX Open buttonDrupal 8 forms are usually classes that inherit from the FormBase class.  The FormBase class provides underlying functionality such as AJAX for Drupal forms.  This article explores how to use only the form API without the FormBase class yet provide a light weight AJAX behavior.  To keep this from getting too complex only buttons and event handling are considered.  While this could be useful on its own it also provides a better understanding of some of the functionality of the FormBase class.

AJAX Close buttonThe custom module will provide two buttons.  The first button displays the second button and the second button removes itself.  No other types of user input is considered.  A demonstration and the actual code are provided.

AJAX Controller

To display the content a route needs to be declared with a controller. The routing is directed to the content() function.

ajax_buttons:
  path: 'ajax_buttons'
  defaults:
    _controller: '\Drupal\ajax_buttons\Controller\AjaxButtonsController::content'
    _title: 'AJAX buttons'
  requirements:
    _permission: 'access content'

The controller has the main functionality and will be broken up and discussed in more detail. Start with the content() function.

namespace Drupal\ajax_buttons\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Drupal\Core\Render\Element\RenderElement;
use Drupal\Core\Render\Element;
use Drupal\Core\Ajax\ReplaceCommand;

/**
 * Controller routines for AJAX buttons.
 */
class AjaxButtonsController extends ControllerBase {

  /**
   * Form to display the first button.
   */
  public function content() {
    $form['open_button'] = array(
      '#type' => 'submit',
      '#value' => $this->t('Open'),
      '#id' => 'edit-open-button',
      '#ajax' => array(
        'url' => Url::fromUri('internal:/ajax_buttons'),
        'options' => array(
          'query' => array(
            'callback' => 'openButton',
          ),
        ),
      ),
    );
    $form['close'] = array(
      '#prefix' => '<div id="close">',
      '#suffix' => '</div>',
    );
    $this->prepareAjaxForm($form);

    return $form;
  }

}

This uses the form API to specify an open button and a placeholder for a close button. The #ajax property specifies the Url and a callback. This will generate a call of the form /ajax_buttons?callback=openButton. The ajaxPrepareForm() function performs some processing of the form so that the event handler is added. In order to attach the event handler the element must have an ID which we add with the #id property. Add the prepareAjaxForm() function.

  /**
   * Attaches event handlers and other form processing.
   */
  public function prepareAjaxForm(&$form) {
    if (!isset($form['#value'])) {
      $form['#value'] = isset($form['#default_value']) ? $form['#default_value'] : '';
    }
    if (isset($form['#type'])) {
      if (($form['#type'] == 'submit' || $form['#type'] == 'image_button') && isset($form['#ajax'])) {
        // Add event handlers.
        $form = RenderElement::preRenderAjaxForm($form);
      }
    }
    foreach (Element::children($form) as $key) {
      $element = &$form[$key];
      $this->prepareAjaxForm($element);
    }
  }

RenderElement::preRenderAjaxForm() is called on each element that may need an event handler. Now we need to add the description of the form for the close button.

  /**
   * Form to display the second button.
   */
  public function closeForm() {
    $form['close'] = array(
      '#prefix' => '<div id="close">',
      '#suffix' => '</div>',
    );
    $form['close']['close_button'] = array(
      '#type' => 'submit',
      '#value' => $this->t('Close'),
      '#id' => 'edit-close-button',
      '#ajax' => array(
        'url' => Url::fromUri('internal:/ajax_buttons'),
        'options' => array(
          'query' => array(
            'callback' => 'closeButton',
          ),
        ),
      ),
    );

    return $form;
}

AJAX response using an Exception

The router will call the content() function so we need to add some code at the beginning of that function to execute the callback.

    // Check for AJAX callback.
    $request = \Drupal::request();
    if ($request->query->has('callback')) {
      throw new AjaxCallbackException($this);
    }

Throwing an exception may seem strange and not good programming practice but this is the way FormBuilder in Drupal 8 core currently works.

    // After processing the form, if this is an AJAX form request, interrupt
    // form rendering and return by throwing an exception that contains the
    // processed form and form state. This exception will be caught by
    // \Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber::onException() and
    // then passed through
    // \Drupal\Core\Form\FormAjaxResponseBuilderInterface::buildResponse() to
    // build a proper AJAX response.

...

    // @todo Exceptions should not be used for code flow control. However, the
    //   Form API does not integrate with the HTTP Kernel based architecture of
    //   Drupal 8. In order to resolve this issue properly it is necessary to
    //   completely separate form submission from rendering.
    //   @see https://www.drupal.org/node/2367555

Three files are need to handle the exception. First, define the AjaxCallbackException class.

namespace Drupal\ajax_buttons\Controller;

/**
 * Custom exception to break out of AJAX form processing.
 */
class AjaxCallbackException extends \Exception {

  /**
   * The AjaxButtonsController.
   *
   * @var object AjaxButtonsController
   */
  protected $ajaxButtonsController;

  /**
   * Constructs a FormAjaxException object.
   *
   * @param AjaxButtonsController $ajaxButtonsController
   *   The AjaxButtonsController object.
   * @param string $message
   *   (optional) The exception message.
   * @param int $code
   *   (optional) A user defined exception code.
   * @param \Exception $previous
   *   (optional) The previous exception for nested exceptions.
   */
  public function __construct(AjaxButtonsController $ajaxButtonsController, $message = "", $code = 0, \Exception $previous = NULL) {
    parent::__construct($message, $code, $previous);
    $this->ajaxButtonsController = $ajaxButtonsController;
  }

  /**
   * Gets the AjaxButtonsController.
   *
   * @return object
   *   The AjaxButtonsController.
   */
  public function getAjaxButtonsController() {
    return $this->ajaxButtonsController;
  }

}

An EventSubscriber needs to be defined to catch the exception. The AjaxCallbackSubscriber is defined as

namespace Drupal\ajax_buttons\Controller\EventSubscriber;

use Drupal\Core\Ajax\AjaxResponse;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Drupal\ajax_buttons\Controller\AjaxCallbackException;

/**
 * Wraps AJAX form submissions that are triggered via an exception.
 */
class AjaxCallbackSubscriber implements EventSubscriberInterface {

  /**
   * Constructs a new AjaxCallbackSubscriber.
   */
  public function __construct() {
  }

  /**
   * Required function.
   */
  public function onView(GetResponseForControllerResultEvent $event) {
  }

  /**
   * Catches an AJAX callback exception and builds a response from it.
   *
   * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event
   *   The event to process.
   */
  public function onException(GetResponseForExceptionEvent $event) {
    $exception = $event->getException();

    // Extract the form AJAX exception (it may have been passed to another
    // exception before reaching here).
    if ($exception = $this->getAjaxCallbackException($exception)) {
      $request = $event->getRequest();
      $callback = $request->query->get('callback');
      $ajaxButtonsController = $exception->getAjaxButtonsController();

      try {
        $commands = $ajaxButtonsController->$callback();
        $response = new AjaxResponse();
        foreach ($commands as $command) {
          $response->addCommand($command);
        }

        // Since this response is being set in place of an exception, explicitly
        // mark this as a 200 status.
        $response->headers->set('X-Status-Code', 200);
        $event->setResponse($response);
      }
      catch (\Exception $e) {
        // Otherwise, replace the existing exception with the new one.
        $event->setException($e);
      }
    }
  }

  /**
   * Extracts an Ajax callback exception.
   *
   * @param \Exception $e
   *   A generic exception that might contain an Ajax callback exception.
   *
   * @return \Drupal\uc_order\Plugin\Ubercart\AjaxCallbackException|null
   *   Either the Ajax callback exception, or NULL if none could be found.
   */
  protected function getAjaxCallbackException(\Exception $e) {
    $exception = NULL;
    while ($e) {
      if ($e instanceof AjaxCallbackException) {
        $exception = $e;
        break;
      }

      $e = $e->getPrevious();
    }
    return $exception;
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    // Run before exception.logger.
    $events[KernelEvents::EXCEPTION] = ['onException', 51];
    // Run before main_content_view_subscriber.
    $events[KernelEvents::VIEW][] = ['onView', 1];

    return $events;
  }

}

In the try block the callback is executed which returns an array of commands. These commands are added to the AjaxResponse object and the response returned. A file defining the service needs to be created to register the subscriber.

services:
  exception.subscriber.ajax_buttons.ajax_callback:
    class: Drupal\ajax_buttons\Controller\EventSubscriber\AjaxCallbackSubscriber
    tags:
      - { name: event_subscriber }

The remaining code defines the two callbacks.  These are added to the controller. The callbacks define the array of commands used to construct the response.

  /**
   * Callback for the open button.
   */
  public function openButton() {
    $commands = array();
    $closeButton = $this->closeForm();
    $this->prepareAjaxForm($closeButton);
    $commands[] = new ReplaceCommand('div#close', $closeButton);
    return $commands;
  }

  /**
   * Callback for the close button.
   */
  public function closeButton() {
    $form['close'] = array(
      '#prefix' => '<div id="close">',
      '#suffix' => '</div>',
    );
    $commands = array();
    $commands[] = new ReplaceCommand('div#close', $form);
    return $commands;
  }

The callback for the open button retrieves the form with the close button and calls the function to process it so the event handler is added. Then the place holder div is replaced. The callback for the close button has the placeholder element which replaces the close button. Most of the code is for preparing the form so the event hadlers are added and the exception which is used to build the reponse. Since this processing code can be used for other forms the amount of code specific for this functionality is small and easy to understand. The behavior is light weight since the entire form is not rebuilt as tends to happen with FormBuilder. Only a limited amount of functionality is provided in this example so comparison with FormBuilder is not a serious comparison but more for instructional purposes.

Categories