Your browser doesn't support the features required by impress.js, so you are presented with a simplified version of this presentation.

For the best experience please use the latest Chrome, Safari or Firefox browser.

Extending Commerce

Drupal's architecture in action

Shawn Duncan, Technical Architect
Digital Pulp

Introduction

Coding Concepts

image/svg+xml

Interface, Class & Object

Interface, Class & Object

  • Concepts implemented in a variety of programming languages.
  • PHP had rudimentary objects for some time,
    but a complete object model was first available in PHP 5
  • PHP 1.0

    1995
  • PHP 4

    2000
  • PHP 5

    2004
  • PHP 7

    2015
  • PHP 7.4

    2019

Interface

  • Example: USB Specification
  • Number of inputs
  • Shape of the plug
  • How the signals are structured
The cover of the USB 3.2 specification document.

Interface

  • Defines methods that implementing classes must implement, including the kind of data passed to these methods.
  • Also define constant values which implementing classes cannot override.

Interface

docroot/core/lib/Drupal/Core/Block/TitleBlockPluginInterface.php


namespace Drupal\Core\Block;

/**
 * The interface for "title" blocks.
 *
 * A title block shows the title returned by the controller.
 *
 * @ingroup block_api
 *
 * @see \Drupal\Core\Render\Element\PageTitle
 */
interface TitleBlockPluginInterface extends BlockPluginInterface {

  /**
   * Sets the title.
   *
   * @param string|array $title
   *   The page title: either a string for plain titles or a render array for
   *   formatted titles.
   */
  public function setTitle($title);

}            

Class

  • Direct analogy becomes imprecise, but for our purposes, a manufacturer implementing the USB specification needs to create a design for a specific model of cable.
  • A class is much like this detailed design.
  • There are a variety designs that implement the USB specification - they are all USB
Five USB cables, each with a differently shaped plug.

Class

  • Class is a programming construct that contains data and logic to manipulate that data.
    • Data is stored in properties.
    • Logic is implemented in methods.
  • A class can implement more than one interface.

Class

docroot/core/lib/Drupal/Core/Block/Plugin/Block/PageTitleBlock.php

namespace Drupal\Core\Block\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Block\TitleBlockPluginInterface;

/**
 * Provides a block to display the page title.
 *
 * @Block(
 *   id = "page_title_block",
 *   admin_label = @Translation("Page title"),
 *   forms = {
 *     "settings_tray" = FALSE,
 *   },
 * )
 */
class PageTitleBlock extends BlockBase implements TitleBlockPluginInterface {

  /**
   * The page title: a string (plain title) or a render array (formatted title).
   *
   * @var string|array
   */
  protected $title = '';

  /**
   * {@inheritdoc}
   */
  public function setTitle($title) {
    $this->title = $title;
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
    return ['label_display' => FALSE];
  }

  /**
   * {@inheritdoc}
   */
  public function build() {
    return [
      '#type' => 'page_title',
      '#title' => $this->title,
    ];
  }

}
            

Object

  • In our USB analogy, this is an actual cable.
  • In programming, it is an instance of the class: initialized into memory with all starting values loaded and ready to work.
A single cable: USB type A

Object

Example object creation

// Simplified example of object creation

$pageTitle = new PageTitleBlock($configuration, $plugin_id, $plugin_definition)
            

Object Composition And Inheritance

Inheritance

  • The ancestry of our family dogs is ancient.
Petroglyph of a person with a dog

Inheritance

  • And they have inherited common characteristics from their ancestors.
  • As a programming concept, it means a class extending a parent class. The child class inherits everything from the parent that the child does not override.
A West Highland Terrier

Composition

  • A car is composed of many distinct components.
  • Similar models are formed by swapping in and out these components.
Partially assembled car on an assembly line

Composition

  • As a programming concept, it means designing a class that also holds objects from other classes

Drupal Architecture

Drupal logo 2020

Controllers, Plugins, & Services

Controller

  • A class whose job is to process a user request and return an appropriate response.
  • Most often extends the BaseController class (Inheritance)
core/modules/block_content/src/Controller/BlockContentController.php

namespace Drupal\block_content\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\block_content\BlockContentTypeInterface;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;

class BlockContentController extends ControllerBase {
                .
                .
                .
}

Controller

  • Drupalā€™s routing system connects the userā€™s request with the controller
  • Drupal discovers routes by looking in specially named files, [module_name].routing.yaml
core/modules/block_content/block_content.routing.yml

block_content.add_page:
  path: '/block/add'
  defaults:
    _controller: '\Drupal\block_content\Controller\BlockContentController::add'
    _title: 'Add custom block'
  options:
    _admin_route: TRUE
  requirements:
    _permission: 'administer blocks'

block_content.add_form:
  path: '/block/add/{block_content_type}'
  defaults:
    _controller: '\Drupal\block_content\Controller\BlockContentController::addForm'
    _title_callback: '\Drupal\block_content\Controller\BlockContentController::getAddFormTitle'
  options:
    _admin_route: TRUE
  requirements:
    _permission: 'administer blocks'

Plugin

  • Configuration packaged with functionality
  • Variations of a plugin type typically extend a base class. (Inheritance)

Plugin

core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/IntegerFormatter.php

namespace Drupal\Core\Field\Plugin\Field\FieldFormatter;

/**
 * Plugin implementation of the 'number_integer' formatter.
 *
 * The 'Default' formatter is different for integer fields on the one hand, and
 * for decimal and float fields on the other hand, in order to be able to use
 * different settings.
 *
 * @FieldFormatter(
 *   id = "number_integer",
 *   label = @Translation("Default"),
 *   field_types = {
 *     "integer"
 *   }
 * )
 */
class IntegerFormatter extends NumericFormatterBase {

  /**
   * {@inheritdoc}
   */
  public static function defaultSettings() {
    return [
      'thousand_separator' => '',
      'prefix_suffix' => TRUE,
    ] + parent::defaultSettings();
  }

  /**
   * {@inheritdoc}
   */
  protected function numberFormat($number) {
    return number_format($number, 0, '', $this->getSetting('thousand_separator'));
  }

}

Plugin

  • Plugins are assembled by a factory class

Service

  • Class specifically created to be used by other classes. (Composition)
  • Since service objects are used by other objects, they are a bit hidden.
  • Drupal discovers services by looking in specially named files, [module_name].services.yaml
  • Drupalā€™s services are listed in core.services.yaml
A masked smuggler holding a package image/svg+xmlOpenclipart

Service

core/modules/block/block.services.yml

services:
  theme.negotiator.block.admin_demo:
    class: Drupal\block\Theme\AdminDemoNegotiator
    tags:
      - { name: theme_negotiator, priority: 1000 }
  block.page_display_variant_subscriber:
    class: Drupal\block\EventSubscriber\BlockPageDisplayVariantSubscriber
    tags:
      - { name: event_subscriber }
  block.repository:
    class: Drupal\block\BlockRepository
    arguments: ['@entity_type.manager', '@theme.manager', '@context.handler']

Service

  • A powerful approach to composition is dependency injection.
  • The specific objects that are added to the class are injected at runtime.

Services

Dependency injection to a service

  block.repository:
    class: Drupal\block\BlockRepository
    arguments: ['@entity_type.manager', '@theme.manager', '@context.handler']

core/modules/block/src/BlockRepository.php

/**
   * Constructs a new BlockRepository.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager service.
   * @param \Drupal\Core\Theme\ThemeManagerInterface $theme_manager
   *   The theme manager.
   * @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $context_handler
   *   The plugin context handler.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager, ThemeManagerInterface $theme_manager, ContextHandlerInterface $context_handler) {
    $this->blockStorage = $entity_type_manager->getStorage('block');
    $this->themeManager = $theme_manager;
    $this->contextHandler = $context_handler;
  }

Extending Commerce

Drupal Commerce Logo: A drupal drop in a shopping cart.

The Goal: Donation Specific Flow

  • What we want is a donation specific flow to collect donation information and payment.
Screenshot of the donation form created by the custom donation flow.

Starting it off: a Controller

  • Provides a user with a way to start a donation.
  • Routes a user through the steps of an existing donation.
commerce_donate.routing.yml

commerce_donate.donation.default:
  path: '/donate'
  defaults:
    _controller: '\Drupal\commerce_donate\Controller\DonationController::frontController'
    _title: 'Donate'
  requirements:
    _permission: 'make donation'

commerce_donate.donation_controller_formPage:
  path: '/donate/{commerce_order}/{step}'
  defaults:
    _controller: '\Drupal\commerce_donate\Controller\DonationController::nextStep'
    _title: 'Donate'
  requirements:
    _custom_access: '\Drupal\commerce_donate\Controller\DonationController::checkAccess'
  options:
    parameters:
      commerce_order:
        type: entity:commerce_order
              

Starting it off: a Controller

DonationController.php

namespace Drupal\commerce_donate\Controller;

use Drupal\commerce_cart\CartSession;
use Drupal\commerce_cart\CartSessionInterface;
use Drupal\commerce_checkout\CheckoutOrderManagerInterface;
use Drupal\commerce_store\CurrentStoreInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\commerce_donate\NewOrder;
use Drupal\commerce_donate\Plugin\Commerce\CheckoutFlow\DonationCheckoutFlow;
use Drupal\commerce_donate\Routing\DonationSecuredRedirectResponse;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\RequestStack;

/**
 * Class DonationController.
 */
class DonationController implements ContainerInjectionInterface {

  /**
   * Drupal\commerce_checkout\CheckoutOrderManagerInterface definition.
   *
   * @var \Drupal\commerce_checkout\CheckoutOrderManagerInterface
   */
  protected $checkoutOrderManager;

  /**
   * Drupal\Core\Form\FormBuilderInterface definition.
   *
   * @var \Drupal\Core\Form\FormBuilderInterface
   */
  protected $formBuilder;

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * Injected service.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * The current store.
   *
   * @var \Drupal\commerce_store\CurrentStoreInterface
   */
  protected $currentStore;

  /**
   * The cart session is used for access to the order on the thank you pane.
   *
   * @var \Drupal\commerce_cart\CartSessionInterface
   */
  protected $cartSession;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Injected service.
   *
   * @var \Drupal\commerce_donate\NewOrder
   */
  protected $newOrders;

  /**
   * DonationController constructor.
   *
   * @param \Drupal\Core\Form\FormBuilderInterface $form_builder
   *   Injected service.
   * @param \Drupal\Core\Session\AccountInterface $current_user
   *   Injected service
   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
   *   Injected service.
   * @param \Drupal\commerce_store\CurrentStoreInterface $current_store
   *   Injected service.
   * @param \Drupal\commerce_checkout\CheckoutOrderManagerInterface $commerce_order_manager
   *   Injected service.
   * @param \Drupal\commerce_cart\CartSessionInterface $session
   *   Injected service.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   Injected service.
   * @param \Drupal\commerce_donate\NewOrder $newOrder
   *   Injected service.
   */
  public function __construct(
    FormBuilderInterface $form_builder,
    AccountInterface $current_user,
    RequestStack $request_stack,
    CurrentStoreInterface $current_store,
    CheckoutOrderManagerInterface $commerce_order_manager,
    CartSessionInterface $session,
    EntityTypeManagerInterface $entity_type_manager,
    NewOrder $newOrder
  ) {
    $this->formBuilder = $form_builder;
    $this->currentUser = $current_user;
    $this->requestStack = $request_stack;
    $this->currentStore = $current_store;
    $this->checkoutOrderManager = $commerce_order_manager;
    $this->cartSession = $session;
    $this->entityTypeManager = $entity_type_manager;
    $this->newOrders = $newOrder;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('form_builder'),
      $container->get('current_user'),
      $container->get('request_stack'),
      $container->get('commerce_store.current_store'),
      $container->get('commerce_checkout.checkout_order_manager'),
      $container->get('commerce_cart.cart_session'),
      $container->get('entity_type.manager'),
      $container->get('commerce_donate.new_order')
    );
  }

  /**
   * Builds the initial presentation of the form provided by the donation flow.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   *
   * @return \Drupal\commerce_donate\Routing\DonationSecuredRedirectResponse
   *   Redirect to an order specific flow.
   */
  public function frontController(RouteMatchInterface $route_match) {
    $orderIds = $this->cartSession->getCartIds();
    // If the requested step is not available, both this controller and the
    // default Commerce controller redirect to the order's indicated step.
    // We need a default value in case our checkout flow has not been used.
    $stepId = 'placeholder';
    if (empty($orderIds)) {
      // Create an order to get the flow plugin.
      $order = $this->newOrders->get('single');
      $order->save();
      $this->cartSession->addCartId($order->id());
    }
    else {
      $order = $this->entityTypeManager->getStorage('commerce_order')->load(reset($orderIds));
    }
    // Always start at the first available step.
    $checkoutFlow = $this->checkoutOrderManager->getCheckoutFlow($order);
    $checkoutFlowPlugin = $checkoutFlow->getPlugin();
    // There are no order parameters so CheckoutFlowBase misses the order.
    if ($checkoutFlowPlugin instanceof DonationCheckoutFlow) {
      $checkoutFlowPlugin->conditionalOrderValue($order);
      $stepId = $this->checkoutOrderManager->getCheckoutStepId($order);
    }
    $parameters = [
      'commerce_order' => $order->id(),
      'step' => $stepId,
    ];
    return $this->constructStepRedirect($parameters);
  }

  /**
   * Builds and processes the form provided by the order's checkout flow.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   *
   * @return array|\Drupal\commerce_donate\Routing\DonationSecuredRedirectResponse
   *   The render form.
   */
  public function nextStep(RouteMatchInterface $route_match) {
    /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
    $order = $route_match->getParameter('commerce_order');
    $requestedStepId = $route_match->getParameter('step');
    $parameters = [
      'commerce_order' => $order->id(),
    ];
    $stepId = $this->checkoutOrderManager->getCheckoutStepId($order, $requestedStepId);
    if ($requestedStepId != $stepId) {
      $parameters['step'] = $stepId;
      return $this->constructStepRedirect($parameters);
    }

    $checkout_flow = $this->checkoutOrderManager->getCheckoutFlow($order);
    $checkout_flow_plugin = $checkout_flow->getPlugin();

    return $this->formBuilder->getForm($checkout_flow_plugin, $stepId);
  }

  /**
   * Builds and processes a quick donation flow.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The route match.
   *
   * @return \Drupal\commerce_donate\Routing\DonationSecuredRedirectResponse
   *   Our own redirection into the flow with order prepared.
   */
  public function quickDonation(RouteMatchInterface $route_match) {
    // Create an order.
    // Always start at the first billing step.
    $stepId = 'payment';
    $order = $this->newOrders->get('single');
    $order->set('checkout_step', $stepId);
    $orderItems = $order->getItems();
    // Should only be one.
    $donation = reset($orderItems);
    $donationAmount = $donation->field_donation_amount;
    $amount = $route_match->getParameter('amount');
    $donationAmount->setValue([
      'number' => $amount,
      'currency_code' => $this->currentStore->getStore()
        ->getDefaultCurrencyCode(),
    ]);
    $donation->save();
    $checkoutFlow = $this->checkoutOrderManager->getCheckoutFlow($order);
    $checkoutFlowPlugin = $checkoutFlow->getPlugin();
    if ($checkoutFlowPlugin instanceof DonationCheckoutFlow) {
      $checkoutFlowPlugin->conditionalOrderValue($order);
    }
    $order->save();
    $this->cartSession->addCartId($order->id());
    $parameters = [
      'commerce_order' => $order->id(),
      'step' => $stepId,
    ];
    return $this->constructStepRedirect($parameters);
  }

  /**
   * Checks access for the donation flow.
   *
   * Assigned as custom access for route
   * commerce_donate.donation_controller_formPage
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   Drupal route match service.
   * @param \Drupal\Core\Session\AccountInterface $account
   *   The current user account.
   *
   * @return \Drupal\Core\Access\AccessResult
   *   The access result.
   */
  public function checkAccess(
    RouteMatchInterface $route_match,
    AccountInterface $account
  ) {
    /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
    $order = $route_match->getParameter('commerce_order');
    if ($order->getState()->value == 'canceled') {
      return AccessResult::forbidden()->addCacheableDependency($order);
    }

    // The user can checkout only their own non-empty orders.
    if ($account->isAuthenticated()) {
      $customer_check = $account->id() == $order->getCustomerId();
    }
    else {
      $active_cart = $this->cartSession->hasCartId(
        $order->id(),
        CartSession::ACTIVE
      );
      $completed_cart = $this->cartSession->hasCartId(
        $order->id(),
        CartSession::COMPLETED
      );
      $customer_check = $active_cart || $completed_cart;
    }

    $access = AccessResult::allowedIf($customer_check)
      ->andIf(AccessResult::allowedIf($order->hasItems()))
      ->andIf(
        AccessResult::allowedIfHasPermission($account, 'make donation')
      )
      ->addCacheableDependency($order);

    return $access;
  }

  /**
   * Utility method to build a redirect.
   *
   * @param array $parameters
   *   The url parameters.
   *
   * @return \Drupal\commerce_donate\Routing\DonationSecuredRedirectResponse
   *   The prepared redirect.
   */
  protected function constructStepRedirect(array $parameters) {
    $url = Url::fromRoute('commerce_donate.donation_controller_formPage', $parameters);
    $query = $this->requestStack->getCurrentRequest()->query;
    if ($query->has('donate_return')) {
      $url->setOption('query', ['donate_return' => $query->get('donate_return')]);
    }
    $generatedUrl = $url->toString(TRUE);
    return new DonationSecuredRedirectResponse($generatedUrl->getGeneratedUrl());
  }

}
              

NewOrder service

  • Commerce needs an order and something to purchase.
  • NewOrder service is injected into DonationController
  • NewOrder service creates orders containing an OrderItem of type Donation.

NewOrder service

NewOrder.php

namespace Drupal\commerce_donate;

use Drupal\commerce_order\Entity\Order;
use Drupal\commerce_order\Entity\OrderItem;
use Drupal\commerce_price\Price;
use Drupal\commerce_store\CurrentStoreInterface;
use Drupal\Core\Session\AccountProxy;
use Symfony\Component\HttpFoundation\RequestStack;

/**
 * Service for creating a new order with a donation item.
 *
 * Used in the checkout flow and the donateNow route method
 * in DonationController.
 */
class NewOrder {

  /**
   * The current store.
   *
   * @var \Drupal\commerce_store\CurrentStoreInterface
   */
  protected $currentStore;

  /**
   * Injected service.
   *
   * @var \Drupal\Core\Session\AccountProxy
   */
  protected $user;

  /**
   * The current request.
   *
   * @var \Symfony\Component\HttpFoundation\Request|null
   */
  protected $currentRequest;

  /**
   * NewOrder constructor.
   *
   * @param \Drupal\commerce_store\CurrentStoreInterface $currentStore
   *   Injected service.
   * @param \Drupal\Core\Session\AccountProxy $accountProxy
   *   Injected service.
   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
   *   Injected service.
   */
  public function __construct(CurrentStoreInterface $currentStore, AccountProxy $accountProxy, RequestStack $requestStack) {
    $this->currentStore = $currentStore;
    $this->user = $accountProxy;
    $this->currentRequest = $requestStack->getCurrentRequest();
  }

  /**
   * Creates a new Order object for the donation.
   *
   * @param string $giftType
   *   Optional gift type.
   *
   * @return \Drupal\commerce_order\Entity\Order
   *   The new order object.
   */
  public function get($giftType = 'single') {
    /** @var \Drupal\commerce_order\Entity\Order $order */
    $order = Order::create(
      [
        'type' => 'default',
        'state' => 'draft',
        'store_id' => $this->currentStore->getStore()->id(),
      ]
    );
    if ($this->user->isAuthenticated()) {
      $order->set('uid', $this->user->id());
    }
    // Start at zero.
    $price = new Price(
      0,
      $this->currentStore->getStore()->getDefaultCurrencyCode()
    );
    $donation = OrderItem::create(
      [
        'type' => 'donation',
      ]
    );
    /** @var \Drupal\commerce_order\Entity\OrderItem $donation */
    $donation->setUnitPrice($price);
    $donation->field_gift_type->setValue($giftType);
    $this->prepopulate($donation);
    $order->addItem($donation);

    return $order;
  }

  /**
   * Prepopulate any matching OrderItem fields with query paramters.
   *
   * @param \Drupal\commerce_order\Entity\OrderItem $donation
   *   Our OrderItems are donations.
   */
  public function prepopulate(OrderItem $donation) {
    foreach ($this->currentRequest->query as $key => $value) {
      if ($donation->hasField('field_' . $key)) {
        $donation->set('field_' . $key, $value);
      }
    }
  }

}

Plugins within Plugins

  • The checkout process in Commerce is a plugin.
  • The steps available in a checkout are also plugins

Plugins within Plugins

DonationCheckoutFlow

namespace Drupal\commerce_donate\Plugin\Commerce\CheckoutFlow;

use Drupal\commerce\Response\NeedsRedirectException;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowWithPanesBase;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowWithPanesInterface;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\Component\Utility\Html;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * A custom checkout flow for donations.
 *
 * @CommerceCheckoutFlow(
 *  id = "donation_checkout_flow",
 *  label = @Translation("Donation Flow"),
 * )
 */
class DonationCheckoutFlow extends CheckoutFlowWithPanesBase implements CheckoutFlowWithPanesInterface
{

  /**
   * Injected service.
   *
   * @var \Drupal\Core\Session\AccountProxy
   */
  protected $user;

  /**
   * Drupal\Core\Routing\CurrentRouteMatch definition.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * Current default currency code.
   *
   * @var string
   */
  protected $currency;

  /**
   * Injected service.
   *
   * @var \CommerceGuys\Intl\Formatter\CurrencyFormatterInterface
   */
  protected $currencyFormatter;

  /**
   * The current route match.
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $routeMatch;

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $pane_id,
    $pane_definition
  ) {
    $instance = parent::create(
      $container,
      $configuration,
      $pane_id,
      $pane_definition
    );
    $instance->user = $container->get('current_user');
    $instance->requestStack = $container->get('request_stack');
    $instance->currencyFormatter = $container->get(
      'commerce_price.currency_formatter'
    );
    $instance->routeMatch = $container->get('current_route_match');

    return $instance;
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration()
  {
    $config = parent::defaultConfiguration();
    $config['offline_link_title'] = '';
    $config['offline_link_url'] = '';

    return $config;
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(
    array $form,
    FormStateInterface $form_state
  ) {
    $form = parent::buildConfigurationForm($form, $form_state);
    $link_title = $this->configuration['offline_link_title'];
    $link_url = $this->configuration['offline_link_url'];
    $form['offline_link_title'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Offline donation link title'),
      '#default_value' => empty($link_title) ? '' : $link_title,
    ];

    $form['offline_link_url'] = [
      '#type' => 'url',
      '#title' => $this->t('Offline donation link URL'),
      '#default_value' => empty($link_url) ? '' : $link_url,
      '#description' => 'You may provide a link to a PDF or other file type that your users may download and use to send donations by mail.',
    ];

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitConfigurationForm(
    array &$form,
    FormStateInterface $form_state
  ) {
    parent::submitConfigurationForm($form, $form_state);
    if (!$form_state->getErrors()) {
      $values = $form_state->getValue($form['#parents']);
      $this->configuration['offline_link_title'] = $values['offline_link_title'];
      $this->configuration['offline_link_url'] = $values['offline_link_url'];
    }
  }

  /**
   * {@inheritdoc}
   */
  public function redirectToStep($step_id)
  {
    // May throw a redirect exception with settings to commerce checkout.
    try {
      parent::redirectToStep($step_id);
    } catch (NeedsRedirectException $e) {
      // Use our controller instead.
      throw new NeedsRedirectException(
        Url::fromRoute(
          'commerce_donate.donation_controller_formPage',
          [
            'commerce_order' => $this->order->id(),
            'step' => $step_id,
          ]
        )->toString()
      );
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getSteps()
  {
    // Note that previous_label and next_label are not the labels
    // shown on the step itself. Instead, they are the labels shown
    // when going back to the step, or proceeding to the step.
    $steps =
      [
        'donation' => [
          'label' => $this->t('Your Donation'),
          'next_label' => $this->t('Continue'),
        ],
        'dedication' => [
          'label' => $this->t('Dedication Information'),
          'next_label' => $this->t('Continue'),
        ],
      ]
      + parent::getSteps();
    // Adjust names to match our use case:
    $steps['payment']['label'] = $this->t('Billing Information');
    $steps['payment']['next_label'] = $this->t('Continue to Payment');
    $steps['complete']['next_label'] = $this->t('Donate');

    if ($this->order instanceof OrderInterface && $this->order->hasItems()) {
      $items = $this->order->getItems();
      // Only one donation OrderItem supported.
      $donations = array_filter(
        $items,
        function ($item) {
          return $item->bundle() == 'donation';
        }
      );
      $donation = reset($donations);
      if ($donation->hasField('field_donation_amount') && !$donation->field_donation_amount->isEmpty()) {
        // Append the amount on the payment button text.
        $amountFormatOptions = [
          'currency_display' => 'symbol',
          'minimum_fraction_digits' => 0,
        ];
        $donationAmount = $donation->field_donation_amount->first()->toPrice();
        $displayAmount = $this->currencyFormatter->format(
          $donationAmount->getNumber(),
          $this->getCurrency(),
          $amountFormatOptions
        );
        // Check for monthly.
        if (
          $donation->hasField('field_gift_type')
          && !$donation->field_gift_type->isEmpty()
          && $donation->field_gift_type->first()->value == 'recurring') {
          $displayAmount .= '/mo';
        }
        $steps['complete']['next_label'] = $this->t(
          'Donate @amount',
          ['@amount' => $displayAmount]
        );
      }
    }
    // All steps in the donation flow have no sidebar.
    foreach ($steps as &$step) {
      $step['has_sidebar'] = false;
    }

    return $steps;
  }


  /**
   * {@inheritdoc}
   */
  public function buildForm(
    array $form,
    FormStateInterface $form_state,
    $step_id = null
  ) {
    $this->order->recalculateTotalPrice();
    // Get the basic form.
    $form = parent::buildForm($form, $form_state, $step_id);
    $form['#attributes']['autocomplete'] = 'off';
    // Now build the summaries, if there are any.
    if ($form['#step_id'] != 'complete') {
      $summarySteps = $this->panesToSummarize($form['#step_id'], $form_state);
      $this->buildSummaries($form, $summarySteps);
    }
    // Identify the step in a class for analytics.
    $form['actions']['next']['#attributes']['class'][] = Html::cleanCssIdentifier(
      "donation-flow-step--$step_id"
    );
    $form['#attributes']['class'][] = Html::cleanCssIdentifier(
      "donation-flow-step--$step_id"
    );
    $link_url = $this->configuration['offline_link_url'] ?? null;
    if ($link_url && $step_id == 'donation') {
      $link_title = $this->configuration['offline_link_title'];
      $form['pdf_link'] = [
        '#title' => $link_title,
        '#type' => 'link',
        '#url' => Url::fromUri($link_url),
        '#weight' => 200,
        '#attributes' => [
          'target' => '_blank',
          'rel' => 'noopener noreferrer',
          'class' => ['pdf-link'],
        ],
        '#theme_wrappers' => ['container'],
      ];
    }

    return $form;
  }

  /**
   * Utility method to generate a step url.
   *
   * Used in this flow in and building the summary.
   *
   * @param string $step
   *   The step ID.
   *
   * @return \Drupal\Core\Url
   *   The url to the step.
   *
   * @see \Drupal\commerce_donate\Plugin\Commerce\CheckoutPane\DonationItemPaneBase::doSummaryBuild
   */
  public function getStepUrl($step)
  {
    $options = [];
    $query = $this->requestStack->getCurrentRequest()->query;
    if ($query->has('donate_return')) {
      $returnPath = ['donate_return' => $query->get('donate_return')];
      $options = ['query' => $returnPath];
    }

    return Url::fromRoute(
      'commerce_donate.donation_controller_formPage',
      [
        'commerce_order' => $this->order->id(),
        'step' => $step,
      ],
      $options
    );
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state)
  {
    // Steps are dynamic based on the submission, so clear to recalculate.
    unset($this->visibleSteps);
    parent::submitForm($form, $form_state);
    // Override the parent redirect data.
    $nextStepId = $this->getNextStepId($form['#step_id']);
    $form_state->setRedirectUrl($this->getStepUrl($nextStepId));
  }

  /**
   * Sort through panes to populate an array of panes to summarize.
   *
   * Only panes in visible steps should summarize.
   *
   * @param string $current_step
   *   The current step id.
   * @param \Drupal\Core\Form\FormStateInterface $formState
   *   The current form state.
   *
   * @return \Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneInterface[]
   *   The selected panes.
   */
  protected function panesToSummarize(
    $current_step,
    FormStateInterface $formState
  ) {
    /** @var \Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneInterface[] $possiblePanes */
    $possiblePanes = array_filter(
      $this->getPanes(),
      function ($pane) {
        return !in_array($pane->getStepId(), ['_sidebar', '_disabled']);
      }
    );
    $visible = $this->getVisibleSteps();
    $stepIds = array_keys($visible);
    $currentIndex = array_search($current_step, $stepIds);
    $indices = array_flip($stepIds);
    $stepsCompleted = [];
    foreach ($indices as $step => $index) {
      if ($index < $currentIndex) {
        $stepsCompleted[$step] = true;
      }
    }
    if (isset($indices["complete"])) {
      // Don't list the thank you page.
      unset($indices["complete"]);
    }
    $panes = array_filter(
      $possiblePanes,
      function ($pane) use ($stepsCompleted) {
        /** @var \Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneInterface $pane */
        return isset($stepsCompleted[$pane->getStepId()]);
      }
    );
    $summaries = [];
    $step_number = 1;
    foreach ($indices as $step => $index) {
      if (isset($stepsCompleted[$step])) {
        $summaries[$step] = [
          'index' => $step_number,
          'label' => $visible[$step]['label'],
          'url' => $this->getStepUrl($step),
          'panes' => [],
        ];
      }
      ++$step_number;
    }
    foreach ($panes as $pane) {
      $summaries[$pane->getStepId()]['panes'][] = $pane;
    }

    return $summaries;
  }

  /**
   * Helper method to build the full summary display and complete overview.
   *
   * @param array $form
   *   The form array being built.
   * @param array $summarySteps
   *   Array with summary data.
   */
  protected function buildSummaries(array &$form, array $summarySteps)
  {
    $form['summaries'] = [
      '#type' => 'container',
      '#weight' => -80,
      '#attributes' => [
        'class' => ['donation-summaries'],
      ],
    ];
    // Adapted from Review::buildPaneForm.
    foreach ($summarySteps as $summaryStep => $data) {
      $form['summaries'][$summaryStep] = [
        '#type' => 'container',
        '#attributes' => [
          'class' => ['donation-summary-step'],
        ],
        'summaries' => [],
        'edit_link' => [
          '#title' => $this->t('Edit'),
          '#type' => 'link',
          '#url' => $data['url'],
          '#attributes' => [
            'class' => [
              'summary-edit',
              'edit-donation',
            ],
          ],
        ],
      ];
      if (isset($data['panes'])) {
        /** @var \Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneInterface $pane */
        foreach ($data['panes'] as $pane) {
          $form['summaries'][$summaryStep]['summaries'][] = $pane->buildPaneSummary(
          );
        }
      }
    }
  }

  /**
   * Helper function to set the order object if it has not been set before.
   *
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   */
  public function conditionalOrderValue(OrderInterface $order)
  {
    if (!$this->order instanceof OrderInterface) {
      $this->order = $order;
    }
  }

  /**
   * Helper function to obtain the current currency code.
   *
   * @return string
   */
  protected function getCurrency() {
    if (!empty($this->currency)) {
      return $this->currency;
    }
    if ($this->order instanceof OrderInterface) {
      $this->currency = $this->order->getStore()
        ->getDefaultCurrencyCode();
      return $this->currency;
    }
    return '';
  }

}

Plugins within Plugins

DonationCheckoutPane

namespace Drupal\commerce_donate\Plugin\Commerce\CheckoutPane;

use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * A custom pane for creating Donation OrderItems.
 *
 * @CommerceCheckoutPane(
 *  id = "commerce_donation_pane",
 *  label = @Translation("Donation"),
 *  display_label = @Translation("Your Donation"),
 *  default_step = "donation",
 *  wrapper_element = "fieldset",
 * )
 */
class DonationCheckoutPane extends DonationItemPaneBase implements CheckoutPaneInterface, ContainerFactoryPluginInterface {

  /**
   * Is commerce_recurring available?
   *
   * Flag for monthly donation elements.
   *
   * @var bool
   */
  protected $hasRecurring;

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition,
    CheckoutFlowInterface $checkout_flow = NULL
  ) {
    $instance = parent::create(
      $container,
      $configuration,
      $plugin_id,
      $plugin_definition,
      $checkout_flow
    );
    $instance->setFormDisplay('donation');
    $instance->hasRecurring = $instance->moduleHandler->moduleExists('commerce_recurring');
    return $instance;
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
    $config = parent::defaultConfiguration();
    $config['message'] = $this->hasRecurring ? 'Your first donation will be charged today and then monthly starting on' : '';
    return $config;
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationSummary() {
    $summary = [];
    if (!empty($this->configuration['message'])) {
      $summary[] = $this->t(
        'Monthly giving message: @message',
        ['@message' => $this->configuration['message']]
      );
    }

    return implode(', ', $summary);
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(
    array $form,
    FormStateInterface $form_state
  ) {
    $form = parent::buildConfigurationForm($form, $form_state);
    if ($this->hasRecurring) {
      $message = $this->configuration['message'];
      $form['message'] = [
        '#type' => 'textfield',
        '#title' => $this
          ->t('Monthly giving message'),
        '#default_value' => empty($message) ? '' : $message,
        '#description' => $this->t(
          'The date one month ahead will be appended to this string'
        ),
        '#size' => 128,
        '#maxlength' => 128,
        '#required' => TRUE,
      ];
    }
    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitConfigurationForm(
    array &$form,
    FormStateInterface $form_state
  ) {
    parent::submitConfigurationForm($form, $form_state);

    if (!$form_state->getErrors()) {
      $values = $form_state->getValue($form['#parents']);
      $this->configuration['message'] = $values['message'];
    }
  }

  /**
   * {@inheritdoc}
   */
  public function buildPaneForm(
    array $pane_form,
    FormStateInterface $form_state,
    array &$complete_form
  ) {
    $class = get_class($this);
    $pane_form = parent::buildPaneForm($pane_form, $form_state, $complete_form);
    // Add ajax to the gift type.
    if (isset($pane_form['field_gift_type']['widget'])) {
      $pane_form['field_gift_type']['widget']['#ajax'] = [
        'callback' => [$class, 'ajaxAmountUpdate'],
        'progress' => 'none',
      ];
    }
    // Block progress if there is no javascript.
    $complete_form["actions"]['#attributes']['class'] = $complete_form["actions"]['#attributes']['class'] ?? [];
    $pane_form['#attributes']['class'] = $pane_form['#attributes']['class'] ?? [];
    $complete_form["actions"]['#attributes']['class'][] = 'js-show';
    $pane_form['#attributes']['class'][] = 'js-show';
    return $pane_form;
  }

  /**
   * An ajax responder for gift type change.
   *
   * @param array $form
   *   The form render array.
   * @param \Drupal\Core\Form\FormStateInterface $formState
   *   The current form state.
   *
   * @return \Drupal\Core\Ajax\AjaxResponse
   *   The ajax response.
   */
  public static function ajaxAmountUpdate(
    array $form,
    FormStateInterface $formState
  ) {
    $response = new AjaxResponse();
    $response->addCommand(
      new ReplaceCommand(
        'fieldset[data-donation-level="fieldset"]',
        $form['commerce_donation_pane']['field_donation_amount']['widget'][0]['donation_level']
      )
    );
    return $response;
  }

  /**
   * {@inheritdoc}
   */
  public function buildPaneSummary() {
    $viewMode = 'donation';
    return $this->doSummaryBuild($viewMode);
  }

}

See it in action: Live Demo!

Open Q & A

“Heeded my words not, did you? Pass on what you have learned. Strength, mastery.”

“But weakness, folly, failure also. Yes, failure most of all. The greatest teacher, failure is.”

References

Photo Credits