commerce_checkout_accordion-1.0.x-dev/src/Form/CheckoutFlowAccordion.php
src/Form/CheckoutFlowAccordion.php
<?php
namespace Drupal\commerce_checkout_accordion\Form;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\commerce\AjaxFormTrait;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
/**
* Implements accordion checkout flow.
*
* @package Drupal\commerce_checkout_accordion\Form
*/
class CheckoutFlowAccordion {
use StringTranslationTrait;
use DependencySerializationTrait;
/**
* The checkout panes array.
*
* The array consists of a generated index only useful within this class and
* the name of the checkout pane as value.
*
* @var array
*/
protected $checkoutPanes;
/**
* Add a button to each checkout pane.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form id.
*
* @see hook_form_alter()
*/
public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
if ($form_id === 'commerce_checkout_flow_multistep_default' && $form["#step_id"] === "order_information") {
$this->checkoutPanes = CheckoutFlowAccordion::getCheckoutPanesNames($form);
// Find the current pane index.
$current_pane_pos = $this->calcCurrentPanePos($form_state);
$total_panes = count($this->checkoutPanes);
/** @var \Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowWithPanesInterface $checkout_flow */
$checkout_flow = $form_state->getFormObject();
foreach ($this->checkoutPanes as $index => $pane_name) {
// Only interested in panes that are like a group.
if ($form[$pane_name]['#type'] !== 'fieldset' && $form[$pane_name]['#type'] !== 'details') {
continue;
}
// Change each pane form element type to "details".
$form[$pane_name]['#type'] = 'details';
// Add the next button to all panes except the last one.
// Even though we really need the next button for the current/next pane,
// adding the next button to all panes is more robust in case of errors.
if ($index != $total_panes - 1) {
$next_pane_name = (string) $form[$this->checkoutPanes[$index + 1]]['#title'];
$form[$pane_name]['next'] = [
'#type' => 'button',
'#value' => "Next: $next_pane_name",
'#name' => 'next',
'#ajax' => [
'callback' => [$this, 'registerCallback'],
],
'#limit_validation_errors' => [$form[$pane_name]['#parents']],
'#attributes' => [
'class' => [
'checkout-next-step',
],
],
'#prefix' => '<div class="checkout-pane__next-step">',
'#suffix' => '</div>',
];
}
// The past panes.
if ($index < $current_pane_pos) {
// Show summary.
$paneObj = $checkout_flow->getPane($pane_name);
if ($summary = $paneObj->buildPaneSummary()) {
// BC layer for panes which still return rendered strings.
if ($summary && !is_array($summary)) {
$summary = [
'#markup' => $summary,
];
}
$form[$pane_name]['summary'] = $summary;
}
// Add an Edit button.
$form[$pane_name]['edit'] = [
'#value' => $this->t("Edit"),
// #value is the same, so #name must be unqiue for Form API.
'#name' => $this->checkoutPanes[$index] . '_edit',
'#type' => 'button',
'#ajax' => [
'callback' => [$this, 'registerCallback'],
],
// Edit button should surpress all validation errors.
'#limit_validation_errors' => [],
'#prefix' => "<span> (",
'#suffix' => ')</span>',
'#attributes' => [
'class' => [
'link',
],
],
];
}
// Disable all details panes except the current one.
if ($index != $current_pane_pos) {
$form[$pane_name]['#attributes']['onclick'] = 'return false';
}
}
// Open the current pane.
$current_pane_name = $this->checkoutPanes[$current_pane_pos];
$form[$current_pane_name]['#attributes']['class'][] = 'collapsible';
$form[$current_pane_name]['#open'] = 'open';
// Attach the JS file.
$form["#attached"]["library"][] = 'commerce_checkout_accordion/panes_accordion';
}
}
/**
* Get the checkout panes.
*
* This won't be needed after
* https://www.drupal.org/project/commerce/issues/3201864
*
* @return array
* The array of checkout panes' names.
*/
public static function getCheckoutPanesNames(array &$form) {
$pane_names = [];
foreach ($form as $key => $leaf) {
if (is_array($leaf) && isset($leaf['#pane_id'])) {
$pane_names[] = $key;
}
}
return $pane_names;
}
/**
* Refresh the checkout pane or animate the accordion to the next one.
*
* This callback will occur *after* the form has been rebuilt by buildForm().
* Since that's the case, the $form array should contain the right values for
* the instrument type field that reflect the current value of the instrument
* family field.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return array
* The portion of the render structure that will replace the form element.
*/
public function registerCallback(array $form, FormStateInterface $form_state) {
$response = AjaxFormTrait::ajaxRefreshForm($form, $form_state);
// Add custom commands to open the next accordion item.
if (!$form_state->hasAnyErrors()) {
// currentPanePos is actually from the form built containting the
// triggering element, not the form just built. See docs
// https://api.drupal.org/api/drupal/core%21core.api.php/group/ajax/9.4.x#sub_callback
$current_pane_pos = $this->calcCurrentPanePos($form_state);
$triggering_pane = $form[$this->checkoutPanes[$current_pane_pos]];
$id = $triggering_pane["#attributes"]["data-drupal-selector"];
// The current checkout pane.
$selector = "[data-drupal-selector=\"$id\"]";
$method = 'attr';
$arguments = ['open', ''];
$response->addCommand(new InvokeCommand($selector, $method, $arguments));
$arguments = ['onclick', ''];
$response->addCommand(new InvokeCommand($selector, $method, $arguments));
}
return $response;
}
/**
* Calculate the current pane index.
*
* We cannot store the current pane as a member variable in the class, which
* will be cached between requests.
*
* The current checkout is the only one with accordion itme item opened. It is
* calculated from a triggering form element (@todo URL bookmark). If there is
* no triggering element, then the first pane should be the current one. The
* panes before the current one should have an edit link, while the ones after
* should all be locked.
*
* @return int
* The current pane index.
*/
protected function calcCurrentPanePos(FormStateInterface $form_state) {
$current_pane_pos = 0;
$triggering_element = $form_state->getTriggeringElement();
$triggering_pane_name = empty($triggering_element) ? NULL : $triggering_element['#parents'][0];
// Find the current pane index.
if ($triggering_element) {
$triggering_pos = array_search($triggering_pane_name, $this->checkoutPanes);
// If there is no form rebuild, it means the customer moves on to the
// the next pane. If the edit button is clicked, then the customer moves
// backward. If the ajax is triggered by other element inside the pane,
// such as coupon, shipping or switching a payment method, the customer
// stays at the current pane.
$current_pane_pos = !$form_state->hasAnyErrors()
&& $triggering_element['#name'] == 'next'
? $triggering_pos + 1 : $triggering_pos;
}
return $current_pane_pos;
}
}
