diff --git a/.travis.yml b/.travis.yml
index d9052853b7..6342f30984 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -41,7 +41,7 @@ env:
# The environment to use, supported are: drupal-7, drupal-8
- DRUPAL_TI_ENVIRONMENT="drupal-8"
- - DRUPAL_TI_CORE_BRANCH="8.4.x"
+ - DRUPAL_TI_CORE_BRANCH="8.5.x"
# Drupal specific variables.
- DRUPAL_TI_DB="drupal_travis_db"
@@ -51,8 +51,8 @@ env:
- DRUPAL_TI_WEBSERVER_PORT="8080"
# Simpletest specific commandline arguments, the DRUPAL_TI_SIMPLETEST_GROUP is appended at the end.
- - DRUPAL_TI_SIMPLETEST_ARGS="--verbose --color --concurrency 20 --url $DRUPAL_TI_WEBSERVER_URL:$DRUPAL_TI_WEBSERVER_PORT --types Simpletest,PHPUnit-Unit,PHPUnit-Kernel,PHPUnit-Functional"
- - DRUPAL_TI_SIMPLETEST_JS_ARGS="--verbose --color --concurrency 1 --url $DRUPAL_TI_WEBSERVER_URL:$DRUPAL_TI_WEBSERVER_PORT --types PHPUnit-FunctionalJavascript"
+ - DRUPAL_TI_SIMPLETEST_ARGS="--verbose --color --concurrency 20 --url $DRUPAL_TI_WEBSERVER_URL:$DRUPAL_TI_WEBSERVER_PORT --types Simpletest,PHPUnit-Unit,PHPUnit-Kernel,PHPUnit-Functional --suppress-deprecations"
+ - DRUPAL_TI_SIMPLETEST_JS_ARGS="--verbose --color --concurrency 1 --url $DRUPAL_TI_WEBSERVER_URL:$DRUPAL_TI_WEBSERVER_PORT --types PHPUnit-FunctionalJavascript --suppress-deprecations"
# === Behat specific variables.
# This is relative to $TRAVIS_BUILD_DIR
@@ -107,8 +107,6 @@ before_install:
- composer global require "hirak/prestissimo:^0.3"
- composer global require "lionsad/drupal_ti:dev-master#396d11d200005eb68491d24170da0a98ae7f51b3"
- composer global require "squizlabs/php_codesniffer:2.*"
- - composer global require "drupal/coder:8.2.*"
- - phpcs --config-set installed_paths $HOME/.composer/vendor/drupal/coder/coder_sniffer
- drupal-ti before_install
install:
@@ -118,6 +116,9 @@ install:
- tar -xvf $PWD/travis-phantomjs/phantomjs-2.0.0-ubuntu-12.04.tar.bz2 -C $PWD/travis-phantomjs
- export PATH=$PWD/travis-phantomjs:$PATH
- phantomjs --version
+ # Installed after Drush to prevent Composer conflicts around symfony/yaml.
+ - composer global require "drupal/coder:8.2.*"
+ - phpcs --config-set installed_paths $HOME/.composer/vendor/drupal/coder/coder_sniffer
before_script:
- drupal-ti --include ".travis-before-script.sh"
diff --git a/README.md b/README.md
index f23acbbea3..51599a11f8 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,6 @@ Drupal Commerce
Drupal Commerce is the leading flexible eCommerce solution for Drupal,
powering over 60,000 online stores of all sizes.
-Note: 8.x-2.x is in beta. It is already usable for production websites
-and upgrades between versions are supported. Bugs are still expected.
Please report bugs in the [issue queue](https://www.drupal.org/project/issues/commerce?version=8.x).
[Documentation](http://docs.drupalcommerce.org)
diff --git a/commerce.info.yml b/commerce.info.yml
index 2f7353e5e2..633eb2b1da 100644
--- a/commerce.info.yml
+++ b/commerce.info.yml
@@ -10,4 +10,4 @@ dependencies:
- datetime
- inline_entity_form
- views
- - system (>=8.4.0)
+ - system (>=8.5.0)
diff --git a/commerce.module b/commerce.module
index 1c367a58cd..90a4274428 100644
--- a/commerce.module
+++ b/commerce.module
@@ -123,7 +123,6 @@ function _commerce_entity_theme_suggestions($entity_type_id, array $variables) {
$sanitized_view_mode = strtr($variables['elements']['#view_mode'], '.', '_');
$suggestions = [];
- $suggestions[] = $original;
$suggestions[] = $original . '__' . $sanitized_view_mode;
$suggestions[] = $original . '__' . $entity->bundle();
$suggestions[] = $original . '__' . $entity->bundle() . '__' . $sanitized_view_mode;
diff --git a/composer.json b/composer.json
index bd53e77806..65f7eeb79e 100644
--- a/composer.json
+++ b/composer.json
@@ -5,9 +5,9 @@
"homepage": "http://drupal.org/project/commerce",
"license": "GPL-2.0+",
"require": {
- "drupal/core": "~8.4",
+ "drupal/core": "~8.5",
"drupal/address": "~1.0",
- "drupal/entity": "~1.0",
+ "drupal/entity": "^1.0-beta3",
"drupal/entity_reference_revisions": "~1.0",
"drupal/inline_entity_form": "~1.0",
"drupal/profile": "~1.0",
diff --git a/config/schema/commerce.schema.yml b/config/schema/commerce.schema.yml
index 09f506aa93..2cf36cb421 100644
--- a/config/schema/commerce.schema.yml
+++ b/config/schema/commerce.schema.yml
@@ -13,6 +13,9 @@ commerce_config_entity_bundle:
orderby: value
sequence:
type: string
+ locked:
+ type: boolean
+ label: 'Locked'
commerce_condition:
type: mapping
diff --git a/modules/cart/commerce_cart.info.yml b/modules/cart/commerce_cart.info.yml
index 4455adb992..5bea2eaa29 100644
--- a/modules/cart/commerce_cart.info.yml
+++ b/modules/cart/commerce_cart.info.yml
@@ -4,11 +4,9 @@ description: 'Implements the shopping cart system and add to cart features.'
package: Commerce
core: 8.x
dependencies:
- - commerce
+ - commerce:commerce
- commerce:commerce_order
- - commerce:commerce_price
- commerce:commerce_product
- - views
config_devel:
install:
- core.entity_form_mode.commerce_order_item.add_to_cart
diff --git a/modules/cart/commerce_cart.install b/modules/cart/commerce_cart.install
deleted file mode 100644
index 645fbdb95b..0000000000
--- a/modules/cart/commerce_cart.install
+++ /dev/null
@@ -1,17 +0,0 @@
-getFormObject() instanceof ViewsForm) {
- /** @var \Drupal\views\ViewExecutable $view */
- $view = reset($form_state->getBuildInfo()['args']);
- if ($view->storage->get('tag') == 'commerce_cart_form' && !empty($view->result)) {
- $form['actions']['submit']['#submit'] = array_merge($form['#submit'], ['commerce_cart_order_item_views_form_submit']);
- $form['actions']['submit']['#order_id'] = $view->argument['order_id']->value[0];
- }
- }
-}
-
-/**
- * Form submission handler for 'views_form_commerce_cart_form_'.
- */
-function commerce_cart_order_item_views_form_submit($form, FormStateInterface $form_state) {
- if (!$form_state->has('quantity_updated')) {
- return;
- }
- $order_id = $form_state->getTriggeringElement()['#order_id'];
- /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
- $order = \Drupal::entityTypeManager()->getStorage('commerce_order')->load($order_id);
- $order->save();
- drupal_set_message(t('Your shopping cart has been updated.'));
-}
-
/**
* Form submission handler for 'commerce_order_type_form'.
*/
diff --git a/modules/cart/commerce_cart.permissions.yml b/modules/cart/commerce_cart.permissions.yml
deleted file mode 100644
index dea08e456d..0000000000
--- a/modules/cart/commerce_cart.permissions.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-access cart:
- title: 'Access cart'
- description: 'View the cart page.'
diff --git a/modules/cart/commerce_cart.routing.yml b/modules/cart/commerce_cart.routing.yml
index cedc593f9e..9df43598fd 100644
--- a/modules/cart/commerce_cart.routing.yml
+++ b/modules/cart/commerce_cart.routing.yml
@@ -4,4 +4,4 @@ commerce_cart.page:
_controller: '\Drupal\commerce_cart\Controller\CartController::cartPage'
_title: 'Shopping cart'
requirements:
- _permission: 'access cart'
+ _access: 'TRUE'
diff --git a/modules/cart/commerce_cart.services.yml b/modules/cart/commerce_cart.services.yml
index d1e6bfe70f..27cb87b828 100644
--- a/modules/cart/commerce_cart.services.yml
+++ b/modules/cart/commerce_cart.services.yml
@@ -11,7 +11,7 @@ services:
commerce_cart.cart_provider:
class: Drupal\commerce_cart\CartProvider
- arguments: ['@entity_type.manager', '@current_user', '@commerce_cart.cart_session']
+ arguments: ['@entity_type.manager', '@commerce_store.current_store', '@current_user', '@commerce_cart.cart_session']
commerce_cart.cart_manager:
class: Drupal\commerce_cart\CartManager
@@ -23,7 +23,7 @@ services:
commerce_cart.cart_subscriber:
class: Drupal\commerce_cart\EventSubscriber\CartEventSubscriber
- arguments: ['@string_translation']
+ arguments: ['@messenger', '@string_translation']
tags:
- { name: event_subscriber }
diff --git a/modules/cart/src/CartManager.php b/modules/cart/src/CartManager.php
index 18f4c40ed9..c0de845a01 100644
--- a/modules/cart/src/CartManager.php
+++ b/modules/cart/src/CartManager.php
@@ -113,6 +113,7 @@ public function addOrderItem(OrderInterface $cart, OrderItemInterface $order_ite
$saved_order_item = $matching_order_item;
}
else {
+ $order_item->set('order_id', $cart->id());
$order_item->save();
$cart->addItem($order_item);
$saved_order_item = $order_item;
diff --git a/modules/cart/src/CartProvider.php b/modules/cart/src/CartProvider.php
index 95bf35c8f3..db0197f60a 100644
--- a/modules/cart/src/CartProvider.php
+++ b/modules/cart/src/CartProvider.php
@@ -4,6 +4,7 @@
use Drupal\commerce_cart\Exception\DuplicateCartException;
use Drupal\commerce_order\Entity\OrderInterface;
+use Drupal\commerce_store\CurrentStoreInterface;
use Drupal\commerce_store\Entity\StoreInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
@@ -20,6 +21,13 @@ class CartProvider implements CartProviderInterface {
*/
protected $orderStorage;
+ /**
+ * The current store.
+ *
+ * @var \Drupal\commerce_store\CurrentStoreInterface
+ */
+ protected $currentStore;
+
/**
* The current user.
*
@@ -57,13 +65,16 @@ class CartProvider implements CartProviderInterface {
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
+ * @param \Drupal\commerce_store\CurrentStoreInterface $current_store
+ * The current store.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Drupal\commerce_cart\CartSessionInterface $cart_session
* The cart session.
*/
- public function __construct(EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user, CartSessionInterface $cart_session) {
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, CurrentStoreInterface $current_store, AccountInterface $current_user, CartSessionInterface $cart_session) {
$this->orderStorage = $entity_type_manager->getStorage('commerce_order');
+ $this->currentStore = $current_store;
$this->currentUser = $current_user;
$this->cartSession = $cart_session;
}
@@ -71,7 +82,8 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager, Acc
/**
* {@inheritdoc}
*/
- public function createCart($order_type, StoreInterface $store, AccountInterface $account = NULL) {
+ public function createCart($order_type, StoreInterface $store = NULL, AccountInterface $account = NULL) {
+ $store = $store ?: $this->currentStore->getStore();
$account = $account ?: $this->currentUser;
$uid = $account->id();
$store_id = $store->id();
@@ -124,7 +136,7 @@ public function finalizeCart(OrderInterface $cart, $save_cart = TRUE) {
/**
* {@inheritdoc}
*/
- public function getCart($order_type, StoreInterface $store, AccountInterface $account = NULL) {
+ public function getCart($order_type, StoreInterface $store = NULL, AccountInterface $account = NULL) {
$cart = NULL;
$cart_id = $this->getCartId($order_type, $store, $account);
if ($cart_id) {
@@ -137,10 +149,11 @@ public function getCart($order_type, StoreInterface $store, AccountInterface $ac
/**
* {@inheritdoc}
*/
- public function getCartId($order_type, StoreInterface $store, AccountInterface $account = NULL) {
+ public function getCartId($order_type, StoreInterface $store = NULL, AccountInterface $account = NULL) {
$cart_id = NULL;
$cart_data = $this->loadCartData($account);
if ($cart_data) {
+ $store = $store ?: $this->currentStore->getStore();
$search = [
'type' => $order_type,
'store_id' => $store->id(),
diff --git a/modules/cart/src/CartProviderInterface.php b/modules/cart/src/CartProviderInterface.php
index 78881e72b2..142333c96c 100644
--- a/modules/cart/src/CartProviderInterface.php
+++ b/modules/cart/src/CartProviderInterface.php
@@ -19,7 +19,7 @@ interface CartProviderInterface {
* @param string $order_type
* The order type ID.
* @param \Drupal\commerce_store\Entity\StoreInterface $store
- * The store.
+ * The store. If empty, the current store is assumed.
* @param \Drupal\Core\Session\AccountInterface $account
* The user. If empty, the current user is assumed.
*
@@ -29,7 +29,7 @@ interface CartProviderInterface {
* @throws \Drupal\commerce_cart\Exception\DuplicateCartException
* When a cart with the given criteria already exists.
*/
- public function createCart($order_type, StoreInterface $store, AccountInterface $account = NULL);
+ public function createCart($order_type, StoreInterface $store = NULL, AccountInterface $account = NULL);
/**
* Finalizes the given cart order.
@@ -51,14 +51,14 @@ public function finalizeCart(OrderInterface $cart, $save_cart = TRUE);
* @param string $order_type
* The order type ID.
* @param \Drupal\commerce_store\Entity\StoreInterface $store
- * The store.
+ * The store. If empty, the current store is assumed.
* @param \Drupal\Core\Session\AccountInterface $account
* The user. If empty, the current user is assumed.
*
* @return \Drupal\commerce_order\Entity\OrderInterface|null
* The cart order, or NULL if none found.
*/
- public function getCart($order_type, StoreInterface $store, AccountInterface $account = NULL);
+ public function getCart($order_type, StoreInterface $store = NULL, AccountInterface $account = NULL);
/**
* Gets the cart order ID for the given store and user.
@@ -66,14 +66,14 @@ public function getCart($order_type, StoreInterface $store, AccountInterface $ac
* @param string $order_type
* The order type ID.
* @param \Drupal\commerce_store\Entity\StoreInterface $store
- * The store.
+ * The store. If empty, the current store is assumed.
* @param \Drupal\Core\Session\AccountInterface $account
* The user. If empty, the current user is assumed.
*
* @return int|null
* The cart order ID, or NULL if none found.
*/
- public function getCartId($order_type, StoreInterface $store, AccountInterface $account = NULL);
+ public function getCartId($order_type, StoreInterface $store = NULL, AccountInterface $account = NULL);
/**
* Gets all cart orders for the given user.
diff --git a/modules/cart/src/EventSubscriber/CartEventSubscriber.php b/modules/cart/src/EventSubscriber/CartEventSubscriber.php
index 991c018c3a..4d18ede462 100644
--- a/modules/cart/src/EventSubscriber/CartEventSubscriber.php
+++ b/modules/cart/src/EventSubscriber/CartEventSubscriber.php
@@ -2,24 +2,35 @@
namespace Drupal\commerce_cart\EventSubscriber;
+use Drupal\Core\Messenger\MessengerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\commerce_cart\Event\CartEntityAddEvent;
use Drupal\commerce_cart\Event\CartEvents;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
-use Drupal\Core\Link;
+use Drupal\Core\Url;
class CartEventSubscriber implements EventSubscriberInterface {
use StringTranslationTrait;
+ /**
+ * The messenger.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
/**
* Constructs a new CartEventSubscriber object.
*
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation.
*/
- public function __construct(TranslationInterface $string_translation) {
+ public function __construct(MessengerInterface $messenger, TranslationInterface $string_translation) {
+ $this->messenger = $messenger;
$this->stringTranslation = $string_translation;
}
@@ -40,10 +51,9 @@ public static function getSubscribedEvents() {
* The add to cart event.
*/
public function displayAddToCartMessage(CartEntityAddEvent $event) {
- $purchased_entity = $event->getEntity();
- drupal_set_message($this->t('@entity added to @cart-link.', [
- '@entity' => $purchased_entity->label(),
- '@cart-link' => Link::createFromRoute($this->t('your cart', [], ['context' => 'cart link']), 'commerce_cart.page')->toString(),
+ $this->messenger->addMessage($this->t('@entity added to your cart.', [
+ '@entity' => $event->getEntity()->label(),
+ ':url' => Url::fromRoute('commerce_cart.page')->toString(),
]));
}
diff --git a/modules/cart/src/Form/AddToCartForm.php b/modules/cart/src/Form/AddToCartForm.php
index 5e97a0c3fa..40d79d4836 100644
--- a/modules/cart/src/Form/AddToCartForm.php
+++ b/modules/cart/src/Form/AddToCartForm.php
@@ -205,7 +205,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
if (!$cart) {
$cart = $this->cartProvider->createCart($order_type_id, $store);
}
- $this->cartManager->addOrderItem($cart, $order_item, $form_state->get(['settings', 'combine']));
+ $this->entity = $this->cartManager->addOrderItem($cart, $order_item, $form_state->get(['settings', 'combine']));
// Other submit handlers might need the cart ID.
$form_state->set('cart_id', $cart->id());
}
diff --git a/modules/cart/src/Plugin/views/area/EmptyCartButton.php b/modules/cart/src/Plugin/views/area/EmptyCartButton.php
index 509275d8bd..39aea69fda 100644
--- a/modules/cart/src/Plugin/views/area/EmptyCartButton.php
+++ b/modules/cart/src/Plugin/views/area/EmptyCartButton.php
@@ -5,6 +5,7 @@
use Drupal\commerce_cart\CartManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Messenger\MessengerInterface;
use Drupal\views\Plugin\views\area\AreaPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -23,11 +24,18 @@ class EmptyCartButton extends AreaPluginBase {
protected $cartManager;
/**
- * The order storage.
+ * The entity type manager.
*
- * @var \Drupal\Core\Entity\EntityStorageInterface
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
- protected $orderStorage;
+ protected $entityTypeManager;
+
+ /**
+ * The messenger.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
/**
* Constructs a new EmptyCartButton object.
@@ -42,12 +50,15 @@ class EmptyCartButton extends AreaPluginBase {
* The cart manager.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger.
*/
- public function __construct(array $configuration, $plugin_id, $plugin_definition, CartManagerInterface $cart_manager, EntityTypeManagerInterface $entity_type_manager) {
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, CartManagerInterface $cart_manager, EntityTypeManagerInterface $entity_type_manager, MessengerInterface $messenger) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->cartManager = $cart_manager;
- $this->orderStorage = $entity_type_manager->getStorage('commerce_order');
+ $this->entityTypeManager = $entity_type_manager;
+ $this->messenger = $messenger;
}
/**
@@ -59,7 +70,8 @@ public static function create(ContainerInterface $container, array $configuratio
$plugin_id,
$plugin_definition,
$container->get('commerce_cart.cart_manager'),
- $container->get('entity_type.manager')
+ $container->get('entity_type.manager'),
+ $container->get('messenger')
);
}
@@ -117,11 +129,12 @@ public function viewsForm(array &$form, FormStateInterface $form_state) {
public function viewsFormSubmit(array &$form, FormStateInterface $form_state) {
$triggering_element = $form_state->getTriggeringElement();
if (!empty($triggering_element['#empty_cart_button'])) {
+ $order_storage = $this->entityTypeManager->getStorage('commerce_order');
/** @var \Drupal\commerce_order\Entity\OrderInterface $cart */
- $cart = $this->orderStorage->load($this->view->argument['order_id']->getValue());
+ $cart = $order_storage->load($this->view->argument['order_id']->getValue());
$this->cartManager->emptyCart($cart);
- drupal_set_message($this->t('Your shopping cart has been emptied.'));
+ $this->messenger->addMessage($this->t('Your shopping cart has been emptied.'));
}
}
diff --git a/modules/cart/src/Plugin/views/field/EditQuantity.php b/modules/cart/src/Plugin/views/field/EditQuantity.php
index f3f11d03dc..fcd0e962ef 100644
--- a/modules/cart/src/Plugin/views/field/EditQuantity.php
+++ b/modules/cart/src/Plugin/views/field/EditQuantity.php
@@ -3,7 +3,9 @@
namespace Drupal\commerce_cart\Plugin\views\field;
use Drupal\commerce_cart\CartManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\Plugin\views\field\UncacheableFieldHandlerTrait;
use Drupal\views\ResultRow;
@@ -25,6 +27,20 @@ class EditQuantity extends FieldPluginBase {
*/
protected $cartManager;
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The messenger.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
/**
* Constructs a new EditQuantity object.
*
@@ -36,11 +52,17 @@ class EditQuantity extends FieldPluginBase {
* The plugin implementation definition.
* @param \Drupal\commerce_cart\CartManagerInterface $cart_manager
* The cart manager.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger.
*/
- public function __construct(array $configuration, $plugin_id, $plugin_definition, CartManagerInterface $cart_manager) {
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, CartManagerInterface $cart_manager, EntityTypeManagerInterface $entity_type_manager, MessengerInterface $messenger) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->cartManager = $cart_manager;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->messenger = $messenger;
}
/**
@@ -51,7 +73,9 @@ public static function create(ContainerInterface $container, array $configuratio
$configuration,
$plugin_id,
$plugin_definition,
- $container->get('commerce_cart.cart_manager')
+ $container->get('commerce_cart.cart_manager'),
+ $container->get('entity_type.manager'),
+ $container->get('messenger')
);
}
@@ -133,8 +157,11 @@ public function viewsForm(array &$form, FormStateInterface $form_state) {
'#min' => 0,
'#max' => 9999,
'#step' => $step,
+ '#required' => TRUE,
];
}
+ $form['actions']['submit']['#update_cart'] = TRUE;
+ $form['actions']['submit']['#show_update_message'] = TRUE;
// Replace the form submit button label.
$form['actions']['submit']['#value'] = $this->t('Update cart');
}
@@ -148,16 +175,45 @@ public function viewsForm(array &$form, FormStateInterface $form_state) {
* The current state of the form.
*/
public function viewsFormSubmit(array &$form, FormStateInterface $form_state) {
+ $triggering_element = $form_state->getTriggeringElement();
+ if (empty($triggering_element['#update_cart'])) {
+ // Don't run when the "Remove" or "Empty cart" buttons are pressed.
+ return;
+ }
+
+ $order_storage = $this->entityTypeManager->getStorage('commerce_order');
+ /** @var \Drupal\commerce_order\Entity\OrderInterface $cart */
+ $cart = $order_storage->load($this->view->argument['order_id']->getValue());
$quantities = $form_state->getValue($this->options['id'], []);
+ $save_cart = FALSE;
foreach ($quantities as $row_index => $quantity) {
+ if (!is_numeric($quantity) || $quantity < 0) {
+ // The input might be invalid if the #required or #min attributes
+ // were removed by an alter hook.
+ continue;
+ }
/** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */
$order_item = $this->getEntity($this->view->result[$row_index]);
- if ($order_item->getQuantity() != $quantity) {
+ if ($order_item->getQuantity() == $quantity) {
+ // The quantity hasn't changed.
+ continue;
+ }
+
+ if ($quantity > 0) {
$order_item->setQuantity($quantity);
- $order = $order_item->getOrder();
- $this->cartManager->updateOrderItem($order, $order_item, FALSE);
- // Tells commerce_cart_order_item_views_form_submit() to save the order.
- $form_state->set('quantity_updated', TRUE);
+ $this->cartManager->updateOrderItem($cart, $order_item, FALSE);
+ }
+ else {
+ // Treat quantity "0" as a request for deletion.
+ $this->cartManager->removeOrderItem($cart, $order_item, FALSE);
+ }
+ $save_cart = TRUE;
+ }
+
+ if ($save_cart) {
+ $cart->save();
+ if (!empty($triggering_element['#show_update_message'])) {
+ $this->messenger->addMessage($this->t('Your shopping cart has been updated.'));
}
}
}
diff --git a/modules/cart/tests/modules/commerce_cart_big_pipe/commerce_cart_big_pipe.info.yml b/modules/cart/tests/modules/commerce_cart_big_pipe/commerce_cart_big_pipe.info.yml
index d979695850..625786da67 100644
--- a/modules/cart/tests/modules/commerce_cart_big_pipe/commerce_cart_big_pipe.info.yml
+++ b/modules/cart/tests/modules/commerce_cart_big_pipe/commerce_cart_big_pipe.info.yml
@@ -4,9 +4,8 @@ description: Provides a slow version of AddToCart form for testing Big Pipe stre
package: Testing
core: 8.x
dependencies:
- - commerce_store
- - commerce_product
- - commerce_cart
- - big_pipe
- - big_pipe_test
- - dynamic_page_cache
+ - commerce:commerce_cart
+ - commerce:commerce_product
+ - drupal:big_pipe
+ - drupal:big_pipe_test
+ - drupal:dynamic_page_cache
diff --git a/modules/cart/tests/modules/commerce_cart_big_pipe/src/Form/BigPipeAddToCartForm.php b/modules/cart/tests/modules/commerce_cart_big_pipe/src/Form/BigPipeAddToCartForm.php
index d5b83eafc7..dece8a4527 100644
--- a/modules/cart/tests/modules/commerce_cart_big_pipe/src/Form/BigPipeAddToCartForm.php
+++ b/modules/cart/tests/modules/commerce_cart_big_pipe/src/Form/BigPipeAddToCartForm.php
@@ -16,7 +16,7 @@ class BigPipeAddToCartForm extends AddToCartForm {
public function buildForm(array $form, FormStateInterface $form_state) {
// Sleep between 0.0 and 2.0 seconds.
$load_slowdown = mt_rand(0, 20) / 10;
- drupal_set_message(sprintf('Delayed form build by %s seconds', $load_slowdown));
+ $this->messenger()->addMessage(sprintf('Delayed form build by %s seconds', $load_slowdown));
sleep($load_slowdown);
return parent::buildForm($form, $form_state);
}
diff --git a/modules/cart/tests/modules/commerce_cart_test/commerce_cart_test.info.yml b/modules/cart/tests/modules/commerce_cart_test/commerce_cart_test.info.yml
index 3e871ccf73..81976f77f9 100644
--- a/modules/cart/tests/modules/commerce_cart_test/commerce_cart_test.info.yml
+++ b/modules/cart/tests/modules/commerce_cart_test/commerce_cart_test.info.yml
@@ -4,7 +4,5 @@ description: Contains various non-specific things needed in tests.
package: Testing
core: 8.x
dependencies:
- - commerce_store
- - commerce_product
- - commerce_cart
- - views
+ - commerce:commerce_cart
+ - commerce:commerce_product
diff --git a/modules/cart/tests/src/Functional/AddToCartFormTest.php b/modules/cart/tests/src/Functional/AddToCartFormTest.php
index a99e45c58b..4234fd3069 100644
--- a/modules/cart/tests/src/Functional/AddToCartFormTest.php
+++ b/modules/cart/tests/src/Functional/AddToCartFormTest.php
@@ -113,17 +113,23 @@ public function testExposedOrderItemFields() {
/** @var \Drupal\Core\Entity\Entity\EntityFormDisplay $order_item_form_display */
$order_item_form_display = EntityFormDisplay::load('commerce_order_item.default.add_to_cart');
$order_item_form_display->setComponent('quantity', [
- 'type' => 'number',
+ 'type' => 'commerce_quantity',
]);
$order_item_form_display->save();
- // Get the existing product page and submit Add to cart form.
+
+ // Confirm that the given quantity was accepted and saved.
$this->postAddToCart($this->variation->getProduct(), [
'quantity[0][value]' => 3,
]);
- // Check if the quantity was increased for the existing order item.
$this->cart = Order::load($this->cart->id());
$order_items = $this->cart->getItems();
$this->assertOrderItemInOrder($this->variation, $order_items[0], 3);
+
+ // Confirm that a zero quantity isn't accepted.
+ $this->postAddToCart($this->variation->getProduct(), [
+ 'quantity[0][value]' => 0,
+ ]);
+ $this->assertSession()->pageTextContains('Quantity must be higher than or equal to 1.');
}
/**
diff --git a/modules/cart/tests/src/Functional/CartBrowserTestBase.php b/modules/cart/tests/src/Functional/CartBrowserTestBase.php
index f417d06451..644a1983a6 100644
--- a/modules/cart/tests/src/Functional/CartBrowserTestBase.php
+++ b/modules/cart/tests/src/Functional/CartBrowserTestBase.php
@@ -66,9 +66,7 @@ protected function getAdministratorPermissions() {
protected function setUp() {
parent::setUp();
- $this->createStore();
-
- $this->cart = \Drupal::service('commerce_cart.cart_provider')->createCart('default', $this->store);
+ $this->cart = \Drupal::service('commerce_cart.cart_provider')->createCart('default');
$this->cartManager = \Drupal::service('commerce_cart.cart_manager');
$this->attributeFieldManager = \Drupal::service('commerce_product.attribute_field_manager');
}
diff --git a/modules/cart/tests/src/Functional/CartTest.php b/modules/cart/tests/src/Functional/CartTest.php
index 301e88bebc..81e979b8c7 100644
--- a/modules/cart/tests/src/Functional/CartTest.php
+++ b/modules/cart/tests/src/Functional/CartTest.php
@@ -3,8 +3,6 @@
namespace Drupal\Tests\commerce_cart\Functional;
use Drupal\Tests\commerce_order\Functional\OrderBrowserTestBase;
-use Drupal\user\Entity\Role;
-use Drupal\user\RoleInterface;
/**
* Tests the cart page.
@@ -41,7 +39,7 @@ class CartTest extends OrderBrowserTestBase {
*/
public static $modules = [
'commerce_cart',
- 'node',
+ 'commerce_checkout',
];
/**
@@ -79,20 +77,19 @@ protected function setUp() {
'variations' => [$variation],
]);
$this->variations[] = $variation;
- $this->cart = \Drupal::service('commerce_cart.cart_provider')->createCart('default', $this->store);
+ $this->cart = \Drupal::service('commerce_cart.cart_provider')->createCart('default');
$this->cartManager = \Drupal::service('commerce_cart.cart_manager');
- }
-
- /**
- * Test the cart page.
- */
- public function testCartPage() {
- $this->drupalLogin($this->adminUser);
+ // Add both variations to the cart.
foreach ($this->variations as $variation) {
$this->cartManager->addEntity($this->cart, $variation);
}
+ }
+ /**
+ * Test the basic functioning of the cart page.
+ */
+ public function testCartPage() {
$this->drupalGet('cart');
// Confirm the presence of the order total summary.
$this->assertSession()->elementTextContains('css', '.order-total-line', 'Subtotal');
@@ -114,19 +111,43 @@ public function testCartPage() {
$this->assertSession()->elementTextContains('css', '.order-total-line', 'Total');
$this->assertSession()->pageTextContains('$3,048.00');
+ // Confirm that setting the quantity to 0 removes an item.
+ $values = [
+ 'edit_quantity[0]' => 0,
+ 'edit_quantity[1]' => 3,
+ ];
+ $this->submitForm($values, t('Update cart'));
+ $this->assertSession()->pageTextContains(t('Your shopping cart has been updated.'));
+ $this->assertSession()->fieldExists('edit-edit-quantity-0');
+ $this->assertSession()->fieldNotExists('edit-edit-quantity-1');
+ $this->assertSession()->pageTextContains('$1,050.00');
+
// Confirm the presence and functioning of the Remove button.
$this->assertSession()->buttonExists('Remove');
$this->submitForm([], t('Remove'));
- $this->submitForm([], t('Remove'));
$this->assertSession()->pageTextContains(t('Your shopping cart is empty.'));
+ }
+
+ /**
+ * Tests the Checkout button added by commerce_checkout.
+ */
+ public function testCheckoutButton() {
+ $this->drupalGet('cart');
+ // Confirm that the "Checkout" button redirects and updates the cart.
+ $this->assertSession()->buttonExists('Checkout');
+ $values = [
+ 'edit_quantity[0]' => 2,
+ 'edit_quantity[1]' => 3,
+ ];
+ $this->submitForm($values, t('Checkout'));
+ $this->assertSession()->addressEquals('checkout/1/order_information');
+ $this->assertSession()->pageTextNotContains(t('Your shopping cart has been updated.'));
- // Test that cart is denied for user without permission.
- Role::load(RoleInterface::ANONYMOUS_ID)
- ->revokePermission('access cart')
- ->save();
- $this->drupalLogout();
$this->drupalGet('cart');
- $this->assertSession()->statusCodeEquals(403);
+ $this->assertSession()->fieldValueEquals('edit-edit-quantity-0', 2);
+ $this->assertSession()->fieldValueEquals('edit-edit-quantity-1', 3);
+ $this->assertSession()->elementTextContains('css', '.order-total-line', 'Total');
+ $this->assertSession()->pageTextContains('$3,048.00');
}
}
diff --git a/modules/cart/tests/src/FunctionalJavascript/AddToCartMultiAttributeTest.php b/modules/cart/tests/src/FunctionalJavascript/AddToCartMultiAttributeTest.php
index de50855286..4e326b6db0 100644
--- a/modules/cart/tests/src/FunctionalJavascript/AddToCartMultiAttributeTest.php
+++ b/modules/cart/tests/src/FunctionalJavascript/AddToCartMultiAttributeTest.php
@@ -166,6 +166,16 @@ public function testRenderedVariationFields() {
$this->getSession()->getPage()->selectFieldOption('purchased_entity[0][attributes][attribute_color]', 'Magenta');
$this->waitForAjaxToFinish();
$this->assertSession()->pageTextContains($variation2->getSku());
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_color]', 'Magenta');
+
+ // Load variation2 directly via the url (?v=).
+ $this->drupalGet($variation2->toUrl());
+ $this->assertSession()->pageTextContains($variation2->getSku());
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_color]', 'Magenta');
+ $this->getSession()->getPage()->selectFieldOption('purchased_entity[0][attributes][attribute_color]', 'Cyan');
+ $this->waitForAjaxToFinish();
+ $this->assertSession()->pageTextContains($variation1->getSku());
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_color]', 'Cyan');
}
}
diff --git a/modules/cart/tests/src/FunctionalJavascript/AddToCartMultilingualTest.php b/modules/cart/tests/src/FunctionalJavascript/AddToCartMultilingualTest.php
new file mode 100644
index 0000000000..a9fbb82a48
--- /dev/null
+++ b/modules/cart/tests/src/FunctionalJavascript/AddToCartMultilingualTest.php
@@ -0,0 +1,295 @@
+setupMultilingual();
+
+ /** @var \Drupal\commerce_product\Entity\ProductVariationTypeInterface $variation_type */
+ $variation_type = ProductVariationType::load($this->variation->bundle());
+ $color_attributes = $this->createAttributeSet($variation_type, 'color', [
+ 'red' => 'Red',
+ 'blue' => 'Blue',
+ ]);
+
+ foreach ($color_attributes as $key => $color_attribute) {
+ $color_attribute->addTranslation('fr', [
+ 'name' => 'FR ' . $color_attribute->label(),
+ ]);
+ $color_attribute->save();
+ }
+ $size_attributes = $this->createAttributeSet($variation_type, 'size', [
+ 'small' => 'Small',
+ 'medium' => 'Medium',
+ 'large' => 'Large',
+ ]);
+ foreach ($size_attributes as $key => $size_attribute) {
+ $size_attribute->addTranslation('fr', [
+ 'name' => 'FR ' . $size_attribute->label(),
+ ]);
+ $size_attribute->save();
+ }
+
+ // Reload the variation since we have new fields.
+ $this->variation = ProductVariation::load($this->variation->id());
+
+ // Translate the product's title.
+ $product = $this->variation->getProduct();
+ $product->setTitle('My Super Product');
+ $product->addTranslation('fr', [
+ 'title' => 'Mon super produit',
+ ]);
+ $product->save();
+
+ // Update first variation to have the attribute's value.
+ $this->variation->get('attribute_color')->setValue($color_attributes['red']);
+ $this->variation->get('attribute_size')->setValue($size_attributes['small']);
+ $this->variation->save();
+
+ // The matrix is intentionally uneven, blue / large is missing.
+ $attribute_values_matrix = [
+ ['red', 'small'],
+ ['red', 'medium'],
+ ['red', 'large'],
+ ['blue', 'small'],
+ ['blue', 'medium'],
+ ];
+
+ // Generate variations off of the attributes values matrix.
+ foreach ($attribute_values_matrix as $key => $value) {
+ /** @var \Drupal\commerce_product\Entity\ProductVariationInterface $variation */
+ $variation = $this->createEntity('commerce_product_variation', [
+ 'type' => $variation_type->id(),
+ 'sku' => $this->randomMachineName(),
+ 'price' => [
+ 'number' => 999,
+ 'currency_code' => 'USD',
+ ],
+ ]);
+ $variation->get('attribute_color')->setValue($color_attributes[$value[0]]);
+ $variation->get('attribute_size')->setValue($size_attributes[$value[1]]);
+ $variation->save();
+ $product->addVariation($variation);
+ }
+
+ $product->save();
+ $this->product = Product::load($product->id());
+
+ // Create a translation for each variation on the product.
+ foreach ($this->product->getVariations() as $variation) {
+ $variation->addTranslation('fr')->save();
+ }
+
+ $this->variations = $product->getVariations();
+ $this->colorAttributes = $color_attributes;
+ $this->sizeAttributes = $size_attributes;
+ }
+
+ /**
+ * Sets up the multilingual items.
+ */
+ protected function setupMultilingual() {
+ // Add a new language.
+ ConfigurableLanguage::createFromLangcode('fr')->save();
+
+ // Enable translation for the product and ensure the change is picked up.
+ $this->container->get('content_translation.manager')->setEnabled('commerce_product', $this->variation->bundle(), TRUE);
+ $this->container->get('content_translation.manager')->setEnabled('commerce_product_variation', $this->variation->bundle(), TRUE);
+ $this->container->get('entity.manager')->clearCachedDefinitions();
+ $this->container->get('router.builder')->rebuild();
+ $this->container->get('entity.definition_update_manager')->applyUpdates();
+
+ // Rebuild the container so that the new languages are picked up by services
+ // that hold a list of languages.
+ $this->rebuildContainer();
+ }
+
+ /**
+ * Tests that the attribute widget uses translated items.
+ */
+ public function testProductVariationAttributesWidget() {
+ $this->drupalGet($this->product->toUrl());
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_color]', 'Red');
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_size]', 'Small');
+ $this->assertAttributeExists('purchased_entity[0][attributes][attribute_color]', $this->colorAttributes['blue']->id());
+ $this->assertAttributeExists('purchased_entity[0][attributes][attribute_size]', $this->sizeAttributes['medium']->id());
+ $this->assertAttributeExists('purchased_entity[0][attributes][attribute_size]', $this->sizeAttributes['large']->id());
+ $this->getSession()->getPage()->pressButton('Add to cart');
+
+ // Change the site language.
+ $this->config('system.site')->set('default_langcode', 'fr')->save();
+ $this->rebuildContainer();
+
+ $this->drupalGet($this->product->getTranslation('fr')->toUrl());
+ // Use AJAX to change the size to Medium, keeping the color on Red.
+ $this->getSession()->getPage()->selectFieldOption('purchased_entity[0][attributes][attribute_size]', 'FR Medium');
+ $this->waitForAjaxToFinish();
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_color]', 'FR Red');
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_size]', 'FR Medium');
+ $this->assertAttributeExists('purchased_entity[0][attributes][attribute_color]', $this->colorAttributes['blue']->id());
+ $this->assertAttributeExists('purchased_entity[0][attributes][attribute_size]', $this->sizeAttributes['small']->id());
+ $this->assertAttributeExists('purchased_entity[0][attributes][attribute_size]', $this->sizeAttributes['large']->id());
+
+ // Use AJAX to change the color to Blue, keeping the size on Medium.
+ $this->getSession()->getPage()->selectFieldOption('purchased_entity[0][attributes][attribute_color]', 'FR Blue');
+ $this->waitForAjaxToFinish();
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_color]', 'FR Blue');
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_size]', 'FR Medium');
+ $this->assertAttributeExists('purchased_entity[0][attributes][attribute_color]', $this->colorAttributes['red']->id());
+ $this->assertAttributeExists('purchased_entity[0][attributes][attribute_size]', $this->sizeAttributes['small']->id());
+ $this->assertAttributeDoesNotExist('purchased_entity[0][attributes][attribute_size]', $this->sizeAttributes['large']->id());
+ $this->getSession()->getPage()->pressButton('Add to cart');
+
+ $this->cart = Order::load($this->cart->id());
+ $order_items = $this->cart->getItems();
+ $this->assertOrderItemInOrder($this->variations[0]->getTranslation('fr'), $order_items[0]);
+ $this->assertOrderItemInOrder($this->variations[5]->getTranslation('fr'), $order_items[1]);
+ }
+
+ /**
+ * Tests the attribute widget default values with a variation url (?v=).
+ */
+ public function testProductVariationAttributesWidgetFromUrl() {
+ $variation = $this->variations[5];
+ $this->drupalGet($variation->toUrl());
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_color]', 'Blue');
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_size]', 'Medium');
+ $this->getSession()->getPage()->pressButton('Add to cart');
+
+ // Change the site language.
+ $this->config('system.site')->set('default_langcode', 'fr')->save();
+ $this->rebuildContainer();
+
+ $variation = $variation->getTranslation('fr');
+ $this->drupalGet($variation->toUrl());
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_color]', 'FR Blue');
+ $this->assertAttributeSelected('purchased_entity[0][attributes][attribute_size]', 'FR Medium');
+ $this->getSession()->getPage()->pressButton('Add to cart');
+ }
+
+ /**
+ * Tests the title widget has translated variation title.
+ */
+ public function testProductVariationTitleWidget() {
+ $order_item_form_display = EntityFormDisplay::load('commerce_order_item.default.add_to_cart');
+ $order_item_form_display->setComponent('purchased_entity', [
+ 'type' => 'commerce_product_variation_title',
+ ]);
+ $order_item_form_display->save();
+
+ $this->drupalGet($this->product->toUrl());
+ $this->assertSession()->selectExists('purchased_entity[0][variation]');
+ $this->assertAttributeSelected('purchased_entity[0][variation]', 'My Super Product - Red, Small');
+ $this->getSession()->getPage()->pressButton('Add to cart');
+
+ // Change the site language.
+ $this->config('system.site')->set('default_langcode', 'fr')->save();
+ $this->rebuildContainer();
+
+ $this->drupalGet($this->product->getTranslation('fr')->toUrl());
+ // Use AJAX to change the size to Medium, keeping the color on Red.
+ $this->assertAttributeSelected('purchased_entity[0][variation]', 'Mon super produit - FR Red, FR Small');
+ $this->getSession()->getPage()->selectFieldOption('purchased_entity[0][variation]', 'Mon super produit - FR Red, FR Medium');
+ $this->waitForAjaxToFinish();
+ $this->assertAttributeSelected('purchased_entity[0][variation]', 'Mon super produit - FR Red, FR Medium');
+ $this->assertSession()->pageTextContains('Mon super produit - FR Red, FR Medium');
+ // Use AJAX to change the color to Blue, keeping the size on Medium.
+ $this->getSession()->getPage()->selectFieldOption('purchased_entity[0][variation]', 'Mon super produit - FR Blue, FR Medium');
+ $this->waitForAjaxToFinish();
+ $this->assertAttributeSelected('purchased_entity[0][variation]', 'Mon super produit - FR Blue, FR Medium');
+ $this->assertSession()->pageTextContains('Mon super produit - FR Blue, FR Medium');
+ $this->getSession()->getPage()->pressButton('Add to cart');
+
+ $this->cart = Order::load($this->cart->id());
+ $order_items = $this->cart->getItems();
+ $this->assertOrderItemInOrder($this->variations[0]->getTranslation('fr'), $order_items[0]);
+ $this->assertOrderItemInOrder($this->variations[5]->getTranslation('fr'), $order_items[1]);
+ }
+
+ /**
+ * Tests the title widget default values with a variation url (?v=).
+ */
+ public function testProductVariationTitleWidgetFromUrl() {
+ $order_item_form_display = EntityFormDisplay::load('commerce_order_item.default.add_to_cart');
+ $order_item_form_display->setComponent('purchased_entity', [
+ 'type' => 'commerce_product_variation_title',
+ ]);
+ $order_item_form_display->save();
+
+ $variation = $this->variations[5];
+ $this->drupalGet($variation->toUrl());
+ $this->assertSession()->selectExists('purchased_entity[0][variation]');
+ $this->assertAttributeSelected('purchased_entity[0][variation]', 'My Super Product - Blue, Medium');
+
+ // Change the site language.
+ $this->config('system.site')->set('default_langcode', 'fr')->save();
+ $this->rebuildContainer();
+
+ $variation = $variation->getTranslation('fr');
+ $this->drupalGet($variation->toUrl());
+ $this->assertSession()->selectExists('purchased_entity[0][variation]');
+ $this->assertAttributeSelected('purchased_entity[0][variation]', 'Mon super produit - FR Blue, FR Medium');
+ }
+
+}
diff --git a/modules/cart/tests/src/FunctionalJavascript/AddToCartViewModeTest.php b/modules/cart/tests/src/FunctionalJavascript/AddToCartViewModeTest.php
new file mode 100644
index 0000000000..875d1b3c8c
--- /dev/null
+++ b/modules/cart/tests/src/FunctionalJavascript/AddToCartViewModeTest.php
@@ -0,0 +1,168 @@
+setComponent('purchased_entity', [
+ 'type' => 'commerce_product_variation_title',
+ ]);
+ $order_item_form_display->save();
+
+ // Set up the Full view modes.
+ EntityViewMode::create([
+ 'id' => 'commerce_product.full',
+ 'label' => 'Full',
+ 'targetEntityType' => 'commerce_product',
+ ])->save();
+ EntityViewMode::create([
+ 'id' => 'commerce_product_variation.full',
+ 'label' => 'Full',
+ 'targetEntityType' => 'commerce_product_variation',
+ ])->save();
+
+ // Use a different price widget for the two displays, to use that as
+ // an indicator of the right view mode being used.
+ $default_view_display = EntityViewDisplay::load('commerce_product_variation.default.default');
+ if (!$default_view_display) {
+ $default_view_display = EntityViewDisplay::create([
+ 'targetEntityType' => 'commerce_product_variation',
+ 'bundle' => 'default',
+ 'mode' => 'default',
+ 'status' => TRUE,
+ ]);
+ }
+ $default_view_display->setComponent('price', [
+ 'type' => 'commerce_price_default',
+ ]);
+ $default_view_display->save();
+ $full_view_display = EntityViewDisplay::load('commerce_product_variation.default.full');
+ if (!$full_view_display) {
+ $full_view_display = EntityViewDisplay::create([
+ 'targetEntityType' => 'commerce_product_variation',
+ 'bundle' => 'default',
+ 'mode' => 'full',
+ 'status' => TRUE,
+ ]);
+ }
+ $full_view_display->setComponent('price', [
+ 'type' => 'commerce_price_plain',
+ ]);
+ $full_view_display->save();
+
+ $this->firstVariation = $this->createEntity('commerce_product_variation', [
+ 'title' => 'First variation',
+ 'type' => 'default',
+ 'sku' => 'first-variation',
+ 'price' => [
+ 'number' => 10,
+ 'currency_code' => 'USD',
+ ],
+ ]);
+ $this->secondVariation = $this->createEntity('commerce_product_variation', [
+ 'title' => 'Second variation',
+ 'type' => 'default',
+ 'sku' => 'second-variation',
+ 'price' => [
+ 'number' => 20,
+ 'currency_code' => 'USD',
+ ],
+ ]);
+ $this->product = $this->createEntity('commerce_product', [
+ 'type' => 'default',
+ 'title' => 'Test product',
+ 'stores' => [$this->store],
+ 'variations' => [$this->firstVariation, $this->secondVariation],
+ ]);
+ }
+
+ /**
+ * Tests changing the product variation.
+ *
+ * The `commerce_product_variation.default.full` configuration uses the
+ * `commerce_price_plain` formatter, but the default view mode still uses the
+ * `commerce_price_default` formatter. The AJAX refresh should return currency
+ * in the plain format of ##.00 USD and not $##.00.
+ */
+ public function testAjaxChange() {
+ $this->drupalGet($this->product->toUrl());
+
+ $page = $this->getSession()->getPage();
+ $renderer = $this->container->get('renderer');
+
+ $first_variation_price = [
+ '#theme' => 'commerce_price_plain',
+ '#number' => $this->firstVariation->getPrice()->getNumber(),
+ '#currency' => Currency::load($this->firstVariation->getPrice()->getCurrencyCode()),
+ ];
+ $first_variation_price = trim($renderer->renderPlain($first_variation_price));
+ $second_variation_price = [
+ '#theme' => 'commerce_price_plain',
+ '#number' => $this->secondVariation->getPrice()->getNumber(),
+ '#currency' => Currency::load($this->secondVariation->getPrice()->getCurrencyCode()),
+ ];
+ $second_variation_price = trim($renderer->renderPlain($second_variation_price));
+
+ $price_field_selector = '.product--variation-field--variation_price__' . $this->product->id();
+
+ $this->assertSession()->elementExists('css', $price_field_selector);
+ $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', $first_variation_price);
+ $this->assertSession()->fieldValueEquals('purchased_entity[0][variation]', $this->firstVariation->id());
+ $page->selectFieldOption('purchased_entity[0][variation]', $this->secondVariation->id());
+ $this->assertSession()->assertWaitOnAjaxRequest();
+
+ $this->assertSession()->elementExists('css', $price_field_selector);
+ $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', $second_variation_price);
+
+ $page->selectFieldOption('purchased_entity[0][variation]', $this->firstVariation->id());
+ $this->assertSession()->assertWaitOnAjaxRequest();
+ $this->assertSession()->elementExists('css', $price_field_selector);
+ $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', $first_variation_price);
+ }
+
+}
diff --git a/modules/cart/tests/src/FunctionalJavascript/MultipleCartFormsTest.php b/modules/cart/tests/src/FunctionalJavascript/MultipleCartFormsTest.php
index eccac21bce..f7de1eaadd 100644
--- a/modules/cart/tests/src/FunctionalJavascript/MultipleCartFormsTest.php
+++ b/modules/cart/tests/src/FunctionalJavascript/MultipleCartFormsTest.php
@@ -56,7 +56,7 @@ protected function setUp() {
/** @var \Drupal\Core\Entity\Entity\EntityFormDisplay $order_item_form_display */
$order_item_form_display = EntityFormDisplay::load('commerce_order_item.default.add_to_cart');
$order_item_form_display->setComponent('quantity', [
- 'type' => 'number',
+ 'type' => 'commerce_quantity',
]);
$order_item_form_display->save();
@@ -145,8 +145,6 @@ public function testUniqueAddToCartFormIds() {
/**
* Tests that a page with multiple add to cart forms works properly.
- *
- * @group debug
*/
public function testMultipleRenderedProducts() {
// View of rendered products, each containing an add to cart form.
diff --git a/modules/cart/tests/src/Kernel/CartManagerTest.php b/modules/cart/tests/src/Kernel/CartManagerTest.php
index 8c447e4206..b7254a87bf 100644
--- a/modules/cart/tests/src/Kernel/CartManagerTest.php
+++ b/modules/cart/tests/src/Kernel/CartManagerTest.php
@@ -118,6 +118,7 @@ public function testCartManager() {
$order_item1 = $this->reloadEntity($order_item1);
$this->assertNotEmpty($cart->hasItem($order_item1));
$this->assertEquals(1, $order_item1->getQuantity());
+ $this->assertEquals($cart->id(), $order_item1->getOrderId());
$this->assertEquals(new Price('1.00', 'USD'), $cart->getTotalPrice());
$order_item1->setQuantity(2);
@@ -131,6 +132,7 @@ public function testCartManager() {
$this->assertNotEmpty($cart->hasItem($order_item1));
$this->assertNotEmpty($cart->hasItem($order_item2));
$this->assertEquals(3, $order_item2->getQuantity());
+ $this->assertEquals($cart->id(), $order_item2->getOrderId());
$this->assertEquals(new Price('8.00', 'USD'), $cart->getTotalPrice());
$this->cartManager->removeOrderItem($cart, $order_item1);
diff --git a/modules/checkout/commerce_checkout.info.yml b/modules/checkout/commerce_checkout.info.yml
index 5f75954494..6e50a94154 100644
--- a/modules/checkout/commerce_checkout.info.yml
+++ b/modules/checkout/commerce_checkout.info.yml
@@ -4,7 +4,7 @@ description: 'Provides configurable checkout flows.'
package: Commerce
core: 8.x
dependencies:
- - commerce
+ - commerce:commerce
- commerce:commerce_order
- commerce:commerce_cart
config_devel:
diff --git a/modules/checkout/commerce_checkout.libraries.yml b/modules/checkout/commerce_checkout.libraries.yml
index 9621b692a7..b64a7f6863 100644
--- a/modules/checkout/commerce_checkout.libraries.yml
+++ b/modules/checkout/commerce_checkout.libraries.yml
@@ -27,3 +27,5 @@ form:
css:
theme:
css/commerce_checkout.form.css: {}
+ dependencies:
+ - core/drupal.form
diff --git a/modules/checkout/commerce_checkout.module b/modules/checkout/commerce_checkout.module
index af6eec7efc..37e7cfa17e 100644
--- a/modules/checkout/commerce_checkout.module
+++ b/modules/checkout/commerce_checkout.module
@@ -52,7 +52,6 @@ function commerce_checkout_theme() {
function commerce_checkout_theme_suggestions_commerce_checkout_form(array $variables) {
$original = $variables['theme_hook_original'];
$suggestions = [];
- $suggestions[] = $original;
// If the checkout form has a sidebar, suggest the enhanced layout.
if (isset($variables['form']['sidebar']) && Element::isVisibleElement($variables['form']['sidebar'])) {
$suggestions[] = $original . '__with_sidebar';
@@ -86,8 +85,7 @@ function commerce_checkout_entity_base_field_info(EntityTypeInterface $entity_ty
->setSetting('target_type', 'commerce_checkout_flow')
->setSetting('handler', 'default')
->setDisplayOptions('form', [
- 'type' => 'hidden',
- 'weight' => 0,
+ 'region' => 'hidden',
])
->setDisplayConfigurable('view', FALSE)
->setDisplayConfigurable('form', FALSE);
@@ -97,8 +95,7 @@ function commerce_checkout_entity_base_field_info(EntityTypeInterface $entity_ty
$fields['checkout_step'] = BaseFieldDefinition::create('string')
->setLabel(t('Checkout step'))
->setDisplayOptions('form', [
- 'type' => 'hidden',
- 'weight' => 0,
+ 'region' => 'hidden',
])
->setDisplayConfigurable('view', FALSE)
->setDisplayConfigurable('form', FALSE);
@@ -180,6 +177,8 @@ function commerce_checkout_form_alter(&$form, FormStateInterface $form_state, $f
'#access' => \Drupal::currentUser()->hasPermission('access checkout'),
'#submit' => array_merge($form['#submit'], ['commerce_checkout_order_item_views_form_submit']),
'#order_id' => $view->argument['order_id']->value[0],
+ '#update_cart' => TRUE,
+ '#show_update_message' => FALSE,
];
}
}
diff --git a/modules/checkout/src/Form/CheckoutFlowForm.php b/modules/checkout/src/Form/CheckoutFlowForm.php
index 654be162b7..bb1fb15b2b 100644
--- a/modules/checkout/src/Form/CheckoutFlowForm.php
+++ b/modules/checkout/src/Form/CheckoutFlowForm.php
@@ -2,12 +2,12 @@
namespace Drupal\commerce_checkout\Form;
-use Drupal\commerce\Form\CommercePluginEntityFormBase;
use Drupal\commerce_checkout\CheckoutFlowManager;
+use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
-class CheckoutFlowForm extends CommercePluginEntityFormBase {
+class CheckoutFlowForm extends EntityForm {
/**
* The checkout flow plugin manager.
@@ -66,6 +66,7 @@ public function form(array $form, FormStateInterface $form_state) {
'#machine_name' => [
'exists' => '\Drupal\commerce_checkout\Entity\CheckoutFlow::load',
],
+ '#disabled' => !$checkout_flow->isNew(),
];
$form['plugin'] = [
'#type' => 'radios',
@@ -82,7 +83,7 @@ public function form(array $form, FormStateInterface $form_state) {
$form['configuration'] = $checkout_flow->getPlugin()->buildConfigurationForm($form['configuration'], $form_state);
}
- return $this->protectPluginIdElement($form);
+ return $form;
}
/**
@@ -116,7 +117,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
*/
public function save(array $form, FormStateInterface $form_state) {
$status = $this->entity->save();
- drupal_set_message($this->t('Saved the %label checkout flow.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Saved the %label checkout flow.', ['%label' => $this->entity->label()]));
if ($status == SAVED_UPDATED) {
$form_state->setRedirect('entity.commerce_checkout_flow.collection');
}
diff --git a/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php b/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php
index 4df1347f59..bbf695ead0 100644
--- a/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php
+++ b/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php
@@ -152,7 +152,7 @@ public function getSteps() {
],
'complete' => [
'label' => $this->t('Complete'),
- 'next_label' => $this->t('Pay and complete purchase'),
+ 'next_label' => $this->t('Complete checkout'),
'has_sidebar' => FALSE,
],
];
diff --git a/modules/checkout/src/Plugin/Commerce/CheckoutPane/Review.php b/modules/checkout/src/Plugin/Commerce/CheckoutPane/Review.php
index e391dcd56b..509a75d09e 100644
--- a/modules/checkout/src/Plugin/Commerce/CheckoutPane/Review.php
+++ b/modules/checkout/src/Plugin/Commerce/CheckoutPane/Review.php
@@ -33,7 +33,7 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state,
];
}
- $label = $pane->getDisplayLabel();
+ $label = isset($summary['#title']) ? $summary['#title'] : $pane->getDisplayLabel();
if ($pane->isVisible()) {
$edit_link = Link::createFromRoute($this->t('Edit'), 'commerce_checkout.form', [
'commerce_order' => $this->order->id(),
diff --git a/modules/checkout/tests/src/Functional/CheckoutOrderTest.php b/modules/checkout/tests/src/Functional/CheckoutOrderTest.php
index 7ed0ccbc54..22cb2539b5 100644
--- a/modules/checkout/tests/src/Functional/CheckoutOrderTest.php
+++ b/modules/checkout/tests/src/Functional/CheckoutOrderTest.php
@@ -161,7 +161,7 @@ public function testGuestOrderCheckout() {
$this->assertSession()->pageTextContains('Contact information');
$this->assertSession()->pageTextContains('Billing information');
$this->assertSession()->pageTextContains('Order Summary');
- $this->submitForm([], 'Pay and complete purchase');
+ $this->submitForm([], 'Complete checkout');
$this->assertSession()->pageTextContains('Your order number is 1. You can view your order on your account page when logged in.');
$this->assertSession()->pageTextContains('0 items');
// Test second order.
@@ -197,7 +197,7 @@ public function testGuestOrderCheckout() {
$this->getSession()->getPage()->pressButton('Continue to review');
$this->assertCheckoutProgressStep('Review');
- $this->submitForm([], 'Pay and complete purchase');
+ $this->submitForm([], 'Complete checkout');
$this->assertSession()->pageTextContains('Your order number is 2. You can view your order on your account page when logged in.');
$this->assertSession()->pageTextContains('0 items');
}
diff --git a/modules/log/commerce_log.info.yml b/modules/log/commerce_log.info.yml
index 8cfa7adb6d..0c41213e49 100644
--- a/modules/log/commerce_log.info.yml
+++ b/modules/log/commerce_log.info.yml
@@ -4,5 +4,4 @@ description: 'Provides activity logs for Commerce entities.'
package: Commerce
core: 8.x
dependencies:
- - commerce
- - user
+ - commerce:commerce
diff --git a/modules/log/src/Entity/Log.php b/modules/log/src/Entity/Log.php
index a790812b17..ec42cc80d9 100644
--- a/modules/log/src/Entity/Log.php
+++ b/modules/log/src/Entity/Log.php
@@ -23,7 +23,7 @@
* "list_builder" = "Drupal\commerce_log\LogListBuilder",
* "storage" = "Drupal\commerce_log\LogStorage",
* "view_builder" = "Drupal\commerce_log\LogViewBuilder",
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* },
* base_table = "commerce_log",
* entity_keys = {
diff --git a/modules/log/tests/module/commerce_log_test.info.yml b/modules/log/tests/module/commerce_log_test.info.yml
index e4c1c535c5..0054d0db35 100644
--- a/modules/log/tests/module/commerce_log_test.info.yml
+++ b/modules/log/tests/module/commerce_log_test.info.yml
@@ -4,5 +4,5 @@ description: 'Test module for Commerce Log.'
package: Commerce
core: 8.x
dependencies:
- - commerce_log
- - entity_test
+ - commerce:commerce_log
+ - drupal:entity_test
diff --git a/modules/order/commerce_order.commerce_adjustment_types.yml b/modules/order/commerce_order.commerce_adjustment_types.yml
index ad929a9e08..e3d016c545 100644
--- a/modules/order/commerce_order.commerce_adjustment_types.yml
+++ b/modules/order/commerce_order.commerce_adjustment_types.yml
@@ -1,19 +1,27 @@
custom:
- label: Custom
+ label: 'Custom'
+ singular_label: 'custom adjustment'
+ plural_label: 'custom adjustments'
has_ui: true
weight: 10
fee:
- label: Fee
+ label: 'Fee'
+ singular_label: 'fee'
+ plural_label: 'fees'
has_ui: true
weight: 70
promotion:
- label: Promotion
+ label: 'Promotion'
+ singular_label: 'promotion'
+ plural_label: 'promotions'
has_ui: true
weight: 0
tax:
- label: Tax
+ label: 'Tax'
+ singular_label: 'tax'
+ plural_label: 'taxes'
has_ui: true
weight: 50
diff --git a/modules/order/commerce_order.info.yml b/modules/order/commerce_order.info.yml
index d7edcd74a8..62999faa8f 100644
--- a/modules/order/commerce_order.info.yml
+++ b/modules/order/commerce_order.info.yml
@@ -4,13 +4,13 @@ description: 'Defines the Order entity and associated features.'
package: Commerce
core: 8.x
dependencies:
- - commerce
+ - commerce:commerce
- commerce:commerce_price
- commerce:commerce_store
- - entity_reference_revisions
- - options
- - profile
- - state_machine
+ - entity_reference_revisions:entity_reference_revisions
+ - profile:profile
+ - state_machine:state_machine
+ - drupal:options
config_devel:
install:
- commerce_order.commerce_order_type.default
diff --git a/modules/order/commerce_order.module b/modules/order/commerce_order.module
index 0b47e43cdd..8a7e95ccb4 100644
--- a/modules/order/commerce_order.module
+++ b/modules/order/commerce_order.module
@@ -5,10 +5,14 @@
* Defines the Order entity and associated features.
*/
-use Drupal\commerce\BundleFieldDefinition;
+use Drupal\commerce_order\Entity\Order;
+use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_order\Entity\OrderTypeInterface;
+use Drupal\commerce_order\Plugin\Field\FieldFormatter\PriceCalculatedFormatter;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
+use Drupal\entity\BundleFieldDefinition;
+use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_theme().
@@ -56,6 +60,51 @@ function commerce_order_local_tasks_alter(&$definitions) {
}
}
+/**
+ * Implements hook_field_formatter_info_alter().
+ *
+ * Replaces the commerce_price PriceCalculatedFormatter with
+ * the expanded commerce_order one.
+ */
+function commerce_order_field_formatter_info_alter(array &$info) {
+ $info['commerce_price_calculated']['class'] = PriceCalculatedFormatter::class;
+ $info['commerce_price_calculated']['provider'] = 'commerce_order';
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_insert() for user entities.
+ *
+ * - Assigns any previous guest orders to new user account.
+ */
+function commerce_order_user_insert(AccountInterface $user) {
+ $order_ids = \Drupal::entityQuery('commerce_order')
+ ->condition('mail', $user->getEmail())
+ ->execute();
+ if ($order_ids) {
+ $orders = Order::loadMultiple($order_ids);
+ foreach ($orders as $order) {
+ $order->setCustomer($user);
+ $order->save();
+ }
+ }
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_presave().
+ */
+function commerce_order_commerce_order_presave(OrderInterface $order) {
+ $place_transition = $order->getState()->getWorkflow()->getTransition('place');
+ if (!$order->isNew() && empty($order->getPlacedTime()) && $place_transition) {
+ $from_states = $place_transition->getFromStates();
+ $from_state = reset($from_states);
+ $from_state = $from_state->getId();
+ $to_state = $place_transition->getToState()->getId();
+ if ($order->original->state->value == $from_state && $order->state->value == $to_state) {
+ $order->setPlacedTime(REQUEST_TIME);
+ }
+ }
+}
+
/**
* Implements hook_field_widget_form_alter().
*
@@ -140,7 +189,7 @@ function commerce_order_add_order_items_field(OrderTypeInterface $order_type) {
->setLabel('Order items')
->setCardinality(BundleFieldDefinition::CARDINALITY_UNLIMITED)
->setRequired(TRUE)
- ->setSetting('target_type', 'commerce_order_type')
+ ->setSetting('target_type', 'commerce_order_item')
->setSetting('handler', 'default')
->setDisplayOptions('form', [
'type' => 'inline_entity_form_complex',
diff --git a/modules/order/commerce_order.post_update.php b/modules/order/commerce_order.post_update.php
index 26dd36ceba..f03caf6935 100644
--- a/modules/order/commerce_order.post_update.php
+++ b/modules/order/commerce_order.post_update.php
@@ -170,3 +170,34 @@ function commerce_order_post_update_6() {
}
}
}
+
+/**
+ * Revert the 'commerce_order_item_table' view - empty text added.
+ */
+function commerce_order_post_update_7() {
+ /** @var \Drupal\commerce\Config\ConfigUpdaterInterface $config_updater */
+ $config_updater = \Drupal::service('commerce.config_updater');
+
+ $views = [
+ 'views.view.commerce_order_item_table',
+ ];
+ $result = $config_updater->revert($views);
+
+ $success_results = $result->getSucceeded();
+ $failure_results = $result->getFailed();
+ if ($success_results) {
+ $message = t('Succeeded:') . '
';
+ foreach ($success_results as $success_message) {
+ $message .= $success_message . '
';
+ }
+ $message .= '
';
+ }
+ if ($failure_results) {
+ $message .= t('Failed:') . '
';
+ foreach ($failure_results as $failure_message) {
+ $message .= $failure_message . '
';
+ }
+ }
+
+ return $message;
+}
diff --git a/modules/order/commerce_order.services.yml b/modules/order/commerce_order.services.yml
index 7015228286..5bbac59b4d 100644
--- a/modules/order/commerce_order.services.yml
+++ b/modules/order/commerce_order.services.yml
@@ -1,4 +1,8 @@
services:
+ plugin.manager.commerce_adjustment_type:
+ class: Drupal\commerce_order\AdjustmentTypeManager
+ arguments: ['@module_handler', '@cache.discovery']
+
commerce_order.chain_order_type_resolver:
class: Drupal\commerce_order\Resolver\ChainOrderTypeResolver
tags:
@@ -10,6 +14,10 @@ services:
tags:
- { name: commerce_order.order_type_resolver, priority: -100 }
+ commerce_order.adjustment_transformer:
+ class: Drupal\commerce_order\AdjustmentTransformer
+ arguments: ['@plugin.manager.commerce_adjustment_type', '@commerce_price.rounder']
+
commerce_order.order_assignment:
class: Drupal\commerce_order\OrderAssignment
arguments: ['@entity_type.manager', '@event_dispatcher']
@@ -34,7 +42,7 @@ services:
commerce_order.timestamp_event_subscriber:
class: Drupal\commerce_order\EventSubscriber\TimestampEventSubscriber
- arguments: ['@entity_type.manager']
+ arguments: ['@datetime.time']
tags:
- { name: event_subscriber }
@@ -49,16 +57,16 @@ services:
tags:
- { name: 'event_subscriber' }
- plugin.manager.commerce_adjustment_type:
- class: Drupal\commerce_order\AdjustmentTypeManager
- arguments: ['@module_handler', '@cache.discovery']
-
commerce_order.order_total_summary:
class: Drupal\commerce_order\OrderTotalSummary
- arguments: ['@plugin.manager.commerce_adjustment_type']
+ arguments: ['@commerce_order.adjustment_transformer']
commerce_order.order_store_resolver:
class: Drupal\commerce_order\Resolver\OrderStoreResolver
arguments: ['@current_route_match']
tags:
- { name: commerce_store.store_resolver, priority: 100 }
+
+ commerce_order.price_calculator:
+ class: Drupal\commerce_order\PriceCalculator
+ arguments: ['@commerce_order.adjustment_transformer', '@commerce_order.chain_order_type_resolver', '@commerce_price.chain_price_resolver', '@entity_type.manager', '@request_stack']
diff --git a/modules/order/config/install/commerce_order.commerce_order_type.default.yml b/modules/order/config/install/commerce_order.commerce_order_type.default.yml
index e97dade97e..a44f1b05da 100644
--- a/modules/order/config/install/commerce_order.commerce_order_type.default.yml
+++ b/modules/order/config/install/commerce_order.commerce_order_type.default.yml
@@ -3,8 +3,9 @@ status: true
label: Default
id: default
workflow: order_default
-traits: { }
refresh_mode: customer
refresh_frequency: 300
sendReceipt: true
receiptBcc: ''
+traits: { }
+locked: false
diff --git a/modules/order/config/install/views.view.commerce_order_item_table.yml b/modules/order/config/install/views.view.commerce_order_item_table.yml
index 93955666f8..d7f962ef79 100644
--- a/modules/order/config/install/views.view.commerce_order_item_table.yml
+++ b/modules/order/config/install/views.view.commerce_order_item_table.yml
@@ -60,6 +60,9 @@ display:
description: ''
columns:
title: title
+ unit_price__number: unit_price__number
+ quantity: quantity
+ total_price__number: total_price__number
info:
title:
sortable: false
@@ -68,8 +71,29 @@ display:
separator: ''
empty_column: false
responsive: ''
+ unit_price__number:
+ sortable: false
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ quantity:
+ sortable: false
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ total_price__number:
+ sortable: false
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
default: '-1'
- empty_table: false
+ empty_table: true
row:
type: fields
options:
@@ -347,7 +371,18 @@ display:
sorts: { }
header: { }
footer: { }
- empty: { }
+ empty:
+ area_text_custom:
+ id: area_text_custom
+ table: views
+ field: area_text_custom
+ relationship: none
+ group_type: group
+ admin_label: ''
+ empty: true
+ tokenize: false
+ content: 'There are no order items yet.'
+ plugin_id: text_custom
relationships: { }
arguments:
order_id:
diff --git a/modules/order/src/Adjustment.php b/modules/order/src/Adjustment.php
index 07f5e4c771..5418dd839b 100644
--- a/modules/order/src/Adjustment.php
+++ b/modules/order/src/Adjustment.php
@@ -196,4 +196,124 @@ public function isLocked() {
return $this->locked;
}
+ /**
+ * Gets the array representation of the adjustment.
+ *
+ * @return array
+ * The array representation of the adjustment.
+ */
+ public function toArray() {
+ return [
+ 'type' => $this->type,
+ 'label' => $this->label,
+ 'amount' => $this->amount,
+ 'percentage' => $this->percentage,
+ 'source_id' => $this->sourceId,
+ 'included' => $this->included,
+ 'locked' => $this->locked,
+ ];
+ }
+
+ /**
+ * Adds the given adjustment to the current adjustment.
+ *
+ * @param \Drupal\commerce_order\Adjustment $adjustment
+ * The adjustment.
+ *
+ * @return static
+ * The resulting adjustment.
+ */
+ public function add(Adjustment $adjustment) {
+ $this->assertSameType($adjustment);
+ $this->assertSameSourceId($adjustment);
+ $definition = [
+ 'amount' => $this->amount->add($adjustment->getAmount()),
+ ] + $this->toArray();
+
+ return new static($definition);
+ }
+
+ /**
+ * Subtracts the given adjustment from the current adjustment.
+ *
+ * @param \Drupal\commerce_order\Adjustment $adjustment
+ * The adjustment.
+ *
+ * @return static
+ * The resulting adjustment.
+ */
+ public function subtract(Adjustment $adjustment) {
+ $this->assertSameType($adjustment);
+ $this->assertSameSourceId($adjustment);
+ $definition = [
+ 'amount' => $this->amount->subtract($adjustment->getAmount()),
+ ] + $this->toArray();
+
+ return new static($definition);
+ }
+
+ /**
+ * Multiplies the adjustment amount by the given number.
+ *
+ * @param string $number
+ * The number.
+ *
+ * @return static
+ * The resulting adjustment.
+ */
+ public function multiply($number) {
+ $definition = [
+ 'amount' => $this->amount->multiply($number),
+ ] + $this->toArray();
+
+ return new static($definition);
+ }
+
+ /**
+ * Divides the adjustment amount by the given number.
+ *
+ * @param string $number
+ * The number.
+ *
+ * @return static
+ * The resulting adjustment.
+ */
+ public function divide($number) {
+ $definition = [
+ 'amount' => $this->amount->divide($number),
+ ] + $this->toArray();
+
+ return new static($definition);
+ }
+
+ /**
+ * Asserts that the given adjustment's type matches the current one.
+ *
+ * @param \Drupal\commerce_order\Adjustment $adjustment
+ * The adjustment to compare.
+ *
+ * @throws \InvalidArgumentException
+ * Thrown when the adjustment type does not match the current one.
+ */
+ protected function assertSameType(Adjustment $adjustment) {
+ if ($this->type != $adjustment->getType()) {
+ throw new \InvalidArgumentException(sprintf('Adjustment type "%s" does not match "%s".', $adjustment->getType(), $this->type));
+ }
+ }
+
+ /**
+ * Asserts that the given adjustment's source ID matches the current one.
+ *
+ * @param \Drupal\commerce_order\Adjustment $adjustment
+ * The adjustment to compare.
+ *
+ * @throws \InvalidArgumentException
+ * Thrown when the adjustment source ID does not match the current one.
+ */
+ protected function assertSameSourceId(Adjustment $adjustment) {
+ if ($this->sourceId != $adjustment->getSourceId()) {
+ throw new \InvalidArgumentException(sprintf('Adjustment source ID "%s" does not match "%s".', $adjustment->getSourceId(), $this->sourceId));
+ }
+ }
+
}
diff --git a/modules/order/src/AdjustmentTransformer.php b/modules/order/src/AdjustmentTransformer.php
new file mode 100644
index 0000000000..0930fd1791
--- /dev/null
+++ b/modules/order/src/AdjustmentTransformer.php
@@ -0,0 +1,120 @@
+adjustmentTypeManager = $adjustment_type_manager;
+ $this->rounder = $rounder;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function processAdjustments(array $adjustments) {
+ $adjustments = $this->combineAdjustments($adjustments);
+ $adjustments = $this->sortAdjustments($adjustments);
+ $adjustments = $this->roundAdjustments($adjustments);
+
+ return $adjustments;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function combineAdjustments(array $adjustments) {
+ $combined_adjustments = [];
+ foreach ($adjustments as $index => $adjustment) {
+ $type = $adjustment->getType();
+ $source_id = $adjustment->getSourceId();
+ if (empty($source_id)) {
+ // Adjustments without a source ID are always shown standalone.
+ $key = $index;
+ }
+ else {
+ // Adjustments with the same type and source ID are combined.
+ $key = $type . '_' . $source_id;
+ }
+
+ if (empty($combined_adjustments[$key])) {
+ $combined_adjustments[$key] = $adjustment;
+ }
+ else {
+ $combined_adjustments[$key] = $combined_adjustments[$key]->add($adjustment);
+ }
+ }
+ // The keys used for combining are irrelevant to the caller.
+ $combined_adjustments = array_values($combined_adjustments);
+
+ return $combined_adjustments;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function sortAdjustments(array $adjustments) {
+ $types = $this->adjustmentTypeManager->getDefinitions();
+ $data = [];
+ foreach ($adjustments as $adjustment) {
+ $data[] = [
+ 'adjustment' => $adjustment,
+ 'weight' => $types[$adjustment->getType()]['weight'],
+ ];
+ }
+ uasort($data, [SortArray::class, 'sortByWeightElement']);
+ // Re-extract the adjustments from the sorted array.
+ $adjustments = array_column($data, 'adjustment');
+
+ return $adjustments;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function roundAdjustments(array $adjustments, $mode = PHP_ROUND_HALF_UP) {
+ foreach ($adjustments as $index => $adjustment) {
+ $adjustments[$index] = $this->roundAdjustment($adjustment, $mode);
+ }
+
+ return $adjustments;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function roundAdjustment(Adjustment $adjustment, $mode = PHP_ROUND_HALF_UP) {
+ $amount = $this->rounder->round($adjustment->getAmount(), $mode);
+ $adjustment = new Adjustment([
+ 'amount' => $amount,
+ ] + $adjustment->toArray());
+
+ return $adjustment;
+ }
+
+}
diff --git a/modules/order/src/AdjustmentTransformerInterface.php b/modules/order/src/AdjustmentTransformerInterface.php
new file mode 100644
index 0000000000..e671d3acd0
--- /dev/null
+++ b/modules/order/src/AdjustmentTransformerInterface.php
@@ -0,0 +1,83 @@
+ '',
'label' => '',
+ 'singular_label' => '',
+ 'plural_label' => '',
'has_ui' => TRUE,
'weight' => 0,
'class' => AdjustmentType::class,
@@ -63,8 +66,19 @@ public function processDefinition(&$definition, $plugin_id) {
parent::processDefinition($definition, $plugin_id);
$definition['id'] = $plugin_id;
- if (empty($definition['label'])) {
- throw new PluginException(sprintf('The adjustment type %s must define the label property.', $plugin_id));
+ foreach (['label'] as $required_property) {
+ if (empty($definition[$required_property])) {
+ throw new PluginException(sprintf('The adjustment type %s must define the %s property.', $plugin_id, $required_property));
+ }
+ }
+ // Provide fallback labels for contrib adjustment types defined before 2.4.
+ if (empty($definition['singular_label'])) {
+ $label = Unicode::strtolower($definition['label']);
+ $definition['singular_label'] = t('@label adjustment', ['@label' => $label]);
+ }
+ if (empty($definition['plural_label'])) {
+ $label = Unicode::strtolower($definition['label']);
+ $definition['plural_label'] = t('@label adjustments', ['@label' => $label]);
}
}
diff --git a/modules/order/src/CommerceOrderServiceProvider.php b/modules/order/src/CommerceOrderServiceProvider.php
new file mode 100644
index 0000000000..8422078d47
--- /dev/null
+++ b/modules/order/src/CommerceOrderServiceProvider.php
@@ -0,0 +1,21 @@
+addCompilerPass(new PriceCalculatorPass());
+ }
+
+}
diff --git a/modules/order/src/DependencyInjection/Compiler/PriceCalculatorPass.php b/modules/order/src/DependencyInjection/Compiler/PriceCalculatorPass.php
new file mode 100644
index 0000000000..de4d064360
--- /dev/null
+++ b/modules/order/src/DependencyInjection/Compiler/PriceCalculatorPass.php
@@ -0,0 +1,54 @@
+getDefinition('commerce_order.price_calculator');
+ $processor_interface = OrderProcessorInterface::class;
+ $processors = [];
+ foreach ($container->findTaggedServiceIds('commerce_order.order_processor') as $id => $attributes) {
+ $processor = $container->getDefinition($id);
+ if (!is_subclass_of($processor->getClass(), $processor_interface)) {
+ throw new LogicException("Service '$id' does not implement $processor_interface.");
+ }
+ $attribute = $attributes[0];
+ if (empty($attribute['adjustment_type'])) {
+ continue;
+ }
+
+ $processors[$id] = [
+ 'priority' => isset($attribute['priority']) ? $attribute['priority'] : 0,
+ 'adjustment_type' => $attribute['adjustment_type'],
+ ];
+ }
+
+ // Sort the processors by priority.
+ uasort($processors, function ($processor1, $processor2) {
+ if ($processor1['priority'] == $processor2['priority']) {
+ return 0;
+ }
+ return ($processor1['priority'] > $processor2['priority']) ? -1 : 1;
+ });
+
+ // Add the processors to PriceCalculator.
+ foreach ($processors as $id => $processor) {
+ $arguments = [new Reference($id), $processor['adjustment_type']];
+ $definition->addMethodCall('addProcessor', $arguments);
+ }
+ }
+
+}
diff --git a/modules/order/src/Entity/Order.php b/modules/order/src/Entity/Order.php
index 99eb6bef5d..d0b6039aa2 100644
--- a/modules/order/src/Entity/Order.php
+++ b/modules/order/src/Entity/Order.php
@@ -32,7 +32,7 @@
* "access" = "Drupal\commerce_order\OrderAccessControlHandler",
* "permission_provider" = "Drupal\commerce_order\OrderPermissionProvider",
* "list_builder" = "Drupal\commerce_order\OrderListBuilder",
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "form" = {
* "default" = "Drupal\commerce_order\Form\OrderForm",
* "add" = "Drupal\commerce_order\Form\OrderForm",
@@ -48,7 +48,6 @@
* base_table = "commerce_order",
* admin_permission = "administer commerce_order",
* permission_granularity = "bundle",
- * fieldable = TRUE,
* entity_keys = {
* "id" = "order_id",
* "label" = "order_number",
@@ -325,16 +324,7 @@ public function collectAdjustments() {
foreach ($order_item->getAdjustments() as $adjustment) {
// Order item adjustments apply to the unit price, they
// must be multiplied by quantity before they are used.
- $multiplied_adjustment = new Adjustment([
- 'type' => $adjustment->getType(),
- 'label' => $adjustment->getLabel(),
- 'amount' => $adjustment->getAmount()->multiply($order_item->getQuantity()),
- 'percentage' => $adjustment->getPercentage(),
- 'source_id' => $adjustment->getSourceId(),
- 'included' => $adjustment->isIncluded(),
- 'locked' => $adjustment->isLocked(),
- ]);
- $adjustments[] = $multiplied_adjustment;
+ $adjustments[] = $adjustment->multiply($order_item->getQuantity());
}
}
foreach ($this->getAdjustments() as $adjustment) {
@@ -352,8 +342,9 @@ public function getSubtotalPrice() {
$subtotal_price = NULL;
if ($this->hasItems()) {
foreach ($this->getItems() as $order_item) {
- $order_item_total = $order_item->getTotalPrice();
- $subtotal_price = $subtotal_price ? $subtotal_price->add($order_item_total) : $order_item_total;
+ if ($order_item_total = $order_item->getTotalPrice()) {
+ $subtotal_price = $subtotal_price ? $subtotal_price->add($order_item_total) : $order_item_total;
+ }
}
}
return $subtotal_price;
@@ -367,12 +358,20 @@ public function recalculateTotalPrice() {
$total_price = NULL;
if ($this->hasItems()) {
foreach ($this->getItems() as $order_item) {
- $order_item_total = $order_item->getTotalPrice();
- $total_price = $total_price ? $total_price->add($order_item_total) : $order_item_total;
+ if ($order_item_total = $order_item->getTotalPrice()) {
+ $total_price = $total_price ? $total_price->add($order_item_total) : $order_item_total;
+ }
}
- foreach ($this->collectAdjustments() as $adjustment) {
- if (!$adjustment->isIncluded()) {
- $total_price = $total_price->add($adjustment->getAmount());
+ $adjustments = $this->collectAdjustments();
+ if ($adjustments) {
+ /** @var \Drupal\commerce_order\AdjustmentTransformerInterface $adjustment_transformer */
+ $adjustment_transformer = \Drupal::service('commerce_order.adjustment_transformer');
+ $adjustments = $adjustment_transformer->combineAdjustments($adjustments);
+ $adjustments = $adjustment_transformer->roundAdjustments($adjustments);
+ foreach ($adjustments as $adjustment) {
+ if (!$adjustment->isIncluded()) {
+ $total_price = $total_price->add($adjustment->getAmount());
+ }
}
}
}
@@ -627,7 +626,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
'weight' => 0,
])
->setDisplayOptions('form', [
- 'type' => 'hidden',
+ 'region' => 'hidden',
'weight' => 0,
])
->setDisplayConfigurable('form', TRUE)
diff --git a/modules/order/src/Entity/OrderInterface.php b/modules/order/src/Entity/OrderInterface.php
index ba21dc201d..73fd4c4880 100644
--- a/modules/order/src/Entity/OrderInterface.php
+++ b/modules/order/src/Entity/OrderInterface.php
@@ -233,12 +233,14 @@ public function clearAdjustments();
/**
* Collects all adjustments that belong to the order.
*
- * Unlike getAdjustments() which returns only order adjustments,
- * this method returns both order and order item adjustments.
+ * Unlike getAdjustments() which returns only order adjustments, this
+ * method returns both order and order item adjustments (multiplied
+ * by quantity).
*
* Important:
- * The returned order item adjustments are multiplied by quantity,
- * so that they can be safely added to the order adjustments.
+ * The returned adjustments are unprocessed, and must be processed before use.
+ *
+ * @see \Drupal\commerce_order\AdjustmentTransformerInterface::processAdjustments()
*
* @return \Drupal\commerce_order\Adjustment[]
* The adjustments.
diff --git a/modules/order/src/Entity/OrderItem.php b/modules/order/src/Entity/OrderItem.php
index 3ee87c99fd..7ba54a8ccb 100644
--- a/modules/order/src/Entity/OrderItem.php
+++ b/modules/order/src/Entity/OrderItem.php
@@ -35,7 +35,6 @@
* },
* base_table = "commerce_order_item",
* admin_permission = "administer commerce_order",
- * fieldable = TRUE,
* entity_keys = {
* "id" = "order_item_id",
* "uuid" = "uuid",
@@ -359,7 +358,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
'type' => 'timestamp',
'weight' => 0,
])
- ->setDisplayConfigurable('form', TRUE);
+ ->setDisplayConfigurable('form', TRUE)
+ ->setDisplayConfigurable('view', TRUE);
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Changed'))
@@ -387,7 +387,7 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type,
// hidden instead. https://www.drupal.org/node/2346347#comment-10254087.
$fields['purchased_entity']->setRequired(FALSE);
$fields['purchased_entity']->setDisplayOptions('form', [
- 'type' => 'hidden',
+ 'region' => 'hidden',
]);
$fields['purchased_entity']->setDisplayConfigurable('form', FALSE);
$fields['purchased_entity']->setDisplayConfigurable('view', FALSE);
diff --git a/modules/order/src/Entity/OrderItemType.php b/modules/order/src/Entity/OrderItemType.php
index e1dea40405..ea94578068 100644
--- a/modules/order/src/Entity/OrderItemType.php
+++ b/modules/order/src/Entity/OrderItemType.php
@@ -18,6 +18,7 @@
* plural = "@count order item types",
* ),
* handlers = {
+ * "access" = "Drupal\commerce\CommerceBundleAccessControlHandler",
* "form" = {
* "add" = "Drupal\commerce_order\Form\OrderItemTypeForm",
* "edit" = "Drupal\commerce_order\Form\OrderItemTypeForm",
@@ -42,6 +43,7 @@
* "purchasableEntityType",
* "orderType",
* "traits",
+ * "locked",
* },
* links = {
* "add-form" = "/admin/commerce/config/order-item-types/add",
diff --git a/modules/order/src/Entity/OrderType.php b/modules/order/src/Entity/OrderType.php
index 663d356187..98ccb3b50f 100644
--- a/modules/order/src/Entity/OrderType.php
+++ b/modules/order/src/Entity/OrderType.php
@@ -18,6 +18,7 @@
* plural = "@count order types",
* ),
* handlers = {
+ * "access" = "Drupal\commerce\CommerceBundleAccessControlHandler",
* "form" = {
* "add" = "Drupal\commerce_order\Form\OrderTypeForm",
* "edit" = "Drupal\commerce_order\Form\OrderTypeForm",
@@ -40,11 +41,12 @@
* "label",
* "id",
* "workflow",
- * "traits",
* "refresh_mode",
* "refresh_frequency",
* "sendReceipt",
* "receiptBcc",
+ * "traits",
+ * "locked",
* },
* links = {
* "add-form" = "/admin/commerce/config/order-types/add",
diff --git a/modules/order/src/EventSubscriber/OrderNumberSubscriber.php b/modules/order/src/EventSubscriber/OrderNumberSubscriber.php
index b29f7858f6..edb59fc4b4 100644
--- a/modules/order/src/EventSubscriber/OrderNumberSubscriber.php
+++ b/modules/order/src/EventSubscriber/OrderNumberSubscriber.php
@@ -8,8 +8,11 @@
/**
* Generates the order number for placed orders.
*
- * Modules wishing to override this logic can register their
- * own event subscriber with a higher weight (e.g. -10).
+ * Modules wishing to provide their own order number logic should register
+ * an event subscriber with a higher priority (for example, 0).
+ *
+ * Modules that need access to the generated order number should register
+ * an event subscriber with a lower priority (for example, -50).
*/
class OrderNumberSubscriber implements EventSubscriberInterface {
@@ -18,13 +21,15 @@ class OrderNumberSubscriber implements EventSubscriberInterface {
*/
public static function getSubscribedEvents() {
$events = [
- 'commerce_order.place.pre_transition' => ['setOrderNumber', -100],
+ 'commerce_order.place.pre_transition' => ['setOrderNumber', -30],
];
return $events;
}
/**
- * Sets the order number, if not already set explicitly, to the order ID.
+ * Sets the order number to the order ID.
+ *
+ * Skipped if the order number has already been set.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The transition event.
diff --git a/modules/order/src/EventSubscriber/TimestampEventSubscriber.php b/modules/order/src/EventSubscriber/TimestampEventSubscriber.php
index 4ac52df690..03c08caec5 100644
--- a/modules/order/src/EventSubscriber/TimestampEventSubscriber.php
+++ b/modules/order/src/EventSubscriber/TimestampEventSubscriber.php
@@ -3,10 +3,28 @@
namespace Drupal\commerce_order\EventSubscriber;
use Drupal\state_machine\Event\WorkflowTransitionEvent;
+use Drupal\Component\Datetime\TimeInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class TimestampEventSubscriber implements EventSubscriberInterface {
+ /**
+ * The time.
+ *
+ * @var \Drupal\Component\Datetime\TimeInterface
+ */
+ protected $time;
+
+ /**
+ * Constructs a new TimestampEventSubscriber object.
+ *
+ * @param \Drupal\Component\Datetime\TimeInterface $time
+ * The time.
+ */
+ public function __construct(TimeInterface $time) {
+ $this->time = $time;
+ }
+
/**
* {@inheritdoc}
*/
@@ -27,7 +45,7 @@ public function onPlaceTransition(WorkflowTransitionEvent $event) {
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $event->getEntity();
if (empty($order->getPlacedTime())) {
- $order->setPlacedTime(\Drupal::time()->getRequestTime());
+ $order->setPlacedTime($this->time->getRequestTime());
}
}
diff --git a/modules/order/src/Form/OrderForm.php b/modules/order/src/Form/OrderForm.php
index 719ed12e72..2beadd96e3 100644
--- a/modules/order/src/Form/OrderForm.php
+++ b/modules/order/src/Form/OrderForm.php
@@ -86,7 +86,7 @@ public function form(array $form, FormStateInterface $form_state) {
'#tag' => 'h3',
'#value' => $order->getState()->getLabel(),
'#attributes' => [
- 'class' => 'entity-meta__title',
+ 'class' => ['entity-meta__title'],
],
// Hide the rendered state if there's a widget for it.
'#access' => empty($form['store_id']),
@@ -162,7 +162,7 @@ protected function fieldAsReadOnly($label, $value) {
*/
public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
- drupal_set_message($this->t('The order %label has been successfully saved.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('The order %label has been successfully saved.', ['%label' => $this->entity->label()]));
$form_state->setRedirect('entity.commerce_order.canonical', ['commerce_order' => $this->entity->id()]);
}
diff --git a/modules/order/src/Form/OrderItemTypeForm.php b/modules/order/src/Form/OrderItemTypeForm.php
index 53c7e41f03..84d3616fea 100644
--- a/modules/order/src/Form/OrderItemTypeForm.php
+++ b/modules/order/src/Form/OrderItemTypeForm.php
@@ -77,7 +77,7 @@ public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
$this->submitTraitForm($form, $form_state);
- drupal_set_message($this->t('Saved the %label order item type.', [
+ $this->messenger()->addMessage($this->t('Saved the %label order item type.', [
'%label' => $this->entity->label(),
]));
$form_state->setRedirect('entity.commerce_order_item_type.collection');
diff --git a/modules/order/src/Form/OrderReassignForm.php b/modules/order/src/Form/OrderReassignForm.php
index 21db4e13f2..85b8c5452d 100644
--- a/modules/order/src/Form/OrderReassignForm.php
+++ b/modules/order/src/Form/OrderReassignForm.php
@@ -99,7 +99,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
$this->order->setEmail($values['mail']);
$this->order->setCustomerId($values['uid']);
$this->order->save();
- drupal_set_message($this->t('The order %label has been assigned to customer %customer.', [
+ $this->messenger()->addMessage($this->t('The order %label has been assigned to customer %customer.', [
'%label' => $this->order->label(),
'%customer' => $this->order->getCustomer()->label(),
]));
diff --git a/modules/order/src/Form/OrderTypeForm.php b/modules/order/src/Form/OrderTypeForm.php
index 7aabb19283..99333400c2 100644
--- a/modules/order/src/Form/OrderTypeForm.php
+++ b/modules/order/src/Form/OrderTypeForm.php
@@ -139,7 +139,7 @@ public function save(array $form, FormStateInterface $form_state) {
$status = $this->entity->save();
$this->submitTraitForm($form, $form_state);
- drupal_set_message($this->t('Saved the %label order type.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Saved the %label order type.', ['%label' => $this->entity->label()]));
$form_state->setRedirect('entity.commerce_order_type.collection');
if ($status == SAVED_NEW) {
diff --git a/modules/order/src/Form/OrderUnlockForm.php b/modules/order/src/Form/OrderUnlockForm.php
index 99d2976e33..f34b07b2b2 100644
--- a/modules/order/src/Form/OrderUnlockForm.php
+++ b/modules/order/src/Form/OrderUnlockForm.php
@@ -42,7 +42,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
$order->unlock();
$order->save();
- drupal_set_message($this->t('The order %label has been unlocked.', [
+ $this->messenger()->addMessage($this->t('The order %label has been unlocked.', [
'%label' => $order->label(),
]));
$form_state->setRedirectUrl($order->toUrl('collection'));
diff --git a/modules/order/src/OrderAccessControlHandler.php b/modules/order/src/OrderAccessControlHandler.php
index 7994308a9e..2063ac0895 100644
--- a/modules/order/src/OrderAccessControlHandler.php
+++ b/modules/order/src/OrderAccessControlHandler.php
@@ -2,10 +2,10 @@
namespace Drupal\commerce_order;
-use Drupal\commerce\EntityAccessControlHandler;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
+use Drupal\entity\EntityAccessControlHandler;
/**
* Controls access based on the Order entity permissions.
diff --git a/modules/order/src/OrderItemViewsData.php b/modules/order/src/OrderItemViewsData.php
index eebb8fe774..7ed769ee3e 100644
--- a/modules/order/src/OrderItemViewsData.php
+++ b/modules/order/src/OrderItemViewsData.php
@@ -2,12 +2,12 @@
namespace Drupal\commerce_order;
-use Drupal\views\EntityViewsData;
+use Drupal\commerce\CommerceEntityViewsData;
/**
* Provides views data for order items.
*/
-class OrderItemViewsData extends EntityViewsData {
+class OrderItemViewsData extends CommerceEntityViewsData {
/**
* {@inheritdoc}
diff --git a/modules/order/src/OrderRefreshInterface.php b/modules/order/src/OrderRefreshInterface.php
index 02dd4ef3bc..2ea6d070ed 100644
--- a/modules/order/src/OrderRefreshInterface.php
+++ b/modules/order/src/OrderRefreshInterface.php
@@ -52,11 +52,11 @@ public function needsRefresh(OrderInterface $order);
/**
* Refreshes the given order.
*
+ * Any modified order items will be automatically saved.
+ * The order itself will not be saved.
+ *
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
- *
- * @return \Drupal\commerce_order\Entity\OrderInterface
- * The refreshed, unsaved order.
*/
public function refresh(OrderInterface $order);
diff --git a/modules/order/src/OrderRouteProvider.php b/modules/order/src/OrderRouteProvider.php
index bb99c7c84f..fe8380dd3c 100644
--- a/modules/order/src/OrderRouteProvider.php
+++ b/modules/order/src/OrderRouteProvider.php
@@ -3,7 +3,7 @@
namespace Drupal\commerce_order;
use Drupal\Core\Entity\EntityTypeInterface;
-use Drupal\Core\Entity\Routing\AdminHtmlRouteProvider;
+use Drupal\entity\Routing\AdminHtmlRouteProvider;
/**
* Provides routes for the Order entity.
diff --git a/modules/order/src/OrderTotalSummary.php b/modules/order/src/OrderTotalSummary.php
index 3ad53d09ca..33780bff59 100644
--- a/modules/order/src/OrderTotalSummary.php
+++ b/modules/order/src/OrderTotalSummary.php
@@ -3,57 +3,40 @@
namespace Drupal\commerce_order;
use Drupal\commerce_order\Entity\OrderInterface;
-use Drupal\Component\Utility\SortArray;
class OrderTotalSummary implements OrderTotalSummaryInterface {
/**
- * The adjustment type manager.
+ * The adjustment transformer.
*
- * @var \Drupal\commerce_order\AdjustmentTypeManager
+ * @var \Drupal\commerce_order\AdjustmentTransformerInterface
*/
- protected $adjustmentTypeManager;
+ protected $adjustmentTransformer;
/**
- * {@inheritdoc}
+ * Constructs a new OrderTotalSummary object.
+ *
+ * @param \Drupal\commerce_order\AdjustmentTransformerInterface $adjustment_transformer
+ * The adjustment transformer.
*/
- public function __construct(AdjustmentTypeManager $adjustment_type_manager) {
- $this->adjustmentTypeManager = $adjustment_type_manager;
+ public function __construct(AdjustmentTransformerInterface $adjustment_transformer) {
+ $this->adjustmentTransformer = $adjustment_transformer;
}
/**
* {@inheritdoc}
*/
public function buildTotals(OrderInterface $order) {
- $types = $this->adjustmentTypeManager->getDefinitions();
- $adjustments = [];
- foreach ($order->collectAdjustments() as $adjustment) {
- $type = $adjustment->getType();
- $source_id = $adjustment->getSourceId();
- if (empty($source_id)) {
- // Adjustments without a source ID are always shown standalone.
- $key = count($adjustments);
- }
- else {
- // Adjustments with the same type and source ID are combined.
- $key = $type . '_' . $source_id;
- }
-
- if (empty($adjustments[$key])) {
- $adjustments[$key] = [
- 'type' => $type,
- 'label' => $adjustment->getLabel(),
- 'total' => $adjustment->getAmount(),
- 'percentage' => $adjustment->getPercentage(),
- 'weight' => $types[$type]['weight'],
- ];
- }
- else {
- $adjustments[$key]['total'] = $adjustments[$key]['total']->add($adjustment->getAmount());
- }
+ $adjustments = $order->collectAdjustments();
+ $adjustments = $this->adjustmentTransformer->processAdjustments($adjustments);
+ // Convert the adjustments to arrays.
+ $adjustments = array_map(function (Adjustment $adjustment) {
+ return $adjustment->toArray();
+ }, $adjustments);
+ // Provide the "total" key for backwards compatibility reasons.
+ foreach ($adjustments as $index => $adjustment) {
+ $adjustments[$index]['total'] = $adjustments[$index]['amount'];
}
- // Sort the adjustments by weight.
- uasort($adjustments, [SortArray::class, 'sortByWeightElement']);
return [
'subtotal' => $order->getSubtotalPrice(),
diff --git a/modules/order/src/OrderTotalSummaryInterface.php b/modules/order/src/OrderTotalSummaryInterface.php
index d95a8805b4..f45dbcd7c6 100644
--- a/modules/order/src/OrderTotalSummaryInterface.php
+++ b/modules/order/src/OrderTotalSummaryInterface.php
@@ -15,11 +15,11 @@ interface OrderTotalSummaryInterface {
* @return array
* An array of totals with the following elements:
* - subtotal: The order subtotal price.
- * - adjustments: An array of adjustment totals:
+ * - adjustments: The adjustments:
* - type: The adjustment type.
* - label: The adjustment label.
- * - total: The adjustment total price.
- * - weight: The adjustment weight, taken from the adjustment type.
+ * - amount: The adjustment amount.
+ * - percentage: The decimal adjustment percentage, when available.
* - total: The order total price.
*/
public function buildTotals(OrderInterface $order);
diff --git a/modules/order/src/Plugin/Commerce/AdjustmentType/AdjustmentType.php b/modules/order/src/Plugin/Commerce/AdjustmentType/AdjustmentType.php
index 630b2b0cf6..ad21326788 100644
--- a/modules/order/src/Plugin/Commerce/AdjustmentType/AdjustmentType.php
+++ b/modules/order/src/Plugin/Commerce/AdjustmentType/AdjustmentType.php
@@ -23,6 +23,20 @@ public function getLabel() {
return $this->pluginDefinition['label'];
}
+ /**
+ * {@inheritdoc}
+ */
+ public function getSingularLabel() {
+ return $this->pluginDefinition['singular_label'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPluralLabel() {
+ return $this->pluginDefinition['plural_label'];
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/modules/order/src/Plugin/Commerce/AdjustmentType/AdjustmentTypeInterface.php b/modules/order/src/Plugin/Commerce/AdjustmentType/AdjustmentTypeInterface.php
index f2d276f9fd..93b34db890 100644
--- a/modules/order/src/Plugin/Commerce/AdjustmentType/AdjustmentTypeInterface.php
+++ b/modules/order/src/Plugin/Commerce/AdjustmentType/AdjustmentTypeInterface.php
@@ -23,6 +23,22 @@ public function getId();
*/
public function getLabel();
+ /**
+ * Gets the adjustment type singular label.
+ *
+ * @return string
+ * The adjustment type singular label.
+ */
+ public function getSingularLabel();
+
+ /**
+ * Gets the adjustment type plural label.
+ *
+ * @return string
+ * The adjustment type plural label.
+ */
+ public function getPluralLabel();
+
/**
* Gets the adjustment type weight.
*
diff --git a/modules/order/src/Plugin/Field/FieldFormatter/OrderItemTable.php b/modules/order/src/Plugin/Field/FieldFormatter/OrderItemTable.php
index f92d2a3c69..3fc8a7ea17 100644
--- a/modules/order/src/Plugin/Field/FieldFormatter/OrderItemTable.php
+++ b/modules/order/src/Plugin/Field/FieldFormatter/OrderItemTable.php
@@ -11,7 +11,7 @@
*
* @FieldFormatter(
* id = "commerce_order_item_table",
- * label = @Translation("order item table"),
+ * label = @Translation("Order item table"),
* field_types = {
* "entity_reference",
* },
@@ -23,14 +23,18 @@ class OrderItemTable extends FormatterBase {
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
+ /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $items->getEntity();
- return [
+ $elements = [];
+ $elements[0] = [
'#type' => 'view',
// @todo Allow the view to be configurable.
'#name' => 'commerce_order_item_table',
'#arguments' => [$order->id()],
'#embed' => TRUE,
];
+
+ return $elements;
}
/**
diff --git a/modules/order/src/Plugin/Field/FieldFormatter/OrderTotalSummary.php b/modules/order/src/Plugin/Field/FieldFormatter/OrderTotalSummary.php
index bb88c6d977..97759e0df8 100644
--- a/modules/order/src/Plugin/Field/FieldFormatter/OrderTotalSummary.php
+++ b/modules/order/src/Plugin/Field/FieldFormatter/OrderTotalSummary.php
@@ -75,11 +75,17 @@ public static function create(ContainerInterface $container, array $configuratio
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
+ /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $items->getEntity();
- return [
- '#theme' => 'commerce_order_total_summary',
- '#totals' => $this->orderTotalSummary->buildTotals($order),
- ];
+ $elements = [];
+ if (!$items->isEmpty()) {
+ $elements[0] = [
+ '#theme' => 'commerce_order_total_summary',
+ '#totals' => $this->orderTotalSummary->buildTotals($order),
+ ];
+ }
+
+ return $elements;
}
/**
diff --git a/modules/order/src/Plugin/Field/FieldFormatter/PriceCalculatedFormatter.php b/modules/order/src/Plugin/Field/FieldFormatter/PriceCalculatedFormatter.php
new file mode 100644
index 0000000000..7096142f30
--- /dev/null
+++ b/modules/order/src/Plugin/Field/FieldFormatter/PriceCalculatedFormatter.php
@@ -0,0 +1,202 @@
+adjustmentTypeManager = $adjustment_type_manager;
+ $this->currentStore = $current_store;
+ $this->currentUser = $current_user;
+ $this->priceCalculator = $price_calculator;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $plugin_id,
+ $plugin_definition,
+ $configuration['field_definition'],
+ $configuration['settings'],
+ $configuration['label'],
+ $configuration['view_mode'],
+ $configuration['third_party_settings'],
+ $container->get('entity_type.manager'),
+ $container->get('commerce_price.number_formatter_factory'),
+ $container->get('plugin.manager.commerce_adjustment_type'),
+ $container->get('commerce_store.current_store'),
+ $container->get('current_user'),
+ $container->get('commerce_order.price_calculator')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function defaultSettings() {
+ return [
+ 'adjustment_types' => [],
+ ] + parent::defaultSettings();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function settingsForm(array $form, FormStateInterface $form_state) {
+ $elements = parent::settingsForm($form, $form_state);
+
+ $elements['adjustment_types'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Adjustments'),
+ '#options' => [],
+ '#default_value' => $this->getSetting('adjustment_types'),
+ ];
+ foreach ($this->adjustmentTypeManager->getDefinitions() as $plugin_id => $definition) {
+ if (!in_array($plugin_id, ['custom'])) {
+ $label = $this->t('Apply @label to the calculated price', ['@label' => $definition['plural_label']]);
+ $elements['adjustment_types']['#options'][$plugin_id] = $label;
+ }
+ }
+
+ return $elements;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function settingsSummary() {
+ $summary = parent::settingsSummary();
+ $enabled_adjustment_types = array_filter($this->getSetting('adjustment_types'));
+ foreach ($this->adjustmentTypeManager->getDefinitions() as $plugin_id => $definition) {
+ if (in_array($plugin_id, $enabled_adjustment_types)) {
+ $summary[] = $this->t('Apply @label to the calculated price', ['@label' => $definition['plural_label']]);
+ }
+ }
+
+ return $summary;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function viewElements(FieldItemListInterface $items, $langcode) {
+ $elements = [];
+ if (!$items->isEmpty()) {
+ $context = new Context($this->currentUser, $this->currentStore->getStore());
+ /** @var \Drupal\commerce\PurchasableEntityInterface $purchasable_entity */
+ $purchasable_entity = $items->getEntity();
+ $adjustment_types = array_filter($this->getSetting('adjustment_types'));
+ $result = $this->priceCalculator->calculate($purchasable_entity, 1, $context, $adjustment_types);
+ $calculated_price = $result->getCalculatedPrice();
+ $number = $calculated_price->getNumber();
+ /** @var \Drupal\commerce_price\Entity\CurrencyInterface $currency */
+ $currency = $this->currencyStorage->load($calculated_price->getCurrencyCode());
+
+ $elements[0] = [
+ '#markup' => $this->numberFormatter->formatCurrency($number, $currency),
+ '#cache' => [
+ 'tags' => $purchasable_entity->getCacheTags(),
+ 'contexts' => Cache::mergeContexts($purchasable_entity->getCacheContexts(), [
+ 'languages:' . LanguageInterface::TYPE_INTERFACE,
+ 'country',
+ ]),
+ ],
+ ];
+ }
+
+ return $elements;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function isApplicable(FieldDefinitionInterface $field_definition) {
+ $entity_type = \Drupal::entityTypeManager()->getDefinition($field_definition->getTargetEntityTypeId());
+ return $entity_type->entityClassImplements(PurchasableEntityInterface::class);
+ }
+
+}
diff --git a/modules/order/src/Plugin/Field/FieldType/AdjustmentItemList.php b/modules/order/src/Plugin/Field/FieldType/AdjustmentItemList.php
index 69c03d387e..cb33856da0 100644
--- a/modules/order/src/Plugin/Field/FieldType/AdjustmentItemList.php
+++ b/modules/order/src/Plugin/Field/FieldType/AdjustmentItemList.php
@@ -17,7 +17,9 @@ public function getAdjustments() {
$adjustments = [];
/** @var \Drupal\commerce_order\Plugin\Field\FieldType\AdjustmentItem $field_item */
foreach ($this->list as $key => $field_item) {
- $adjustments[$key] = $field_item->value;
+ if (!$field_item->isEmpty()) {
+ $adjustments[$key] = $field_item->value;
+ }
}
return $adjustments;
diff --git a/modules/order/src/Plugin/Field/FieldWidget/AdjustmentDefaultWidget.php b/modules/order/src/Plugin/Field/FieldWidget/AdjustmentDefaultWidget.php
index e4c11ad465..091cb4ce21 100644
--- a/modules/order/src/Plugin/Field/FieldWidget/AdjustmentDefaultWidget.php
+++ b/modules/order/src/Plugin/Field/FieldWidget/AdjustmentDefaultWidget.php
@@ -37,7 +37,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
'_none' => $this->t('- Select -'),
];
foreach ($plugin_manager->getDefinitions() as $id => $definition) {
- if ($definition['has_ui'] == TRUE) {
+ if (!empty($definition['has_ui'])) {
$types[$id] = $definition['label'];
}
}
@@ -57,6 +57,12 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
'#type' => 'value',
'#value' => empty($source_id) ? 'custom' : $source_id,
];
+ // If this is being added through the UI, the adjustment should be locked.
+ // UI added adjustments need to be locked to persist after an order refresh.
+ $element['locked'] = [
+ '#type' => 'value',
+ '#value' => ($adjustment) ? $adjustment->isLocked() : TRUE,
+ ];
$states_selector_name = $this->fieldDefinition->getName() . "[$delta][type]";
$element['definition'] = [
@@ -101,6 +107,13 @@ public function massageFormValues(array $values, array $form, FormStateInterface
if ($value['type'] == '_none') {
continue;
}
+ // The method can be called with invalid or incomplete data, in
+ // preparation for validation. Passing such data to the Adjustment
+ // object would result in an exception.
+ if (empty($value['definition']['label'])) {
+ $form_state->setErrorByName('adjustments[' . $key . '][definition][label]', $this->t('The adjustment label field is required.'));
+ continue;
+ }
$values[$key] = new Adjustment([
'type' => $value['type'],
@@ -108,6 +121,7 @@ public function massageFormValues(array $values, array $form, FormStateInterface
'amount' => new Price($value['definition']['amount']['number'], $value['definition']['amount']['currency_code']),
'source_id' => $value['source_id'],
'included' => $value['definition']['included'],
+ 'locked' => $value['locked'],
]);
}
return $values;
diff --git a/modules/order/src/Plugin/Field/FieldWidget/QuantityWidget.php b/modules/order/src/Plugin/Field/FieldWidget/QuantityWidget.php
index b65ecc6117..f0027ccb86 100644
--- a/modules/order/src/Plugin/Field/FieldWidget/QuantityWidget.php
+++ b/modules/order/src/Plugin/Field/FieldWidget/QuantityWidget.php
@@ -104,6 +104,7 @@ public function settingsSummary() {
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$element = parent::formElement($items, $delta, $element, $form, $form_state);
$element['value']['#step'] = $this->getSetting('step');
+ $element['value']['#min'] = $this->getSetting('step');
return $element;
}
diff --git a/modules/order/src/Plugin/views/area/OrderTotal.php b/modules/order/src/Plugin/views/area/OrderTotal.php
index 3ca0d6d259..0fbf2ae1a5 100644
--- a/modules/order/src/Plugin/views/area/OrderTotal.php
+++ b/modules/order/src/Plugin/views/area/OrderTotal.php
@@ -76,7 +76,7 @@ public function render($empty = FALSE) {
if (!$argument instanceof NumericArgument) {
continue;
}
- if ($argument->getField() !== 'commerce_order.order_id') {
+ if (!in_array($argument->getField(), ['commerce_order.order_id', 'commerce_order_item.order_id'])) {
continue;
}
if ($order = $this->orderStorage->load($argument->getValue())) {
diff --git a/modules/order/src/PriceCalculator.php b/modules/order/src/PriceCalculator.php
new file mode 100644
index 0000000000..1feec0aea4
--- /dev/null
+++ b/modules/order/src/PriceCalculator.php
@@ -0,0 +1,170 @@
+adjustmentTransformer = $adjustment_transformer;
+ $this->chainOrderTypeResolver = $chain_order_type_resolver;
+ $this->chainPriceResolver = $chain_price_resolver;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->requestStack = $request_stack;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addProcessor(OrderProcessorInterface $processor, $adjustment_type) {
+ $this->processors[$adjustment_type][] = $processor;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getProcessors($adjustment_type) {
+ if (!isset($this->processors[$adjustment_type])) {
+ return [];
+ }
+ return $this->processors[$adjustment_type];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function calculate(PurchasableEntityInterface $purchasable_entity, $quantity, Context $context, array $adjustment_types = []) {
+ $resolved_price = $this->chainPriceResolver->resolve($purchasable_entity, $quantity, $context);
+ $processors = [];
+ foreach ($adjustment_types as $adjustment_type) {
+ $processors = array_merge($processors, $this->getProcessors($adjustment_type));
+ }
+ if (empty($adjustment_types) || empty($processors)) {
+ return new PriceCalculatorResult($resolved_price, $resolved_price);
+ }
+
+ /** @var \Drupal\commerce_order\OrderItemStorageInterface $order_item_storage */
+ $order_item_storage = $this->entityTypeManager->getStorage('commerce_order_item');
+ $order_item = $order_item_storage->createFromPurchasableEntity($purchasable_entity);
+ $order_item->setUnitPrice($resolved_price);
+ $order_item->setQuantity($quantity);
+ $order_type_id = $this->chainOrderTypeResolver->resolve($order_item);
+
+ $order = $this->prepareOrder($order_type_id, $context);
+ $order_item->order_id = $order;
+ $order->setItems([$order_item]);
+ // Allow each selected processor to add its adjustments.
+ foreach ($processors as $processor) {
+ $processor->process($order);
+ }
+ $calculated_price = $order_item->getAdjustedUnitPrice();
+ $adjustments = $order_item->getAdjustments();
+ $adjustments = $this->adjustmentTransformer->processAdjustments($adjustments);
+
+ return new PriceCalculatorResult($calculated_price, $resolved_price, $adjustments);
+ }
+
+ /**
+ * Prepares an unsaved order for the given type/context.
+ *
+ * @param string $order_type_id
+ * The order type ID.
+ * @param \Drupal\commerce\Context $context
+ * The context.
+ *
+ * @return \Drupal\commerce_order\Entity\OrderInterface
+ * The order.
+ */
+ protected function prepareOrder($order_type_id, Context $context) {
+ if (!isset($this->orders[$order_type_id])) {
+ $order_storage = $this->entityTypeManager->getStorage('commerce_order');
+ $this->orders[$order_type_id] = $order_storage->create([
+ 'type' => $order_type_id,
+ 'ip_address' => $this->requestStack->getCurrentRequest()->getClientIp(),
+ // Provide a flag that can be used in the order create hook/event
+ // to identify orders used for price calculation purposes.
+ 'data' => ['provider' => 'order_price_calculator'],
+ ]);
+ }
+
+ $order = $this->orders[$order_type_id];
+ // Make sure that the order data matches the data passed in the context.
+ $order->setStoreId($context->getStore()->id());
+ $order->setCustomerId($context->getCustomer()->id());
+ $order->setEmail($context->getCustomer()->getEmail());
+ // Start from a clear set of adjustments each time.
+ $order->clearAdjustments();
+
+ return $order;
+ }
+
+}
diff --git a/modules/order/src/PriceCalculatorInterface.php b/modules/order/src/PriceCalculatorInterface.php
new file mode 100644
index 0000000000..7bcf1fe0ab
--- /dev/null
+++ b/modules/order/src/PriceCalculatorInterface.php
@@ -0,0 +1,57 @@
+calculatedPrice = $calculated_price;
+ $this->basePrice = $base_price;
+ $this->adjustments = $adjustments;
+ }
+
+ /**
+ * Gets the calculated price.
+ *
+ * This is the resolved unit price with adjustments applied.
+ *
+ * @return \Drupal\commerce_price\Price
+ * The calculated price.
+ */
+ public function getCalculatedPrice() {
+ return $this->calculatedPrice;
+ }
+
+ /**
+ * Gets the base price.
+ *
+ * This is the resolved unit price without any adjustments.
+ *
+ * @return \Drupal\commerce_price\Price
+ * The base price.
+ */
+ public function getBasePrice() {
+ return $this->basePrice;
+ }
+
+ /**
+ * Gets the adjustments.
+ *
+ * @return \Drupal\commerce_order\Adjustment[]
+ * The adjustments.
+ */
+ public function getAdjustments() {
+ return $this->adjustments;
+ }
+
+}
diff --git a/modules/order/templates/commerce-order-total-summary.html.twig b/modules/order/templates/commerce-order-total-summary.html.twig
index 2f857fa258..2be3907ae0 100644
--- a/modules/order/templates/commerce-order-total-summary.html.twig
+++ b/modules/order/templates/commerce-order-total-summary.html.twig
@@ -7,12 +7,11 @@
* - attributes: HTML attributes for the wrapper.
* - totals: An array of order totals values with the following keys:
* - subtotal: The order subtotal price.
- * - adjustments: An array of adjustment totals:
+ * - adjustments: The adjustments:
* - type: The adjustment type.
* - label: The adjustment label.
- * - total: The adjustment total price.
+ * - amount: The adjustment amount.
* - percentage: The decimal adjustment percentage, when available. For example, "0.2" for a 20% adjustment.
- * - weight: The adjustment weight, taken from the adjustment type.
* - total: The order total price.
*
* @ingroup themeable
@@ -25,7 +24,7 @@
{% for adjustment in totals.adjustments %}
diff --git a/modules/order/tests/modules/commerce_order_test/commerce_order_test.commerce_adjustment_types.yml b/modules/order/tests/modules/commerce_order_test/commerce_order_test.commerce_adjustment_types.yml
index aa07a1622c..291c8ec855 100644
--- a/modules/order/tests/modules/commerce_order_test/commerce_order_test.commerce_adjustment_types.yml
+++ b/modules/order/tests/modules/commerce_order_test/commerce_order_test.commerce_adjustment_types.yml
@@ -1,4 +1,6 @@
test_adjustment_type:
- label: Test order adjustment type
+ label: 'Test'
+ singular_label: 'test'
+ plural_label: 'tests'
has_ui: false
weight: -1
diff --git a/modules/order/tests/modules/commerce_order_test/commerce_order_test.info.yml b/modules/order/tests/modules/commerce_order_test/commerce_order_test.info.yml
index 2ba06d7772..87ce3479d8 100644
--- a/modules/order/tests/modules/commerce_order_test/commerce_order_test.info.yml
+++ b/modules/order/tests/modules/commerce_order_test/commerce_order_test.info.yml
@@ -4,6 +4,5 @@ description: Contains various non-specific things needed in order tests.
package: Testing
core: 8.x
dependencies:
- - commerce_store
- - commerce_product
- - commerce_order
+ - commerce:commerce_order
+ - commerce:commerce_product
diff --git a/modules/order/tests/modules/commerce_order_test/commerce_order_test.module b/modules/order/tests/modules/commerce_order_test/commerce_order_test.module
new file mode 100644
index 0000000000..e1dfe1e194
--- /dev/null
+++ b/modules/order/tests/modules/commerce_order_test/commerce_order_test.module
@@ -0,0 +1,22 @@
+getFormObject()->getEntity();
+ // This will error if getAdjustments() returns invalid items.
+ $order->recalculateTotalPrice();
+ \Drupal::state()->set("commerce_order_test_field_widget_form_alter", $order->getAdjustments());
+ }
+}
diff --git a/modules/order/tests/modules/commerce_order_test/commerce_order_test.services.yml b/modules/order/tests/modules/commerce_order_test/commerce_order_test.services.yml
index eebeb01418..5ffb199d7d 100644
--- a/modules/order/tests/modules/commerce_order_test/commerce_order_test.services.yml
+++ b/modules/order/tests/modules/commerce_order_test/commerce_order_test.services.yml
@@ -2,4 +2,4 @@ services:
commerce_order_test.test_adjustment_processor:
class: Drupal\commerce_order_test\TestAdjustmentProcessor
tags:
- - { name: commerce_order.order_processor, priority: 500 }
+ - { name: commerce_order.order_processor, priority: 500, adjustment_type: test_adjustment_type }
diff --git a/modules/order/tests/modules/commerce_order_test/src/TestAdjustmentProcessor.php b/modules/order/tests/modules/commerce_order_test/src/TestAdjustmentProcessor.php
index 4d0f83f595..04fc15c57e 100644
--- a/modules/order/tests/modules/commerce_order_test/src/TestAdjustmentProcessor.php
+++ b/modules/order/tests/modules/commerce_order_test/src/TestAdjustmentProcessor.php
@@ -2,8 +2,10 @@
namespace Drupal\commerce_order_test;
+use Drupal\commerce_order\Adjustment;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_order\OrderProcessorInterface;
+use Drupal\commerce_price\Price;
/**
* Adds order and order item adjustments for testing purposes.
@@ -19,6 +21,15 @@ public function process(OrderInterface $order) {
foreach ($test_adjustments as $test_adjustment) {
$order_item->addAdjustment($test_adjustment);
}
+
+ // Add adjustment for PriceCalculatorTest.
+ if ($order->getEmail() == 'user2@example.com') {
+ $order_item->addAdjustment(new Adjustment([
+ 'type' => 'test_adjustment_type',
+ 'label' => '$2.00 fee',
+ 'amount' => new Price('2.00', 'USD'),
+ ]));
+ }
}
$test_adjustments = $order->getData('test_adjustments', []);
diff --git a/modules/order/tests/src/Functional/OrderAccountTest.php b/modules/order/tests/src/Functional/OrderAccountTest.php
new file mode 100644
index 0000000000..c965e56e46
--- /dev/null
+++ b/modules/order/tests/src/Functional/OrderAccountTest.php
@@ -0,0 +1,33 @@
+createEntity('commerce_order_item', [
+ 'type' => 'product_variation',
+ ]);
+ $order = $this->createEntity('commerce_order', [
+ 'type' => 'default',
+ 'uid' => 0,
+ 'mail' => 'guest@example.com',
+ 'order_items' => [$order_item],
+ ]);
+ $this->assertEmpty($order->getOwnerId(), 'The guest order has no owner account.');
+ $user = $this->createUser([], 'guest');
+ $order = Order::load($order->id());
+ $this->assertEquals($user->id(), $order->getOwnerId(), 'New user account owns previous guest order.');
+ }
+
+}
diff --git a/modules/order/tests/src/Functional/OrderAdminTest.php b/modules/order/tests/src/Functional/OrderAdminTest.php
index 4b9451e729..94760002b3 100644
--- a/modules/order/tests/src/Functional/OrderAdminTest.php
+++ b/modules/order/tests/src/Functional/OrderAdminTest.php
@@ -62,6 +62,10 @@ public function testCreateOrder() {
$this->assertSession()->buttonExists('Create order item');
$entity = $this->variation->getSku() . ' (' . $this->variation->id() . ')';
+ // Test that commerce_order_test_field_widget_form_alter() has the expected
+ // outcome.
+ $this->assertSame([], \Drupal::state()->get("commerce_order_test_field_widget_form_alter"));
+
$checkbox = $this->getSession()->getPage()->findField('Override the unit price');
if ($checkbox) {
$checkbox->check();
@@ -94,11 +98,24 @@ public function testCreateOrder() {
'billing_profile[0][profile][address][0][address][postal_code]' => '94043',
'billing_profile[0][profile][address][0][address][locality]' => 'Mountain View',
'billing_profile[0][profile][address][0][address][administrative_area]' => 'CA',
- 'adjustments[0][type]' => 'custom',
- 'adjustments[0][definition][label]' => 'Test fee',
+ ];
+ // There is no adjustment - the order should save successfully.
+ $this->submitForm($edit, 'Save');
+ $this->assertSession()->pageTextContains('The order has been successfully saved.');
+
+ // Use an adjustment that is not locked by default.
+ $this->clickLink('Edit');
+ $edit = [
+ 'adjustments[0][type]' => 'fee',
+ 'adjustments[0][definition][label]' => '',
'adjustments[0][definition][amount][number]' => '2.00',
];
$this->submitForm($edit, 'Save');
+ $this->assertSession()->pageTextContains('The adjustment label field is required.');
+ $edit['adjustments[0][definition][label]'] = 'Test fee';
+ $this->submitForm($edit, 'Save');
+ $this->assertSession()->pageTextContains('The order has been successfully saved.');
+
$this->drupalGet('/admin/commerce/orders');
$order_number = $this->getSession()->getPage()->find('css', 'tr td.views-field-order-number');
$this->assertEquals(1, count($order_number), 'Order exists in the table.');
@@ -106,6 +123,7 @@ public function testCreateOrder() {
$order = Order::load(1);
$this->assertEquals(1, count($order->getItems()));
$this->assertEquals(new Price('5.33', 'USD'), $order->getTotalPrice());
+ $this->assertCount(1, $order->getAdjustments());
}
/**
@@ -193,18 +211,12 @@ public function testUnlockOrder() {
* Tests that an admin can view an order's details.
*/
public function testAdminOrderView() {
- $order_item = $this->createEntity('commerce_order_item', [
- 'type' => 'default',
- 'unit_price' => [
- 'number' => '999',
- 'currency_code' => 'USD',
- ],
- ]);
+ // Start from an order without any order items.
+ /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $this->createEntity('commerce_order', [
'type' => 'default',
'store_id' => $this->store->id(),
'mail' => $this->loggedInUser->getEmail(),
- 'order_items' => [$order_item],
'state' => 'draft',
'uid' => $this->loggedInUser,
]);
@@ -214,6 +226,10 @@ public function testAdminOrderView() {
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains($this->loggedInUser->getEmail());
+ // Confirm that the order item table is showing the empty text.
+ $this->assertSession()->pageTextContains('There are no order items yet.');
+ $this->assertSession()->pageTextNotContains('Subtotal');
+
// Confirm that the transition buttons are visible and functional.
$workflow = $order->getState()->getWorkflow();
$transitions = $workflow->getAllowedTransitions($order->getState()->value, $order);
@@ -224,6 +240,23 @@ public function testAdminOrderView() {
$this->assertSession()->buttonNotExists('Place order');
$this->assertSession()->buttonNotExists('Cancel order');
+ // Add an order item, confirm that it is displayed.
+ $order_item = $this->createEntity('commerce_order_item', [
+ 'type' => 'default',
+ 'unit_price' => [
+ 'number' => '999',
+ 'currency_code' => 'USD',
+ ],
+ ]);
+ $order->setItems([$order_item]);
+ $order->save();
+
+ $this->drupalGet($order->toUrl()->toString());
+ $this->assertSession()->statusCodeEquals(200);
+ $this->assertSession()->pageTextNotContains('There are no order items yet.');
+ $this->assertSession()->pageTextContains('$999.00');
+ $this->assertSession()->pageTextContains('Subtotal');
+
// Logout and check that anonymous users cannot see the order admin screen
// and receive a 403 error code.
$this->drupalLogout();
diff --git a/modules/order/tests/src/Functional/OrderItemTypeTest.php b/modules/order/tests/src/Functional/OrderItemTypeTest.php
index 8b57b90452..84db88273f 100644
--- a/modules/order/tests/src/Functional/OrderItemTypeTest.php
+++ b/modules/order/tests/src/Functional/OrderItemTypeTest.php
@@ -67,19 +67,28 @@ public function testOrderItemTypeEditing() {
* Tests deleting an order item type programmatically and through the form.
*/
public function testOrderItemTypeDeletion() {
- $values = [
+ /** @var \Drupal\commerce_order\Entity\OrderItemTypeInterface $type */
+ $type = $this->createEntity('commerce_order_item_type', [
'id' => strtolower($this->randomMachineName(8)),
'label' => $this->randomMachineName(16),
'purchasableEntityType' => 'commerce_product_variation',
'orderType' => 'default',
- ];
- $order_item_type = $this->createEntity('commerce_order_item_type', $values);
+ ]);
+
+ // Confirm that the delete page is not available when the type is locked.
+ $type->lock();
+ $type->save();
+ $this->drupalGet($type->toUrl('delete-form'));
+ $this->assertSession()->statusCodeEquals('403');
- $this->drupalGet($order_item_type->toUrl('delete-form'));
+ // Unlock the type, confirm that deletion works.
+ $type->unlock();
+ $type->save();
+ $this->drupalGet($type->toUrl('delete-form'));
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains(t('This action cannot be undone.'));
$this->submitForm([], t('Delete'));
- $order_item_type_exists = (bool) OrderItemType::load($order_item_type->id());
+ $order_item_type_exists = (bool) OrderItemType::load($type->id());
$this->assertEmpty($order_item_type_exists, 'The order item type has been deleted form the database.');
}
diff --git a/modules/order/tests/src/Functional/OrderTypeTest.php b/modules/order/tests/src/Functional/OrderTypeTest.php
index e2e24cb717..c23eb95985 100644
--- a/modules/order/tests/src/Functional/OrderTypeTest.php
+++ b/modules/order/tests/src/Functional/OrderTypeTest.php
@@ -26,6 +26,10 @@ public function testDefaultOrderType() {
* Tests creating an order Type programaticaly and through the add form.
*/
public function testCreateOrderType() {
+ // Remove the default order type to be able to test creating the
+ // order_items field anew.
+ OrderType::load('default')->delete();
+
// Create an order type programmaticaly.
$type = $this->createEntity('commerce_order_type', [
'id' => 'kitten',
@@ -48,6 +52,10 @@ public function testCreateOrderType() {
$type_exists = (bool) OrderType::load($values['id']);
$this->assertNotEmpty($type_exists, 'The new order type has been created in the database.');
+
+ // Testing the target type of the order_items field.
+ $settings = $this->config('field.storage.commerce_order.order_items')->get('settings');
+ $this->assertEquals('commerce_order_item', $settings['target_type'], t('Order item field target type is correct.'));
}
/**
@@ -69,37 +77,43 @@ public function testDraftOrderRefreshSettings() {
}
/**
- * Tests deleting an order Type programmaticaly and through the form.
+ * Tests deleting an order Type through the form.
*/
public function testDeleteOrderType() {
- // Create an order type programmaticaly.
+ /** @var \Drupal\commerce_order\Entity\OrderTypeInterface $type */
$type = $this->createEntity('commerce_order_type', [
'id' => 'foo',
'label' => 'Label for foo',
'workflow' => 'order_default',
]);
commerce_order_add_order_items_field($type);
-
- // Create an order.
$order = $this->createEntity('commerce_order', [
'type' => $type->id(),
'mail' => $this->loggedInUser->getEmail(),
'store_id' => $this->store,
]);
- // Try to delete the order type.
- $this->drupalGet('admin/commerce/config/order-types/' . $type->id() . '/delete');
+ // Confirm that the type can't be deleted while there's an order.
+ $this->drupalGet($type->toUrl('delete-form'));
$this->assertSession()->pageTextContains(t('@type is used by 1 order on your site. You cannot remove this order type until you have removed all of the @type orders.', ['@type' => $type->label()]));
$this->assertSession()->pageTextNotContains(t('This action cannot be undone.'));
- // Deleting the order type when its not being referenced by an order.
+ // Confirm that the delete page is not available when the type is locked.
+ $type->lock();
+ $type->save();
+ $this->drupalGet($type->toUrl('delete-form'));
+ $this->assertSession()->statusCodeEquals('403');
+
+ // Delete the order, unlock the type, confirm that deletion works.
$order->delete();
- $this->drupalGet('admin/commerce/config/order-types/' . $type->id() . '/delete');
+ $type->unlock();
+ $type->save();
+ $this->drupalGet($type->toUrl('delete-form'));
$this->assertSession()->pageTextContains(t('Are you sure you want to delete the order type @label?', ['@label' => $type->label()]));
$this->assertSession()->pageTextContains(t('This action cannot be undone.'));
$this->submitForm([], t('Delete'));
$type_exists = (bool) OrderType::load($type->id());
- $this->assertEmpty($type_exists, 'The order type has been deleted from the database.');
+ $this->assertEmpty($type_exists);
}
}
diff --git a/modules/order/tests/src/Kernel/AdjustmentTest.php b/modules/order/tests/src/Kernel/AdjustmentTest.php
index c65ab15058..b1eaeb7221 100644
--- a/modules/order/tests/src/Kernel/AdjustmentTest.php
+++ b/modules/order/tests/src/Kernel/AdjustmentTest.php
@@ -107,7 +107,7 @@ public function testValidAdjustmentConstruct() {
}
/**
- * Tests methods on the adjustment object.
+ * Tests getters.
*
* @covers ::getType
* @covers ::getLabel
@@ -118,8 +118,9 @@ public function testValidAdjustmentConstruct() {
* @covers ::getSourceId
* @covers ::isIncluded
* @covers ::isLocked
+ * @covers ::toArray
*/
- public function testAdjustmentMethods() {
+ public function testGetters() {
$definition = [
'type' => 'custom',
'label' => '10% off',
@@ -141,6 +142,113 @@ public function testAdjustmentMethods() {
$this->assertEquals('1', $adjustment->getSourceId());
$this->assertTrue($adjustment->isIncluded());
$this->assertTrue($adjustment->isLocked());
+ $this->assertEquals($definition, $adjustment->toArray());
+ }
+
+ /**
+ * Tests the arithmetic operators.
+ *
+ * @covers ::add
+ * @covers ::subtract
+ * @covers ::multiply
+ * @covers ::divide
+ */
+ public function testArithmetic() {
+ $first_adjustment = new Adjustment([
+ 'type' => 'custom',
+ 'amount' => new Price('2.00', 'USD'),
+ 'label' => '10% off',
+ 'percentage' => '0.1',
+ 'source_id' => '1',
+ 'included' => TRUE,
+ 'locked' => TRUE,
+ ]);
+ $second_adjustment = new Adjustment([
+ 'type' => 'custom',
+ 'amount' => new Price('3.00', 'USD'),
+ 'label' => '10% off',
+ 'percentage' => '0.1',
+ 'source_id' => '1',
+ 'included' => TRUE,
+ 'locked' => TRUE,
+ ]);
+ $third_adjustment = new Adjustment([
+ 'type' => 'custom',
+ 'amount' => new Price('5.00', 'USD'),
+ 'label' => '10% off',
+ 'percentage' => '0.1',
+ 'source_id' => '1',
+ 'included' => TRUE,
+ 'locked' => TRUE,
+ ]);
+ $fourth_adjustment = new Adjustment([
+ 'type' => 'custom',
+ 'amount' => new Price('6.00', 'USD'),
+ 'label' => '10% off',
+ 'percentage' => '0.1',
+ 'source_id' => '1',
+ 'included' => TRUE,
+ 'locked' => TRUE,
+ ]);
+
+ $this->assertEquals($third_adjustment, $first_adjustment->add($second_adjustment));
+ $this->assertEquals($second_adjustment, $third_adjustment->subtract($first_adjustment));
+ $this->assertEquals($fourth_adjustment, $second_adjustment->multiply('2'));
+ $this->assertEquals($first_adjustment, $fourth_adjustment->divide('3'));
+ }
+
+ /**
+ * @covers ::add
+ */
+ public function testMismatchedTypes() {
+ $first_adjustment = new Adjustment([
+ 'type' => 'custom',
+ 'amount' => new Price('2.00', 'USD'),
+ 'label' => '10% off',
+ 'percentage' => '0.1',
+ 'source_id' => '1',
+ 'included' => TRUE,
+ 'locked' => TRUE,
+ ]);
+ $second_adjustment = new Adjustment([
+ 'type' => 'promotion',
+ 'amount' => new Price('3.00', 'USD'),
+ 'label' => '10% off',
+ 'percentage' => '0.1',
+ 'source_id' => '1',
+ 'included' => TRUE,
+ 'locked' => TRUE,
+ ]);
+
+ $this->setExpectedException(\InvalidArgumentException::class, 'Adjustment type "promotion" does not match "custom".');
+ $first_adjustment->add($second_adjustment);
+ }
+
+ /**
+ * @covers ::add
+ */
+ public function testMismatchedSourceIds() {
+ $first_adjustment = new Adjustment([
+ 'type' => 'custom',
+ 'amount' => new Price('2.00', 'USD'),
+ 'label' => '10% off',
+ 'percentage' => '0.1',
+ 'source_id' => '1',
+ 'included' => TRUE,
+ 'locked' => TRUE,
+ ]);
+ $second_adjustment = new Adjustment([
+ 'type' => 'custom',
+ 'amount' => new Price('3.00', 'USD'),
+ 'label' => '10% off',
+ 'percentage' => '0.1',
+ 'source_id' => '2',
+ 'included' => TRUE,
+ 'locked' => TRUE,
+ ]);
+
+ $this->setExpectedException(\InvalidArgumentException::class, 'Adjustment source ID "2" does not match "1".');
+ $first_adjustment->add($second_adjustment);
}
}
diff --git a/modules/order/tests/src/Kernel/AdjustmentTransformerTest.php b/modules/order/tests/src/Kernel/AdjustmentTransformerTest.php
new file mode 100644
index 0000000000..aa18335988
--- /dev/null
+++ b/modules/order/tests/src/Kernel/AdjustmentTransformerTest.php
@@ -0,0 +1,184 @@
+adjustmentTransformer = $this->container->get('commerce_order.adjustment_transformer');
+ }
+
+ /**
+ * Tests adjustment combining.
+ *
+ * @covers ::combineAdjustments
+ */
+ public function testCombining() {
+ $adjustments = [];
+ // Adjustments 0 and 2 are supposed to be combined.
+ $adjustments[0] = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'VAT',
+ 'amount' => new Price('10', 'USD'),
+ 'source_id' => 'us_vat|default|standard',
+ 'percentage' => '0.1',
+ ]);
+ $adjustments[1] = new Adjustment([
+ 'type' => 'promotion',
+ 'label' => '20% off',
+ 'amount' => new Price('20', 'USD'),
+ 'percentage' => '0.2',
+ ]);
+ $adjustments[2] = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'VAT',
+ 'amount' => new Price('3', 'USD'),
+ 'source_id' => 'us_vat|default|standard',
+ 'percentage' => '0.1',
+ ]);
+ $adjustments[3] = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'VAT',
+ 'amount' => new Price('4', 'USD'),
+ 'source_id' => 'us_vat|default|reduced',
+ 'percentage' => '0.1',
+ ]);
+ $combined_adjustments = [];
+ $combined_adjustments[0] = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'VAT',
+ 'amount' => new Price('13', 'USD'),
+ 'source_id' => 'us_vat|default|standard',
+ 'percentage' => '0.1',
+ ]);
+ $combined_adjustments[1] = new Adjustment([
+ 'type' => 'promotion',
+ 'label' => '20% off',
+ 'amount' => new Price('20', 'USD'),
+ 'percentage' => '0.2',
+ ]);
+ $combined_adjustments[2] = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'VAT',
+ 'amount' => new Price('4', 'USD'),
+ 'source_id' => 'us_vat|default|reduced',
+ 'percentage' => '0.1',
+ ]);
+
+ $result = $this->adjustmentTransformer->combineAdjustments($adjustments);
+ $this->assertCount(3, $result);
+ $this->assertEquals($combined_adjustments, $result);
+ }
+
+ /**
+ * Tests adjustment sorting.
+ *
+ * @covers ::sortAdjustments
+ */
+ public function testSorting() {
+ $first_adjustment = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'VAT',
+ 'amount' => new Price('10', 'USD'),
+ 'percentage' => '0.1',
+ ]);
+ $second_adjustment = new Adjustment([
+ 'type' => 'promotion',
+ 'label' => '20% off',
+ 'amount' => new Price('20', 'USD'),
+ 'percentage' => '0.2',
+ ]);
+
+ $adjustments = $this->adjustmentTransformer->sortAdjustments([$first_adjustment, $second_adjustment]);
+ $this->assertEquals([$second_adjustment, $first_adjustment], $adjustments);
+ }
+
+ /**
+ * Tests adjustment rounding.
+ *
+ * @covers ::roundAdjustments
+ * @covers ::roundAdjustment
+ */
+ public function testRounding() {
+ $first_adjustment = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'VAT',
+ 'amount' => new Price('10.489', 'USD'),
+ 'percentage' => '0.1',
+ ]);
+ $second_adjustment = new Adjustment([
+ 'type' => 'promotion',
+ 'label' => '20% off',
+ 'amount' => new Price('20.555', 'USD'),
+ ]);
+ $first_rounded_adjustment = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'VAT',
+ 'amount' => new Price('10.49', 'USD'),
+ 'percentage' => '0.1',
+ ]);
+ $second_rounded_adjustment = new Adjustment([
+ 'type' => 'promotion',
+ 'label' => '20% off',
+ 'amount' => new Price('20.56', 'USD'),
+ ]);
+ $second_rounded_down_adjustment = new Adjustment([
+ 'type' => 'promotion',
+ 'label' => '20% off',
+ 'amount' => new Price('20.55', 'USD'),
+ ]);
+
+ $adjustments = $this->adjustmentTransformer->roundAdjustments([$first_adjustment, $second_adjustment]);
+ $this->assertEquals([$first_rounded_adjustment, $second_rounded_adjustment], $adjustments);
+
+ $adjustment = $this->adjustmentTransformer->roundAdjustment($first_adjustment);
+ $this->assertEquals($first_rounded_adjustment, $adjustment);
+
+ $adjustment = $this->adjustmentTransformer->roundAdjustment($second_adjustment);
+ $this->assertEquals($second_rounded_adjustment, $adjustment);
+
+ // Confirm that the $mode is passed along.
+ $adjustments = $this->adjustmentTransformer->roundAdjustments([$second_adjustment], PHP_ROUND_HALF_DOWN);
+ $this->assertEquals([$second_rounded_down_adjustment], $adjustments);
+
+ $adjustment = $this->adjustmentTransformer->roundAdjustment($second_adjustment, PHP_ROUND_HALF_DOWN);
+ $this->assertEquals($second_rounded_down_adjustment, $adjustment);
+ }
+
+}
diff --git a/modules/order/tests/src/Kernel/Entity/OrderTest.php b/modules/order/tests/src/Kernel/Entity/OrderTest.php
index a7bff3d94b..c89e2762b0 100644
--- a/modules/order/tests/src/Kernel/Entity/OrderTest.php
+++ b/modules/order/tests/src/Kernel/Entity/OrderTest.php
@@ -181,8 +181,8 @@ public function testOrder() {
$order->addItem($another_order_item);
$this->assertEquals([$order_item, $another_order_item], $order->getItems());
$this->assertNotEmpty($order->hasItem($another_order_item));
-
$this->assertEquals(new Price('8.00', 'USD'), $order->getTotalPrice());
+
$adjustments = [];
$adjustments[] = new Adjustment([
'type' => 'custom',
@@ -195,68 +195,28 @@ public function testOrder() {
'amount' => new Price('10.00', 'USD'),
'locked' => TRUE,
]);
- // Included adjustments do not affect the order total.
- $adjustments[] = new Adjustment([
- 'type' => 'tax',
- 'label' => 'Tax',
- 'amount' => new Price('12.00', 'USD'),
- 'included' => TRUE,
- ]);
$order->addAdjustment($adjustments[0]);
$order->addAdjustment($adjustments[1]);
- $order->addAdjustment($adjustments[2]);
$this->assertEquals($adjustments, $order->getAdjustments());
$collected_adjustments = $order->collectAdjustments();
$this->assertEquals($adjustments[0]->getAmount(), $collected_adjustments[0]->getAmount());
$this->assertEquals($adjustments[1]->getAmount(), $collected_adjustments[1]->getAmount());
- $this->assertEquals($adjustments[2]->getAmount(), $collected_adjustments[2]->getAmount());
$order->removeAdjustment($adjustments[0]);
$this->assertEquals(new Price('8.00', 'USD'), $order->getSubtotalPrice());
$this->assertEquals(new Price('18.00', 'USD'), $order->getTotalPrice());
- $this->assertEquals([$adjustments[1], $adjustments[2]], $order->getAdjustments());
+ $this->assertEquals([$adjustments[1]], $order->getAdjustments());
$order->setAdjustments($adjustments);
$this->assertEquals($adjustments, $order->getAdjustments());
$this->assertEquals(new Price('17.00', 'USD'), $order->getTotalPrice());
- // Add an adjustment to the second order item, confirm it's a part of the
- // order total, multiplied by quantity.
- $order->removeItem($another_order_item);
- $order_item_adjustments = [];
- $order_item_adjustments[] = new Adjustment([
- 'type' => 'fee',
- 'label' => 'Random fee',
- 'amount' => new Price('5.00', 'USD'),
- ]);
- $order_item_adjustments[] = new Adjustment([
- 'type' => 'fee',
- 'label' => 'Non-random fee',
- 'amount' => new Price('7.00', 'USD'),
- 'locked' => TRUE,
- ]);
- $multiplied_order_item_adjustments = [];
- $multiplied_order_item_adjustments[] = new Adjustment([
+ // Confirm that locked adjustments persist after clear.
+ // Custom adjustments are locked by default.
+ $order->addAdjustment(new Adjustment([
'type' => 'fee',
'label' => 'Random fee',
'amount' => new Price('10.00', 'USD'),
- ]);
- $multiplied_order_item_adjustments[] = new Adjustment([
- 'type' => 'fee',
- 'label' => 'Non-random fee',
- 'amount' => new Price('14.00', 'USD'),
- 'locked' => TRUE,
- ]);
- $another_order_item->setAdjustments($order_item_adjustments);
- $order->addItem($another_order_item);
- $this->assertEquals(new Price('41.00', 'USD'), $order->getTotalPrice());
- $collected_adjustments = $order->collectAdjustments();
- $this->assertEquals($multiplied_order_item_adjustments[0], $collected_adjustments[0]);
- $this->assertEquals($multiplied_order_item_adjustments[1], $collected_adjustments[1]);
- // Confirm that locked adjustments persist after clear.
- // Custom adjustments are locked by default.
- $order->setAdjustments($adjustments);
+ ]));
$order->clearAdjustments();
- unset($adjustments[2]);
- unset($multiplied_order_item_adjustments[0]);
- $this->assertEquals(array_merge($multiplied_order_item_adjustments, $adjustments), $order->collectAdjustments());
+ $this->assertEquals($adjustments, $order->getAdjustments());
$this->assertEquals('completed', $order->getState()->value);
@@ -283,6 +243,72 @@ public function testOrder() {
$this->assertEquals(635879900, $order->getCompletedTime());
}
+ /**
+ * Tests the order total recalculation logic.
+ *
+ * @covers ::recalculateTotalPrice
+ */
+ public function testTotalCalculation() {
+ $order = Order::create([
+ 'type' => 'default',
+ 'state' => 'completed',
+ ]);
+ $order->save();
+
+ /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */
+ $order_item = OrderItem::create([
+ 'type' => 'test',
+ 'quantity' => '2',
+ 'unit_price' => new Price('2.00', 'USD'),
+ ]);
+ $order_item->save();
+ $order_item = $this->reloadEntity($order_item);
+ /** @var \Drupal\commerce_order\Entity\OrderItemInterface $another_order_item */
+ $another_order_item = OrderItem::create([
+ 'type' => 'test',
+ 'quantity' => '1',
+ 'unit_price' => new Price('3.00', 'USD'),
+ ]);
+ $another_order_item->save();
+ $another_order_item = $this->reloadEntity($another_order_item);
+
+ $adjustments = [];
+ $adjustments[0] = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'Tax',
+ 'amount' => new Price('100.00', 'USD'),
+ 'included' => TRUE,
+ ]);
+ $adjustments[1] = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'Tax',
+ 'amount' => new Price('2.121', 'USD'),
+ 'source_id' => 'us_sales_tax',
+ ]);
+ $adjustments[2] = new Adjustment([
+ 'type' => 'tax',
+ 'label' => 'Tax',
+ 'amount' => new Price('5.344', 'USD'),
+ 'source_id' => 'us_sales_tax',
+ ]);
+
+ // Included adjustments do not affect the order total.
+ $order->addAdjustment($adjustments[0]);
+ // Order item adjustments are multiplied by quantity.
+ $order_item->addAdjustment($adjustments[1]);
+ $another_order_item->addAdjustment($adjustments[2]);
+ $order->setItems([$order_item, $another_order_item]);
+
+ $collected_adjustments = $order->collectAdjustments();
+ $this->assertCount(3, $collected_adjustments);
+ $this->assertEquals($adjustments[1]->multiply('2'), $collected_adjustments[0]);
+ $this->assertEquals($adjustments[2], $collected_adjustments[1]);
+ $this->assertEquals($adjustments[0], $collected_adjustments[2]);
+ // The total will be correct only if the adjustments were correctly
+ // multiplied, combined, and rounded.
+ $this->assertEquals(new Price('16.59', 'USD'), $order->getTotalPrice());
+ }
+
/**
* Tests the order with order items using different currencies.
*
diff --git a/modules/order/tests/src/Kernel/Formatter/PriceCalculatedFormatterTest.php b/modules/order/tests/src/Kernel/Formatter/PriceCalculatedFormatterTest.php
new file mode 100644
index 0000000000..bb19bcb492
--- /dev/null
+++ b/modules/order/tests/src/Kernel/Formatter/PriceCalculatedFormatterTest.php
@@ -0,0 +1,198 @@
+installEntitySchema('profile');
+ $this->installEntitySchema('commerce_order');
+ $this->installEntitySchema('commerce_order_item');
+ $this->installEntitySchema('commerce_product');
+ $this->installEntitySchema('commerce_product_variation');
+ $this->installEntitySchema('commerce_promotion');
+ $this->installConfig(['commerce_product', 'commerce_order']);
+
+ $promotion = Promotion::create([
+ 'name' => 'Promotion 1',
+ 'order_types' => ['default'],
+ 'stores' => [$this->store->id()],
+ 'status' => TRUE,
+ 'offer' => [
+ 'target_plugin_id' => 'order_item_percentage_off',
+ 'target_plugin_configuration' => [
+ 'percentage' => '0.5',
+ ],
+ ],
+ ]);
+ $promotion->save();
+
+ // The default store is US-WI, so imagine that the US has VAT.
+ TaxType::create([
+ 'id' => 'us_vat',
+ 'label' => 'US VAT',
+ 'plugin' => 'custom',
+ 'configuration' => [
+ 'display_inclusive' => TRUE,
+ 'rates' => [
+ [
+ 'id' => 'standard',
+ 'label' => 'Standard',
+ 'percentage' => '0.2',
+ ],
+ ],
+ 'territories' => [
+ ['country_code' => 'US', 'administrative_area' => 'WI'],
+ ['country_code' => 'US', 'administrative_area' => 'SC'],
+ ],
+ ],
+ ])->save();
+
+ $first_variation = ProductVariation::create([
+ 'type' => 'default',
+ 'sku' => 'TEST_CALCULATED_PRICE',
+ 'status' => 1,
+ 'price' => new Price('3.00', 'USD'),
+ ]);
+ $first_variation->save();
+
+ $second_variation = ProductVariation::create([
+ 'type' => 'default',
+ 'sku' => 'TEST_CALCULATED_PRICE2',
+ 'status' => 1,
+ 'price' => new Price('4.00', 'USD'),
+ ]);
+ $second_variation->save();
+
+ $product = Product::create([
+ 'type' => 'default',
+ 'title' => 'Default testing product',
+ 'stores' => [$this->store->id()],
+ 'variations' => [$first_variation, $second_variation],
+ ]);
+ $product->save();
+
+ $this->firstVariation = $this->reloadEntity($first_variation);
+ $this->secondVariation = $this->reloadEntity($second_variation);
+
+ $user = $this->createUser(['mail' => 'user1@example.com']);
+ $this->container->get('current_user')->setAccount($user);
+
+ $this->viewBuilder = $this->container->get('entity_type.manager')->getViewBuilder('commerce_product_variation');
+ }
+
+ /**
+ * Tests the rendered output.
+ */
+ public function testRender() {
+ $variation_display = commerce_get_entity_display('commerce_product_variation', 'default', 'view');
+ $variation_display->setComponent('price', [
+ 'label' => 'above',
+ 'type' => 'commerce_price_calculated',
+ 'settings' => [],
+ ]);
+ $variation_display->save();
+
+ $variation_build = $this->viewBuilder->view($this->firstVariation);
+ $this->render($variation_build);
+ $this->assertEscaped('$3.00');
+
+ $variation_build = $this->viewBuilder->view($this->secondVariation);
+ $this->render($variation_build);
+ $this->assertEscaped('$4.00');
+
+ $variation_display->setComponent('price', [
+ 'label' => 'above',
+ 'type' => 'commerce_price_calculated',
+ 'settings' => [
+ 'adjustment_types' => [
+ 'tax' => 'tax',
+ ],
+ ],
+ ]);
+ $variation_display->save();
+
+ $variation_build = $this->viewBuilder->view($this->firstVariation);
+ $this->render($variation_build);
+ $this->assertEscaped('$3.60');
+
+ $variation_build = $this->viewBuilder->view($this->secondVariation);
+ $this->render($variation_build);
+ $this->assertEscaped('$4.80');
+
+ $variation_display->setComponent('price', [
+ 'label' => 'above',
+ 'type' => 'commerce_price_calculated',
+ 'settings' => [
+ 'adjustment_types' => [
+ 'tax' => 'tax',
+ 'promotion' => 'promotion',
+ ],
+ ],
+ ]);
+ $variation_display->save();
+
+ $variation_build = $this->viewBuilder->view($this->firstVariation);
+ $this->render($variation_build);
+ $this->assertEscaped('$1.80');
+
+ $variation_build = $this->viewBuilder->view($this->secondVariation);
+ $this->render($variation_build);
+ $this->assertEscaped('$2.40');
+ }
+
+}
diff --git a/modules/order/tests/src/Kernel/OrderTotalSummaryTest.php b/modules/order/tests/src/Kernel/OrderTotalSummaryTest.php
index 68b7dcb318..987f884209 100644
--- a/modules/order/tests/src/Kernel/OrderTotalSummaryTest.php
+++ b/modules/order/tests/src/Kernel/OrderTotalSummaryTest.php
@@ -144,9 +144,8 @@ public function testWithOrderAdjustments() {
$first = array_shift($totals['adjustments']);
$this->assertEquals('promotion', $first['type']);
$this->assertEquals('Back to school discount', $first['label']);
- $this->assertEquals(new Price('-5', 'USD'), $first['total']);
+ $this->assertEquals(new Price('-5', 'USD'), $first['amount']);
$this->assertEquals('0.1', $first['percentage']);
- $this->assertEquals(0, $first['weight']);
}
/**
@@ -181,9 +180,8 @@ public function testWithOrderItemAdjustments() {
$first = array_shift($totals['adjustments']);
$this->assertEquals('promotion', $first['type']);
$this->assertEquals('Back to school discount', $first['label']);
- $this->assertEquals(new Price('-1', 'USD'), $first['total']);
+ $this->assertEquals(new Price('-1', 'USD'), $first['amount']);
$this->assertEquals('0.1', $first['percentage']);
- $this->assertEquals(0, $first['weight']);
}
/**
@@ -240,23 +238,20 @@ public function testWithAllAdjustments() {
$first = array_shift($totals['adjustments']);
$this->assertEquals('test_adjustment_type', $first['type']);
$this->assertEquals('50 cent item fee', $first['label']);
- $this->assertEquals(new Price('1', 'USD'), $first['total']);
+ $this->assertEquals(new Price('1', 'USD'), $first['amount']);
$this->assertNull($first['percentage']);
- $this->assertEquals(-1, $first['weight']);
$second = array_shift($totals['adjustments']);
$this->assertEquals('promotion', $second['type']);
$this->assertEquals('Back to school discount', $second['label']);
- $this->assertEquals(new Price('-7', 'USD'), $second['total']);
+ $this->assertEquals(new Price('-7', 'USD'), $second['amount']);
$this->assertEquals('0.1', $second['percentage']);
- $this->assertEquals(0, $second['weight']);
$third = array_shift($totals['adjustments']);
$this->assertEquals('custom', $third['type']);
$this->assertEquals('Handling fee', $third['label']);
- $this->assertEquals(new Price('10', 'USD'), $third['total']);
+ $this->assertEquals(new Price('10', 'USD'), $third['amount']);
$this->assertNull($third['percentage']);
- $this->assertEquals(10, $third['weight']);
}
}
diff --git a/modules/order/tests/src/Kernel/PriceCalculatorTest.php b/modules/order/tests/src/Kernel/PriceCalculatorTest.php
new file mode 100644
index 0000000000..1f5e14fa88
--- /dev/null
+++ b/modules/order/tests/src/Kernel/PriceCalculatorTest.php
@@ -0,0 +1,232 @@
+installEntitySchema('profile');
+ $this->installEntitySchema('commerce_order');
+ $this->installEntitySchema('commerce_order_item');
+ $this->installEntitySchema('commerce_product');
+ $this->installEntitySchema('commerce_product_variation');
+ $this->installEntitySchema('commerce_promotion');
+ $this->installConfig(['commerce_product', 'commerce_order']);
+
+ $promotion = Promotion::create([
+ 'name' => 'Promotion 1',
+ 'order_types' => ['default'],
+ 'stores' => [$this->store->id()],
+ 'status' => TRUE,
+ 'offer' => [
+ 'target_plugin_id' => 'order_item_percentage_off',
+ 'target_plugin_configuration' => [
+ 'percentage' => '0.5',
+ ],
+ ],
+ ]);
+ $promotion->save();
+
+ // The default store is US-WI, so imagine that the US has VAT.
+ TaxType::create([
+ 'id' => 'us_vat',
+ 'label' => 'US VAT',
+ 'plugin' => 'custom',
+ 'configuration' => [
+ 'display_inclusive' => TRUE,
+ 'rates' => [
+ [
+ 'id' => 'standard',
+ 'label' => 'Standard',
+ 'percentage' => '0.2',
+ ],
+ ],
+ 'territories' => [
+ ['country_code' => 'US', 'administrative_area' => 'WI'],
+ ['country_code' => 'US', 'administrative_area' => 'SC'],
+ ],
+ ],
+ ])->save();
+
+ $first_variation = ProductVariation::create([
+ 'type' => 'default',
+ 'sku' => 'TEST_CALCULATED_PRICE',
+ 'status' => 1,
+ 'price' => new Price('3.00', 'USD'),
+ ]);
+ $first_variation->save();
+
+ $second_variation = ProductVariation::create([
+ 'type' => 'default',
+ 'sku' => 'TEST_CALCULATED_PRICE2',
+ 'status' => 1,
+ 'price' => new Price('4.00', 'USD'),
+ ]);
+ $second_variation->save();
+
+ $product = Product::create([
+ 'type' => 'default',
+ 'title' => 'Default testing product',
+ 'stores' => [$this->store->id()],
+ 'variations' => [$first_variation, $second_variation],
+ ]);
+ $product->save();
+
+ $this->firstVariation = $this->reloadEntity($first_variation);
+ $this->secondVariation = $this->reloadEntity($second_variation);
+
+ $this->firstUser = $this->createUser(['mail' => 'user1@example.com']);
+ $this->secondUser = $this->createUser(['mail' => 'user2@example.com']);
+
+ $this->priceCalculator = $this->container->get('commerce_order.price_calculator');
+ }
+
+ /**
+ * Tests the calculator.
+ *
+ * @covers ::calculate
+ */
+ public function testCalculation() {
+ $first_context = new Context($this->firstUser, $this->store);
+ $second_context = new Context($this->secondUser, $this->store);
+
+ // No adjustment types specified.
+ $result = $this->priceCalculator->calculate($this->firstVariation, 1, $first_context);
+ $this->assertEquals(new Price('3.00', 'USD'), $result->getCalculatedPrice());
+ $this->assertEquals(new Price('3.00', 'USD'), $result->getBasePrice());
+ $this->assertEquals([], $result->getAdjustments());
+
+ // Unknown adjustment type specified.
+ $result = $this->priceCalculator->calculate($this->secondVariation, 1, $first_context, ['invalid']);
+ $this->assertEquals(new Price('4.00', 'USD'), $result->getCalculatedPrice());
+ $this->assertEquals(new Price('4.00', 'USD'), $result->getBasePrice());
+ $this->assertEquals([], $result->getAdjustments());
+
+ // Only tax.
+ $result = $this->priceCalculator->calculate($this->firstVariation, 1, $first_context, ['tax']);
+ $this->assertEquals(new Price('3.60', 'USD'), $result->getCalculatedPrice());
+ $this->assertEquals(new Price('3.00', 'USD'), $result->getBasePrice());
+ $this->assertCount(1, $result->getAdjustments());
+ $adjustments = $result->getAdjustments();
+ $first_adjustment = reset($adjustments);
+ $this->assertEquals('tax', $first_adjustment->getType());
+ $this->assertEquals(new Price('0.60', 'USD'), $first_adjustment->getAmount());
+
+ $result = $this->priceCalculator->calculate($this->secondVariation, 1, $first_context, ['tax']);
+ $this->assertEquals(new Price('4.80', 'USD'), $result->getCalculatedPrice());
+ $this->assertEquals(new Price('4.00', 'USD'), $result->getBasePrice());
+ $this->assertCount(1, $result->getAdjustments());
+ $adjustments = $result->getAdjustments();
+ $first_adjustment = reset($adjustments);
+ $this->assertEquals('tax', $first_adjustment->getType());
+ $this->assertEquals(new Price('0.80', 'USD'), $first_adjustment->getAmount());
+
+ // Tax and promotions.
+ $result = $this->priceCalculator->calculate($this->firstVariation, 1, $first_context, ['tax', 'promotion']);
+ $this->assertEquals(new Price('1.80', 'USD'), $result->getCalculatedPrice());
+ $this->assertEquals(new Price('3.00', 'USD'), $result->getBasePrice());
+ $this->assertCount(2, $result->getAdjustments());
+ $adjustments = $result->getAdjustments();
+ $first_adjustment = reset($adjustments);
+ $this->assertEquals('promotion', $first_adjustment->getType());
+ $this->assertEquals(new Price('-1.80', 'USD'), $first_adjustment->getAmount());
+ $second_adjustment = end($adjustments);
+ $this->assertEquals('tax', $second_adjustment->getType());
+ $this->assertEquals(new Price('0.60', 'USD'), $second_adjustment->getAmount());
+
+ $result = $this->priceCalculator->calculate($this->secondVariation, 1, $first_context, ['tax', 'promotion']);
+ $this->assertEquals(new Price('2.40', 'USD'), $result->getCalculatedPrice());
+ $this->assertEquals(new Price('4.00', 'USD'), $result->getBasePrice());
+ $this->assertCount(2, $result->getAdjustments());
+ $adjustments = $result->getAdjustments();
+ $first_adjustment = reset($adjustments);
+ $this->assertEquals('promotion', $first_adjustment->getType());
+ $this->assertEquals(new Price('-2.40', 'USD'), $first_adjustment->getAmount());
+ $second_adjustment = end($adjustments);
+ $this->assertEquals('tax', $second_adjustment->getType());
+ $this->assertEquals(new Price('0.80', 'USD'), $second_adjustment->getAmount());
+
+ // User-specific adjustment added by TestAdjustmentProcessor.
+ $result = $this->priceCalculator->calculate($this->secondVariation, 1, $second_context, ['test_adjustment_type']);
+ $this->assertEquals(new Price('6.00', 'USD'), $result->getCalculatedPrice());
+ $this->assertEquals(new Price('4.00', 'USD'), $result->getBasePrice());
+ $this->assertCount(1, $result->getAdjustments());
+ $adjustments = $result->getAdjustments();
+ $first_adjustment = reset($adjustments);
+ $this->assertEquals('test_adjustment_type', $first_adjustment->getType());
+ $this->assertEquals(new Price('2.00', 'USD'), $first_adjustment->getAmount());
+ }
+
+}
diff --git a/modules/order/tests/src/Kernel/TimestampEventTest.php b/modules/order/tests/src/Kernel/TimestampEventTest.php
new file mode 100644
index 0000000000..ffbf4d229d
--- /dev/null
+++ b/modules/order/tests/src/Kernel/TimestampEventTest.php
@@ -0,0 +1,77 @@
+installEntitySchema('profile');
+ $this->installEntitySchema('commerce_order');
+ $this->installEntitySchema('commerce_order_item');
+ $this->installEntitySchema('commerce_product');
+ $this->installEntitySchema('commerce_product_variation');
+ $this->installConfig(['commerce_product', 'commerce_order']);
+ }
+
+ /**
+ * Tests setting the order number.
+ */
+ public function testOnPlaceTransition() {
+ $user = $this->createUser(['mail' => $this->randomString() . '@example.com']);
+
+ $order_item = OrderItem::create([
+ 'type' => 'default',
+ 'quantity' => 1,
+ 'unit_price' => new Price('12.00', 'USD'),
+ ]);
+ $order_item->save();
+
+ /** @var \Drupal\commerce_order\Entity\Order $order */
+ $order = Order::create([
+ 'type' => 'default',
+ 'state' => 'draft',
+ 'mail' => 'text@example.com',
+ 'uid' => $user->id(),
+ 'ip_address' => '127.0.0.1',
+ 'store_id' => $this->store->id(),
+ 'order_items' => [$order_item],
+ ]);
+ $order->save();
+
+ $transition = $order->getState()->getTransitions();
+ $order->getState()->applyTransition($transition['place']);
+ $order->save();
+ $this->assertEquals(\Drupal::time()->getRequestTime(), $order->getPlacedTime(), 'During placement transition, the order placed timestamp is set to the request time.');
+ }
+
+}
diff --git a/modules/payment/commerce_payment.info.yml b/modules/payment/commerce_payment.info.yml
index 3c6613bce7..6707ff6e86 100644
--- a/modules/payment/commerce_payment.info.yml
+++ b/modules/payment/commerce_payment.info.yml
@@ -4,10 +4,10 @@ description: 'Provides payment functionality.'
package: Commerce
core: 8.x
dependencies:
+ - commerce:commerce
- commerce:commerce_order
- - entity_reference_revisions
- - filter
- - user
+ - entity_reference_revisions:entity_reference_revisions
+ - drupal:filter
config_devel:
install:
- field.storage.user.commerce_remote_id
diff --git a/modules/payment/commerce_payment.module b/modules/payment/commerce_payment.module
index 66c9f62432..095c6edb72 100644
--- a/modules/payment/commerce_payment.module
+++ b/modules/payment/commerce_payment.module
@@ -105,7 +105,6 @@ function template_preprocess_commerce_payment_method(array &$variables) {
$payment_method = $variables['elements']['#commerce_payment_method'];
$variables['payment_method_entity'] = $payment_method;
- $variables['payment_method_url'] = $payment_method->toUrl();
$variables['payment_method'] = [
// The label is generated dynamically, so it's not present in 'elements'.
'label' => [
diff --git a/modules/payment/commerce_payment.routing.yml b/modules/payment/commerce_payment.routing.yml
index 57327676f5..560d89497f 100644
--- a/modules/payment/commerce_payment.routing.yml
+++ b/modules/payment/commerce_payment.routing.yml
@@ -49,7 +49,7 @@ entity.commerce_payment_method.add_form:
_form: '\Drupal\commerce_payment\Form\PaymentMethodAddForm'
_title: 'Add payment method'
requirements:
- _custom_access: '\Drupal\commerce_payment\PaymentMethodAccessCheck::checkAccess'
+ _custom_access: '\Drupal\commerce_payment\Access\PaymentMethodAccessCheck::checkAccess'
options:
parameters:
user:
@@ -61,7 +61,7 @@ entity.commerce_payment_method.collection:
_entity_list: 'commerce_payment_method'
_title: 'Payment methods'
requirements:
- _custom_access: '\Drupal\commerce_payment\PaymentMethodAccessCheck::checkAccess'
+ _custom_access: '\Drupal\commerce_payment\Access\PaymentMethodAccessCheck::checkAccess'
options:
parameters:
user:
diff --git a/modules/payment/src/PaymentMethodAccessCheck.php b/modules/payment/src/Access/PaymentMethodAccessCheck.php
similarity index 96%
rename from modules/payment/src/PaymentMethodAccessCheck.php
rename to modules/payment/src/Access/PaymentMethodAccessCheck.php
index bf46d91248..a585c46547 100644
--- a/modules/payment/src/PaymentMethodAccessCheck.php
+++ b/modules/payment/src/Access/PaymentMethodAccessCheck.php
@@ -1,6 +1,6 @@
checkoutOrderManager = $checkout_order_manager;
+ $this->messenger = $messenger;
}
/**
@@ -38,7 +49,8 @@ public function __construct(CheckoutOrderManagerInterface $checkout_order_manage
*/
public static function create(ContainerInterface $container) {
return new static(
- $container->get('commerce_checkout.checkout_order_manager')
+ $container->get('commerce_checkout.checkout_order_manager'),
+ $container->get('messenger')
);
}
@@ -70,7 +82,7 @@ public function returnPage(OrderInterface $commerce_order, Request $request) {
}
catch (PaymentGatewayException $e) {
\Drupal::logger('commerce_payment')->error($e->getMessage());
- drupal_set_message(t('Payment failed at the payment server. Please review your information and try again.'), 'error');
+ $this->messenger->addError(t('Payment failed at the payment server. Please review your information and try again.'));
$redirect_step_id = $checkout_flow_plugin->getPreviousStepId($step_id);
}
$checkout_flow_plugin->redirectToStep($redirect_step_id);
diff --git a/modules/payment/src/CreditCard.php b/modules/payment/src/CreditCard.php
index 91a269f532..4b41fe067c 100644
--- a/modules/payment/src/CreditCard.php
+++ b/modules/payment/src/CreditCard.php
@@ -126,7 +126,7 @@ public static function getTypeLabels() {
*/
public static function detectType($number) {
if (!is_numeric($number)) {
- return FALSE;
+ return NULL;
}
$types = self::getTypes();
foreach ($types as $type) {
@@ -137,7 +137,7 @@ public static function detectType($number) {
}
}
- return FALSE;
+ return NULL;
}
/**
diff --git a/modules/payment/src/Element/PaymentGatewayForm.php b/modules/payment/src/Element/PaymentGatewayForm.php
index bcadea56eb..ae43de8cf8 100644
--- a/modules/payment/src/Element/PaymentGatewayForm.php
+++ b/modules/payment/src/Element/PaymentGatewayForm.php
@@ -99,7 +99,7 @@ public static function processForm(array $element, FormStateInterface $form_stat
catch (PaymentGatewayException $e) {
\Drupal::logger('commerce_payment')->error($e->getMessage());
if (!empty($element['#exception_url'])) {
- drupal_set_message($element['#exception_message'], 'error');
+ \Drupal::messenger()->addError($element['#exception_message']);
throw new NeedsRedirectException($element['#exception_url']);
}
else {
diff --git a/modules/payment/src/Entity/Payment.php b/modules/payment/src/Entity/Payment.php
index f887f6f957..5a5213eba7 100644
--- a/modules/payment/src/Entity/Payment.php
+++ b/modules/payment/src/Entity/Payment.php
@@ -32,14 +32,13 @@
* "operation" = "Drupal\commerce_payment\Form\PaymentOperationForm",
* "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
* },
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "route_provider" = {
* "default" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
* },
* },
* base_table = "commerce_payment",
* admin_permission = "administer commerce_payment",
- * fieldable = TRUE,
* entity_keys = {
* "id" = "payment_id",
* "bundle" = "type",
@@ -255,6 +254,13 @@ public function setExpiresTime($timestamp) {
return $this;
}
+ /**
+ * {@inheritdoc}
+ */
+ public function isCompleted() {
+ return !$this->get('completed')->isEmpty();
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/modules/payment/src/Entity/PaymentGatewayInterface.php b/modules/payment/src/Entity/PaymentGatewayInterface.php
index 6a20e95f1b..cfc4e58201 100644
--- a/modules/payment/src/Entity/PaymentGatewayInterface.php
+++ b/modules/payment/src/Entity/PaymentGatewayInterface.php
@@ -60,7 +60,7 @@ public function setPluginId($plugin_id);
/**
* Gets the payment gateway plugin configuration.
*
- * @return string
+ * @return array
* The payment gateway plugin configuration.
*/
public function getPluginConfiguration();
diff --git a/modules/payment/src/Entity/PaymentInterface.php b/modules/payment/src/Entity/PaymentInterface.php
index c27acf7261..939f30212f 100644
--- a/modules/payment/src/Entity/PaymentInterface.php
+++ b/modules/payment/src/Entity/PaymentInterface.php
@@ -207,6 +207,14 @@ public function getExpiresTime();
*/
public function setExpiresTime($timestamp);
+ /**
+ * Gets whether the payment has been completed.
+ *
+ * @return bool
+ * TRUE if the payment has been completed, FALSE otherwise.
+ */
+ public function isCompleted();
+
/**
* Gets the payment completed timestamp.
*
diff --git a/modules/payment/src/Entity/PaymentMethod.php b/modules/payment/src/Entity/PaymentMethod.php
index 89fe9b146a..685fb14a98 100644
--- a/modules/payment/src/Entity/PaymentMethod.php
+++ b/modules/payment/src/Entity/PaymentMethod.php
@@ -30,6 +30,7 @@
* "access" = "Drupal\commerce_payment\PaymentMethodAccessControlHandler",
* "list_builder" = "Drupal\commerce_payment\PaymentMethodListBuilder",
* "storage" = "Drupal\commerce_payment\PaymentMethodStorage",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "form" = {
* "edit" = "Drupal\commerce_payment\Form\PaymentMethodEditForm",
* "delete" = "Drupal\commerce_payment\Form\PaymentMethodDeleteForm"
@@ -40,7 +41,6 @@
* },
* base_table = "commerce_payment_method",
* admin_permission = "administer commerce_payment_method",
- * fieldable = TRUE,
* entity_keys = {
* "id" = "method_id",
* "uuid" = "uuid",
@@ -48,7 +48,6 @@
* },
* links = {
* "collection" = "/user/{user}/payment-methods",
- * "canonical" = "/user/{user}/payment-methods/{commerce_payment_method}/edit",
* "edit-form" = "/user/{user}/payment-methods/{commerce_payment_method}/edit",
* "delete-form" = "/user/{user}/payment-methods/{commerce_payment_method}/delete",
* },
diff --git a/modules/payment/src/Form/PaymentAddForm.php b/modules/payment/src/Form/PaymentAddForm.php
index a7f821ab93..9699e184b7 100644
--- a/modules/payment/src/Form/PaymentAddForm.php
+++ b/modules/payment/src/Form/PaymentAddForm.php
@@ -248,7 +248,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
elseif ($step == 'payment') {
/** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */
$payment = $form_state->getValue('payment');
- drupal_set_message($this->t('Payment saved.'));
+ $this->messenger()->addMessage($this->t('Payment saved.'));
$form_state->setRedirect('entity.commerce_payment.collection', ['commerce_order' => $payment->getOrderId()]);
}
}
diff --git a/modules/payment/src/Form/PaymentGatewayForm.php b/modules/payment/src/Form/PaymentGatewayForm.php
index 6a93886d0c..ad8614abd5 100644
--- a/modules/payment/src/Form/PaymentGatewayForm.php
+++ b/modules/payment/src/Form/PaymentGatewayForm.php
@@ -2,13 +2,13 @@
namespace Drupal\commerce_payment\Form;
-use Drupal\commerce\Form\CommercePluginEntityFormBase;
use Drupal\commerce_payment\PaymentGatewayManager;
use Drupal\Component\Utility\Html;
+use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
-class PaymentGatewayForm extends CommercePluginEntityFormBase {
+class PaymentGatewayForm extends EntityForm {
/**
* The payment gateway plugin manager.
@@ -90,6 +90,7 @@ public function form(array $form, FormStateInterface $form_state) {
'#machine_name' => [
'exists' => '\Drupal\commerce_payment\Entity\PaymentGateway::load',
],
+ '#disabled' => !$gateway->isNew(),
];
$form['plugin'] = [
'#type' => 'radios',
@@ -136,7 +137,7 @@ public function form(array $form, FormStateInterface $form_state) {
'#default_value' => (int) $gateway->status(),
];
- return $this->protectPluginIdElement($form);
+ return $form;
}
/**
@@ -162,7 +163,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
*/
public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
- drupal_set_message($this->t('Saved the %label payment gateway.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Saved the %label payment gateway.', ['%label' => $this->entity->label()]));
$form_state->setRedirect('entity.commerce_payment_gateway.collection');
}
diff --git a/modules/payment/src/Form/PaymentMethodAddForm.php b/modules/payment/src/Form/PaymentMethodAddForm.php
index d71df8a9c5..ef5182eaf7 100644
--- a/modules/payment/src/Form/PaymentMethodAddForm.php
+++ b/modules/payment/src/Form/PaymentMethodAddForm.php
@@ -2,7 +2,6 @@
namespace Drupal\commerce_payment\Form;
-use Drupal\commerce\EntityHelper;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsStoredPaymentMethodsInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
@@ -102,10 +101,15 @@ public function buildForm(array $form, FormStateInterface $form_state, UserInter
*/
protected function buildPaymentMethodTypeForm(array $form, FormStateInterface $form_state) {
$payment_method_types = $form_state->get('payment_gateway')->getPlugin()->getPaymentMethodTypes();
+ $payment_method_type_options = array_map(function ($payment_method_type) {
+ /** @var \Drupal\commerce_payment\Plugin\Commerce\PaymentMethodType\PaymentMethodTypeInterface $payment_method_type */
+ return $payment_method_type->getLabel();
+ }, $payment_method_types);
+
$form['payment_method_type'] = [
'#type' => 'radios',
'#title' => $this->t('Payment method type'),
- '#options' => EntityHelper::extractLabels($payment_method_types),
+ '#options' => $payment_method_type_options,
'#default_value' => '',
'#required' => TRUE,
];
@@ -164,7 +168,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
elseif ($step == 'payment_method') {
/** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
$payment_method = $form_state->getValue('payment_method');
- drupal_set_message($this->t('%label saved to your payment methods.', ['%label' => $payment_method->label()]));
+ $this->messenger()->addMessage($this->t('%label saved to your payment methods.', ['%label' => $payment_method->label()]));
$form_state->setRedirect('entity.commerce_payment_method.collection', ['user' => $payment_method->getOwnerId()]);
}
}
diff --git a/modules/payment/src/Form/PaymentMethodDeleteForm.php b/modules/payment/src/Form/PaymentMethodDeleteForm.php
index 94176d5244..a32e1149cb 100644
--- a/modules/payment/src/Form/PaymentMethodDeleteForm.php
+++ b/modules/payment/src/Form/PaymentMethodDeleteForm.php
@@ -24,11 +24,11 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
$payment_gateway_plugin->deletePaymentMethod($payment_method);
}
catch (PaymentGatewayException $e) {
- drupal_set_message($e->getMessage(), 'error');
+ $this->messenger()->addError($e->getMessage());
return;
}
- drupal_set_message($this->getDeletionMessage());
+ $this->messenger()->addMessage($this->getDeletionMessage());
$this->logDeletionMessage();
}
diff --git a/modules/payment/src/Form/PaymentMethodEditForm.php b/modules/payment/src/Form/PaymentMethodEditForm.php
index f92815061c..72d2c9e45e 100644
--- a/modules/payment/src/Form/PaymentMethodEditForm.php
+++ b/modules/payment/src/Form/PaymentMethodEditForm.php
@@ -37,7 +37,7 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
*/
public function save(array $form, FormStateInterface $form_state) {
// The entity was saved by the plugin form. Redirect.
- drupal_set_message($this->t('Saved the %label @entity-type.', [
+ $this->messenger()->addMessage($this->t('Saved the %label @entity-type.', [
'%label' => $this->entity->label(),
'@entity-type' => $this->entity->getEntityType()->getLowercaseLabel(),
]));
diff --git a/modules/payment/src/Form/PaymentOperationForm.php b/modules/payment/src/Form/PaymentOperationForm.php
index 38ae3d207c..d418025859 100644
--- a/modules/payment/src/Form/PaymentOperationForm.php
+++ b/modules/payment/src/Form/PaymentOperationForm.php
@@ -51,7 +51,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
if (!empty($form['payment']['#success_message'])) {
- drupal_set_message($form['payment']['#success_message']);
+ $this->messenger()->addMessage($form['payment']['#success_message']);
}
$form_state->setRedirect('entity.commerce_payment.collection', ['commerce_order' => $this->entity->getOrderId()]);
}
diff --git a/modules/payment/src/PaymentMethodStorage.php b/modules/payment/src/PaymentMethodStorage.php
index 8330b3d2d7..a5f7fda686 100644
--- a/modules/payment/src/PaymentMethodStorage.php
+++ b/modules/payment/src/PaymentMethodStorage.php
@@ -87,7 +87,7 @@ public function loadReusable(UserInterface $account, PaymentGatewayInterface $pa
->condition($query->orConditionGroup()
->condition('expires', $this->time->getRequestTime(), '>')
->condition('expires', 0))
- ->sort('created', 'DESC');
+ ->sort('method_id', 'DESC');
$result = $query->execute();
if (empty($result)) {
return [];
diff --git a/modules/payment/src/PaymentStorage.php b/modules/payment/src/PaymentStorage.php
index f59d567fed..45f9616739 100644
--- a/modules/payment/src/PaymentStorage.php
+++ b/modules/payment/src/PaymentStorage.php
@@ -37,13 +37,13 @@ public function loadMultipleByOrder(OrderInterface $order) {
* {@inheritdoc}
*/
protected function doCreate(array $values) {
- if (!isset($values['payment_gateway'])) {
+ if (empty($values['payment_gateway'])) {
throw new EntityStorageException('Missing "payment_gateway" property when creating a payment.');
}
// Populate the type using the payment gateway.
if (!isset($values['type'])) {
$payment_gateway = $values['payment_gateway'];
- if ($payment_gateway) {
+ if (is_string($payment_gateway)) {
// The caller passed tha payment gateway ID, load the full entity.
$payment_gateway_storage = $this->entityManager->getStorage('commerce_payment_gateway');
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
diff --git a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php
index 2b2e1b0eba..53ba14846a 100644
--- a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php
+++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php
@@ -2,11 +2,17 @@
namespace Drupal\commerce_payment\Plugin\Commerce\CheckoutPane;
+use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsStoredPaymentMethodsInterface;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides the payment information pane.
@@ -20,36 +26,94 @@
*/
class PaymentInformation extends CheckoutPaneBase {
+ /**
+ * The current user.
+ *
+ * @var \Drupal\Core\Session\AccountInterface
+ */
+ protected $currentUser;
+
+ /**
+ * The messenger.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
+ /**
+ * Constructs a new PaymentInformation object.
+ *
+ * @param array $configuration
+ * A configuration array containing information about the plugin instance.
+ * @param string $plugin_id
+ * The plugin_id for the plugin instance.
+ * @param mixed $plugin_definition
+ * The plugin implementation definition.
+ * @param \Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface $checkout_flow
+ * The parent checkout flow.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\Session\AccountInterface $current_user
+ * The current user.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow, EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user, MessengerInterface $messenger) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition, $checkout_flow, $entity_type_manager);
+
+ $this->currentUser = $current_user;
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow = NULL) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $checkout_flow,
+ $container->get('entity_type.manager'),
+ $container->get('current_user'),
+ $container->get('messenger')
+ );
+ }
+
/**
* {@inheritdoc}
*/
public function buildPaneSummary() {
+ $billing_profile = $this->order->getBillingProfile();
+ if ($this->order->getTotalPrice()->isZero() && $billing_profile) {
+ // Only the billing information was collected.
+ $view_builder = $this->entityTypeManager->getViewBuilder('profile');
+ $summary = [
+ '#title' => $this->t('Billing information'),
+ 'profile' => $view_builder->view($billing_profile, 'default'),
+ ];
+ return $summary;
+ }
+
$summary = [];
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
$payment_gateway = $this->order->get('payment_gateway')->entity;
if (!$payment_gateway) {
return $summary;
}
-
- $payment_gateway_plugin = $payment_gateway->getPlugin();
$payment_method = $this->order->get('payment_method')->entity;
- if ($payment_gateway_plugin instanceof SupportsStoredPaymentMethodsInterface && $payment_method) {
+ if ($payment_method) {
$view_builder = $this->entityTypeManager->getViewBuilder('commerce_payment_method');
$summary = $view_builder->view($payment_method, 'default');
}
- else {
- $billing_profile = $this->order->getBillingProfile();
- if ($billing_profile) {
- $profile_view_builder = $this->entityTypeManager->getViewBuilder('profile');
- $profile_view = $profile_view_builder->view($billing_profile, 'default');
- $label = $payment_gateway->getPlugin()->getDisplayLabel();
- $summary = [
- 'label' => [
- '#markup' => $label,
- ],
- 'profile' => $profile_view,
- ];
- }
+ elseif ($billing_profile) {
+ $view_builder = $this->entityTypeManager->getViewBuilder('profile');
+ $summary = [
+ 'payment_gateway' => [
+ '#markup' => $payment_gateway->getPlugin()->getDisplayLabel(),
+ ],
+ 'profile' => $view_builder->view($billing_profile, 'default'),
+ ];
}
return $summary;
@@ -59,13 +123,22 @@ public function buildPaneSummary() {
* {@inheritdoc}
*/
public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
+ if ($this->order->getTotalPrice()->isZero()) {
+ // Free orders don't need payment, collect just the billing information.
+ $pane_form['#title'] = $this->t('Billing information');
+ $pane_form = $this->buildBillingProfileForm($pane_form, $form_state);
+ return $pane_form;
+ }
+
/** @var \Drupal\commerce_payment\PaymentGatewayStorageInterface $payment_gateway_storage */
$payment_gateway_storage = $this->entityTypeManager->getStorage('commerce_payment_gateway');
+ // Load the payment gateways. This fires an event for filtering the
+ // available gateways, and then evaluates conditions on all remaining ones.
$payment_gateways = $payment_gateway_storage->loadMultipleForOrder($this->order);
// Can't proceed without any payment gateways.
if (empty($payment_gateways)) {
- drupal_set_message($this->noPaymentGatewayErrorMessage(), 'error');
- return [];
+ $this->messenger->addError($this->noPaymentGatewayErrorMessage());
+ return $pane_form;
}
$options = $this->buildPaymentMethodOptions($payment_gateways);
@@ -103,6 +176,11 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state,
],
'#access' => count($options) > 1,
];
+ // Add a class to each individual radio, to help themers.
+ foreach ($options as $option) {
+ $class_name = isset($option['payment_method']) ? 'stored' : 'new';
+ $pane_form['payment_method'][$option['id']]['#attributes']['class'][] = "payment-method--$class_name";
+ }
// Store the values for submitPaneForm().
foreach ($options as $option_id => $option) {
$pane_form['payment_method'][$option_id]['#payment_gateway'] = $option['payment_gateway'];
@@ -124,6 +202,7 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state,
'type' => $selected_option['#payment_method_type'],
'payment_gateway' => $selected_option['#payment_gateway'],
'uid' => $this->order->getCustomerId(),
+ 'billing_profile' => $this->order->getBillingProfile(),
]);
$pane_form['add_payment_method'] = [
@@ -134,21 +213,7 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state,
}
}
else {
- $store = $this->order->getStore();
- $billing_profile = $this->order->getBillingProfile();
- if (!$billing_profile) {
- $billing_profile = $this->entityTypeManager->getStorage('profile')->create([
- 'uid' => $this->order->getCustomerId(),
- 'type' => 'customer',
- ]);
- }
-
- $pane_form['billing_information'] = [
- '#type' => 'commerce_profile_select',
- '#default_value' => $billing_profile,
- '#default_country' => $store->getAddress()->getCountryCode(),
- '#available_countries' => $store->getBillingCountries(),
- ];
+ $pane_form = $this->buildBillingProfileForm($pane_form, $form_state);
}
return $pane_form;
@@ -157,32 +222,36 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state,
/**
* Builds the payment method options for the given payment gateways.
*
- * Ordering:
- * 1) Stored payment methods.
- * 2) The order's payment method (if not listed above).
- * 3) "Create new $payment_method_type" options.
- * 4) Other gateways (off-site, manual).
+ * The payment method options will be derived from the given payment gateways
+ * and added to the return array in the following order:
+ * 1) The customer's stored payment methods.
+ * 2) The order's payment method (if not added in the previous step).
+ * 3) Options to create new payment methods of valid types.
+ * 4) Options for the remaining gateways (off-site, manual, etc).
*
* @param \Drupal\commerce_payment\Entity\PaymentGatewayInterface[] $payment_gateways
* The payment gateways.
*
* @return array
- * The options.
+ * The options array keyed by payment method ID (or in the case of the new
+ * payment method options, a key indicating the type of payment method to
+ * create) whose values are associative arrays with the following keys:
+ * - id: the payment method ID (or new payment method key).
+ * - label: the label to use for selecting this payment method.
+ * - payment_gateway: the ID of the gateway the payment method is for.
+ * - payment_method: the ID of an existing stored payment method.
+ * - payment_method_type: the payment method type ID for new payment methods
*/
protected function buildPaymentMethodOptions(array $payment_gateways) {
- $customer = $this->order->getCustomer();
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface[] $payment_gateways_with_payment_methods */
$payment_gateways_with_payment_methods = array_filter($payment_gateways, function ($payment_gateway) {
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
return $payment_gateway->getPlugin() instanceof SupportsStoredPaymentMethodsInterface;
});
- /** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface[] $other_payment_gateways */
- $other_payment_gateways = array_diff_key($payment_gateways, $payment_gateways_with_payment_methods);
- /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $order_payment_method */
- $order_payment_method = $this->order->get('payment_method')->entity;
$options = [];
- // 1) Stored payment methods.
+ // 1) Add options to reuse stored payment methods for known customers.
+ $customer = $this->order->getCustomer();
if ($customer) {
$billing_countries = $this->order->getStore()->getBillingCountries();
/** @var \Drupal\commerce_payment\PaymentMethodStorageInterface $payment_method_storage */
@@ -190,10 +259,10 @@ protected function buildPaymentMethodOptions(array $payment_gateways) {
foreach ($payment_gateways_with_payment_methods as $payment_gateway_id => $payment_gateway) {
$payment_methods = $payment_method_storage->loadReusable($customer, $payment_gateway, $billing_countries);
+
foreach ($payment_methods as $payment_method_id => $payment_method) {
- $option_id = $payment_method_id;
- $options[$option_id] = [
- 'id' => $option_id,
+ $options[$payment_method_id] = [
+ 'id' => $payment_method_id,
'label' => $payment_method->label(),
'payment_gateway' => $payment_gateway_id,
'payment_method' => $payment_method_id,
@@ -201,36 +270,49 @@ protected function buildPaymentMethodOptions(array $payment_gateways) {
}
}
}
- // 2) The order's payment method (if not listed above).
- if ($order_payment_method && !isset($options[$order_payment_method->id()])) {
- $option_id = $order_payment_method->id();
- $options[$option_id] = [
- 'id' => $option_id,
- 'label' => $order_payment_method->label(),
- 'payment_gateway' => $order_payment_method->getPaymentGatewayId(),
- 'payment_method' => $order_payment_method->id(),
- ];
+
+ // 2) Add the order's payment method if it was not included above.
+ /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $order_payment_method */
+ $order_payment_method = $this->order->get('payment_method')->entity;
+ if ($order_payment_method) {
+ $order_payment_method_id = $order_payment_method->id();
+
+ if (!isset($options[$order_payment_method_id])) {
+ $options[$order_payment_method_id] = [
+ 'id' => $order_payment_method_id,
+ 'label' => $order_payment_method->label(),
+ 'payment_gateway' => $order_payment_method->getPaymentGatewayId(),
+ 'payment_method' => $order_payment_method_id,
+ ];
+ }
}
- // 3) "Create new $payment_method_type" options.
+
+ // 3) Add options to create new stored payment methods of supported types.
$payment_method_type_counts = [];
+ // Count how many new payment method options will be built per gateway.
foreach ($payment_gateways_with_payment_methods as $payment_gateway) {
$payment_method_types = $payment_gateway->getPlugin()->getPaymentMethodTypes();
+
foreach ($payment_method_types as $payment_method_type_id => $payment_method_type) {
- $previous_count = 0;
- if (isset($payment_method_type_counts[$payment_method_type_id])) {
- $previous_count = $payment_method_type_counts[$payment_method_type_id];
- };
- $payment_method_type_counts[$payment_method_type_id] = $previous_count + 1;
+ if (!isset($payment_method_type_counts[$payment_method_type_id])) {
+ $payment_method_type_counts[$payment_method_type_id] = 1;
+ }
+ else {
+ $payment_method_type_counts[$payment_method_type_id]++;
+ }
}
}
+
foreach ($payment_gateways_with_payment_methods as $payment_gateway) {
$payment_gateway_plugin = $payment_gateway->getPlugin();
$payment_method_types = $payment_gateway_plugin->getPaymentMethodTypes();
+
foreach ($payment_method_types as $payment_method_type_id => $payment_method_type) {
$option_id = 'new--' . $payment_method_type_id . '--' . $payment_gateway->id();
$option_label = $payment_method_type->getCreateLabel();
+ // If there is more than one option for this payment method type,
+ // append the payment gateway label to avoid duplicate option labels.
if ($payment_method_type_counts[$payment_method_type_id] > 1) {
- // Append the payment gateway label to avoid duplicate labels.
$option_label = $this->t('@payment_method_label (@payment_gateway_label)', [
'@payment_method_label' => $payment_method_type->getCreateLabel(),
'@payment_gateway_label' => $payment_gateway_plugin->getDisplayLabel(),
@@ -245,13 +327,15 @@ protected function buildPaymentMethodOptions(array $payment_gateways) {
];
}
}
- // 4) Other gateways (off-site, manual).
- foreach ($other_payment_gateways as $payment_gateway) {
- $option_id = $payment_gateway->id();
- $options[$option_id] = [
- 'id' => $option_id,
+
+ // 4) Add options for the remaining gateways (off-site, manual, etc).
+ /** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface[] $other_payment_gateways */
+ $other_payment_gateways = array_diff_key($payment_gateways, $payment_gateways_with_payment_methods);
+ foreach ($other_payment_gateways as $payment_gateway_id => $payment_gateway) {
+ $options[$payment_gateway_id] = [
+ 'id' => $payment_gateway_id,
'label' => $payment_gateway->getPlugin()->getDisplayLabel(),
- 'payment_gateway' => $payment_gateway->id(),
+ 'payment_gateway' => $payment_gateway_id,
];
}
@@ -294,6 +378,37 @@ protected function getDefaultPaymentMethodOption(array $options) {
return $default_option;
}
+ /**
+ * Builds the billing profile form.
+ *
+ * @param array $pane_form
+ * The pane form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The form state of the parent form.
+ *
+ * @return array
+ * The modified pane form.
+ */
+ protected function buildBillingProfileForm(array $pane_form, FormStateInterface $form_state) {
+ $store = $this->order->getStore();
+ $billing_profile = $this->order->getBillingProfile();
+ if (!$billing_profile) {
+ $billing_profile = $this->entityTypeManager->getStorage('profile')->create([
+ 'uid' => $this->order->getCustomerId(),
+ 'type' => 'customer',
+ ]);
+ }
+
+ $pane_form['billing_information'] = [
+ '#type' => 'commerce_profile_select',
+ '#default_value' => $billing_profile,
+ '#default_country' => $store->getAddress()->getCountryCode(),
+ '#available_countries' => $store->getBillingCountries(),
+ ];
+
+ return $pane_form;
+ }
+
/**
* Ajax callback.
*/
@@ -307,8 +422,11 @@ public static function ajaxRefresh(array $form, FormStateInterface $form_state)
* {@inheritdoc}
*/
public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
- $values = $form_state->getValue($pane_form['#parents']);
+ if ($this->order->getTotalPrice()->isZero()) {
+ return;
+ }
+ $values = $form_state->getValue($pane_form['#parents']);
if (!isset($values['payment_method'])) {
$form_state->setError($complete_form, $this->noPaymentGatewayErrorMessage());
}
@@ -318,6 +436,11 @@ public function validatePaneForm(array &$pane_form, FormStateInterface $form_sta
* {@inheritdoc}
*/
public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
+ if ($this->order->getTotalPrice()->isZero()) {
+ $this->order->setBillingProfile($pane_form['billing_information']['#profile']);
+ return;
+ }
+
$values = $form_state->getValue($pane_form['#parents']);
$selected_option = $pane_form['payment_method'][$values['payment_method']];
/** @var \Drupal\commerce_payment\PaymentGatewayStorageInterface $payment_gateway_storage */
@@ -352,13 +475,21 @@ public function submitPaneForm(array &$pane_form, FormStateInterface $form_state
}
/**
- * Returns an error message in case there are no payment gateways.
+ * Returns an error message in case there are no available payment gateways.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* The error message.
*/
protected function noPaymentGatewayErrorMessage() {
- return $this->t('No payment gateways are defined, create one first.');
+ if ($this->currentUser->hasPermission('administer commerce_payment_gateway')) {
+ $message = $this->t('There are no
payment gateways available for this order.', [
+ ':url' => Url::fromRoute('entity.commerce_payment_gateway.collection')->toString(),
+ ]);
+ }
+ else {
+ $message = $this->t('There are no payment gateways available for this order. Please try again later.');
+ }
+ return $message;
}
}
diff --git a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php
index 5784b57656..d43bd8c03e 100644
--- a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php
+++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php
@@ -3,15 +3,19 @@
namespace Drupal\commerce_payment\Plugin\Commerce\CheckoutPane;
use Drupal\commerce\Response\NeedsRedirectException;
+use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase;
use Drupal\commerce_payment\Exception\DeclineException;
use Drupal\commerce_payment\Exception\PaymentGatewayException;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\ManualPaymentGatewayInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OnsitePaymentGatewayInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
+use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Url;
+use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides the payment process pane.
@@ -25,6 +29,49 @@
*/
class PaymentProcess extends CheckoutPaneBase {
+ /**
+ * The messenger.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
+ /**
+ * Constructs a new PaymentProcess object.
+ *
+ * @param array $configuration
+ * A configuration array containing information about the plugin instance.
+ * @param string $plugin_id
+ * The plugin_id for the plugin instance.
+ * @param mixed $plugin_definition
+ * The plugin implementation definition.
+ * @param \Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface $checkout_flow
+ * The parent checkout flow.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow, EntityTypeManagerInterface $entity_type_manager, MessengerInterface $messenger) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition, $checkout_flow, $entity_type_manager);
+
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow = NULL) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $checkout_flow,
+ $container->get('entity_type.manager'),
+ $container->get('messenger')
+ );
+ }
+
/**
* {@inheritdoc}
*/
@@ -83,9 +130,17 @@ public function submitConfigurationForm(array &$form, FormStateInterface $form_s
* {@inheritdoc}
*/
public function isVisible() {
- // This pane can't be used without the PaymentInformation pane.
+ if ($this->order->getTotalPrice()->isZero()) {
+ // Hide the pane for free orders, since they don't need a payment.
+ return FALSE;
+ }
$payment_info_pane = $this->checkoutFlow->getPane('payment_information');
- return $payment_info_pane->isVisible() && $payment_info_pane->getStepId() != '_disabled';
+ if (!$payment_info_pane->isVisible() || $payment_info_pane->getStepId() == '_disabled') {
+ // Hide the pane if the PaymentInformation pane has been disabled.
+ return FALSE;
+ }
+
+ return TRUE;
}
/**
@@ -94,7 +149,7 @@ public function isVisible() {
public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
// The payment gateway is currently always required to be set.
if ($this->order->get('payment_gateway')->isEmpty()) {
- drupal_set_message($this->t('No payment gateway selected.'), 'error');
+ $this->messenger->addError($this->t('No payment gateway selected.'));
$this->redirectToPreviousStep();
}
@@ -120,13 +175,13 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state,
}
catch (DeclineException $e) {
$message = $this->t('We encountered an error processing your payment method. Please verify your details and try again.');
- drupal_set_message($message, 'error');
+ $this->messenger->addError($message);
$this->redirectToPreviousStep();
}
catch (PaymentGatewayException $e) {
\Drupal::logger('commerce_payment')->error($e->getMessage());
$message = $this->t('We encountered an unexpected error processing your payment method. Please try again later.');
- drupal_set_message($message, 'error');
+ $this->messenger->addError($message);
$this->redirectToPreviousStep();
}
}
@@ -163,7 +218,7 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state,
catch (PaymentGatewayException $e) {
\Drupal::logger('commerce_payment')->error($e->getMessage());
$message = $this->t('We encountered an unexpected error processing your payment. Please try again later.');
- drupal_set_message($message, 'error');
+ $this->messenger->addError($message);
$this->redirectToPreviousStep();
}
}
diff --git a/modules/payment/src/Plugin/Commerce/PaymentGateway/PaymentGatewayBase.php b/modules/payment/src/Plugin/Commerce/PaymentGateway/PaymentGatewayBase.php
index 37fca37521..b4883d5b8e 100644
--- a/modules/payment/src/Plugin/Commerce/PaymentGateway/PaymentGatewayBase.php
+++ b/modules/payment/src/Plugin/Commerce/PaymentGateway/PaymentGatewayBase.php
@@ -9,6 +9,7 @@
use Drupal\commerce_payment\Exception\InvalidRequestException;
use Drupal\commerce_payment\PaymentMethodTypeManager;
use Drupal\commerce_payment\PaymentTypeManager;
+use Drupal\commerce_price\Calculator;
use Drupal\commerce_price\Price;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\NestedArray;
@@ -360,6 +361,30 @@ public function buildPaymentOperations(PaymentInterface $payment) {
return $operations;
}
+ /**
+ * Converts the given amount to its minor units.
+ *
+ * For example, 9.99 USD becomes 999.
+ *
+ * @param \Drupal\commerce_price\Price $amount
+ * The amount.
+ *
+ * @return int
+ * The amount in minor units, as an integer.
+ */
+ protected function toMinorUnits(Price $amount) {
+ $currency_storage = $this->entityTypeManager->getStorage('commerce_currency');
+ /** @var \Drupal\commerce_price\Entity\CurrencyInterface $currency */
+ $currency = $currency_storage->load($amount->getCurrencyCode());
+ $fraction_digits = $currency->getFractionDigits();
+ $number = $amount->getNumber();
+ if ($fraction_digits > 0) {
+ $number = Calculator::multiply($number, pow(10, $fraction_digits));
+ }
+
+ return round($number, 0);
+ }
+
/**
* Gets the remote customer ID for the given user.
*
diff --git a/modules/payment/src/Plugin/Commerce/PaymentMethodType/CreditCard.php b/modules/payment/src/Plugin/Commerce/PaymentMethodType/CreditCard.php
index e2ccfa3038..1e92956133 100644
--- a/modules/payment/src/Plugin/Commerce/PaymentMethodType/CreditCard.php
+++ b/modules/payment/src/Plugin/Commerce/PaymentMethodType/CreditCard.php
@@ -2,7 +2,7 @@
namespace Drupal\commerce_payment\Plugin\Commerce\PaymentMethodType;
-use Drupal\commerce\BundleFieldDefinition;
+use Drupal\entity\BundleFieldDefinition;
use Drupal\commerce_payment\CreditCard as CreditCardHelper;
use Drupal\commerce_payment\Entity\PaymentMethodInterface;
diff --git a/modules/payment/src/Plugin/Commerce/PaymentMethodType/PayPal.php b/modules/payment/src/Plugin/Commerce/PaymentMethodType/PayPal.php
index 72dc8c79d3..1f3cf30ac9 100644
--- a/modules/payment/src/Plugin/Commerce/PaymentMethodType/PayPal.php
+++ b/modules/payment/src/Plugin/Commerce/PaymentMethodType/PayPal.php
@@ -2,7 +2,7 @@
namespace Drupal\commerce_payment\Plugin\Commerce\PaymentMethodType;
-use Drupal\commerce\BundleFieldDefinition;
+use Drupal\entity\BundleFieldDefinition;
use Drupal\commerce_payment\Entity\PaymentMethodInterface;
/**
diff --git a/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeInterface.php b/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeInterface.php
index 3ffc5adc98..e28eb2dbcd 100644
--- a/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeInterface.php
+++ b/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeInterface.php
@@ -2,7 +2,7 @@
namespace Drupal\commerce_payment\Plugin\Commerce\PaymentMethodType;
-use Drupal\commerce\BundlePluginInterface;
+use Drupal\entity\BundlePlugin\BundlePluginInterface;
use Drupal\commerce_payment\Entity\PaymentMethodInterface;
/**
diff --git a/modules/payment/src/Plugin/Commerce/PaymentType/PaymentTypeInterface.php b/modules/payment/src/Plugin/Commerce/PaymentType/PaymentTypeInterface.php
index 259f40e5a1..790e828aef 100644
--- a/modules/payment/src/Plugin/Commerce/PaymentType/PaymentTypeInterface.php
+++ b/modules/payment/src/Plugin/Commerce/PaymentType/PaymentTypeInterface.php
@@ -2,7 +2,7 @@
namespace Drupal\commerce_payment\Plugin\Commerce\PaymentType;
-use Drupal\commerce\BundlePluginInterface;
+use Drupal\entity\BundlePlugin\BundlePluginInterface;
/**
* Defines the interface for payment types.
diff --git a/modules/payment/src/PluginForm/PaymentMethodAddForm.php b/modules/payment/src/PluginForm/PaymentMethodAddForm.php
index 0a3ece9eba..44c6c69bb1 100644
--- a/modules/payment/src/PluginForm/PaymentMethodAddForm.php
+++ b/modules/payment/src/PluginForm/PaymentMethodAddForm.php
@@ -55,10 +55,15 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta
/** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
$payment_method = $this->entity;
/** @var \Drupal\profile\Entity\ProfileInterface $billing_profile */
- $billing_profile = Profile::create([
- 'type' => 'customer',
- 'uid' => $payment_method->getOwnerId(),
- ]);
+ $billing_profile = $payment_method->getBillingProfile();
+ if (!$billing_profile) {
+ /** @var \Drupal\profile\Entity\ProfileInterface $billing_profile */
+ $billing_profile = Profile::create([
+ 'type' => 'customer',
+ 'uid' => $payment_method->getOwnerId(),
+ ]);
+ }
+
if ($order = $this->routeMatch->getParameter('commerce_order')) {
$store = $order->getStore();
}
@@ -121,11 +126,11 @@ public function submitConfigurationForm(array &$form, FormStateInterface $form_s
}
catch (DeclineException $e) {
\Drupal::logger('commerce_payment')->warning($e->getMessage());
- throw new DeclineException('We encountered an error processing your payment method. Please verify your details and try again.');
+ throw new DeclineException(t('We encountered an error processing your payment method. Please verify your details and try again.'));
}
catch (PaymentGatewayException $e) {
\Drupal::logger('commerce_payment')->error($e->getMessage());
- throw new PaymentGatewayException('We encountered an unexpected error processing your payment method. Please try again later.');
+ throw new PaymentGatewayException(t('We encountered an unexpected error processing your payment method. Please try again later.'));
}
}
diff --git a/modules/payment/templates/commerce-payment-method--credit-card.html.twig b/modules/payment/templates/commerce-payment-method--credit-card.html.twig
index 5a027c71c4..67ba8ad6aa 100644
--- a/modules/payment/templates/commerce-payment-method--credit-card.html.twig
+++ b/modules/payment/templates/commerce-payment-method--credit-card.html.twig
@@ -14,7 +14,6 @@
* {{ payment_method|without('label') }}
* @endcode
* - payment_method_entity: The payment_method entity.
- * - payment_method_url: The payment_method URL.
*
* @ingroup themeable
*/
diff --git a/modules/payment/templates/commerce-payment-method.html.twig b/modules/payment/templates/commerce-payment-method.html.twig
index 01dc1c47f3..9bb5f3066c 100644
--- a/modules/payment/templates/commerce-payment-method.html.twig
+++ b/modules/payment/templates/commerce-payment-method.html.twig
@@ -14,7 +14,6 @@
* {{ payment_method|without('label') }}
* @endcode
* - payment_method_entity: The payment_method entity.
- * - payment_method_url: The payment_method URL.
*
* @ingroup themeable
*/
diff --git a/modules/payment/tests/modules/commerce_payment_test/commerce_payment_test.info.yml b/modules/payment/tests/modules/commerce_payment_test/commerce_payment_test.info.yml
index e0d14fd9c2..ff5fb161ab 100644
--- a/modules/payment/tests/modules/commerce_payment_test/commerce_payment_test.info.yml
+++ b/modules/payment/tests/modules/commerce_payment_test/commerce_payment_test.info.yml
@@ -3,4 +3,4 @@ description: Provides testing items for Payment
core: 8.x
type: module
dependencies:
- - commerce_payment
+ - commerce:commerce_payment
diff --git a/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php b/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php
index 31221e0d97..d2238400fe 100644
--- a/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php
+++ b/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php
@@ -3,6 +3,7 @@
namespace Drupal\Tests\commerce_payment\FunctionalJavascript;
use Drupal\commerce_checkout\Entity\CheckoutFlow;
+use Drupal\commerce_order\Adjustment;
use Drupal\commerce_order\Entity\Order;
use Drupal\commerce_payment\Entity\Payment;
use Drupal\commerce_payment\Entity\PaymentGateway;
@@ -527,4 +528,43 @@ public function testCheckoutWithManual() {
$this->assertEquals($payment->getAmount(), $order->getTotalPrice());
}
+ /**
+ * Tests a free order, where only the billing information is collected.
+ */
+ public function testFreeOrder() {
+ $this->drupalGet($this->product->toUrl()->toString());
+ $this->submitForm([], 'Add to cart');
+
+ // Add an adjustment to zero out the order total.
+ $order = Order::load(1);
+ $order->addAdjustment(new Adjustment([
+ 'type' => 'custom',
+ 'label' => 'Surprise, it is free!',
+ 'amount' => $order->getTotalPrice()->multiply('-1'),
+ 'locked' => TRUE,
+ ]));
+ $order->save();
+
+ $this->drupalGet('checkout/1');
+ $this->assertSession()->pageTextContains('Billing information');
+ $this->assertSession()->pageTextNotContains('Payment information');
+ $this->submitForm([
+ 'payment_information[billing_information][address][0][address][given_name]' => 'Johnny',
+ 'payment_information[billing_information][address][0][address][family_name]' => 'Appleseed',
+ 'payment_information[billing_information][address][0][address][address_line1]' => '123 New York Drive',
+ 'payment_information[billing_information][address][0][address][locality]' => 'New York City',
+ 'payment_information[billing_information][address][0][address][administrative_area]' => 'NY',
+ 'payment_information[billing_information][address][0][address][postal_code]' => '10001',
+ ], 'Continue to review');
+
+ $this->assertSession()->pageTextContains('Billing information');
+ $this->assertSession()->pageTextNotContains('Payment information');
+ $this->assertSession()->pageTextContains('Example');
+ $this->assertSession()->pageTextContains('Johnny Appleseed');
+ $this->assertSession()->pageTextContains('123 New York Drive');
+
+ $this->submitForm([], 'Complete checkout');
+ $this->assertSession()->pageTextContains('Your order number is 1. You can view your order on your account page when logged in.');
+ }
+
}
diff --git a/modules/payment/tests/src/Kernel/Entity/PaymentMethodTest.php b/modules/payment/tests/src/Kernel/Entity/PaymentMethodTest.php
index fdbdf38974..b3e2544efd 100644
--- a/modules/payment/tests/src/Kernel/Entity/PaymentMethodTest.php
+++ b/modules/payment/tests/src/Kernel/Entity/PaymentMethodTest.php
@@ -130,13 +130,13 @@ public function testPaymentMethod() {
$payment_method->setReusable(FALSE);
$this->assertEmpty($payment_method->isReusable());
- $this->assertEmpty($payment_method->isDefault());
+ $this->assertFalse($payment_method->isDefault());
$payment_method->setDefault(TRUE);
- $this->assertNotEmpty($payment_method->isDefault());
+ $this->assertTrue($payment_method->isDefault());
- $this->assertEmpty($payment_method->isExpired());
+ $this->assertFalse($payment_method->isExpired());
$payment_method->setExpiresTime(635879700);
- $this->assertNotEmpty($payment_method->isExpired());
+ $this->assertTrue($payment_method->isExpired());
$this->assertEquals(635879700, $payment_method->getExpiresTime());
$payment_method->setCreatedTime(635879700);
diff --git a/modules/payment/tests/src/Kernel/Entity/PaymentTest.php b/modules/payment/tests/src/Kernel/Entity/PaymentTest.php
new file mode 100644
index 0000000000..ecca78e91d
--- /dev/null
+++ b/modules/payment/tests/src/Kernel/Entity/PaymentTest.php
@@ -0,0 +1,148 @@
+installEntitySchema('profile');
+ $this->installEntitySchema('commerce_order');
+ $this->installEntitySchema('commerce_order_item');
+ $this->installEntitySchema('commerce_payment');
+ $this->installConfig('commerce_order');
+ $this->installConfig('commerce_payment');
+
+ PaymentGateway::create([
+ 'id' => 'example',
+ 'label' => 'Example',
+ 'plugin' => 'example_onsite',
+ ])->save();
+
+ $user = $this->createUser();
+ $this->user = $this->reloadEntity($user);
+ }
+
+ /**
+ * @covers ::getType
+ * @covers ::getPaymentGatewayId
+ * @covers ::getPaymentGatewayMode
+ * @covers ::getOrder
+ * @covers ::getOrderId
+ * @covers ::getRemoteId
+ * @covers ::setRemoteId
+ * @covers ::getRemoteState
+ * @covers ::setRemoteState
+ * @covers ::getBalance
+ * @covers ::getAmount
+ * @covers ::setAmount
+ * @covers ::getRefundedAmount
+ * @covers ::setRefundedAmount
+ * @covers ::getState
+ * @covers ::setState
+ * @covers ::isExpired
+ * @covers ::getExpiresTime
+ * @covers ::setExpiresTime
+ * @covers ::isCompleted
+ * @covers ::getCompletedTime
+ * @covers ::setCompletedTime
+ */
+ public function testPayment() {
+ $order = Order::create([
+ 'type' => 'default',
+ 'mail' => $this->user->getEmail(),
+ 'uid' => $this->user->id(),
+ 'store_id' => $this->store->id(),
+ ]);
+ $order->save();
+ $order = $this->reloadEntity($order);
+
+ /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */
+ $payment = Payment::create([
+ 'type' => 'payment_default',
+ 'payment_gateway' => 'example',
+ 'order_id' => $order,
+ 'amount' => new Price('30', 'USD'),
+ 'refunded_amount' => new Price('10', 'USD'),
+ 'state' => 'refunded',
+ ]);
+ $payment->save();
+
+ $this->assertInstanceOf(PaymentDefault::class, $payment->getType());
+ $this->assertEquals('example', $payment->getPaymentGatewayId());
+ $this->assertEquals('test', $payment->getPaymentGatewayMode());
+
+ $this->assertEquals($order, $payment->getOrder());
+ $this->assertEquals($order->id(), $payment->getOrderId());
+
+ $payment->setRemoteId('123456');
+ $this->assertEquals('123456', $payment->getRemoteId());
+
+ $payment->setRemoteState('pending');
+ $this->assertEquals('pending', $payment->getRemoteState());
+
+ $this->assertEquals(new Price('30', 'USD'), $payment->getAmount());
+ $this->assertEquals(new Price('10', 'USD'), $payment->getRefundedAmount());
+ $this->assertEquals(new Price('20', 'USD'), $payment->getBalance());
+
+ $payment->setAmount(new Price('40', 'USD'));
+ $this->assertEquals(new Price('40', 'USD'), $payment->getAmount());
+ $payment->setRefundedAmount(new Price('15', 'USD'));
+ $this->assertEquals(new Price('15', 'USD'), $payment->getRefundedAmount());
+
+ $this->assertEquals('refunded', $payment->getState()->value);
+ $payment->setState('completed');
+ $this->assertEquals('completed', $payment->getState()->value);
+
+ $this->assertFalse($payment->isExpired());
+ $payment->setExpiresTime(635879700);
+ $this->assertTrue($payment->isExpired());
+ $this->assertEquals(635879700, $payment->getExpiresTime());
+
+ $this->assertFalse($payment->isCompleted());
+ $payment->setCompletedTime(635879700);
+ $this->assertEquals(635879700, $payment->getCompletedTime());
+ $this->assertTrue($payment->isCompleted());
+ }
+
+}
diff --git a/modules/payment/tests/src/Kernel/PaymentMethodStorageTest.php b/modules/payment/tests/src/Kernel/PaymentMethodStorageTest.php
index 33a6d21e20..0932e763e8 100644
--- a/modules/payment/tests/src/Kernel/PaymentMethodStorageTest.php
+++ b/modules/payment/tests/src/Kernel/PaymentMethodStorageTest.php
@@ -120,7 +120,7 @@ public function testLoadReusable() {
$payment_method_unlimited->save();
// Confirm that the expired payment method was not loaded.
$reusable_payment_methods = $this->storage->loadReusable($this->user, $this->paymentGateway);
- $this->assertEquals([$payment_method_active->id(), $payment_method_unlimited->id()], array_keys($reusable_payment_methods));
+ $this->assertEquals([$payment_method_unlimited->id(), $payment_method_active->id()], array_keys($reusable_payment_methods));
// Confirm that anonymous users cannot have reusable payment methods.
$payment_method_active->setOwnerId(0);
diff --git a/modules/payment_example/commerce_payment_example.info.yml b/modules/payment_example/commerce_payment_example.info.yml
index 26bf54ab07..65bb8c9014 100644
--- a/modules/payment_example/commerce_payment_example.info.yml
+++ b/modules/payment_example/commerce_payment_example.info.yml
@@ -4,4 +4,5 @@ description: 'Provides payment gateway examples.'
package: Commerce
core: 8.x
dependencies:
+ - commerce:commerce
- commerce:commerce_payment
diff --git a/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php
index 90d452c6fe..358770c256 100644
--- a/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php
+++ b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php
@@ -81,7 +81,6 @@ public function onReturn(OrderInterface $order, Request $request) {
'remote_state' => $request->query->get('payment_status'),
]);
$payment->save();
- drupal_set_message('Payment was processed');
}
}
diff --git a/modules/price/commerce_price.info.yml b/modules/price/commerce_price.info.yml
index 65715e6a67..835c008ff8 100644
--- a/modules/price/commerce_price.info.yml
+++ b/modules/price/commerce_price.info.yml
@@ -4,4 +4,4 @@ description: 'Defines the Currency entity.'
package: Commerce
core: 8.x
dependencies:
- - commerce
+ - commerce:commerce
diff --git a/modules/price/config/schema/commerce_price.schema.yml b/modules/price/config/schema/commerce_price.schema.yml
index 9c812638e1..cb2bf12bf2 100644
--- a/modules/price/config/schema/commerce_price.schema.yml
+++ b/modules/price/config/schema/commerce_price.schema.yml
@@ -61,3 +61,10 @@ field.formatter.settings.commerce_price_calculated:
display_currency_code:
type: boolean
label: 'Display the currency code instead of the currency symbol'
+ # Needed by the commerce_order version of the formatter.
+ adjustment_types:
+ type: sequence
+ label: 'Adjustment types'
+ sequence:
+ type: string
+ label: 'Adjustment type'
diff --git a/modules/price/src/Element/Number.php b/modules/price/src/Element/Number.php
index 6bc391d687..e1cdc54309 100644
--- a/modules/price/src/Element/Number.php
+++ b/modules/price/src/Element/Number.php
@@ -95,6 +95,10 @@ public static function valueCallback(&$element, $input, FormStateInterface $form
* The built commerce_number form element.
*/
public static function processElement(array $element, FormStateInterface $form_state, array &$complete_form) {
+ // Add a sensible default AJAX event.
+ if (isset($element['#ajax']) && !isset($element['#ajax']['event'])) {
+ $element['#ajax']['event'] = 'blur';
+ }
// Provide an example to the end user so that they know which decimal
// separator to use. This is the same pattern Drupal core uses.
$number_formatter = self::getNumberFormatter($element);
diff --git a/modules/price/src/Element/Price.php b/modules/price/src/Element/Price.php
index b3b746b8e2..2088971bbb 100644
--- a/modules/price/src/Element/Price.php
+++ b/modules/price/src/Element/Price.php
@@ -103,6 +103,7 @@ public static function processElement(array $element, FormStateInterface $form_s
$element['number'] = [
'#type' => 'commerce_number',
'#title' => $element['#title'],
+ '#title_display' => $element['#title_display'],
'#default_value' => $default_value ? $default_value['number'] : NULL,
'#required' => $element['#required'],
'#size' => $element['#size'],
@@ -112,8 +113,9 @@ public static function processElement(array $element, FormStateInterface $form_s
'#max_fraction_digits' => 6,
'#min' => $element['#allow_negative'] ? NULL : 0,
];
- unset($element['#size']);
- unset($element['#maxlength']);
+ if (isset($element['#ajax'])) {
+ $element['number']['#ajax'] = $element['#ajax'];
+ }
if (count($currency_codes) == 1) {
$last_visible_element = 'number';
@@ -134,11 +136,18 @@ public static function processElement(array $element, FormStateInterface $form_s
'#title_display' => 'invisible',
'#field_suffix' => '',
];
+ if (isset($element['#ajax'])) {
+ $element['currency_code']['#ajax'] = $element['#ajax'];
+ }
}
// Add the help text if specified.
if (!empty($element['#description'])) {
$element[$last_visible_element]['#field_suffix'] .= '
' . $element['#description'] . '
';
}
+ // Remove the keys that were transfered to child elements.
+ unset($element['#size']);
+ unset($element['#maxlength']);
+ unset($element['#ajax']);
return $element;
}
diff --git a/modules/price/src/Form/CurrencyForm.php b/modules/price/src/Form/CurrencyForm.php
index 991cd8d2cb..a9fc31e2c6 100644
--- a/modules/price/src/Form/CurrencyForm.php
+++ b/modules/price/src/Form/CurrencyForm.php
@@ -139,7 +139,7 @@ public function validateNumericCode(array $element, FormStateInterface $form_sta
public function save(array $form, FormStateInterface $form_state) {
$currency = $this->entity;
$currency->save();
- drupal_set_message($this->t('Saved the %label currency.', [
+ $this->messenger()->addMessage($this->t('Saved the %label currency.', [
'%label' => $currency->label(),
]));
$form_state->setRedirect('entity.commerce_currency.collection');
diff --git a/modules/price/src/Form/CurrencyImportForm.php b/modules/price/src/Form/CurrencyImportForm.php
index ed4b43e3c3..c77faf7bf7 100644
--- a/modules/price/src/Form/CurrencyImportForm.php
+++ b/modules/price/src/Form/CurrencyImportForm.php
@@ -82,7 +82,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
foreach ($currency_codes as $currency_code) {
$this->importer->import($currency_code);
}
- drupal_set_message($this->t('Imported the selected currencies.'));
+ $this->messenger()->addMessage($this->t('Imported the selected currencies.'));
$form_state->setRedirect('entity.commerce_currency.collection');
}
diff --git a/modules/price/src/Plugin/Field/FieldFormatter/PriceCalculatedFormatter.php b/modules/price/src/Plugin/Field/FieldFormatter/PriceCalculatedFormatter.php
index f7884978b3..0234fa1f57 100644
--- a/modules/price/src/Plugin/Field/FieldFormatter/PriceCalculatedFormatter.php
+++ b/modules/price/src/Plugin/Field/FieldFormatter/PriceCalculatedFormatter.php
@@ -21,7 +21,7 @@
*
* @FieldFormatter(
* id = "commerce_price_calculated",
- * label = @Translation("Calculated price"),
+ * label = @Translation("Calculated"),
* field_types = {
* "commerce_price"
* }
@@ -89,8 +89,8 @@ public function __construct($plugin_id, $plugin_definition, FieldDefinitionInter
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings, $entity_type_manager, $number_formatter_factory);
$this->chainPriceResolver = $chain_price_resolver;
- $this->currentStore = $current_store;
$this->currencyStorage = $entity_type_manager->getStorage('commerce_currency');
+ $this->currentStore = $current_store;
$this->currentUser = $current_user;
}
@@ -118,17 +118,16 @@ public static function create(ContainerInterface $container, array $configuratio
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
- $context = new Context($this->currentUser, $this->currentStore->getStore());
$elements = [];
- /** @var \Drupal\commerce_price\Plugin\Field\FieldType\PriceItem $item */
- foreach ($items as $delta => $item) {
+ if (!$items->isEmpty()) {
+ $context = new Context($this->currentUser, $this->currentStore->getStore());
/** @var \Drupal\commerce\PurchasableEntityInterface $purchasable_entity */
$purchasable_entity = $items->getEntity();
$resolved_price = $this->chainPriceResolver->resolve($purchasable_entity, 1, $context);
$number = $resolved_price->getNumber();
$currency = $this->currencyStorage->load($resolved_price->getCurrencyCode());
- $elements[$delta] = [
+ $elements[0] = [
'#markup' => $this->numberFormatter->formatCurrency($number, $currency),
'#cache' => [
'tags' => $purchasable_entity->getCacheTags(),
diff --git a/modules/price/src/TwigExtension/PriceTwigExtension.php b/modules/price/src/TwigExtension/PriceTwigExtension.php
index 8a15f5d849..1fcda627e9 100644
--- a/modules/price/src/TwigExtension/PriceTwigExtension.php
+++ b/modules/price/src/TwigExtension/PriceTwigExtension.php
@@ -28,7 +28,9 @@ public function getName() {
/**
* Formats a price object/array.
*
- * Example: {{ order.getTotalPrice|commerce_price_format }}
+ * Examples:
+ * {{ order.getTotalPrice|commerce_price_format }}
+ * {{ order.getTotalPrice|commerce_price_format|default('N/A') }}
*
* @param mixed $price
* Either a Price object, or an array with number and currency_code keys.
@@ -39,10 +41,13 @@ public function getName() {
* @throws \InvalidArgumentException
*/
public static function formatPrice($price) {
+ if (empty($price)) {
+ return '';
+ }
+
if ($price instanceof Price) {
$price = $price->toArray();
}
-
if (is_array($price) && isset($price['currency_code']) && isset($price['number'])) {
$number_formatter = \Drupal::service('commerce_price.number_formatter_factory')->createInstance();
$currency_storage = \Drupal::entityTypeManager()->getStorage('commerce_currency');
diff --git a/modules/price/tests/modules/commerce_price_test/commerce_price_test.info.yml b/modules/price/tests/modules/commerce_price_test/commerce_price_test.info.yml
index 85b108aa57..a486e6ebbb 100644
--- a/modules/price/tests/modules/commerce_price_test/commerce_price_test.info.yml
+++ b/modules/price/tests/modules/commerce_price_test/commerce_price_test.info.yml
@@ -3,5 +3,5 @@ type: module
package: Testing
core: 8.x
dependencies:
- - commerce_store
- - commerce_price
+ - commerce:commerce_store
+ - commerce:commerce_price
diff --git a/modules/price/tests/modules/commerce_price_test/commerce_price_test.module b/modules/price/tests/modules/commerce_price_test/commerce_price_test.module
index 74cbd369ab..a3d883cb5d 100644
--- a/modules/price/tests/modules/commerce_price_test/commerce_price_test.module
+++ b/modules/price/tests/modules/commerce_price_test/commerce_price_test.module
@@ -5,37 +5,15 @@
* Test module for Price.
*/
-use Drupal\commerce_price\Price;
-
/**
* Implements hook_theme().
*/
function commerce_price_test_theme($existing, $type, $theme, $path) {
return [
- 'working_commerce_price' => [
- 'template' => 'commerce-price-test-price-filter',
- 'variables' => [
- // Correct keys.
- 'price' => [
- 'number' => '9.99',
- 'currency_code' => 'USD',
- ],
- ],
- ],
- 'broken_commerce_price' => [
- 'template' => 'commerce-price-test-price-filter',
- 'variables' => [
- // Incorrect keys.
- 'price' => [
- 'numb' => '9.99',
- 'currency_cod' => 'USD',
- ],
- ],
- ],
- 'commerce_price_object' => [
+ 'commerce_price_test' => [
'template' => 'commerce-price-test-price-filter',
'variables' => [
- 'price' => new Price('9.99', 'USD'),
+ 'price' => NULL,
],
],
];
diff --git a/modules/price/tests/modules/commerce_price_test/commerce_price_test.routing.yml b/modules/price/tests/modules/commerce_price_test/commerce_price_test.routing.yml
index 80daa11579..af065f86d2 100644
--- a/modules/price/tests/modules/commerce_price_test/commerce_price_test.routing.yml
+++ b/modules/price/tests/modules/commerce_price_test/commerce_price_test.routing.yml
@@ -13,3 +13,11 @@ commerce_price_test.price_test_form:
_title: 'Price test form'
requirements:
_access: 'TRUE'
+
+commerce_price_test.ajax_price_test_form:
+ path: '/commerce_price_test/ajax_price_test_form'
+ defaults:
+ _form: '\Drupal\commerce_price_test\Form\AjaxPriceTestForm'
+ _title: 'AJAX price test form'
+ requirements:
+ _access: 'TRUE'
diff --git a/modules/price/tests/modules/commerce_price_test/src/Form/AjaxPriceTestForm.php b/modules/price/tests/modules/commerce_price_test/src/Form/AjaxPriceTestForm.php
new file mode 100644
index 0000000000..a3d6c27bac
--- /dev/null
+++ b/modules/price/tests/modules/commerce_price_test/src/Form/AjaxPriceTestForm.php
@@ -0,0 +1,81 @@
+ 'commerce_price',
+ '#title' => $this->t('Amount'),
+ '#default_value' => ['number' => '99.99', 'currency_code' => 'USD'],
+ '#required' => TRUE,
+ '#available_currencies' => ['USD', 'EUR'],
+ '#ajax' => [
+ 'callback' => [$this, 'ajaxSubmit'],
+ 'wrapper' => 'ajax-replace',
+ ],
+ ];
+ $form['ajax_info'] = [
+ '#prefix' => '
',
+ '#markup' => 'waiting',
+ '#suffix' => '
',
+ ];
+ $form['submit'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Submit'),
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ // Create a Price to ensure the values are valid.
+ $value = $form_state->getValue('amount');
+ $price = new Price($value['number'], $value['currency_code']);
+ $this->messenger()->addMessage(t('The number is "@number" and the currency code is "@currency_code".', [
+ '@number' => $price->getNumber(),
+ '@currency_code' => $price->getCurrencyCode(),
+ ]));
+ }
+
+ /**
+ * An AJAX callback for the form.
+ *
+ * @param array $form
+ * The form array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The form state.
+ *
+ * @return array
+ * Some markup.
+ */
+ public function ajaxSubmit(array $form, FormStateInterface $form_state) {
+ return [
+ '#prefix' => '
',
+ '#markup' => 'AJAX successful: ' . $form_state->getTriggeringElement()['#name'],
+ '#suffix' => '
',
+ ];
+ }
+
+}
diff --git a/modules/price/tests/modules/commerce_price_test/src/Form/NumberTestForm.php b/modules/price/tests/modules/commerce_price_test/src/Form/NumberTestForm.php
index 3b0f474e8d..70627846a3 100644
--- a/modules/price/tests/modules/commerce_price_test/src/Form/NumberTestForm.php
+++ b/modules/price/tests/modules/commerce_price_test/src/Form/NumberTestForm.php
@@ -38,7 +38,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
- drupal_set_message(t('The number is "@number".', [
+ $this->messenger()->addMessage(t('The number is "@number".', [
'@number' => $form_state->getValue('number'),
]));
}
diff --git a/modules/price/tests/modules/commerce_price_test/src/Form/PriceTestForm.php b/modules/price/tests/modules/commerce_price_test/src/Form/PriceTestForm.php
index 16f4048dfa..65322d70b8 100644
--- a/modules/price/tests/modules/commerce_price_test/src/Form/PriceTestForm.php
+++ b/modules/price/tests/modules/commerce_price_test/src/Form/PriceTestForm.php
@@ -26,6 +26,14 @@ public function buildForm(array $form, FormStateInterface $form_state) {
'#required' => TRUE,
'#available_currencies' => ['USD', 'EUR'],
];
+ $form['amount_hidden_title'] = [
+ '#type' => 'commerce_price',
+ '#title' => $this->t('Hidden title amount'),
+ '#title_display' => 'invisible',
+ '#default_value' => ['number' => '99.99', 'currency_code' => 'USD'],
+ '#required' => TRUE,
+ '#available_currencies' => ['USD', 'EUR'],
+ ];
$form['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Submit'),
@@ -41,7 +49,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
// Create a Price to ensure the values are valid.
$value = $form_state->getValue('amount');
$price = new Price($value['number'], $value['currency_code']);
- drupal_set_message(t('The number is "@number" and the currency code is "@currency_code".', [
+ $this->messenger()->addMessage(t('The number is "@number" and the currency code is "@currency_code".', [
'@number' => $price->getNumber(),
'@currency_code' => $price->getCurrencyCode(),
]));
diff --git a/modules/price/tests/modules/commerce_price_test/templates/commerce-price-test-price-filter.html.twig b/modules/price/tests/modules/commerce_price_test/templates/commerce-price-test-price-filter.html.twig
index 6fcb1e8204..01d4f4e67b 100644
--- a/modules/price/tests/modules/commerce_price_test/templates/commerce-price-test-price-filter.html.twig
+++ b/modules/price/tests/modules/commerce_price_test/templates/commerce-price-test-price-filter.html.twig
@@ -1 +1 @@
-{{ price|commerce_price_format }}
+{{ price|commerce_price_format|default('N/A') }}
diff --git a/modules/price/tests/src/Functional/PriceElementTest.php b/modules/price/tests/src/Functional/PriceElementTest.php
index 2d536c9ab2..abc0c0f5ac 100644
--- a/modules/price/tests/src/Functional/PriceElementTest.php
+++ b/modules/price/tests/src/Functional/PriceElementTest.php
@@ -43,6 +43,13 @@ public function testSingleCurrency() {
];
$this->submitForm($edit, 'Submit');
$this->assertSession()->pageTextContains('The number is "10.99" and the currency code is "USD".');
+
+ // Ensure that the form titles are displayed as expected.
+ $elements = $this->xpath('//input[@id="edit-amount-hidden-title-number"]/preceding-sibling::label[@for="edit-amount-hidden-title-number" and contains(@class, "visually-hidden")]');
+ $this->assertTrue(isset($elements[0]), 'Label preceding field and label class is visually-hidden.');
+
+ $elements = $this->xpath('//input[@id="edit-amount-number"]/preceding-sibling::label[@for="edit-amount-number" and not(contains(@class, "visually-hidden"))]');
+ $this->assertTrue(isset($elements[0]), 'Label preceding field and label class is not visually visually-hidden.');
}
/**
diff --git a/modules/price/tests/src/FunctionalJavascript/AjaxPriceElementTest.php b/modules/price/tests/src/FunctionalJavascript/AjaxPriceElementTest.php
new file mode 100644
index 0000000000..f7396e0a41
--- /dev/null
+++ b/modules/price/tests/src/FunctionalJavascript/AjaxPriceElementTest.php
@@ -0,0 +1,50 @@
+container->get('commerce_price.currency_importer')->import('EUR');
+ $this->drupalGet('/commerce_price_test/ajax_price_test_form');
+ $this->assertSession()->fieldExists('amount[number]');
+
+ // Default value.
+ $this->assertSession()->fieldValueEquals('amount[number]', '99.99');
+ $this->assertSession()->pageTextNotContains('Ajax successful');
+
+ // Change the amount[currency_code] field to trigger AJAX request.
+ $this->getSession()->getPage()->findField('amount[currency_code]')->selectOption('EUR');
+ $this->assertSession()->assertWaitOnAjaxRequest();
+ $this->assertSession()->pageTextNotContains('AJAX successful: amount[number]');
+ $this->assertSession()->pageTextContains('AJAX successful: amount[currency_code]');
+
+ // Blur the amount[number] field to trigger AJAX request.
+ $this->getSession()->getPage()->findField('amount[number]')->blur();
+ $this->assertSession()->assertWaitOnAjaxRequest();
+ $this->assertSession()->pageTextContains('AJAX successful: amount[number]');
+ $this->assertSession()->pageTextNotContains('AJAX successful: amount[currency_code]');
+ }
+
+}
diff --git a/modules/price/tests/src/Kernel/PriceTwigExtensionTest.php b/modules/price/tests/src/Kernel/PriceTwigExtensionTest.php
index 1bc456e2ef..4dc59a460b 100644
--- a/modules/price/tests/src/Kernel/PriceTwigExtensionTest.php
+++ b/modules/price/tests/src/Kernel/PriceTwigExtensionTest.php
@@ -2,6 +2,7 @@
namespace Drupal\Tests\commerce_price\Kernel;
+use Drupal\commerce_price\Price;
use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase;
/**
@@ -19,30 +20,50 @@ class PriceTwigExtensionTest extends CommerceKernelTestBase {
];
/**
- * Tests an improperly formatted price array.
+ * Tests passing an invalid value.
*/
- public function testBrokenPrice() {
- $theme = ['#theme' => 'broken_commerce_price'];
+ public function testInvalidPrice() {
+ $theme = [
+ '#theme' => 'commerce_price_test',
+ '#price' => [
+ // Invalid keys.
+ 'numb' => '9.99',
+ 'currency_co' => 'USD',
+ ],
+ ];
$this->setExpectedException('InvalidArgumentException');
$this->render($theme);
}
/**
- * Tests a properly formatted price array.
+ * Tests passing a valid value.
*/
- public function testWorkingPrice() {
- $theme = ['#theme' => 'working_commerce_price'];
+ public function testValidPrice() {
+ $theme = [
+ '#theme' => 'commerce_price_test',
+ '#price' => [
+ 'number' => '9.99',
+ 'currency_code' => 'USD',
+ ],
+ ];
$this->render($theme);
$this->assertText('$9.99');
+
+ $theme = [
+ '#theme' => 'commerce_price_test',
+ '#price' => new Price('20.99', 'USD'),
+ ];
+ $this->render($theme);
+ $this->assertText('$20.99');
}
/**
- * Tests a price object.
+ * Tests passing an empty value.
*/
- public function testPriceObject() {
- $theme = ['#theme' => 'commerce_price_object'];
+ public function testEmptyPrice() {
+ $theme = ['#theme' => 'commerce_price_test'];
$this->render($theme);
- $this->assertText('$9.99');
+ $this->assertText('N/A');
}
}
diff --git a/modules/product/commerce_product.info.yml b/modules/product/commerce_product.info.yml
index 2b13ba2a55..7a38bdfa61 100644
--- a/modules/product/commerce_product.info.yml
+++ b/modules/product/commerce_product.info.yml
@@ -4,11 +4,11 @@ description: 'Defines the Product entity and associated features.'
package: Commerce
core: 8.x
dependencies:
- - commerce
+ - commerce:commerce
- commerce:commerce_price
- commerce:commerce_store
- - path
- - text
+ - drupal:path
+ - drupal:text
config_devel:
install:
- commerce_product.commerce_product_type.default
diff --git a/modules/product/commerce_product.module b/modules/product/commerce_product.module
index 313362a0b4..fcc7ddbc4b 100644
--- a/modules/product/commerce_product.module
+++ b/modules/product/commerce_product.module
@@ -5,7 +5,7 @@
* Defines the Product entity and associated features.
*/
-use Drupal\commerce\BundleFieldDefinition;
+use Drupal\entity\BundleFieldDefinition;
use Drupal\commerce_product\Entity\ProductTypeInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Form\FormStateInterface;
@@ -43,6 +43,30 @@ function commerce_product_entity_view_display_update(EntityInterface $entity) {
}
}
+/**
+ * Implements hook_theme_registry_alter().
+ */
+function commerce_product_theme_registry_alter(&$theme_registry) {
+ // The preprocess function must run after quickedit_preprocess_field().
+ $theme_registry['field']['preprocess functions'][] = 'commerce_product_remove_quickedit';
+}
+
+/**
+ * Turn off Quick Edit for injected variation fields, to avoid warnings.
+ */
+function commerce_product_remove_quickedit(&$variables) {
+ $entity_type_id = $variables['element']['#entity_type'];
+ if ($entity_type_id != 'commerce_product_variation' || empty($variables['#ajax_replace_class'])) {
+ return;
+ }
+
+ if (isset($variables['attributes']['data-quickedit-field-id'])) {
+ unset($variables['attributes']['data-quickedit-field-id']);
+ $context_key = array_search('user.permissions', $variables['#cache']['contexts']);
+ unset($variables['#cache']['contexts'][$context_key]);
+ }
+}
+
/**
* Implements hook_theme().
*/
@@ -99,7 +123,7 @@ function template_preprocess_commerce_product(array &$variables) {
$product = $variables['elements']['#commerce_product'];
$variables['product_entity'] = $product;
- $variables['product_url'] = $product->toUrl();
+ $variables['product_url'] = $product->isNew() ? '' : $product->toUrl();
$variables['product'] = [];
foreach (Element::children($variables['elements']) as $key) {
$variables['product'][$key] = $variables['elements'][$key];
@@ -122,7 +146,11 @@ function template_preprocess_commerce_product_variation(array &$variables) {
$product = $product_variation->getProduct();
$variables['product_variation_entity'] = $product_variation;
- $variables['product_url'] = $product->toUrl();
+ $variables['product_url'] = '';
+ if ($product && !$product->isNew()) {
+ $variables['product_url'] = $product->toUrl();
+ }
+
$variables['product_variation'] = [];
foreach (Element::children($variables['elements']) as $key) {
$variables['product_variation'][$key] = $variables['elements'][$key];
@@ -193,6 +221,7 @@ function commerce_product_add_body_field(ProductTypeInterface $product_type, $la
->setTargetBundle($product_type->id())
->setName('body')
->setLabel($label)
+ ->setTranslatable(TRUE)
->setSetting('display_summary', FALSE)
->setDisplayOptions('form', [
'type' => 'text_textarea_with_summary',
diff --git a/modules/product/commerce_product.services.yml b/modules/product/commerce_product.services.yml
index cca824dbe1..e709e17227 100644
--- a/modules/product/commerce_product.services.yml
+++ b/modules/product/commerce_product.services.yml
@@ -5,8 +5,18 @@ services:
commerce_product.lazy_builders:
class: Drupal\commerce_product\ProductLazyBuilders
- arguments: ['@entity_type.manager', '@form_builder']
+ arguments: ['@entity_type.manager', '@form_builder', '@entity.repository']
commerce_product.variation_field_renderer:
class: Drupal\commerce_product\ProductVariationFieldRenderer
arguments: ['@entity_type.manager', '@entity_field.manager']
+
+ commerce_product.product_route_context:
+ class: Drupal\commerce_product\ContextProvider\ProductRouteContext
+ arguments: ['@current_route_match']
+ tags:
+ - { name: 'context_provider' }
+
+ commerce_product.variation_attribute_mapper:
+ class: Drupal\commerce_product\ProductVariationAttributeMapper
+ arguments: ['@commerce_product.attribute_field_manager', '@entity_type.manager', '@entity.repository']
diff --git a/modules/product/config/install/commerce_product.commerce_product_type.default.yml b/modules/product/config/install/commerce_product.commerce_product_type.default.yml
index 94a18c9f09..f079b36960 100644
--- a/modules/product/config/install/commerce_product.commerce_product_type.default.yml
+++ b/modules/product/config/install/commerce_product.commerce_product_type.default.yml
@@ -7,3 +7,4 @@ description: ''
variationType: default
injectVariationFields: true
traits: { }
+locked: false
diff --git a/modules/product/config/install/commerce_product.commerce_product_variation_type.default.yml b/modules/product/config/install/commerce_product.commerce_product_variation_type.default.yml
index 8b5d72a574..a47d109762 100644
--- a/modules/product/config/install/commerce_product.commerce_product_variation_type.default.yml
+++ b/modules/product/config/install/commerce_product.commerce_product_variation_type.default.yml
@@ -6,3 +6,4 @@ label: Default
orderItemType: default
generateTitle: true
traits: { }
+locked: false
diff --git a/modules/product/config/optional/commerce_order.commerce_order_item_type.default.yml b/modules/product/config/optional/commerce_order.commerce_order_item_type.default.yml
index d96a4c4b13..0f1efec75c 100644
--- a/modules/product/config/optional/commerce_order.commerce_order_item_type.default.yml
+++ b/modules/product/config/optional/commerce_order.commerce_order_item_type.default.yml
@@ -9,3 +9,4 @@ id: default
purchasableEntityType: commerce_product_variation
orderType: default
traits: { }
+locked: false
diff --git a/modules/product/src/ContextProvider/ProductRouteContext.php b/modules/product/src/ContextProvider/ProductRouteContext.php
new file mode 100644
index 0000000000..3fe5ebd0b4
--- /dev/null
+++ b/modules/product/src/ContextProvider/ProductRouteContext.php
@@ -0,0 +1,71 @@
+routeMatch = $route_match;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRuntimeContexts(array $unqualified_context_ids) {
+ $context_definition = new ContextDefinition('entity:commerce_product', NULL, FALSE);
+ $value = NULL;
+ if ($product = $this->routeMatch->getParameter('commerce_product')) {
+ $value = $product;
+ }
+ elseif ($this->routeMatch->getRouteName() == 'entity.commerce_product.add_form') {
+ $product_type = $this->routeMatch->getParameter('commerce_product_type');
+ $value = Product::create(['type' => $product_type->id()]);
+ }
+
+ $cacheability = new CacheableMetadata();
+ $cacheability->setCacheContexts(['route']);
+ $context = new Context($context_definition, $value);
+ $context->addCacheableDependency($cacheability);
+
+ return ['commerce_product' => $context];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAvailableContexts() {
+ $context = new Context(new ContextDefinition(
+ 'entity:commerce_product', $this->t('Product from URL')
+ ));
+ return ['commerce_product' => $context];
+ }
+
+}
diff --git a/modules/product/src/Entity/Product.php b/modules/product/src/Entity/Product.php
index f2469355f3..9a7fe8ba7e 100644
--- a/modules/product/src/Entity/Product.php
+++ b/modules/product/src/Entity/Product.php
@@ -3,6 +3,7 @@
namespace Drupal\commerce_product\Entity;
use Drupal\commerce\Entity\CommerceContentEntityBase;
+use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityPublishedTrait;
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityStorageInterface;
@@ -27,11 +28,11 @@
* handlers = {
* "event" = "Drupal\commerce_product\Event\ProductEvent",
* "storage" = "Drupal\commerce\CommerceContentEntityStorage",
- * "access" = "Drupal\commerce\EntityAccessControlHandler",
- * "permission_provider" = "Drupal\commerce\EntityPermissionProvider",
+ * "access" = "Drupal\entity\EntityAccessControlHandler",
+ * "permission_provider" = "Drupal\entity\EntityPermissionProvider",
* "view_builder" = "Drupal\commerce_product\ProductViewBuilder",
* "list_builder" = "Drupal\commerce_product\ProductListBuilder",
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "form" = {
* "default" = "Drupal\commerce_product\Form\ProductForm",
* "add" = "Drupal\commerce_product\Form\ProductForm",
@@ -39,14 +40,13 @@
* "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm"
* },
* "route_provider" = {
- * "default" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
+ * "default" = "Drupal\entity\Routing\AdminHtmlRouteProvider",
* "delete-multiple" = "Drupal\entity\Routing\DeleteMultipleRouteProvider",
* },
* "translation" = "Drupal\commerce_product\ProductTranslationHandler"
* },
* admin_permission = "administer commerce_product",
* permission_granularity = "bundle",
- * fieldable = TRUE,
* translatable = TRUE,
* base_table = "commerce_product",
* data_table = "commerce_product_field_data",
@@ -288,6 +288,13 @@ public function postSave(EntityStorageInterface $storage, $update = TRUE) {
}
}
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheContexts() {
+ return Cache::mergeContexts(parent::getCacheContexts(), ['url.query_args:v']);
+ }
+
/**
* {@inheritdoc}
*/
@@ -345,7 +352,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
'type' => 'string_textfield',
'weight' => -5,
])
- ->setDisplayConfigurable('form', TRUE);
+ ->setDisplayConfigurable('form', TRUE)
+ ->setDisplayConfigurable('view', TRUE);
$fields['path'] = BaseFieldDefinition::create('path')
->setLabel(t('URL alias'))
@@ -373,12 +381,12 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
->setLabel(t('Created'))
->setDescription(t('The time when the product was created.'))
->setTranslatable(TRUE)
- ->setDisplayConfigurable('view', TRUE)
->setDisplayOptions('form', [
'type' => 'datetime_timestamp',
'weight' => 10,
])
- ->setDisplayConfigurable('form', TRUE);
+ ->setDisplayConfigurable('form', TRUE)
+ ->setDisplayConfigurable('view', TRUE);
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Changed'))
diff --git a/modules/product/src/Entity/ProductAttribute.php b/modules/product/src/Entity/ProductAttribute.php
index a0a2cc96c5..37bc3513a4 100644
--- a/modules/product/src/Entity/ProductAttribute.php
+++ b/modules/product/src/Entity/ProductAttribute.php
@@ -19,8 +19,8 @@
* plural = "@count product attributes",
* ),
* handlers = {
- * "access" = "Drupal\commerce\EntityAccessControlHandler",
- * "permission_provider" = "Drupal\commerce\EntityPermissionProvider",
+ * "access" = "Drupal\entity\EntityAccessControlHandler",
+ * "permission_provider" = "Drupal\entity\EntityPermissionProvider",
* "list_builder" = "Drupal\commerce_product\ProductAttributeListBuilder",
* "form" = {
* "add" = "Drupal\commerce_product\Form\ProductAttributeForm",
@@ -28,7 +28,7 @@
* "delete" = "Drupal\commerce_product\Form\ProductAttributeDeleteForm",
* },
* "route_provider" = {
- * "default" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
+ * "default" = "Drupal\entity\Routing\DefaultHtmlRouteProvider",
* },
* },
* config_prefix = "commerce_product_attribute",
diff --git a/modules/product/src/Entity/ProductAttributeValue.php b/modules/product/src/Entity/ProductAttributeValue.php
index ddc9cf7931..8ffb36c008 100644
--- a/modules/product/src/Entity/ProductAttributeValue.php
+++ b/modules/product/src/Entity/ProductAttributeValue.php
@@ -25,11 +25,10 @@
* "storage" = "Drupal\commerce_product\ProductAttributeValueStorage",
* "access" = "Drupal\commerce\EmbeddedEntityAccessControlHandler",
* "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "translation" = "Drupal\content_translation\ContentTranslationHandler"
* },
* admin_permission = "administer commerce_product_attribute",
- * fieldable = TRUE,
* translatable = TRUE,
* content_translation_ui_skip = TRUE,
* base_table = "commerce_product_attribute_value",
diff --git a/modules/product/src/Entity/ProductType.php b/modules/product/src/Entity/ProductType.php
index 8596ac39c2..8943c042b6 100644
--- a/modules/product/src/Entity/ProductType.php
+++ b/modules/product/src/Entity/ProductType.php
@@ -18,6 +18,7 @@
* plural = "@count product types",
* ),
* handlers = {
+ * "access" = "Drupal\commerce\CommerceBundleAccessControlHandler",
* "list_builder" = "Drupal\commerce_product\ProductTypeListBuilder",
* "form" = {
* "add" = "Drupal\commerce_product\Form\ProductTypeForm",
@@ -43,6 +44,7 @@
* "variationType",
* "injectVariationFields",
* "traits",
+ * "locked",
* },
* links = {
* "add-form" = "/admin/commerce/config/product-types/add",
diff --git a/modules/product/src/Entity/ProductVariation.php b/modules/product/src/Entity/ProductVariation.php
index 249caca735..6a9e00ef84 100644
--- a/modules/product/src/Entity/ProductVariation.php
+++ b/modules/product/src/Entity/ProductVariation.php
@@ -32,7 +32,7 @@
* "storage" = "Drupal\commerce_product\ProductVariationStorage",
* "access" = "Drupal\commerce\EmbeddedEntityAccessControlHandler",
* "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "form" = {
* "default" = "Drupal\Core\Entity\ContentEntityForm",
* },
@@ -40,7 +40,6 @@
* "translation" = "Drupal\content_translation\ContentTranslationHandler"
* },
* admin_permission = "administer commerce_product",
- * fieldable = TRUE,
* translatable = TRUE,
* content_translation_ui_skip = TRUE,
* base_table = "commerce_product_variation",
diff --git a/modules/product/src/Entity/ProductVariationType.php b/modules/product/src/Entity/ProductVariationType.php
index c5a393d4f9..24b9828665 100644
--- a/modules/product/src/Entity/ProductVariationType.php
+++ b/modules/product/src/Entity/ProductVariationType.php
@@ -18,6 +18,7 @@
* plural = "@count product variation types",
* ),
* handlers = {
+ * "access" = "Drupal\commerce\CommerceBundleAccessControlHandler",
* "list_builder" = "Drupal\commerce_product\ProductVariationTypeListBuilder",
* "form" = {
* "add" = "Drupal\commerce_product\Form\ProductVariationTypeForm",
@@ -42,6 +43,7 @@
* "orderItemType",
* "generateTitle",
* "traits",
+ * "locked",
* },
* links = {
* "add-form" = "/admin/commerce/config/product-variation-types/add",
diff --git a/modules/product/src/Form/ProductAttributeForm.php b/modules/product/src/Form/ProductAttributeForm.php
index c9da3da8fe..1ebcb5ba18 100644
--- a/modules/product/src/Form/ProductAttributeForm.php
+++ b/modules/product/src/Form/ProductAttributeForm.php
@@ -405,13 +405,13 @@ public function save(array $form, FormStateInterface $form_state) {
}
if ($status == SAVED_NEW) {
- drupal_set_message($this->t('Created the %label product attribute.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Created the %label product attribute.', ['%label' => $this->entity->label()]));
// Send the user to the edit form to create the attribute values.
$form_state->setRedirectUrl($this->entity->toUrl('edit-form'));
}
else {
$this->saveValues($form, $form_state);
- drupal_set_message($this->t('Updated the %label product attribute.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Updated the %label product attribute.', ['%label' => $this->entity->label()]));
$form_state->setRedirectUrl($this->entity->toUrl('collection'));
}
}
diff --git a/modules/product/src/Form/ProductForm.php b/modules/product/src/Form/ProductForm.php
index 7c99dda89a..f225c1275f 100644
--- a/modules/product/src/Form/ProductForm.php
+++ b/modules/product/src/Form/ProductForm.php
@@ -89,14 +89,6 @@ public function form(array $form, FormStateInterface $form_state) {
'#type' => 'hidden',
'#default_value' => $product->getChangedTime(),
];
-
- $form['footer'] = [
- '#type' => 'container',
- '#weight' => 99,
- '#attributes' => [
- 'class' => ['product-form-footer'],
- ],
- ];
$form['status']['#group'] = 'footer';
$last_saved = t('Not saved yet');
@@ -169,9 +161,6 @@ public function form(array $form, FormStateInterface $form_state) {
'#attributes' => [
'class' => ['product-form-author'],
],
- '#attached' => [
- 'library' => ['commerce_product/drupal.commerce_product'],
- ],
'#weight' => 90,
'#optional' => TRUE,
];
@@ -222,7 +211,7 @@ public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\commerce_product\Entity\ProductInterface $product */
$product = $this->getEntity();
$product->save();
- drupal_set_message($this->t('The product %label has been successfully saved.', ['%label' => $product->label()]));
+ $this->messenger()->addMessage($this->t('The product %label has been successfully saved.', ['%label' => $product->label()]));
$form_state->setRedirect('entity.commerce_product.canonical', ['commerce_product' => $product->id()]);
}
diff --git a/modules/product/src/Form/ProductTypeForm.php b/modules/product/src/Form/ProductTypeForm.php
index 8d64f29c54..c3b0b82f09 100644
--- a/modules/product/src/Form/ProductTypeForm.php
+++ b/modules/product/src/Form/ProductTypeForm.php
@@ -156,7 +156,7 @@ public function save(array $form, FormStateInterface $form_state) {
}
$this->submitTraitForm($form, $form_state);
- drupal_set_message($this->t('The product type %label has been successfully saved.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('The product type %label has been successfully saved.', ['%label' => $this->entity->label()]));
$form_state->setRedirect('entity.commerce_product_type.collection');
if ($status == SAVED_NEW) {
commerce_product_add_stores_field($this->entity);
diff --git a/modules/product/src/Form/ProductVariationTypeForm.php b/modules/product/src/Form/ProductVariationTypeForm.php
index 0d0344ae0c..9c072ed306 100644
--- a/modules/product/src/Form/ProductVariationTypeForm.php
+++ b/modules/product/src/Form/ProductVariationTypeForm.php
@@ -159,7 +159,7 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
*/
public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
- drupal_set_message($this->t('Saved the %label product variation type.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Saved the %label product variation type.', ['%label' => $this->entity->label()]));
$form_state->setRedirect('entity.commerce_product_variation_type.collection');
$this->submitTraitForm($form, $form_state);
diff --git a/modules/product/src/Plugin/Field/FieldFormatter/AddToCartFormatter.php b/modules/product/src/Plugin/Field/FieldFormatter/AddToCartFormatter.php
index 8c8fee6f80..deb71c0fb7 100644
--- a/modules/product/src/Plugin/Field/FieldFormatter/AddToCartFormatter.php
+++ b/modules/product/src/Plugin/Field/FieldFormatter/AddToCartFormatter.php
@@ -70,6 +70,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) {
$items->getEntity()->id(),
$this->viewMode,
$this->getSetting('combine'),
+ $langcode,
],
],
'#create_placeholder' => TRUE,
diff --git a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php
index b997494242..1dee09b4e9 100644
--- a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php
+++ b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php
@@ -2,10 +2,11 @@
namespace Drupal\commerce_product\Plugin\Field\FieldWidget;
-use Drupal\commerce_product\Entity\ProductVariationInterface;
use Drupal\commerce_product\ProductAttributeFieldManagerInterface;
+use Drupal\commerce_product\ProductVariationAttributeMapperInterface;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
@@ -27,18 +28,18 @@
class ProductVariationAttributesWidget extends ProductVariationWidgetBase implements ContainerFactoryPluginInterface {
/**
- * The attribute field manager.
+ * The product attribute field manager.
*
* @var \Drupal\commerce_product\ProductAttributeFieldManagerInterface
*/
protected $attributeFieldManager;
/**
- * The product attribute storage.
+ * The product variation attribute mapper.
*
- * @var \Drupal\Core\Entity\EntityStorageInterface
+ * @var \Drupal\commerce_product\ProductVariationAttributeMapperInterface
*/
- protected $attributeStorage;
+ protected $variationAttributeMapper;
/**
* Constructs a new ProductVariationAttributesWidget object.
@@ -55,14 +56,18 @@ class ProductVariationAttributesWidget extends ProductVariationWidgetBase implem
* Any third party settings.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
+ * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+ * The entity repository.
* @param \Drupal\commerce_product\ProductAttributeFieldManagerInterface $attribute_field_manager
- * The attribute field manager.
+ * The product attribute field manager.
+ * @param \Drupal\commerce_product\ProductVariationAttributeMapperInterface $variation_attribute_mapper
+ * The product variation attribute mapper.
*/
- public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, ProductAttributeFieldManagerInterface $attribute_field_manager) {
- parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $entity_type_manager);
+ public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, EntityRepositoryInterface $entity_repository, ProductAttributeFieldManagerInterface $attribute_field_manager, ProductVariationAttributeMapperInterface $variation_attribute_mapper) {
+ parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $entity_type_manager, $entity_repository);
$this->attributeFieldManager = $attribute_field_manager;
- $this->attributeStorage = $entity_type_manager->getStorage('commerce_product_attribute');
+ $this->variationAttributeMapper = $variation_attribute_mapper;
}
/**
@@ -76,7 +81,9 @@ public static function create(ContainerInterface $container, array $configuratio
$configuration['settings'],
$configuration['third_party_settings'],
$container->get('entity_type.manager'),
- $container->get('commerce_product.attribute_field_manager')
+ $container->get('entity.repository'),
+ $container->get('commerce_product.attribute_field_manager'),
+ $container->get('commerce_product.variation_attribute_mapper')
);
}
@@ -86,7 +93,7 @@ public static function create(ContainerInterface $container, array $configuratio
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
/** @var \Drupal\commerce_product\Entity\ProductInterface $product */
$product = $form_state->get('product');
- $variations = $this->variationStorage->loadEnabled($product);
+ $variations = $this->loadEnabledVariations($product);
if (count($variations) === 0) {
// Nothing to purchase, tell the parent form to hide itself.
$form_state->set('hide_form', TRUE);
@@ -122,9 +129,9 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
// If an operation caused the form to rebuild, select the variation from
// the user's current input.
if ($form_state->isRebuilding()) {
- $parents = array_merge($element['#field_parents'], [$items->getName(), $delta]);
- $user_input = (array) NestedArray::getValue($form_state->getUserInput(), $parents);
- $selected_variation = $this->selectVariationFromUserInput($variations, $user_input);
+ $parents = array_merge($element['#field_parents'], [$items->getName(), $delta, 'attributes']);
+ $attribute_values = (array) NestedArray::getValue($form_state->getUserInput(), $parents);
+ $selected_variation = $this->variationAttributeMapper->selectVariation($variations, $attribute_values);
}
// Otherwise load from the current context.
else {
@@ -134,13 +141,10 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
$selected_variation = $order_item->getPurchasedEntity();
}
else {
- $selected_variation = $this->variationStorage->loadFromContext($product);
- // The returned variation must also be enabled.
- if (!in_array($selected_variation, $variations)) {
- $selected_variation = reset($variations);
- }
+ $selected_variation = $this->getDefaultVariation($product, $variations);
}
}
+
$element['variation'] = [
'#type' => 'value',
'#value' => $selected_variation->id(),
@@ -154,38 +158,41 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
'class' => ['attribute-widgets'],
],
];
- foreach ($this->getAttributeInfo($selected_variation, $variations) as $field_name => $attribute) {
- $element['attributes'][$field_name] = [
- '#type' => $attribute['element_type'],
- '#title' => $attribute['title'],
- '#options' => $attribute['values'],
- '#required' => $attribute['required'],
+ foreach ($this->variationAttributeMapper->prepareAttributes($selected_variation, $variations) as $field_name => $attribute) {
+ $attribute_element = [
+ '#type' => $attribute->getElementType(),
+ '#title' => $attribute->getLabel(),
+ '#options' => $attribute->getValues(),
+ '#required' => $attribute->isRequired(),
'#default_value' => $selected_variation->getAttributeValueId($field_name),
+ '#limit_validation_errors' => [],
'#ajax' => [
'callback' => [get_class($this), 'ajaxRefresh'],
'wrapper' => $form['#wrapper_id'],
],
];
// Convert the _none option into #empty_value.
- if (isset($element['attributes'][$field_name]['#options']['_none'])) {
- if (!$element['attributes'][$field_name]['#required']) {
- $element['attributes'][$field_name]['#empty_value'] = '';
+ if (isset($attribute_element['#options']['_none'])) {
+ if (!$attribute_element['#required']) {
+ $attribute_element['#empty_value'] = '';
}
- unset($element['attributes'][$field_name]['#options']['_none']);
+ unset($attribute_element['#options']['_none']);
}
// 1 required value -> Disable the element to skip unneeded ajax calls.
- if ($attribute['required'] && count($attribute['values']) === 1) {
- $element['attributes'][$field_name]['#disabled'] = TRUE;
+ if ($attribute_element['#required'] && count($attribute->getValues()) === 1) {
+ $attribute_element['#disabled'] = TRUE;
}
// Optimize the UX of optional attributes:
// - Hide attributes that have no values.
// - Require attributes that have a value on each variation.
- if (empty($element['attributes'][$field_name]['#options'])) {
- $element['attributes'][$field_name]['#access'] = FALSE;
+ if (empty($attribute_element['#options'])) {
+ $attribute_element['#access'] = FALSE;
}
if (!isset($element['attributes'][$field_name]['#empty_value'])) {
- $element['attributes'][$field_name]['#required'] = TRUE;
+ $attribute_element['#required'] = TRUE;
}
+
+ $element['attributes'][$field_name] = $attribute_element;
}
return $element;
@@ -200,130 +207,12 @@ public function massageFormValues(array $values, array $form, FormStateInterface
$variations = $this->variationStorage->loadEnabled($product);
foreach ($values as &$value) {
- $selected_variation = $this->selectVariationFromUserInput($variations, $value);
+ $attribute_values = isset($value['attributes']) ? $value['attributes'] : [];
+ $selected_variation = $this->variationAttributeMapper->selectVariation($variations, $attribute_values);
$value['variation'] = $selected_variation->id();
}
return parent::massageFormValues($values, $form, $form_state);
}
- /**
- * Selects a product variation from user input.
- *
- * If there's no user input (form viewed for the first time), the default
- * variation is returned.
- *
- * @param \Drupal\commerce_product\Entity\ProductVariationInterface[] $variations
- * An array of product variations.
- * @param array $user_input
- * The user input.
- *
- * @return \Drupal\commerce_product\Entity\ProductVariationInterface
- * The selected variation.
- */
- protected function selectVariationFromUserInput(array $variations, array $user_input) {
- $current_variation = reset($variations);
- if (!empty($user_input['attributes'])) {
- $attributes = $user_input['attributes'];
- foreach ($variations as $variation) {
- $match = TRUE;
- foreach ($attributes as $field_name => $value) {
- if ($variation->getAttributeValueId($field_name) != $value) {
- $match = FALSE;
- }
- }
- if ($match) {
- $current_variation = $variation;
- break;
- }
- }
- }
-
- return $current_variation;
- }
-
- /**
- * Gets the attribute information for the selected product variation.
- *
- * @param \Drupal\commerce_product\Entity\ProductVariationInterface $selected_variation
- * The selected product variation.
- * @param \Drupal\commerce_product\Entity\ProductVariationInterface[] $variations
- * The available product variations.
- *
- * @return array[]
- * The attribute information, keyed by field name.
- */
- protected function getAttributeInfo(ProductVariationInterface $selected_variation, array $variations) {
- $attributes = [];
- $field_definitions = $this->attributeFieldManager->getFieldDefinitions($selected_variation->bundle());
- $field_map = $this->attributeFieldManager->getFieldMap($selected_variation->bundle());
- $field_names = array_column($field_map, 'field_name');
- $attribute_ids = array_column($field_map, 'attribute_id');
- $index = 0;
- foreach ($field_names as $field_name) {
- $field = $field_definitions[$field_name];
- /** @var \Drupal\commerce_product\Entity\ProductAttributeInterface $attribute */
- $attribute = $this->attributeStorage->load($attribute_ids[$index]);
-
- $attributes[$field_name] = [
- 'field_name' => $field_name,
- 'title' => $field->getLabel(),
- 'required' => $field->isRequired(),
- 'element_type' => $attribute->getElementType(),
- ];
- // The first attribute gets all values. Every next attribute gets only
- // the values from variations matching the previous attribute value.
- // For 'Color' and 'Size' attributes that means getting the colors of all
- // variations, but only the sizes of variations with the selected color.
- $callback = NULL;
- if ($index > 0) {
- $previous_field_name = $field_names[$index - 1];
- $previous_field_value = $selected_variation->getAttributeValueId($previous_field_name);
- $callback = function ($variation) use ($previous_field_name, $previous_field_value) {
- /** @var \Drupal\commerce_product\Entity\ProductVariationInterface $variation */
- return $variation->getAttributeValueId($previous_field_name) == $previous_field_value;
- };
- }
-
- $attributes[$field_name]['values'] = $this->getAttributeValues($variations, $field_name, $callback);
- $index++;
- }
- // Filter out attributes with no values.
- $attributes = array_filter($attributes, function ($attribute) {
- return !empty($attribute['values']);
- });
-
- return $attributes;
- }
-
- /**
- * Gets the attribute values of a given set of variations.
- *
- * @param \Drupal\commerce_product\Entity\ProductVariationInterface[] $variations
- * The variations.
- * @param string $field_name
- * The field name of the attribute.
- * @param callable|null $callback
- * An optional callback to use for filtering the list.
- *
- * @return array[]
- * The attribute values, keyed by attribute ID.
- */
- protected function getAttributeValues(array $variations, $field_name, callable $callback = NULL) {
- $values = [];
- foreach ($variations as $variation) {
- if (is_null($callback) || call_user_func($callback, $variation)) {
- $attribute_value = $variation->getAttributeValue($field_name);
- if ($attribute_value) {
- $values[$attribute_value->id()] = $attribute_value->label();
- }
- else {
- $values['_none'] = '';
- }
- }
- }
-
- return $values;
- }
-
}
diff --git a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationTitleWidget.php b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationTitleWidget.php
index e6a95d672b..0b348d4b25 100644
--- a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationTitleWidget.php
+++ b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationTitleWidget.php
@@ -71,8 +71,7 @@ public function settingsSummary() {
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
/** @var \Drupal\commerce_product\Entity\ProductInterface $product */
$product = $form_state->get('product');
- /** @var \Drupal\commerce_product\Entity\ProductVariationInterface[] $variations */
- $variations = $this->variationStorage->loadEnabled($product);
+ $variations = $this->loadEnabledVariations($product);
if (count($variations) === 0) {
// Nothing to purchase, tell the parent form to hide itself.
$form_state->set('hide_form', TRUE);
@@ -105,11 +104,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
$selected_variation = $this->selectVariationFromUserInput($variations, $user_input);
}
else {
- $selected_variation = $this->variationStorage->loadFromContext($product);
- // The returned variation must also be enabled.
- if (!in_array($selected_variation, $variations)) {
- $selected_variation = reset($variations);
- }
+ $selected_variation = $this->getDefaultVariation($product, $variations);
}
// Set the selected variation in the form state for our AJAX callback.
diff --git a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationWidgetBase.php b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationWidgetBase.php
index 4c9fd84b00..71fe8d3cc3 100644
--- a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationWidgetBase.php
+++ b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationWidgetBase.php
@@ -2,9 +2,11 @@
namespace Drupal\commerce_product\Plugin\Field\FieldWidget;
+use Drupal\commerce_product\Entity\ProductInterface;
use Drupal\commerce_product\Entity\ProductVariation;
use Drupal\commerce_product\Event\ProductVariationAjaxChangeEvent;
use Drupal\commerce_product\Event\ProductEvents;
+use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\WidgetBase;
@@ -29,6 +31,13 @@ abstract class ProductVariationWidgetBase extends WidgetBase implements Containe
*/
protected $variationStorage;
+ /**
+ * The entity repository service.
+ *
+ * @var \Drupal\Core\Entity\EntityRepositoryInterface
+ */
+ protected $entityRepository;
+
/**
* Constructs a new ProductVariationWidgetBase object.
*
@@ -44,10 +53,13 @@ abstract class ProductVariationWidgetBase extends WidgetBase implements Containe
* Any third party settings.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
+ * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+ * The entity repository.
*/
- public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager) {
+ public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, EntityRepositoryInterface $entity_repository) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
+ $this->entityRepository = $entity_repository;
$this->variationStorage = $entity_type_manager->getStorage('commerce_product_variation');
}
@@ -61,7 +73,8 @@ public static function create(ContainerInterface $container, array $configuratio
$configuration['field_definition'],
$configuration['settings'],
$configuration['third_party_settings'],
- $container->get('entity_type.manager')
+ $container->get('entity_type.manager'),
+ $container->get('entity.repository')
);
}
@@ -103,9 +116,14 @@ public static function ajaxRefresh(array $form, FormStateInterface $form_state)
$response = $ajax_renderer->renderResponse($form, $request, $route_match);
$variation = ProductVariation::load($form_state->get('selected_variation'));
+ /** @var \Drupal\commerce_product\Entity\ProductInterface $product */
+ $product = $form_state->get('product');
+ if ($variation->hasTranslation($product->language()->getId())) {
+ $variation = $variation->getTranslation($product->language()->getId());
+ }
/** @var \Drupal\commerce_product\ProductVariationFieldRendererInterface $variation_field_renderer */
$variation_field_renderer = \Drupal::service('commerce_product.variation_field_renderer');
- $view_mode = $form_state->get('form_display')->getMode();
+ $view_mode = $form_state->get('view_mode');
$variation_field_renderer->replaceRenderedFields($response, $variation, $view_mode);
// Allow modules to add arbitrary ajax commands to the response.
$event = new ProductVariationAjaxChangeEvent($variation, $response, $view_mode);
@@ -115,4 +133,44 @@ public static function ajaxRefresh(array $form, FormStateInterface $form_state)
return $response;
}
+ /**
+ * Gets the default variation for the widget.
+ *
+ * @param \Drupal\commerce_product\Entity\ProductInterface $product
+ * The product.
+ * @param array $variations
+ * An array of available variations.
+ *
+ * @return \Drupal\commerce_product\Entity\ProductVariationInterface
+ * The default variation.
+ */
+ protected function getDefaultVariation(ProductInterface $product, array $variations) {
+ $langcode = $product->language()->getId();
+ $selected_variation = $this->variationStorage->loadFromContext($product);
+ $selected_variation = $this->entityRepository->getTranslationFromContext($selected_variation, $langcode);
+ // The returned variation must also be enabled.
+ if (!in_array($selected_variation, $variations)) {
+ $selected_variation = reset($variations);
+ }
+ return $selected_variation;
+ }
+
+ /**
+ * Gets the enabled variations for the product.
+ *
+ * @param \Drupal\commerce_product\Entity\ProductInterface $product
+ * The product.
+ *
+ * @return \Drupal\commerce_product\Entity\ProductVariationInterface[]
+ * An array of variations.
+ */
+ protected function loadEnabledVariations(ProductInterface $product) {
+ $langcode = $product->language()->getId();
+ $variations = $this->variationStorage->loadEnabled($product);
+ foreach ($variations as $key => $variation) {
+ $variations[$key] = $this->entityRepository->getTranslationFromContext($variation, $langcode);
+ }
+ return $variations;
+ }
+
}
diff --git a/modules/product/src/Plugin/Validation/Constraint/ProductVariationSkuConstraintValidator.php b/modules/product/src/Plugin/Validation/Constraint/ProductVariationSkuConstraintValidator.php
index bb9f3de731..36c27eb7e6 100644
--- a/modules/product/src/Plugin/Validation/Constraint/ProductVariationSkuConstraintValidator.php
+++ b/modules/product/src/Plugin/Validation/Constraint/ProductVariationSkuConstraintValidator.php
@@ -14,7 +14,11 @@ class ProductVariationSkuConstraintValidator extends ConstraintValidator {
* {@inheritdoc}
*/
public function validate($items, Constraint $constraint) {
- $sku = $items->first()->value;
+ if (!$item = $items->first()) {
+ return;
+ }
+
+ $sku = $item->value;
if (isset($sku) && $sku !== '') {
$sku_exists = (bool) \Drupal::entityQuery('commerce_product_variation')
->condition('sku', $sku)
diff --git a/modules/product/src/Plugin/views/argument_default/Product.php b/modules/product/src/Plugin/views/argument_default/Product.php
new file mode 100644
index 0000000000..8f631350b2
--- /dev/null
+++ b/modules/product/src/Plugin/views/argument_default/Product.php
@@ -0,0 +1,82 @@
+routeMatch = $route_match;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get('current_route_match')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getArgument() {
+ if (($product = $this->routeMatch->getParameter('commerce_product')) && $product instanceof ProductInterface) {
+ return $product->id();
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheMaxAge() {
+ return Cache::PERMANENT;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheContexts() {
+ return ['url'];
+ }
+
+}
diff --git a/modules/product/src/PreparedAttribute.php b/modules/product/src/PreparedAttribute.php
new file mode 100644
index 0000000000..4f26e06816
--- /dev/null
+++ b/modules/product/src/PreparedAttribute.php
@@ -0,0 +1,136 @@
+id = $definition['id'];
+ $this->label = $definition['label'];
+ $this->elementType = $definition['element_type'];
+ $this->required = $definition['required'];
+ $this->values = $definition['values'];
+ }
+
+ /**
+ * Gets the ID.
+ *
+ * @return string
+ * The ID.
+ */
+ public function getId() {
+ return $this->id;
+ }
+
+ /**
+ * Gets the label.
+ *
+ * @return string
+ * The label.
+ */
+ public function getLabel() {
+ return $this->label;
+ }
+
+ /**
+ * Gets the element type.
+ *
+ * @return string
+ * The element type.
+ */
+ public function getElementType() {
+ return $this->elementType;
+ }
+
+ /**
+ * Gets whether the attribute is required.
+ *
+ * @return bool
+ * TRUE if the attribute is required, FALSE otherwise.
+ */
+ public function isRequired() {
+ return $this->required;
+ }
+
+ /**
+ * Gets the attribute values.
+ *
+ * @return string[]
+ * The attribute values.
+ */
+ public function getValues() {
+ return $this->values;
+ }
+
+ /**
+ * Gets the array representation of the prepared attribute.
+ *
+ * @return array
+ * The array representation of the prepared attribute.
+ */
+ public function toArray() {
+ return [
+ 'id' => $this->id,
+ 'label' => $this->label,
+ 'element_type' => $this->elementType,
+ 'required' => $this->required,
+ 'values' => $this->values,
+ ];
+ }
+
+}
diff --git a/modules/product/src/ProductLazyBuilders.php b/modules/product/src/ProductLazyBuilders.php
index 1a259124b0..92db20ef3c 100644
--- a/modules/product/src/ProductLazyBuilders.php
+++ b/modules/product/src/ProductLazyBuilders.php
@@ -2,6 +2,7 @@
namespace Drupal\commerce_product;
+use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Form\FormState;
@@ -25,6 +26,13 @@ class ProductLazyBuilders {
*/
protected $formBuilder;
+ /**
+ * The entity repository.
+ *
+ * @var \Drupal\Core\Entity\EntityRepositoryInterface
+ */
+ protected $entityRepository;
+
/**
* Constructs a new CartLazyBuilders object.
*
@@ -32,10 +40,13 @@ class ProductLazyBuilders {
* The entity type manager.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The form builder.
+ * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+ * The entity repository.
*/
- public function __construct(EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder) {
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, EntityRepositoryInterface $entity_repository) {
$this->entityTypeManager = $entity_type_manager;
$this->formBuilder = $form_builder;
+ $this->entityRepository = $entity_repository;
}
/**
@@ -47,15 +58,20 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager, For
* The view mode used to render the product.
* @param bool $combine
* TRUE to combine order items containing the same product variation.
+ * @param string $langcode
+ * The langcode for the language that should be used in form.
*
* @return array
* A renderable array containing the cart form.
*/
- public function addToCartForm($product_id, $view_mode, $combine) {
+ public function addToCartForm($product_id, $view_mode, $combine, $langcode) {
/** @var \Drupal\commerce_order\OrderItemStorageInterface $order_item_storage */
$order_item_storage = $this->entityTypeManager->getStorage('commerce_order_item');
/** @var \Drupal\commerce_product\Entity\ProductInterface $product */
$product = $this->entityTypeManager->getStorage('commerce_product')->load($product_id);
+ // Load Product for current language.
+ $product = $this->entityRepository->getTranslationFromContext($product, $langcode);
+
$default_variation = $product->getDefaultVariation();
if (!$default_variation) {
return [];
diff --git a/modules/product/src/ProductVariationAttributeMapper.php b/modules/product/src/ProductVariationAttributeMapper.php
new file mode 100644
index 0000000000..95896c99b0
--- /dev/null
+++ b/modules/product/src/ProductVariationAttributeMapper.php
@@ -0,0 +1,160 @@
+attributeFieldManager = $attribute_field_manager;
+ $this->attributeStorage = $entity_type_manager->getStorage('commerce_product_attribute');
+ $this->entityRepository = $entity_repository;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function selectVariation(array $variations, array $attribute_values = []) {
+ $selected_variation = reset($variations);
+ // Select the first variation that matches the most attribute values.
+ // Start with all attribute values, reduce them by 1 until a match is found.
+ while (!empty($attribute_values)) {
+ foreach ($variations as $variation) {
+ $match = TRUE;
+ foreach ($attribute_values as $field_name => $attribute_value_id) {
+ if ($variation->getAttributeValueId($field_name) != $attribute_value_id) {
+ $match = FALSE;
+ }
+ }
+ if ($match) {
+ $selected_variation = $variation;
+ break 2;
+ }
+ }
+ array_pop($attribute_values);
+ }
+
+ return $selected_variation;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function prepareAttributes(ProductVariationInterface $selected_variation, array $variations) {
+ $attributes = [];
+ $field_definitions = $this->attributeFieldManager->getFieldDefinitions($selected_variation->bundle());
+ $field_map = $this->attributeFieldManager->getFieldMap($selected_variation->bundle());
+ $field_names = array_column($field_map, 'field_name');
+ $attribute_ids = array_column($field_map, 'attribute_id');
+ $index = 0;
+ foreach ($field_names as $field_name) {
+ $field = $field_definitions[$field_name];
+ /** @var \Drupal\commerce_product\Entity\ProductAttributeInterface $attribute */
+ $attribute = $this->attributeStorage->load($attribute_ids[$index]);
+ // Make sure we have translation for attribute.
+ $attribute = $this->entityRepository->getTranslationFromContext($attribute, $selected_variation->language()->getId());
+
+ $definition = [
+ 'id' => $attribute->id(),
+ 'label' => $attribute->label(),
+ 'element_type' => $attribute->getElementType(),
+ 'required' => $field->isRequired(),
+ ];
+ // The first attribute gets all values. Every next attribute gets only
+ // the values from variations matching the previous attribute value.
+ // For 'Color' and 'Size' attributes that means getting the colors of all
+ // variations, but only the sizes of variations with the selected color.
+ $callback = NULL;
+ if ($index > 0) {
+ $index_limit = $index - 1;
+ // Get all previous field values.
+ $previous_variation_field_values = [];
+ for ($i = 0; $i <= $index_limit; $i++) {
+ $previous_variation_field_values[$field_names[$i]] = $selected_variation->getAttributeValueId($field_names[$i]);
+ }
+
+ $callback = function (ProductVariationInterface $variation) use ($previous_variation_field_values) {
+ $results = [];
+ foreach ($previous_variation_field_values as $previous_field_name => $previous_field_value) {
+ $results[] = $variation->getAttributeValueId($previous_field_name) == $previous_field_value;
+ }
+ return !in_array(FALSE, $results, TRUE);
+ };
+ }
+ $definition['values'] = $this->getAttributeValues($variations, $field_name, $callback);
+ // Return the attribute only if it has at least one value.
+ if (!empty($definition['values'])) {
+ $attributes[$field_name] = new PreparedAttribute($definition);
+ }
+
+ $index++;
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Gets the attribute values of a given set of variations.
+ *
+ * @param \Drupal\commerce_product\Entity\ProductVariationInterface[] $variations
+ * The variations.
+ * @param string $field_name
+ * The field name of the attribute.
+ * @param callable|null $callback
+ * An optional callback to use for filtering the list.
+ *
+ * @return array[]
+ * The attribute values, keyed by attribute ID.
+ */
+ protected function getAttributeValues(array $variations, $field_name, callable $callback = NULL) {
+ $values = [];
+ foreach ($variations as $variation) {
+ if (is_null($callback) || call_user_func($callback, $variation)) {
+ $attribute_value = $variation->getAttributeValue($field_name);
+ if ($attribute_value) {
+ $values[$attribute_value->id()] = $attribute_value->label();
+ }
+ else {
+ $values['_none'] = '';
+ }
+ }
+ }
+
+ return $values;
+ }
+
+}
diff --git a/modules/product/src/ProductVariationAttributeMapperInterface.php b/modules/product/src/ProductVariationAttributeMapperInterface.php
new file mode 100644
index 0000000000..f9659ffb14
--- /dev/null
+++ b/modules/product/src/ProductVariationAttributeMapperInterface.php
@@ -0,0 +1,51 @@
+ 'foo',
'label' => 'foo',
]);
+ /** @var \Drupal\commerce_product\Entity\ProductTypeInterface $product_type */
$product_type = $this->createEntity('commerce_product_type', [
'id' => 'foo',
'label' => 'foo',
@@ -90,23 +91,32 @@ public function testProductTypeDeletion() {
]);
commerce_product_add_stores_field($product_type);
commerce_product_add_variations_field($product_type);
-
$product = $this->createEntity('commerce_product', [
'type' => $product_type->id(),
'title' => $this->randomMachineName(),
]);
+ // Confirm that the type can't be deleted while there's a product.
// @todo Make sure $product_type->delete() also does nothing if there's
// a product of that type. Right now the check is done on the form level.
- $this->drupalGet('admin/commerce/config/product-types/' . $product_type->id() . '/delete');
+ $this->drupalGet($product_type->toUrl('delete-form'));
$this->assertSession()->pageTextContains(
t('@type is used by 1 product on your site. You cannot remove this product type until you have removed all of the @type products.', ['@type' => $product_type->label()]),
'The product type will not be deleted until all products of that type are deleted.'
);
$this->assertSession()->pageTextNotContains(t('This action cannot be undone.'));
+ // Confirm that the delete page is not available when the type is locked.
+ $product_type->lock();
+ $product_type->save();
+ $this->drupalGet($product_type->toUrl('delete-form'));
+ $this->assertSession()->statusCodeEquals('403');
+
+ // Delete the product, unlock the type, confirm that deletion works.
$product->delete();
- $this->drupalGet('admin/commerce/config/product-types/' . $product_type->id() . '/delete');
+ $product_type->unlock();
+ $product_type->save();
+ $this->drupalGet($product_type->toUrl('delete-form'));
$this->assertSession()->pageTextContains(
t('Are you sure you want to delete the product type @type?', ['@type' => $product_type->label()]),
'The product type is available for deletion'
@@ -114,7 +124,7 @@ public function testProductTypeDeletion() {
$this->assertSession()->pageTextContains(t('This action cannot be undone.'));
$this->submitForm([], 'Delete');
$exists = (bool) ProductType::load($product_type->id());
- $this->assertEmpty($exists, 'The new product type has been deleted from the database.');
+ $this->assertEmpty($exists);
}
}
diff --git a/modules/product/tests/src/Functional/ProductVariationFieldInjectionTest.php b/modules/product/tests/src/Functional/ProductVariationFieldInjectionTest.php
index 087e31f302..a06b301cc7 100644
--- a/modules/product/tests/src/Functional/ProductVariationFieldInjectionTest.php
+++ b/modules/product/tests/src/Functional/ProductVariationFieldInjectionTest.php
@@ -80,6 +80,7 @@ public function testInjectedVariationDefault() {
$this->assertSession()->pageTextContains('Testing product variation field injection!');
$this->assertSession()->pageTextContains('Price');
$this->assertSession()->pageTextContains('$999.00');
+ $this->assertSession()->elementNotExists('css', 'div[data-quickedit-field-id="commerce_product_variation/*"]');
// We hide the SKU by default.
$this->assertSession()->pageTextNotContains('INJECTION-CYAN');
@@ -111,4 +112,35 @@ public function testInjectedVariationDefault() {
$this->assertSession()->pageTextContains('$999.00');
}
+ /**
+ * Tests that the default injected variation respects the URL context.
+ */
+ public function testInjectedVariationFromUrl() {
+ $this->drupalGet($this->product->toUrl());
+ // We hide the SKU by default.
+ $this->assertSession()->pageTextNotContains('INJECTION-CYAN');
+
+ /** @var \Drupal\Core\Entity\Entity\EntityViewDisplay $variation_view_display */
+ $variation_view_display = commerce_get_entity_display('commerce_product_variation', 'default', 'view');
+ $variation_view_display->removeComponent('title');
+ $variation_view_display->setComponent('attribute_color', [
+ 'label' => 'above',
+ 'type' => 'entity_reference_label',
+ ]);
+ $variation_view_display->setComponent('sku', [
+ 'label' => 'hidden',
+ 'type' => 'string',
+ ]);
+ $variation_view_display->save();
+
+ $this->drupalGet($this->product->toUrl());
+ $this->assertSession()->pageTextContains('INJECTION-CYAN');
+
+ $variations = $this->product->getVariations();
+ foreach ($variations as $variation) {
+ $this->drupalGet($variation->toUrl());
+ $this->assertSession()->pageTextContains($variation->label());
+ }
+ }
+
}
diff --git a/modules/product/tests/src/Functional/ProductVariationTypeTest.php b/modules/product/tests/src/Functional/ProductVariationTypeTest.php
index fd4e95ae3c..1cc317172f 100644
--- a/modules/product/tests/src/Functional/ProductVariationTypeTest.php
+++ b/modules/product/tests/src/Functional/ProductVariationTypeTest.php
@@ -79,6 +79,7 @@ public function testProductVariationTypeEditing() {
* Tests deleting a product variation type via a form.
*/
public function testProductVariationTypeDeletion() {
+ /** @var \Drupal\commerce_product\Entity\ProductTypeInterface $variation_type */
$variation_type = $this->createEntity('commerce_product_variation_type', [
'id' => 'foo',
'label' => 'foo',
@@ -89,17 +90,25 @@ public function testProductVariationTypeDeletion() {
'title' => $this->randomMachineName(),
]);
- // @todo Make sure $variation_type->delete() also does nothing if there's
- // a variation of that type. Right now the check is done on the form level.
- $this->drupalGet('admin/commerce/config/product-variation-types/' . $variation_type->id() . '/delete');
+ // Confirm that the type can't be deleted while there's a variation.
+ $this->drupalGet($variation_type->toUrl('delete-form'));
$this->assertSession()->pageTextContains(
t('@type is used by 1 product variation on your site. You cannot remove this product variation type until you have removed all of the @type product variations.', ['@type' => $variation_type->label()]),
'The product variation type will not be deleted until all variations of that type are deleted.'
);
$this->assertSession()->pageTextNotContains(t('This action cannot be undone.'), 'The product variation type deletion confirmation form is not available');
+ // Confirm that the delete page is not available when the type is locked.
+ $variation_type->lock();
+ $variation_type->save();
+ $this->drupalGet($variation_type->toUrl('delete-form'));
+ $this->assertSession()->statusCodeEquals('403');
+
+ // Delete the variation, unlock the type, confirm that deletion works.
$variation->delete();
- $this->drupalGet('admin/commerce/config/product-variation-types/' . $variation_type->id() . '/delete');
+ $variation_type->unlock();
+ $variation_type->save();
+ $this->drupalGet($variation_type->toUrl('delete-form'));
$this->assertSession()->pageTextContains(
t('Are you sure you want to delete the product variation type @type?', ['@type' => $variation_type->label()]),
'The product variation type is available for deletion'
@@ -107,7 +116,7 @@ public function testProductVariationTypeDeletion() {
$this->assertSession()->pageTextContains(t('This action cannot be undone.'));
$this->getSession()->getPage()->pressButton('Delete');
$exists = (bool) ProductVariationType::load($variation_type->id());
- $this->assertEmpty($exists, 'The new product variation type has been deleted from the database.');
+ $this->assertEmpty($exists);
}
/**
diff --git a/modules/product/tests/src/Kernel/ProductVariationAttributeMapperTest.php b/modules/product/tests/src/Kernel/ProductVariationAttributeMapperTest.php
new file mode 100644
index 0000000000..7e9a9c54fb
--- /dev/null
+++ b/modules/product/tests/src/Kernel/ProductVariationAttributeMapperTest.php
@@ -0,0 +1,686 @@
+installEntitySchema('commerce_product_variation');
+ $this->installEntitySchema('commerce_product');
+ $this->installEntitySchema('commerce_product_attribute');
+ $this->installEntitySchema('commerce_product_attribute_value');
+ $this->installConfig(['commerce_product']);
+
+ $this->attributeFieldManager = $this->container->get('commerce_product.attribute_field_manager');
+ $this->mapper = $this->container->get('commerce_product.variation_attribute_mapper');
+ $variation_type = ProductVariationType::load('default');
+
+ $this->colorAttributes = $this->createAttributeSet($variation_type, 'color', [
+ 'black' => 'Black',
+ 'blue' => 'Blue',
+ 'green' => 'Green',
+ 'red' => 'Red',
+ 'white' => 'White',
+ 'yellow' => 'Yellow',
+ ]);
+ $this->sizeAttributes = $this->createAttributeSet($variation_type, 'size', [
+ 'small' => 'Small',
+ 'medium' => 'Medium',
+ 'large' => 'Large',
+ ]);
+ $this->ramAttributes = $this->createAttributeSet($variation_type, 'ram', [
+ '4gb' => '4GB',
+ '8gb' => '8GB',
+ '16gb' => '16GB',
+ '32gb' => '32GB',
+ ]);
+ $this->disk1Attributes = $this->createAttributeSet($variation_type, 'disk1', [
+ '1tb' => '1TB',
+ '2tb' => '2TB',
+ '3tb' => '3TB',
+ ]);
+ $this->disk2Attributes = $this->createAttributeSet($variation_type, 'disk2', [
+ '1tb' => '1TB',
+ '2tb' => '2TB',
+ '3tb' => '3TB',
+ ]);
+ }
+
+ /**
+ * Tests selecting a variation.
+ *
+ * @covers ::selectVariation
+ */
+ public function testSelect() {
+ $product = $this->generateThreeByTwoScenario();
+ $variations = $product->getVariations();
+
+ // No attribute values.
+ $selected_variation = $this->mapper->selectVariation($product->getVariations());
+ $this->assertEquals($product->getDefaultVariation()->id(), $selected_variation->id());
+
+ // Empty attribute values.
+ $selected_variation = $this->mapper->selectVariation($product->getVariations(), [
+ 'attribute_color' => '',
+ 'attribute_size' => '',
+ ]);
+ $this->assertEquals($product->getDefaultVariation()->id(), $selected_variation->id());
+
+ // Single attribute value.
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_color' => $this->colorAttributes['blue']->id(),
+ ]);
+ $this->assertEquals($variations[3]->id(), $selected_variation->id());
+
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_size' => $this->sizeAttributes['large']->id(),
+ ]);
+ $this->assertEquals($variations[2]->id(), $selected_variation->id());
+
+ // Two attribute values.
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_color' => $this->colorAttributes['red']->id(),
+ 'attribute_size' => $this->sizeAttributes['large']->id(),
+ ]);
+ $this->assertEquals($variations[2]->id(), $selected_variation->id());
+
+ // Invalid attribute combination.
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_color' => $this->colorAttributes['blue']->id(),
+ 'attribute_size' => $this->sizeAttributes['large']->id(),
+ ]);
+ $this->assertEquals($variations[3]->id(), $selected_variation->id());
+ $this->assertEquals('Blue', $selected_variation->getAttributeValue('attribute_color')->label());
+ $this->assertEquals('Small', $selected_variation->getAttributeValue('attribute_size')->label());
+
+ // Missing first attribute.
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_color' => '',
+ 'attribute_size' => $this->sizeAttributes['large']->id(),
+ ]);
+ $this->assertEquals($product->getDefaultVariation()->id(), $selected_variation->id());
+ }
+
+ /**
+ * Tests selecting a variation when there are optional attributes.
+ *
+ * @covers ::selectVariation
+ */
+ public function testSelectWithOptionalAttributes() {
+ $product = $this->generateThreeByTwoOptionalScenario();
+ $variations = $product->getVariations();
+
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_ram' => $this->ramAttributes['16gb']->id(),
+ ]);
+ $this->assertEquals($variations[1]->id(), $selected_variation->id());
+
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_ram' => $this->ramAttributes['16gb']->id(),
+ 'attribute_disk1' => $this->disk1Attributes['1tb']->id(),
+ 'attribute_disk2' => $this->disk2Attributes['1tb']->id(),
+ ]);
+ $this->assertEquals($variations[2]->id(), $selected_variation->id());
+
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_ram' => $this->ramAttributes['16gb']->id(),
+ 'attribute_disk1' => $this->disk1Attributes['1tb']->id(),
+ 'attribute_disk2' => $this->disk2Attributes['2tb']->id(),
+ ]);
+ // Falls back to 16GBx1TB, 16GBx1TBx2TB is invalid.
+ $this->assertEquals($variations[1]->id(), $selected_variation->id());
+ }
+
+ /**
+ * Tests preparing attributes.
+ *
+ * @covers ::prepareAttributes
+ */
+ public function testPrepareAttributes() {
+ $product = $this->generateThreeByTwoScenario();
+ $variations = $product->getVariations();
+
+ // Test from the initial variation.
+ $attributes = $this->mapper->prepareAttributes(reset($variations), $variations);
+
+ $color_attribute = $attributes['attribute_color'];
+ $this->assertEquals('color', $color_attribute->getId());
+ $this->assertEquals('Color', $color_attribute->getLabel());
+ $this->assertEquals('select', $color_attribute->getElementType());
+ $this->assertTrue($color_attribute->isRequired());
+ $this->assertEquals(['2' => 'Blue', '4' => 'Red'], $color_attribute->getValues());
+
+ $size_attribute = $attributes['attribute_size'];
+ $this->assertEquals('size', $size_attribute->getId());
+ $this->assertEquals('Size', $size_attribute->getLabel());
+ $this->assertEquals('select', $size_attribute->getElementType());
+ $this->assertTrue($size_attribute->isRequired());
+ $this->assertEquals(['7' => 'Small', '8' => 'Medium', '9' => 'Large'], $size_attribute->getValues());
+
+ // Test Blue Medium.
+ $attributes = $this->mapper->prepareAttributes($variations[4], $variations);
+
+ $color_attribute = $attributes['attribute_color'];
+ $this->assertEquals('color', $color_attribute->getId());
+ $this->assertEquals('Color', $color_attribute->getLabel());
+ $this->assertEquals('select', $color_attribute->getElementType());
+ $this->assertTrue($color_attribute->isRequired());
+ $this->assertEquals(['2' => 'Blue', '4' => 'Red'], $color_attribute->getValues());
+
+ $size_attribute = $attributes['attribute_size'];
+ $this->assertEquals('size', $size_attribute->getId());
+ $this->assertEquals('Size', $size_attribute->getLabel());
+ $this->assertEquals('select', $size_attribute->getElementType());
+ $this->assertTrue($size_attribute->isRequired());
+ $this->assertEquals(['7' => 'Small', '8' => 'Medium'], $size_attribute->getValues());
+ }
+
+ /**
+ * Tests preparing attributes when there are optional attributes.
+ *
+ * @covers ::prepareAttributes
+ */
+ public function testPrepareAttributesOptional() {
+ $product = $this->generateThreeByTwoOptionalScenario();
+ $variations = $product->getVariations();
+
+ // Test from the initial variation.
+ $attributes = $this->mapper->prepareAttributes(reset($variations), $variations);
+
+ $ram_attribute = $attributes['attribute_ram'];
+ $this->assertEquals('ram', $ram_attribute->getId());
+ $this->assertEquals('Ram', $ram_attribute->getLabel());
+ $this->assertEquals('select', $ram_attribute->getElementType());
+ $this->assertTrue($ram_attribute->isRequired());
+ $this->assertEquals(['11' => '8GB', '12' => '16GB'], $ram_attribute->getValues());
+
+ $disk1_attribute = $attributes['attribute_disk1'];
+ $this->assertEquals('disk1', $disk1_attribute->getId());
+ $this->assertEquals('Disk1', $disk1_attribute->getLabel());
+ $this->assertEquals('select', $disk1_attribute->getElementType());
+ $this->assertTrue($disk1_attribute->isRequired());
+ $this->assertEquals(['14' => '1TB'], $disk1_attribute->getValues());
+
+ // The Disk 2 1TB option should not show. Only "none".
+ // The default variation is 8GB x 1TB, which does not have the Disk 2 value
+ // so it should only return "_none". The Disk 2 option should have only have
+ // this option is the 16GB RAM option is chosen.
+ $disk2_attribute = $attributes['attribute_disk2'];
+ $this->assertEquals('disk2', $disk2_attribute->getId());
+ $this->assertEquals('Disk2', $disk2_attribute->getLabel());
+ $this->assertEquals('select', $disk2_attribute->getElementType());
+ $this->assertTrue($disk2_attribute->isRequired());
+ $this->assertEquals(['_none' => ''], $disk2_attribute->getValues());
+
+ // Test from the 16GB x 1TB x None variation.
+ $attributes = $this->mapper->prepareAttributes($variations[1], $variations);
+
+ $ram_attribute = $attributes['attribute_ram'];
+ $this->assertEquals('ram', $ram_attribute->getId());
+ $this->assertEquals('Ram', $ram_attribute->getLabel());
+ $this->assertEquals('select', $ram_attribute->getElementType());
+ $this->assertTrue($ram_attribute->isRequired());
+ $this->assertEquals(['11' => '8GB', '12' => '16GB'], $ram_attribute->getValues());
+
+ $disk1_attribute = $attributes['attribute_disk1'];
+ $this->assertEquals('disk1', $disk1_attribute->getId());
+ $this->assertEquals('Disk1', $disk1_attribute->getLabel());
+ $this->assertEquals('select', $disk1_attribute->getElementType());
+ $this->assertTrue($disk1_attribute->isRequired());
+ $this->assertEquals(['14' => '1TB'], $disk1_attribute->getValues());
+
+ $disk2_attribute = $attributes['attribute_disk2'];
+ $this->assertEquals('disk2', $disk2_attribute->getId());
+ $this->assertEquals('Disk2', $disk2_attribute->getLabel());
+ $this->assertEquals('select', $disk2_attribute->getElementType());
+ $this->assertTrue($disk2_attribute->isRequired());
+ $this->assertEquals(['_none' => '', '17' => '1TB'], $disk2_attribute->getValues());
+ }
+
+ /**
+ * Tests preparing attributes when the values are mutually exclusive.
+ *
+ * @covers ::prepareAttributes
+ */
+ public function testMutuallyExclusiveAttributeMatrixTwoByTwoByTwo() {
+ $product = Product::create([
+ 'type' => 'default',
+ 'title' => $this->randomMachineName(),
+ 'stores' => [$this->store],
+ 'variations' => [],
+ ]);
+ $attribute_values_matrix = [
+ ['4gb', '2tb', '2tb'],
+ ['8gb', '1tb', '2tb'],
+ ['8gb', '2tb', '1tb'],
+ ];
+ $variations = [];
+ foreach ($attribute_values_matrix as $key => $value) {
+ $variation = ProductVariation::create([
+ 'type' => 'default',
+ 'sku' => $this->randomMachineName(),
+ 'price' => [
+ 'number' => 999,
+ 'currency_code' => 'USD',
+ ],
+ 'attribute_ram' => $this->ramAttributes[$value[0]],
+ 'attribute_disk1' => $this->disk1Attributes[$value[1]],
+ 'attribute_disk2' => isset($this->disk2Attributes[$value[2]]) ? $this->disk2Attributes[$value[2]] : NULL,
+ ]);
+ $variation->save();
+ $variations[] = $variation;
+ $product->addVariation($variation);
+ }
+ $product->save();
+
+ // Test from the initial variation.
+ $attributes = $this->mapper->prepareAttributes(reset($variations), $variations);
+
+ $ram_attribute = $attributes['attribute_ram'];
+ $this->assertEquals('ram', $ram_attribute->getId());
+ $this->assertEquals('Ram', $ram_attribute->getLabel());
+ $this->assertEquals('select', $ram_attribute->getElementType());
+ $this->assertTrue($ram_attribute->isRequired());
+ $this->assertEquals(['11' => '8GB', '10' => '4GB'], $ram_attribute->getValues());
+
+ $disk1_attribute = $attributes['attribute_disk1'];
+ $this->assertEquals('disk1', $disk1_attribute->getId());
+ $this->assertEquals('Disk1', $disk1_attribute->getLabel());
+ $this->assertEquals('select', $disk1_attribute->getElementType());
+ $this->assertTrue($disk1_attribute->isRequired());
+ $this->assertNotCount(3, $disk1_attribute->getValues(), 'Out of the three available attribute values, only the one used is returned.');
+ $this->assertCount(1, $disk1_attribute->getValues());
+ $this->assertEquals(['15' => '2TB'], $disk1_attribute->getValues());
+
+ $disk2_attribute = $attributes['attribute_disk2'];
+ $this->assertEquals('disk2', $disk2_attribute->getId());
+ $this->assertEquals('Disk2', $disk2_attribute->getLabel());
+ $this->assertEquals('select', $disk2_attribute->getElementType());
+ $this->assertTrue($disk2_attribute->isRequired());
+ $this->assertEquals(['18' => '2TB'], $disk2_attribute->getValues());
+
+ // Test 8GB x 1TB x 2TB.
+ $attributes = $this->mapper->prepareAttributes($variations[1], $variations);
+
+ $ram_attribute = $attributes['attribute_ram'];
+ $this->assertEquals('ram', $ram_attribute->getId());
+ $this->assertEquals('Ram', $ram_attribute->getLabel());
+ $this->assertEquals('select', $ram_attribute->getElementType());
+ $this->assertTrue($ram_attribute->isRequired());
+ $this->assertNotCount(4, $ram_attribute->getValues(), 'Out of the four available attribute values, only the two used are returned.');
+ $this->assertCount(2, $ram_attribute->getValues());
+ $this->assertEquals(['11' => '8GB', '10' => '4GB'], $ram_attribute->getValues());
+
+ $disk1_attribute = $attributes['attribute_disk1'];
+ $this->assertEquals('disk1', $disk1_attribute->getId());
+ $this->assertEquals('Disk1', $disk1_attribute->getLabel());
+ $this->assertEquals('select', $disk1_attribute->getElementType());
+ $this->assertTrue($disk1_attribute->isRequired());
+ $this->assertEquals(['15' => '2TB', '14' => '1TB'], $disk1_attribute->getValues());
+
+ $disk2_attribute = $attributes['attribute_disk2'];
+ $this->assertEquals('disk2', $disk2_attribute->getId());
+ $this->assertEquals('Disk2', $disk2_attribute->getLabel());
+ $this->assertEquals('select', $disk2_attribute->getElementType());
+ $this->assertTrue($disk2_attribute->isRequired());
+ // There should only be one Disk 2 option, since the other 8GB RAM option
+ // has a Disk 1 value of 2TB.
+ $this->assertEquals(['18' => '2TB'], $disk2_attribute->getValues());
+
+ // Test 8GB x 2TB x 1TB.
+ $attributes = $this->mapper->prepareAttributes($variations[2], $variations);
+
+ $ram_attribute = $attributes['attribute_ram'];
+ $this->assertEquals('ram', $ram_attribute->getId());
+ $this->assertEquals('Ram', $ram_attribute->getLabel());
+ $this->assertEquals('select', $ram_attribute->getElementType());
+ $this->assertTrue($ram_attribute->isRequired());
+ $this->assertEquals(['11' => '8GB', '10' => '4GB'], $ram_attribute->getValues());
+
+ $disk1_attribute = $attributes['attribute_disk1'];
+ $this->assertEquals('disk1', $disk1_attribute->getId());
+ $this->assertEquals('Disk1', $disk1_attribute->getLabel());
+ $this->assertEquals('select', $disk1_attribute->getElementType());
+ $this->assertTrue($disk1_attribute->isRequired());
+ $this->assertEquals(['15' => '2TB', '14' => '1TB'], $disk1_attribute->getValues());
+
+ $disk2_attribute = $attributes['attribute_disk2'];
+ $this->assertEquals('disk2', $disk2_attribute->getId());
+ $this->assertEquals('Disk2', $disk2_attribute->getLabel());
+ $this->assertEquals('select', $disk2_attribute->getElementType());
+ $this->assertTrue($disk2_attribute->isRequired());
+ // There should only be one Disk 2 option, since the other 8GB RAM option
+ // has a Disk 1 value of 2TB.
+ $this->assertEquals(['17' => '1TB'], $disk2_attribute->getValues());
+ }
+
+ /**
+ * Tests having three attributes and six variations.
+ *
+ * @covers ::selectVariation
+ * @covers ::prepareAttributes
+ */
+ public function testThreeAttributesSixVariations() {
+ $variation_type = ProductVariationType::load('default');
+
+ $pack = $this->createAttributeSet($variation_type, 'pack', [
+ 'one' => '1',
+ 'twenty' => '20',
+ 'hundred' => '100',
+ 'twohundred' => '200',
+ ]);
+
+ $product = Product::create([
+ 'type' => 'default',
+ 'title' => $this->randomMachineName(),
+ 'stores' => [$this->store],
+ 'variations' => [],
+ ]);
+ $product->save();
+
+ // The Size attribute needs a lighter weight than Color for this scenario.
+ // @todo This is an undocumented item, where the order of the attributes on
+ // the form display correlate to how they display in the widget / returned
+ // values.
+ $form_display = commerce_get_entity_display('commerce_product_variation', $variation_type->id(), 'form');
+ $form_display->setComponent('attribute_size', ['weight' => 0] + $form_display->getComponent('attribute_size'));
+ $form_display->setComponent('attribute_color', ['weight' => 1] + $form_display->getComponent('attribute_color'));
+ $form_display->setComponent('attribute_pack', ['weight' => 2] + $form_display->getComponent('attribute_pack'));
+ $form_display->save();
+
+ $attribute_values_matrix = [
+ ['small', 'black', 'one'],
+ ['small', 'blue', 'twenty'],
+ ['medium', 'green', 'hundred'],
+ ['medium', 'red', 'twohundred'],
+ ['large', 'white', 'hundred'],
+ ['large', 'yellow', 'twenty'],
+ ];
+ $variations = [];
+ foreach ($attribute_values_matrix as $key => $value) {
+ $variation = ProductVariation::create([
+ 'type' => 'default',
+ 'sku' => $this->randomMachineName(),
+ 'price' => [
+ 'number' => 999,
+ 'currency_code' => 'USD',
+ ],
+ 'attribute_size' => $this->sizeAttributes[$value[0]],
+ 'attribute_color' => $this->colorAttributes[$value[1]],
+ 'attribute_pack' => $pack[$value[2]],
+ ]);
+ $variation->save();
+ $variations[] = $variation;
+ $product->addVariation($variation);
+ }
+ $product->save();
+
+ /** @var \Drupal\commerce_product\Entity\ProductVariation[] $variations */
+ $variations = $product->getVariations();
+ $selected_variation = $this->mapper->selectVariation($product->getVariations());
+ $this->assertEquals($product->getDefaultVariation()->id(), $selected_variation->id());
+ $this->assertEquals('Small', $selected_variation->getAttributeValue('attribute_size')->label());
+ $this->assertEquals('Black', $selected_variation->getAttributeValue('attribute_color')->label());
+ $this->assertEquals('1', $selected_variation->getAttributeValue('attribute_pack')->label());
+
+ // Verify available attribute selections.
+ $attributes = $this->mapper->prepareAttributes($selected_variation, $product->getVariations());
+ $size_attribute = $attributes['attribute_size'];
+ $this->assertEquals(['7' => 'Small', '8' => 'Medium', '9' => 'Large'], $size_attribute->getValues());
+ $color_attribute = $attributes['attribute_color'];
+ $this->assertEquals(['2' => 'Blue', '1' => 'Black'], $color_attribute->getValues());
+ $pack_attribute = $attributes['attribute_pack'];
+ // The resolved variation is Small -> Black -> 1, cannot choose 20 for the
+ // pack size, since that is Small -> Blue -> 20.
+ $this->assertEquals(['20' => '1'], $pack_attribute->getValues());
+
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_size' => $this->sizeAttributes['small']->id(),
+ 'attribute_color' => $this->colorAttributes['blue']->id(),
+ ]);
+ $this->assertEquals($variations[1]->id(), $selected_variation->id());
+
+ // Medium only has Green & Red as color, so selecting this size should
+ // cause the color to reset.
+ $selected_variation = $this->mapper->selectVariation($variations, [
+ 'attribute_size' => $this->sizeAttributes['medium']->id(),
+ 'attribute_color' => $this->colorAttributes['blue']->id(),
+ ]);
+ $this->assertEquals($variations[2]->id(), $selected_variation->id());
+ $this->assertEquals('Medium', $selected_variation->getAttributeValue('attribute_size')->label());
+ $this->assertEquals('Green', $selected_variation->getAttributeValue('attribute_color')->label());
+ $this->assertEquals('100', $selected_variation->getAttributeValue('attribute_pack')->label());
+
+ // Verify available attribute selections.
+ $attributes = $this->mapper->prepareAttributes($selected_variation, $product->getVariations());
+ $size_attribute = $attributes['attribute_size'];
+ $this->assertEquals(['7' => 'Small', '8' => 'Medium', '9' => 'Large'], $size_attribute->getValues());
+ $color_attribute = $attributes['attribute_color'];
+ $this->assertEquals(['3' => 'Green', '4' => 'Red'], $color_attribute->getValues());
+ $pack_attribute = $attributes['attribute_pack'];
+ // The resolved variation is Medium -> Green -> 100, cannot choose 200 for
+ // the pack size, since that is Medium -> Red -> 200.
+ $this->assertEquals(['22' => '100'], $pack_attribute->getValues());
+ }
+
+ /**
+ * Generates a three by two scenario.
+ *
+ * There are three sizes and two colors. Missing one color option.
+ * Generated product variations:
+ * [ RS, RM, RL ]
+ * [ BS, BM, X ]
+ *
+ * @return \Drupal\commerce_product\Entity\ProductInterface
+ * The product.
+ */
+ protected function generateThreeByTwoScenario() {
+ $product = Product::create([
+ 'type' => 'default',
+ 'title' => $this->randomMachineName(),
+ 'stores' => [$this->store],
+ 'variations' => [],
+ ]);
+ $attribute_values_matrix = [
+ ['red', 'small'],
+ ['red', 'medium'],
+ ['red', 'large'],
+ ['blue', 'small'],
+ ['blue', 'medium'],
+ ];
+ $variations = [];
+ foreach ($attribute_values_matrix as $key => $value) {
+ $variation = ProductVariation::create([
+ 'type' => 'default',
+ 'sku' => $this->randomMachineName(),
+ 'price' => [
+ 'number' => 999,
+ 'currency_code' => 'USD',
+ ],
+ 'attribute_color' => $this->colorAttributes[$value[0]],
+ 'attribute_size' => $this->sizeAttributes[$value[1]],
+ ]);
+ $variation->save();
+ $variations[] = $variation;
+ $product->addVariation($variation);
+ }
+ $product->save();
+
+ return $product;
+ }
+
+ /**
+ * Generates a three by two (optional) scenario.
+ *
+ * Generated product variations:
+ * [ 8GBx1TB, X , X ]
+ * [ X , 16GBx1TB , X ]
+ * [ X , 16GBx1TBx1TB, X ]
+ *
+ * @return \Drupal\commerce_product\Entity\ProductInterface
+ * The product.
+ */
+ protected function generateThreeByTwoOptionalScenario() {
+ $product = Product::create([
+ 'type' => 'default',
+ 'title' => $this->randomMachineName(),
+ 'stores' => [$this->store],
+ 'variations' => [],
+ ]);
+ $attribute_values_matrix = [
+ ['8gb', '1tb', ''],
+ ['16gb', '1tb', ''],
+ ['16gb', '1tb', '1tb'],
+ ];
+ $variations = [];
+ foreach ($attribute_values_matrix as $key => $value) {
+ $variation = ProductVariation::create([
+ 'type' => 'default',
+ 'sku' => $this->randomMachineName(),
+ 'price' => [
+ 'number' => 999,
+ 'currency_code' => 'USD',
+ ],
+ 'attribute_ram' => $this->ramAttributes[$value[0]],
+ 'attribute_disk1' => $this->disk1Attributes[$value[1]],
+ 'attribute_disk2' => isset($this->disk2Attributes[$value[2]]) ? $this->disk2Attributes[$value[2]] : NULL,
+ ]);
+ $variation->save();
+ $variations[] = $variation;
+ $product->addVariation($variation);
+ }
+ $product->save();
+
+ return $product;
+ }
+
+ /**
+ * Creates an attribute field and set of attribute values.
+ *
+ * @param \Drupal\commerce_product\Entity\ProductVariationTypeInterface $variation_type
+ * The variation type.
+ * @param string $name
+ * The attribute field name.
+ * @param array $options
+ * Associative array of key name values. [red => Red].
+ *
+ * @return \Drupal\commerce_product\Entity\ProductAttributeValueInterface[]
+ * Array of attribute entities.
+ */
+ protected function createAttributeSet(ProductVariationTypeInterface $variation_type, $name, array $options) {
+ $attribute = ProductAttribute::create([
+ 'id' => $name,
+ 'label' => ucfirst($name),
+ ]);
+ $attribute->save();
+ $this->attributeFieldManager->createField($attribute, $variation_type->id());
+
+ $attribute_set = [];
+ foreach ($options as $key => $value) {
+ $attribute_set[$key] = $this->createAttributeValue($name, $value);
+ }
+
+ return $attribute_set;
+ }
+
+ /**
+ * Creates an attribute value.
+ *
+ * @param string $attribute
+ * The attribute ID.
+ * @param string $name
+ * The attribute value name.
+ *
+ * @return \Drupal\commerce_product\Entity\ProductAttributeValueInterface
+ * The attribute value entity.
+ */
+ protected function createAttributeValue($attribute, $name) {
+ $attribute_value = ProductAttributeValue::create([
+ 'attribute' => $attribute,
+ 'name' => $name,
+ ]);
+ $attribute_value->save();
+
+ return $attribute_value;
+ }
+
+}
diff --git a/modules/promotion/commerce_promotion.info.yml b/modules/promotion/commerce_promotion.info.yml
index 11ed6052c8..7cba720d77 100644
--- a/modules/promotion/commerce_promotion.info.yml
+++ b/modules/promotion/commerce_promotion.info.yml
@@ -4,7 +4,6 @@ description: 'Provides a UI for managing promotions.'
package: Commerce
core: 8.x
dependencies:
- - options
- - inline_entity_form
- - commerce
+ - commerce:commerce
- commerce:commerce_order
+ - drupal:options
diff --git a/modules/promotion/commerce_promotion.links.action.yml b/modules/promotion/commerce_promotion.links.action.yml
index 66dda9b1e9..85149571a0 100644
--- a/modules/promotion/commerce_promotion.links.action.yml
+++ b/modules/promotion/commerce_promotion.links.action.yml
@@ -9,3 +9,9 @@ entity.commerce_promotion_coupon.add_form:
title: 'Add coupon'
appears_on:
- entity.commerce_promotion_coupon.collection
+
+entity.commerce_promotion_coupon.generate_form:
+ route_name: entity.commerce_promotion_coupon.generate_form
+ title: 'Generate coupons'
+ appears_on:
+ - entity.commerce_promotion_coupon.collection
diff --git a/modules/promotion/commerce_promotion.module b/modules/promotion/commerce_promotion.module
index 05c517e8c1..9395bab46f 100644
--- a/modules/promotion/commerce_promotion.module
+++ b/modules/promotion/commerce_promotion.module
@@ -49,7 +49,7 @@ function template_preprocess_commerce_promotion(array &$variables) {
$promotion = $variables['elements']['#commerce_promotion'];
$variables['promotion_entity'] = $promotion;
- $variables['promotion_url'] = $promotion->toUrl();
+ $variables['promotion_url'] = $promotion->isNew() ? '' : $promotion->toUrl();
$variables['promotion'] = [];
foreach (Element::children($variables['elements']) as $key) {
$variables['promotion'][$key] = $variables['elements'][$key];
diff --git a/modules/promotion/commerce_promotion.permissions.yml b/modules/promotion/commerce_promotion.permissions.yml
new file mode 100644
index 0000000000..977c1ab7b3
--- /dev/null
+++ b/modules/promotion/commerce_promotion.permissions.yml
@@ -0,0 +1,2 @@
+bulk generate commerce_promotion_coupon:
+ title: 'Bulk generate coupons'
diff --git a/modules/promotion/commerce_promotion.routing.yml b/modules/promotion/commerce_promotion.routing.yml
index 0a949b02d3..594ae95717 100644
--- a/modules/promotion/commerce_promotion.routing.yml
+++ b/modules/promotion/commerce_promotion.routing.yml
@@ -10,3 +10,16 @@ entity.commerce_promotion_coupon.collection:
type: 'entity:commerce_promotion'
requirements:
_entity_access: 'commerce_promotion.update'
+
+entity.commerce_promotion_coupon.generate_form:
+ path: '/promotion/{commerce_promotion}/coupons/generate'
+ defaults:
+ _form: '\Drupal\commerce_promotion\Form\CouponGenerateForm'
+ _title: 'Generate coupons'
+ options:
+ _admin_route: TRUE
+ parameters:
+ commerce_promotion:
+ type: 'entity:commerce_promotion'
+ requirements:
+ _permission: 'bulk generate commerce_promotion_coupon'
diff --git a/modules/promotion/commerce_promotion.services.yml b/modules/promotion/commerce_promotion.services.yml
index 565cdf54d3..e393b0d252 100644
--- a/modules/promotion/commerce_promotion.services.yml
+++ b/modules/promotion/commerce_promotion.services.yml
@@ -7,7 +7,7 @@ services:
class: Drupal\commerce_promotion\PromotionOrderProcessor
arguments: ['@entity_type.manager']
tags:
- - { name: commerce_order.order_processor, priority: 50 }
+ - { name: commerce_order.order_processor, priority: 50, adjustment_type: promotion }
commerce_promotion.usage:
class: Drupal\commerce_promotion\PromotionUsage
@@ -25,3 +25,7 @@ services:
arguments: ['@entity_type.manager', '@commerce_promotion.usage']
tags:
- { name: event_subscriber }
+
+ commerce_promotion.coupon_code_generator:
+ class: Drupal\commerce_promotion\CouponCodeGenerator
+ arguments: ['@database']
diff --git a/modules/promotion/src/CouponCodeGenerator.php b/modules/promotion/src/CouponCodeGenerator.php
new file mode 100644
index 0000000000..feddbb4e50
--- /dev/null
+++ b/modules/promotion/src/CouponCodeGenerator.php
@@ -0,0 +1,109 @@
+connection = $connection;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validatePattern(CouponCodePattern $pattern, $quantity = 1) {
+ $character_set = $this->getCharacterSet($pattern->getType());
+ $combinations = pow(count($character_set), $pattern->getLength());
+ return !($quantity > $combinations);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function generateCodes(CouponCodePattern $pattern, $quantity = 1) {
+ $character_set = $this->getCharacterSet($pattern->getType());
+ $character_set_size = count($character_set);
+ $length = $pattern->getLength();
+ $prefix = $pattern->getPrefix();
+ $suffix = $pattern->getSuffix();
+
+ // Generate twice the requested quantity, to improve chances of having
+ // the needed quantity after removing non-unique/existing codes.
+ $codes = [];
+ for ($i = 0; $i < ($quantity * 2); $i++) {
+ $code = '';
+ while (strlen($code) < $length) {
+ $random_index = mt_rand(0, $character_set_size - 1);
+ $code .= $character_set[$random_index];
+ }
+ $codes[] = $prefix . $code . $suffix;
+ }
+ $codes = array_unique($codes);
+
+ // Remove codes which already exist in the database.
+ $result = $this->connection->select('commerce_promotion_coupon', 'c')
+ ->fields('c', ['code'])
+ ->condition('code', $codes, 'IN')
+ ->execute();
+ $existing_codes = $result->fetchCol();
+ $codes = array_diff($codes, $existing_codes);
+
+ return array_slice($codes, 0, $quantity);
+ }
+
+ /**
+ * Gets the character set for the given pattern type.
+ *
+ * @param string $pattern_type
+ * The pattern type.
+ *
+ * @return string[]
+ * The character set.
+ */
+ protected function getCharacterSet($pattern_type) {
+ $characters = [];
+ switch ($pattern_type) {
+ // No 'I', 'O', 'i', 'l', '0', '1' to avoid recognition issues.
+ case CouponCodePattern::ALPHANUMERIC:
+ $characters = [
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M',
+ 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n',
+ 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ '2', '3', '4', '5', '6', '7', '8', '9',
+ ];
+ break;
+
+ case CouponCodePattern::ALPHABETIC:
+ // No 'I', 'i', 'l' to avoid recognition issues.
+ $characters = [
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M',
+ 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n',
+ 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
+ ];
+ break;
+
+ case CouponCodePattern::NUMERIC:
+ $characters = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
+ }
+
+ return $characters;
+ }
+
+}
diff --git a/modules/promotion/src/CouponCodeGeneratorInterface.php b/modules/promotion/src/CouponCodeGeneratorInterface.php
new file mode 100644
index 0000000000..09e35f8fd7
--- /dev/null
+++ b/modules/promotion/src/CouponCodeGeneratorInterface.php
@@ -0,0 +1,40 @@
+type = $type;
+ $this->prefix = $prefix;
+ $this->suffix = $suffix;
+ $this->length = $length;
+ }
+
+ /**
+ * Gets the pattern type.
+ *
+ * @return string
+ * The pattern type.
+ */
+ public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * Gets the prefix.
+ *
+ * @return string
+ * The prefix.
+ */
+ public function getPrefix() {
+ return $this->prefix;
+ }
+
+ /**
+ * Gets the suffix.
+ *
+ * @return string
+ * The suffix.
+ */
+ public function getSuffix() {
+ return $this->suffix;
+ }
+
+ /**
+ * Gets the length.
+ *
+ * @return int
+ * The length.
+ */
+ public function getLength() {
+ return $this->length;
+ }
+
+}
diff --git a/modules/promotion/src/Element/CouponRedemptionForm.php b/modules/promotion/src/Element/CouponRedemptionForm.php
index 7a2dde64d0..8022041e66 100644
--- a/modules/promotion/src/Element/CouponRedemptionForm.php
+++ b/modules/promotion/src/Element/CouponRedemptionForm.php
@@ -100,6 +100,11 @@ public static function processForm(array $element, FormStateInterface $form_stat
'#title' => $element['#title'],
'#description' => $element['#description'],
'#access' => !$cardinality_reached,
+ // Chrome autofills this field with the address line 1, and ignores
+ // autocomplete => 'off', but respects 'new-password'.
+ '#attributes' => [
+ 'autocomplete' => 'new-password',
+ ],
];
$element['apply'] = [
'#type' => 'submit',
diff --git a/modules/promotion/src/Entity/Coupon.php b/modules/promotion/src/Entity/Coupon.php
index 9a2ac82781..640e21bd82 100644
--- a/modules/promotion/src/Entity/Coupon.php
+++ b/modules/promotion/src/Entity/Coupon.php
@@ -25,7 +25,7 @@
* "list_builder" = "Drupal\commerce_promotion\CouponListBuilder",
* "storage" = "Drupal\commerce_promotion\CouponStorage",
* "access" = "Drupal\commerce_promotion\CouponAccessControlHandler",
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "form" = {
* "add" = "Drupal\commerce_promotion\Form\CouponForm",
* "edit" = "Drupal\commerce_promotion\Form\CouponForm",
@@ -164,6 +164,18 @@ public static function postDelete(EntityStorageInterface $storage, array $entiti
/** @var \Drupal\commerce_promotion\PromotionUsageInterface $usage */
$usage = \Drupal::service('commerce_promotion.usage');
$usage->deleteByCoupon($entities);
+ // Delete references to those coupons in promotions.
+ foreach ($entities as $coupon) {
+ $coupons_id[] = $coupon->id();
+ }
+ $promotions = \Drupal::entityTypeManager()->getStorage('commerce_promotion')->loadByProperties(['coupons' => $coupons_id]);
+ /** @var \Drupal\commerce_promotion\Entity\PromotionInterface $promotion */
+ foreach ($promotions as $promotion) {
+ foreach ($entities as $entity) {
+ $promotion->removeCoupon($entity);
+ }
+ $promotion->save();
+ }
}
/**
diff --git a/modules/promotion/src/Entity/Promotion.php b/modules/promotion/src/Entity/Promotion.php
index 8837a3fff6..4d21bd8f18 100644
--- a/modules/promotion/src/Entity/Promotion.php
+++ b/modules/promotion/src/Entity/Promotion.php
@@ -28,11 +28,11 @@
* handlers = {
* "event" = "Drupal\commerce_promotion\Event\PromotionEvent",
* "storage" = "Drupal\commerce_promotion\PromotionStorage",
- * "access" = "Drupal\commerce\EntityAccessControlHandler",
- * "permission_provider" = "Drupal\commerce\EntityPermissionProvider",
+ * "access" = "Drupal\entity\EntityAccessControlHandler",
+ * "permission_provider" = "Drupal\entity\EntityPermissionProvider",
* "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
* "list_builder" = "Drupal\commerce_promotion\PromotionListBuilder",
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "form" = {
* "default" = "Drupal\commerce_promotion\Form\PromotionForm",
* "add" = "Drupal\commerce_promotion\Form\PromotionForm",
@@ -40,7 +40,7 @@
* "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm"
* },
* "route_provider" = {
- * "default" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
+ * "default" = "Drupal\entity\Routing\AdminHtmlRouteProvider",
* "delete-multiple" = "Drupal\entity\Routing\DeleteMultipleRouteProvider",
* },
* "translation" = "Drupal\content_translation\ContentTranslationHandler"
@@ -48,7 +48,6 @@
* base_table = "commerce_promotion",
* data_table = "commerce_promotion_field_data",
* admin_permission = "administer commerce_promotion",
- * fieldable = TRUE,
* translatable = TRUE,
* entity_keys = {
* "id" = "promotion_id",
diff --git a/modules/promotion/src/Form/CouponForm.php b/modules/promotion/src/Form/CouponForm.php
index c5884ac73b..9406fcbed1 100644
--- a/modules/promotion/src/Form/CouponForm.php
+++ b/modules/promotion/src/Form/CouponForm.php
@@ -22,7 +22,7 @@ protected function prepareEntity() {
*/
public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
- drupal_set_message($this->t('Saved the %label coupon.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Saved the %label coupon.', ['%label' => $this->entity->label()]));
$form_state->setRedirectUrl($this->entity->toUrl('collection'));
}
diff --git a/modules/promotion/src/Form/CouponGenerateForm.php b/modules/promotion/src/Form/CouponGenerateForm.php
new file mode 100644
index 0000000000..24ad5ee8ea
--- /dev/null
+++ b/modules/promotion/src/Form/CouponGenerateForm.php
@@ -0,0 +1,290 @@
+couponCodeGenerator = $coupon_code_generator;
+ $this->promotion = $current_route_match->getParameter('commerce_promotion');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('commerce_promotion.coupon_code_generator'),
+ $container->get('current_route_match')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'commerce_coupon_generate_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $form['quantity'] = [
+ '#type' => 'number',
+ '#title' => $this->t('Number of coupons'),
+ '#required' => TRUE,
+ '#default_value' => '10',
+ '#min' => 1,
+ '#max' => 1000,
+ '#step' => 1,
+ ];
+ $form['pattern'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Coupon code pattern'),
+ ];
+ $form['pattern']['format'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Format'),
+ '#required' => TRUE,
+ '#options' => [
+ 'alphanumeric' => $this->t('Alphanumeric'),
+ 'alphabetic' => $this->t('Alphabetic'),
+ 'numeric' => $this->t('Numeric'),
+ ],
+ '#default_value' => 'alphanumeric',
+ ];
+ $form['pattern']['prefix'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Prefix'),
+ '#size' => 20,
+ ];
+ $form['pattern']['suffix'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Suffix'),
+ '#size' => 20,
+ ];
+ $form['pattern']['length'] = [
+ '#type' => 'number',
+ '#title' => $this->t('Length'),
+ '#description' => $this->t('Length does not include prefix/suffix.'),
+ '#required' => TRUE,
+ '#default_value' => 8,
+ '#min' => 1,
+ ];
+ $form['limit'] = [
+ '#type' => 'radios',
+ '#title' => $this->t('Number of uses per coupon'),
+ '#options' => [
+ 0 => $this->t('Unlimited'),
+ 1 => $this->t('Limited number of uses'),
+ ],
+ '#default_value' => 1,
+ ];
+ $form['usage_limit'] = [
+ '#type' => 'number',
+ '#title' => $this->t('Number of uses'),
+ '#title_display' => 'invisible',
+ '#default_value' => 1,
+ '#min' => 1,
+ '#states' => [
+ 'invisible' => [
+ ':input[name="limit"]' => ['value' => 0],
+ ],
+ ],
+ ];
+ $form['actions']['#type'] = 'actions';
+ $form['actions']['submit'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Generate'),
+ '#button_type' => 'primary',
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ $values = $form_state->getValues();
+ // Make sure that the total length doesn't exceed the database limit.
+ $code_length = strlen($values['prefix']) + strlen($values['suffix']) + $values['length'];
+ if ($code_length > self::MAX_CODE_LENGTH) {
+ $form_state->setError($form['pattern'], $this->t('The total pattern length (@coupon_length) exceeds the maximum length allowed (@max_length).', [
+ '@coupon_length' => $code_length,
+ '@max_length' => self::MAX_CODE_LENGTH,
+ ]));
+ }
+
+ // Validate that pattern for the given quantity.
+ $quantity = $values['quantity'];
+ $pattern = new CouponCodePattern($values['format'], $values['prefix'], $values['suffix'], $values['length']);
+ if (!$this->couponCodeGenerator->validatePattern($pattern, $quantity)) {
+ $form_state->setError($form['pattern'], $this->t('This pattern cannot be used to generate @quantity coupons.', [
+ '@quantity' => $quantity,
+ ]));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $values = $form_state->getValues();
+ $quantity = $values['quantity'];
+ $coupon_values = [
+ 'promotion_id' => $this->promotion->id(),
+ 'usage_limit' => $values['usage_limit'],
+ ];
+ $pattern = new CouponCodePattern($values['format'], $values['prefix'], $values['suffix'], $values['length']);
+
+ $batch = [
+ 'title' => $this->t('Generating coupons'),
+ 'progress_message' => '',
+ 'operations' => [
+ [
+ [get_class($this), 'processBatch'],
+ [$quantity, $coupon_values, $pattern],
+ ],
+ ],
+ 'finished' => [$this, 'finishBatch'],
+ ];
+ batch_set($batch);
+ $form_state->setRedirect('entity.commerce_promotion_coupon.collection', [
+ 'commerce_promotion' => $this->promotion->id(),
+ ]);
+ }
+
+ /**
+ * Processes the batch and generates the coupons.
+ *
+ * @param int $quantity
+ * The number of coupons to generate.
+ * @param string[] $coupon_values
+ * The initial coupon entity values.
+ * @param \Drupal\commerce_promotion\CouponCodePattern $pattern
+ * The pattern.
+ * @param array $context
+ * The batch context information.
+ */
+ public static function processBatch($quantity, array $coupon_values, CouponCodePattern $pattern, array &$context) {
+ if (empty($context['sandbox'])) {
+ $context['sandbox']['total_quantity'] = (int) $quantity;
+ $context['sandbox']['created'] = 0;
+ $context['results']['codes'] = [];
+ $context['results']['total_quantity'] = $quantity;
+ }
+
+ $total_quantity = $context['sandbox']['total_quantity'];
+ $created = &$context['sandbox']['created'];
+ $remaining = $total_quantity - $created;
+
+ $coupon_storage = \Drupal::entityTypeManager()->getStorage('commerce_promotion_coupon');
+ $limit = ($remaining < self::BATCH_SIZE) ? $remaining : self::BATCH_SIZE;
+ $coupon_code_generator = \Drupal::service('commerce_promotion.coupon_code_generator');
+ $codes = $coupon_code_generator->generateCodes($pattern, $limit);
+ if (!empty($codes)) {
+ foreach ($codes as $code) {
+ $coupon = $coupon_storage->create([
+ 'code' => $code,
+ ] + $coupon_values);
+ $coupon->save();
+ $context['results']['codes'][] = $code;
+ $created++;
+ }
+ $context['message'] = t('Creating coupon @created of @total_quantity', [
+ '@created' => $created,
+ '@total_quantity' => $total_quantity,
+ ]);
+ $context['finished'] = $created / $total_quantity;
+ }
+ else {
+ $context['finished'] = 1;
+ }
+ }
+
+ /**
+ * Batch finished callback: display batch statistics.
+ *
+ * @param bool $success
+ * Indicates whether the batch has completed successfully.
+ * @param mixed[] $results
+ * The array of results gathered by the batch processing.
+ * @param string[] $operations
+ * If $success is FALSE, contains the operations that remained unprocessed.
+ */
+ public static function finishBatch($success, array $results, array $operations) {
+ if ($success) {
+ $created = count($results['codes']);
+ // An incomplete set of coupons was generated.
+ if ($created != $results['total_quantity']) {
+ \Drupal::messenger()->addWarning(t('Generated %created out of %total requested coupons. Consider adding a unique prefix/suffix or increasing the pattern length to improve results.', [
+ '%created' => $created,
+ '%total' => $results['total_quantity'],
+ ]));
+ }
+ else {
+ \Drupal::messenger()->addMessage(\Drupal::translation()->formatPlural(
+ $created,
+ 'Generated 1 coupon.',
+ 'Generated @count coupons.'
+ ));
+ }
+ }
+ else {
+ $error_operation = reset($operations);
+ \Drupal::messenger()->addError(t('An error occurred while processing @operation with arguments: @args', [
+ '@operation' => $error_operation[0],
+ '@args' => print_r($error_operation[0], TRUE),
+ ]));
+ }
+ }
+
+}
diff --git a/modules/promotion/src/Form/PromotionForm.php b/modules/promotion/src/Form/PromotionForm.php
index c7f54e1d26..172a34f4a4 100644
--- a/modules/promotion/src/Form/PromotionForm.php
+++ b/modules/promotion/src/Form/PromotionForm.php
@@ -114,7 +114,7 @@ protected function actions(array $form, FormStateInterface $form_state) {
*/
public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
- drupal_set_message($this->t('Saved the %label promotion.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Saved the %label promotion.', ['%label' => $this->entity->label()]));
if (!empty($form_state->getTriggeringElement()['#continue'])) {
$form_state->setRedirect('entity.commerce_promotion_coupon.collection', ['commerce_promotion' => $this->entity->id()]);
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderFixedAmountOff.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderFixedAmountOff.php
index 45e5a07060..8a65e52c59 100644
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderFixedAmountOff.php
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderFixedAmountOff.php
@@ -11,7 +11,7 @@
*
* @CommercePromotionOffer(
* id = "order_fixed_amount_off",
- * label = @Translation("Fixed amount off the order total"),
+ * label = @Translation("Fixed amount off the order subtotal"),
* entity_type = "commerce_order",
* )
*/
@@ -24,14 +24,15 @@ public function apply(EntityInterface $entity, PromotionInterface $promotion) {
$this->assertEntity($entity);
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $entity;
- $total_price = $order->getTotalPrice();
+ $subtotal_price = $order->getSubTotalPrice();
$adjustment_amount = $this->getAmount();
- if ($total_price->getCurrencyCode() != $adjustment_amount->getCurrencyCode()) {
+ if ($subtotal_price->getCurrencyCode() != $adjustment_amount->getCurrencyCode()) {
return;
}
- // Don't reduce the order total past zero.
- if ($adjustment_amount->greaterThan($total_price)) {
- $adjustment_amount = $total_price;
+ // The promotion amount can't be larger than the subtotal, to avoid
+ // potentially having a negative order total.
+ if ($adjustment_amount->greaterThan($subtotal_price)) {
+ $adjustment_amount = $subtotal_price;
}
$order->addAdjustment(new Adjustment([
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php
index 95bb698cea..9574c1f42b 100644
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php
@@ -11,7 +11,7 @@
*
* @CommercePromotionOffer(
* id = "order_percentage_off",
- * label = @Translation("Percentage off the order total"),
+ * label = @Translation("Percentage off the order subtotal"),
* entity_type = "commerce_order",
* )
*/
@@ -24,7 +24,7 @@ public function apply(EntityInterface $entity, PromotionInterface $promotion) {
$this->assertEntity($entity);
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $entity;
- $adjustment_amount = $order->getTotalPrice()->multiply($this->getPercentage());
+ $adjustment_amount = $order->getSubtotalPrice()->multiply($this->getPercentage());
$adjustment_amount = $this->rounder->round($adjustment_amount);
$order->addAdjustment(new Adjustment([
diff --git a/modules/promotion/src/PromotionStorage.php b/modules/promotion/src/PromotionStorage.php
index 7cbae1178e..ff0013ab61 100644
--- a/modules/promotion/src/PromotionStorage.php
+++ b/modules/promotion/src/PromotionStorage.php
@@ -83,7 +83,7 @@ public function loadAvailable(OrderInterface $order) {
$query = $this->getQuery();
$or_condition = $query->orConditionGroup()
->condition('end_date', $today, '>=')
- ->notExists('end_date', $today);
+ ->notExists('end_date');
$query
->condition('stores', [$order->getStoreId()], 'IN')
->condition('order_types', [$order->bundle()], 'IN')
diff --git a/modules/promotion/tests/modules/commerce_promotion_test/commerce_promotion_test.info.yml b/modules/promotion/tests/modules/commerce_promotion_test/commerce_promotion_test.info.yml
index 23674867ea..72b0c681dc 100644
--- a/modules/promotion/tests/modules/commerce_promotion_test/commerce_promotion_test.info.yml
+++ b/modules/promotion/tests/modules/commerce_promotion_test/commerce_promotion_test.info.yml
@@ -4,5 +4,5 @@ description: Provides items for testing Commerce Promotion.
package: Testing
core: 8.x
dependencies:
- - commerce_cart
- - commerce_promotion
+ - commerce:commerce_cart
+ - commerce:commerce_promotion
diff --git a/modules/promotion/tests/src/FunctionalJavascript/CouponTest.php b/modules/promotion/tests/src/Functional/CouponTest.php
similarity index 68%
rename from modules/promotion/tests/src/FunctionalJavascript/CouponTest.php
rename to modules/promotion/tests/src/Functional/CouponTest.php
index d4525040a6..d0059590b7 100644
--- a/modules/promotion/tests/src/FunctionalJavascript/CouponTest.php
+++ b/modules/promotion/tests/src/Functional/CouponTest.php
@@ -1,11 +1,10 @@
assertFalse($coupon_exists);
}
+ /**
+ * Tests bulk generation of coupons.
+ */
+ public function testGenerateCoupons() {
+ $coupon_quantity = 52;
+
+ $this->drupalGet('/promotion/' . $this->promotion->id() . '/coupons');
+ $this->getSession()->getPage()->clickLink('Generate coupons');
+
+ // Check the integrity of the form and set values.
+ $this->assertSession()->fieldExists('format');
+ $this->getSession()->getPage()->selectFieldOption('format', 'numeric');
+
+ $this->assertSession()->fieldExists('quantity');
+ $this->getSession()->getPage()->fillField('quantity', (string) $coupon_quantity);
+ $this->getSession()->getPage()->pressButton('Generate');
+ $this->checkForMetaRefresh();
+
+ $this->assertSession()->pageTextContains("Generated $coupon_quantity coupons.");
+ $coupon_count = $this->getSession()->getPage()->findAll('xpath', '//table/tbody/tr/td[text()="0 / 1"]');
+
+ $this->assertEquals(count($coupon_count), $coupon_quantity, 'Coupons exist in the table.');
+
+ $coupons = Coupon::loadMultiple();
+ $this->assertEquals(count($coupons), $coupon_quantity, 'Coupons created');
+ foreach ($coupons as $id => $coupon) {
+ $this->assertEquals($this->promotion->id(), $coupon->getPromotionId());
+ $this->assertTrue(ctype_digit($coupon->getCode()));
+ $this->assertEquals(strlen($coupon->getCode()), 8);
+ }
+
+ \Drupal::service('entity_type.manager')->getStorage('commerce_promotion')->resetCache([$this->promotion->id()]);
+ $this->promotion = Promotion::load($this->promotion->id());
+ foreach ($coupons as $id => $coupon) {
+ $this->assertTrue($this->promotion->hasCoupon($coupon));
+ }
+ }
+
}
diff --git a/modules/promotion/tests/src/FunctionalJavascript/CouponRedemptionPaneTest.php b/modules/promotion/tests/src/FunctionalJavascript/CouponRedemptionPaneTest.php
index 3faa3bad07..8cbf9a3234 100644
--- a/modules/promotion/tests/src/FunctionalJavascript/CouponRedemptionPaneTest.php
+++ b/modules/promotion/tests/src/FunctionalJavascript/CouponRedemptionPaneTest.php
@@ -265,7 +265,7 @@ public function testCheckoutWithMainSubmit() {
$this->getSession()->getPage()->fillField('Coupon code', $coupon->getCode());
$this->submitForm([], 'Continue to review');
- $this->assertSession()->pageTextContains('Visa ending in 1111');
+ $this->assertSession()->pageTextContains('Visa ending in 9999');
$this->assertSession()->pageTextContains($coupon->getCode());
$this->assertSession()->pageTextContains('-$99.90');
$this->assertSession()->pageTextContains('$899.10');
diff --git a/modules/promotion/tests/src/Kernel/CouponCodeGeneratorTest.php b/modules/promotion/tests/src/Kernel/CouponCodeGeneratorTest.php
new file mode 100644
index 0000000000..7e4c5cdabd
--- /dev/null
+++ b/modules/promotion/tests/src/Kernel/CouponCodeGeneratorTest.php
@@ -0,0 +1,154 @@
+installEntitySchema('profile');
+ $this->installEntitySchema('commerce_order');
+ $this->installEntitySchema('commerce_order_item');
+ $this->installEntitySchema('commerce_promotion');
+ $this->installEntitySchema('commerce_promotion_coupon');
+ $this->installConfig(['commerce_order']);
+
+ $promotion = Promotion::create([
+ 'name' => 'Promotion 1',
+ 'order_types' => ['default'],
+ 'stores' => [$this->store->id()],
+ 'status' => TRUE,
+ 'offer' => [
+ 'target_plugin_id' => 'order_item_percentage_off',
+ 'target_plugin_configuration' => [
+ 'percentage' => '0.5',
+ ],
+ ],
+ ]);
+ $promotion->save();
+
+ $this->numericCoupons = [];
+ for ($i = 0; $i < 10; $i++) {
+ $coupon = Coupon::create([
+ 'promotion_id' => $promotion->id(),
+ 'code' => 'COUPON' . $i,
+ 'usage_limit' => 1,
+ 'status' => 1,
+ ]);
+ $coupon->save();
+ $this->numericCoupons[] = $coupon;
+ }
+
+ $this->couponCodeGenerator = $this->container->get('commerce_promotion.coupon_code_generator');
+ }
+
+ /**
+ * Tests the validatePattern method.
+ *
+ * @covers ::validatePattern
+ */
+ public function testPatternValidityChecker() {
+ // Numeric pattern length 1 is too short for coupon quantity > 10.
+ $pattern = new CouponCodePattern('numeric', '', '', 1);
+ $result = $this->couponCodeGenerator->validatePattern($pattern, 11);
+ $this->assertFalse($result);
+
+ // Numeric pattern length 1 is long enough for coupon quantity = 10.
+ $result = $this->couponCodeGenerator->validatePattern($pattern, 10);
+ $this->assertTrue($result);
+
+ // Numeric pattern length 1 is long enough for coupon quantity < 10.
+ $result = $this->couponCodeGenerator->validatePattern($pattern, 9);
+ $this->assertTrue($result);
+
+ // Numeric pattern length 8 is long enough for coupon quantity 1000.
+ $pattern = new CouponCodePattern('numeric', '', '', 8);
+ $result = $this->couponCodeGenerator->validatePattern($pattern, 1000);
+ $this->assertTrue($result);
+ }
+
+ /**
+ * Tests the code generator.
+ *
+ * @covers ::generateCodes
+ */
+ public function testCouponGenerator() {
+ // Test numeric type pattern, length 10, 1 code.
+ $pattern = new CouponCodePattern('numeric', '', '', 10);
+ $result = $this->couponCodeGenerator->generateCodes($pattern, 1);
+ $this->assertNotEmpty($result);
+ $this->assertTrue(ctype_digit($result[0]));
+ $this->assertEquals(strlen($result[0]), 10);
+
+ // Test alphabetic type pattern, length 100, 10 codes.
+ $pattern = new CouponCodePattern('alphabetic', '', '', 100);
+ $result = $this->couponCodeGenerator->generateCodes($pattern, 10);
+ $this->assertEquals(count($result), 10);
+ $this->assertTrue(ctype_alpha($result[0]));
+ $this->assertEquals(strlen($result[0]), 100);
+
+ // Test alphanumeric type pattern, length 50, 25 codes.
+ $pattern = new CouponCodePattern('alphanumeric', '', '', 50);
+ $result = $this->couponCodeGenerator->generateCodes($pattern, 25);
+ $this->assertEquals(count($result), 25);
+ $this->assertTrue(ctype_alnum($result[0]));
+ $this->assertEquals(strlen($result[0]), 50);
+
+ // Test prefix and suffix options.
+ $pattern = new CouponCodePattern('numeric', 'save', 'XX', 2);
+ $result = $this->couponCodeGenerator->generateCodes($pattern, 1);
+ $this->assertNotEmpty($result);
+ $this->assertEquals(substr($result[0], 0, 4), 'save');
+ $this->assertTrue(ctype_digit(substr($result[0], 4, 2)));
+ $this->assertEquals(substr($result[0], 6), 'XX');
+
+ // Test coupon code conflict.
+ $pattern = new CouponCodePattern('numeric', 'COUPON', '', 1);
+ $result = $this->couponCodeGenerator->generateCodes($pattern, 1);
+ $this->assertEmpty($result);
+ }
+
+}
diff --git a/modules/promotion/tests/src/Kernel/Entity/PromotionTest.php b/modules/promotion/tests/src/Kernel/Entity/PromotionTest.php
index 112f7cae5e..529d754f0f 100644
--- a/modules/promotion/tests/src/Kernel/Entity/PromotionTest.php
+++ b/modules/promotion/tests/src/Kernel/Entity/PromotionTest.php
@@ -45,6 +45,7 @@ protected function setUp() {
$this->installEntitySchema('commerce_order_item');
$this->installEntitySchema('commerce_promotion');
$this->installEntitySchema('commerce_promotion_coupon');
+ $this->installSchema('commerce_promotion', ['commerce_promotion_usage']);
$this->installConfig([
'profile',
'commerce_order',
@@ -152,6 +153,13 @@ public function testPromotion() {
$promotion->addCoupon($coupon1);
$this->assertTrue($promotion->hasCoupon($coupon1));
+ // Check Coupon::postDelete() remove Coupon reference from promotion.
+ $promotion->save();
+ $promotion = $this->reloadEntity($promotion);
+ $this->assertEquals($promotion->id(), 1);
+ $coupon1->delete();
+ $this->assertFalse($promotion->hasCoupon($coupon1));
+
$promotion->setUsageLimit(10);
$this->assertEquals(10, $promotion->getUsageLimit());
diff --git a/modules/promotion/tests/src/Kernel/PromotionOfferTest.php b/modules/promotion/tests/src/Kernel/PromotionOfferTest.php
index 489c6bc71d..281c145474 100644
--- a/modules/promotion/tests/src/Kernel/PromotionOfferTest.php
+++ b/modules/promotion/tests/src/Kernel/PromotionOfferTest.php
@@ -90,7 +90,6 @@ protected function setUp() {
* Tests order percentage off.
*/
public function testOrderPercentageOff() {
- // Use addOrderItem so the total is calculated.
$order_item = OrderItem::create([
'type' => 'test',
'quantity' => '2',
@@ -164,12 +163,12 @@ public function testOrderFixedAmountOff() {
$this->order->save();
$this->order = $this->reloadEntity($this->order);
- // Offer amount larger than the order total.
+ // Offer amount larger than the order subtotal.
$this->assertEquals(1, count($this->order->getAdjustments()));
$this->assertEquals(new Price('-20.00', 'USD'), $this->order->getAdjustments()[0]->getAmount());
$this->assertEquals(new Price('0.00', 'USD'), $this->order->getTotalPrice());
- // Offer amount smaller than the order total.
+ // Offer amount smaller than the order subtotal.
$order_item->setQuantity(2);
$order_item->save();
$this->order->save();
diff --git a/modules/store/commerce_store.info.yml b/modules/store/commerce_store.info.yml
index da5f4cbb89..661976034a 100644
--- a/modules/store/commerce_store.info.yml
+++ b/modules/store/commerce_store.info.yml
@@ -4,13 +4,14 @@ description: 'Defines the Store entity and associated features.'
package: Commerce
core: 8.x
dependencies:
- - commerce
+ - commerce:commerce
- commerce:commerce_price
- - options
+ - drupal:options
config_devel:
install:
- commerce_store.commerce_store_type.online
- commerce_store.settings
+ - core.entity_form_display.commerce_store.online.default
- core.entity_view_display.commerce_store.online.default
- views.view.commerce_stores
- system.action.commerce_delete_store_action
diff --git a/modules/store/commerce_store.links.task.yml b/modules/store/commerce_store.links.task.yml
index 4454b95a81..cef7049bbe 100644
--- a/modules/store/commerce_store.links.task.yml
+++ b/modules/store/commerce_store.links.task.yml
@@ -10,7 +10,7 @@ entity.commerce_store.collection:
entity.commerce_store.edit_form:
route_name: entity.commerce_store.edit_form
- base_route: entity.commerce_store.edit_form
+ base_route: entity.commerce_store.canonical
title: Edit
entity.commerce_store_type.collection:
diff --git a/modules/store/commerce_store.module b/modules/store/commerce_store.module
index fe12873721..9de1282cbb 100644
--- a/modules/store/commerce_store.module
+++ b/modules/store/commerce_store.module
@@ -56,7 +56,7 @@ function template_preprocess_commerce_store(array &$variables) {
$store = $variables['elements']['#commerce_store'];
$variables['store_entity'] = $store;
- $variables['store_url'] = $store->toUrl();
+ $variables['store_url'] = $store->isNew() ? '' : $store->toUrl();
$variables['store'] = [];
foreach (Element::children($variables['elements']) as $key) {
$variables['store'][$key] = $variables['elements'][$key];
diff --git a/modules/store/config/install/commerce_store.commerce_store_type.online.yml b/modules/store/config/install/commerce_store.commerce_store_type.online.yml
index 1d5fb95d68..20b62bf4f9 100644
--- a/modules/store/config/install/commerce_store.commerce_store_type.online.yml
+++ b/modules/store/config/install/commerce_store.commerce_store_type.online.yml
@@ -5,3 +5,4 @@ id: online
label: Online
description: ''
traits: { }
+locked: false
diff --git a/modules/store/config/install/core.entity_form_display.commerce_store.online.default.yml b/modules/store/config/install/core.entity_form_display.commerce_store.online.default.yml
new file mode 100644
index 0000000000..b4effdc058
--- /dev/null
+++ b/modules/store/config/install/core.entity_form_display.commerce_store.online.default.yml
@@ -0,0 +1,61 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - commerce_store.commerce_store_type.online
+ module:
+ - address
+id: commerce_store.online.default
+targetEntityType: commerce_store
+bundle: online
+mode: default
+content:
+ address:
+ type: address_default
+ settings:
+ default_country: site_default
+ weight: 3
+ region: content
+ third_party_settings: { }
+ billing_countries:
+ type: options_select
+ weight: 4
+ region: content
+ settings: { }
+ third_party_settings: { }
+ default_currency:
+ type: options_select
+ weight: 2
+ region: content
+ settings: { }
+ third_party_settings: { }
+ mail:
+ type: email_default
+ weight: 1
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ name:
+ type: string_textfield
+ weight: 0
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ prices_include_tax:
+ type: boolean_checkbox
+ settings:
+ display_label: true
+ weight: 3
+ region: content
+ third_party_settings: { }
+ tax_registrations:
+ type: options_select
+ weight: 4
+ region: content
+ settings: { }
+ third_party_settings: { }
+hidden: { }
diff --git a/modules/store/src/Command/CreateStoreCommand.php b/modules/store/src/Command/CreateStoreCommand.php
index e5e09054d1..982c0d32b5 100644
--- a/modules/store/src/Command/CreateStoreCommand.php
+++ b/modules/store/src/Command/CreateStoreCommand.php
@@ -13,7 +13,7 @@
use Drupal\Console\Annotations\DrupalCommand;
use Drupal\commerce_price\CurrencyImporter;
use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\address\Repository\CountryRepository;
+use CommerceGuys\Intl\Country\CountryRepositoryInterface;
use Drupal\Core\Render\MetadataBubblingUrlGenerator;
use Egulias\EmailValidator\EmailValidator;
use Symfony\Component\Console\Question\Question;
@@ -50,7 +50,7 @@ class CreateStoreCommand extends Command {
/**
* The country repository.
*
- * @var \Drupal\address\Repository\CountryRepository
+ * @var \CommerceGuys\Intl\Country\CountryRepositoryInterface
*/
protected $countryRepository;
@@ -75,17 +75,17 @@ class CreateStoreCommand extends Command {
* The currency importer.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
- * @param \Drupal\address\Repository\CountryRepository $address_country_repository
+ * @param \CommerceGuys\Intl\Country\CountryRepositoryInterface $country_repository
* The country repository.
* @param \Drupal\Core\Render\MetadataBubblingUrlGenerator $url_generator
* The URL generator.
* @param \Egulias\EmailValidator\EmailValidator $email_validator
* The email validator.
*/
- public function __construct(CurrencyImporter $commerce_price_currency_importer, EntityTypeManagerInterface $entity_type_manager, CountryRepository $address_country_repository, MetadataBubblingUrlGenerator $url_generator, EmailValidator $email_validator) {
+ public function __construct(CurrencyImporter $commerce_price_currency_importer, EntityTypeManagerInterface $entity_type_manager, CountryRepositoryInterface $country_repository, MetadataBubblingUrlGenerator $url_generator, EmailValidator $email_validator) {
$this->currencyImporter = $commerce_price_currency_importer;
$this->entityTypeManager = $entity_type_manager;
- $this->countryRepository = $address_country_repository;
+ $this->countryRepository = $country_repository;
$this->urlGenerator = $url_generator;
$this->emailValidator = $email_validator;
parent::__construct();
diff --git a/modules/store/src/Entity/Store.php b/modules/store/src/Entity/Store.php
index 1d7c534be5..edd5f78037 100644
--- a/modules/store/src/Entity/Store.php
+++ b/modules/store/src/Entity/Store.php
@@ -27,11 +27,11 @@
* handlers = {
* "event" = "Drupal\commerce_store\Event\StoreEvent",
* "storage" = "Drupal\commerce_store\StoreStorage",
- * "access" = "Drupal\commerce\EntityAccessControlHandler",
- * "permission_provider" = "Drupal\commerce\EntityPermissionProvider",
+ * "access" = "Drupal\entity\EntityAccessControlHandler",
+ * "permission_provider" = "Drupal\entity\EntityPermissionProvider",
* "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
* "list_builder" = "Drupal\commerce_store\StoreListBuilder",
- * "views_data" = "Drupal\views\EntityViewsData",
+ * "views_data" = "Drupal\commerce\CommerceEntityViewsData",
* "form" = {
* "default" = "Drupal\commerce_store\Form\StoreForm",
* "add" = "Drupal\commerce_store\Form\StoreForm",
@@ -39,7 +39,7 @@
* "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm"
* },
* "route_provider" = {
- * "default" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
+ * "default" = "Drupal\entity\Routing\AdminHtmlRouteProvider",
* "delete-multiple" = "Drupal\entity\Routing\DeleteMultipleRouteProvider",
* },
* "translation" = "Drupal\content_translation\ContentTranslationHandler"
@@ -48,7 +48,6 @@
* data_table = "commerce_store_field_data",
* admin_permission = "administer commerce_store",
* permission_granularity = "bundle",
- * fieldable = TRUE,
* translatable = TRUE,
* entity_keys = {
* "id" = "store_id",
diff --git a/modules/store/src/Entity/StoreType.php b/modules/store/src/Entity/StoreType.php
index 943b113038..31657c9444 100644
--- a/modules/store/src/Entity/StoreType.php
+++ b/modules/store/src/Entity/StoreType.php
@@ -18,6 +18,7 @@
* plural = "@count store types",
* ),
* handlers = {
+ * "access" = "Drupal\commerce\CommerceBundleAccessControlHandler",
* "list_builder" = "Drupal\commerce_store\StoreTypeListBuilder",
* "form" = {
* "add" = "Drupal\commerce_store\Form\StoreTypeForm",
@@ -42,6 +43,7 @@
* "uuid",
* "description",
* "traits",
+ * "locked",
* },
* links = {
* "add-form" = "/admin/commerce/config/store-types/add",
diff --git a/modules/store/src/Form/StoreForm.php b/modules/store/src/Form/StoreForm.php
index 001516d4cd..572f5c5dbe 100644
--- a/modules/store/src/Form/StoreForm.php
+++ b/modules/store/src/Form/StoreForm.php
@@ -43,7 +43,7 @@ public function save(array $form, FormStateInterface $form_state) {
$store_storage = $this->entityTypeManager->getStorage('commerce_store');
$store_storage->markAsDefault($this->entity);
}
- drupal_set_message($this->t('Saved the %label store.', [
+ $this->messenger()->addMessage($this->t('Saved the %label store.', [
'%label' => $this->entity->label(),
]));
$form_state->setRedirect('entity.commerce_store.collection');
diff --git a/modules/store/src/Form/StoreTypeForm.php b/modules/store/src/Form/StoreTypeForm.php
index 7449ff3a21..4e7b3a0d67 100644
--- a/modules/store/src/Form/StoreTypeForm.php
+++ b/modules/store/src/Form/StoreTypeForm.php
@@ -73,7 +73,7 @@ public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
$this->submitTraitForm($form, $form_state);
- drupal_set_message($this->t('Saved the %label store type.', [
+ $this->messenger()->addMessage($this->t('Saved the %label store type.', [
'%label' => $this->entity->label(),
]));
$form_state->setRedirect('entity.commerce_store_type.collection');
diff --git a/modules/store/tests/src/Functional/StoreTypeTest.php b/modules/store/tests/src/Functional/StoreTypeTest.php
index 8167d69bd3..1901a7321b 100644
--- a/modules/store/tests/src/Functional/StoreTypeTest.php
+++ b/modules/store/tests/src/Functional/StoreTypeTest.php
@@ -97,30 +97,36 @@ public function testUpdateStoreType() {
* Tests deleting a Store Type through the form.
*/
public function testDeleteStoreType() {
- // Create a store type programmaticaly.
+ /** @var \Drupal\commerce_store\Entity\StoreTypeInterface $type */
$type = $this->createEntity('commerce_store_type', [
'id' => 'foo',
'label' => 'Label for foo',
]);
-
- // Create a store.
$store = $this->createStore(NULL, NULL, $type->id());
- // Try to delete the store type.
- $this->drupalGet('admin/commerce/config/store-types/' . $type->id() . '/delete');
+ // Confirm that the type can't be deleted while there's a store.
+ $this->drupalGet($type->toUrl('delete-form'));
$this->assertSession()->pageTextContains(t('@type is used by 1 store on your site. You cannot remove this store type until you have removed all of the @type stores.', ['@type' => $type->label()]));
$this->assertSession()->pageTextNotContains('This action cannot be undone.');
$this->assertSession()->pageTextNotContains('The store type deletion confirmation form is not available');
- // Deleting the store type when its not being referenced by a store.
+ // Confirm that the delete page is not available when the type is locked.
+ $type->lock();
+ $type->save();
+ $this->drupalGet($type->toUrl('delete-form'));
+ $this->assertSession()->statusCodeEquals('403');
+
+ // Delete the store, unlock the type, confirm that deletion works.
$store->delete();
- $this->drupalGet('admin/commerce/config/store-types/' . $type->id() . '/delete');
+ $type->unlock();
+ $type->save();
+ $this->drupalGet($type->toUrl('delete-form'));
$this->assertSession()->pageTextContains(t('Are you sure you want to delete the store type @type?', ['@type' => $type->label()]));
$this->saveHtmlOutput();
$this->assertSession()->pageTextContains('This action cannot be undone.');
$this->submitForm([], 'Delete');
$type_exists = (bool) StoreType::load($type->id());
- $this->assertEmpty($type_exists, 'The new store type has been deleted from the database.');
+ $this->assertEmpty($type_exists);
}
}
diff --git a/modules/tax/commerce_tax.info.yml b/modules/tax/commerce_tax.info.yml
index 8a2be9e2ad..9261619c41 100644
--- a/modules/tax/commerce_tax.info.yml
+++ b/modules/tax/commerce_tax.info.yml
@@ -4,4 +4,5 @@ description: 'Provides tax functionality.'
package: Commerce
core: 8.x
dependencies:
+ - commerce:commerce
- commerce:commerce_order
diff --git a/modules/tax/commerce_tax.services.yml b/modules/tax/commerce_tax.services.yml
index dc38e83919..5f1b4c5870 100644
--- a/modules/tax/commerce_tax.services.yml
+++ b/modules/tax/commerce_tax.services.yml
@@ -17,4 +17,4 @@ services:
class: Drupal\commerce_tax\TaxOrderProcessor
arguments: ['@entity_type.manager']
tags:
- - { name: commerce_order.order_processor, priority: 100 }
+ - { name: commerce_order.order_processor, priority: 100, adjustment_type: tax }
diff --git a/modules/tax/src/Entity/TaxTypeInterface.php b/modules/tax/src/Entity/TaxTypeInterface.php
index 9438ca7e09..a80f8b5bc6 100644
--- a/modules/tax/src/Entity/TaxTypeInterface.php
+++ b/modules/tax/src/Entity/TaxTypeInterface.php
@@ -41,7 +41,7 @@ public function setPluginId($plugin_id);
/**
* Gets the tax type plugin configuration.
*
- * @return string
+ * @return array
* The tax type plugin configuration.
*/
public function getPluginConfiguration();
diff --git a/modules/tax/src/Form/TaxTypeForm.php b/modules/tax/src/Form/TaxTypeForm.php
index 66de801a0e..f60a83ff59 100644
--- a/modules/tax/src/Form/TaxTypeForm.php
+++ b/modules/tax/src/Form/TaxTypeForm.php
@@ -2,13 +2,13 @@
namespace Drupal\commerce_tax\Form;
-use Drupal\commerce\Form\CommercePluginEntityFormBase;
use Drupal\commerce_tax\TaxTypeManager;
use Drupal\Component\Utility\Html;
+use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
-class TaxTypeForm extends CommercePluginEntityFormBase {
+class TaxTypeForm extends EntityForm {
/**
* The tax type plugin manager.
@@ -79,6 +79,7 @@ public function form(array $form, FormStateInterface $form_state) {
'#machine_name' => [
'exists' => '\Drupal\commerce_tax\Entity\TaxType::load',
],
+ '#disabled' => !$type->isNew(),
];
$form['plugin'] = [
'#type' => 'radios',
@@ -105,7 +106,7 @@ public function form(array $form, FormStateInterface $form_state) {
'#default_value' => $type->status(),
];
- return $this->protectPluginIdElement($form);
+ return $form;
}
/**
@@ -131,7 +132,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
*/
public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
- drupal_set_message($this->t('Saved the %label tax type.', ['%label' => $this->entity->label()]));
+ $this->messenger()->addMessage($this->t('Saved the %label tax type.', ['%label' => $this->entity->label()]));
$form_state->setRedirect('entity.commerce_tax_type.collection');
}
diff --git a/modules/tax/src/Plugin/Commerce/TaxType/EuropeanUnionVat.php b/modules/tax/src/Plugin/Commerce/TaxType/EuropeanUnionVat.php
index 68c15c31d4..d3b2f9de68 100644
--- a/modules/tax/src/Plugin/Commerce/TaxType/EuropeanUnionVat.php
+++ b/modules/tax/src/Plugin/Commerce/TaxType/EuropeanUnionVat.php
@@ -511,7 +511,7 @@ public function buildZones() {
]);
$zones['gb'] = new TaxZone([
'id' => 'gb',
- 'label' => $this->t('Great Britain'),
+ 'label' => $this->t('United Kingdom'),
'display_label' => $labels['vat'],
'territories' => [
['country_code' => 'GB'],
diff --git a/modules/tax/src/Plugin/Commerce/TaxType/SwissVat.php b/modules/tax/src/Plugin/Commerce/TaxType/SwissVat.php
index 9a4a125f97..631a9d5d2a 100644
--- a/modules/tax/src/Plugin/Commerce/TaxType/SwissVat.php
+++ b/modules/tax/src/Plugin/Commerce/TaxType/SwissVat.php
@@ -49,7 +49,8 @@ public function buildZones() {
'id' => 'standard',
'label' => $this->t('Standard'),
'percentages' => [
- ['number' => '0.08', 'start_date' => '2011-01-01'],
+ ['number' => '0.08', 'start_date' => '2011-01-01', 'end_date' => '2017-12-31'],
+ ['number' => '0.077', 'start_date' => '2018-01-01'],
],
'default' => TRUE,
],
@@ -57,7 +58,8 @@ public function buildZones() {
'id' => 'hotel',
'label' => $this->t('Hotel'),
'percentages' => [
- ['number' => '0.038', 'start_date' => '2011-01-01'],
+ ['number' => '0.038', 'start_date' => '2011-01-01', 'end_date' => '2017-12-31'],
+ ['number' => '0.037', 'start_date' => '2018-01-01'],
],
],
[
diff --git a/modules/tax/src/Plugin/Commerce/TaxType/TaxTypeBase.php b/modules/tax/src/Plugin/Commerce/TaxType/TaxTypeBase.php
index 6fed8212c1..766a1ec6e7 100644
--- a/modules/tax/src/Plugin/Commerce/TaxType/TaxTypeBase.php
+++ b/modules/tax/src/Plugin/Commerce/TaxType/TaxTypeBase.php
@@ -222,17 +222,15 @@ protected function getTaxableType(OrderItemInterface $order_item) {
*/
protected function resolveCustomerProfile(OrderItemInterface $order_item) {
$order = $order_item->getOrder();
- $store = $order->getStore();
- $prices_include_tax = $store->get('prices_include_tax')->value;
$customer_profile = $order->getBillingProfile();
- // A shipping profile is prefered, when available.
+ // A shipping profile is preferred, when available.
$event = new CustomerProfileEvent($customer_profile, $order_item);
$this->eventDispatcher->dispatch(TaxEvents::CUSTOMER_PROFILE, $event);
$customer_profile = $event->getCustomerProfile();
- if (!$customer_profile && $prices_include_tax) {
- // The customer is still unknown, but prices include tax (VAT scenario),
- // better to show the store's default tax than nothing.
- $customer_profile = $this->buildStoreProfile($store);
+ if (!$customer_profile && $this->isDisplayInclusive()) {
+ // The customer is still unknown, but prices are displayed tax-inclusive
+ // (VAT scenario), better to show the store's default tax than nothing.
+ $customer_profile = $this->buildStoreProfile($order->getStore());
}
return $customer_profile;
diff --git a/modules/tax/src/TaxRate.php b/modules/tax/src/TaxRate.php
index 97407d3a11..e80ef769a2 100644
--- a/modules/tax/src/TaxRate.php
+++ b/modules/tax/src/TaxRate.php
@@ -105,14 +105,17 @@ public function getPercentages() {
public function getPercentage(DrupalDateTime $date = NULL) {
// Default to the current date.
$date = $date ?: new DrupalDateTime();
- // Percentage start/end dates don't include the time, so discard the
- // time portion of the given date to make the matching precise.
- $date->setTime(0, 0);
foreach ($this->percentages as $percentage) {
- $start_date = $percentage->getStartDate();
- $end_date = $percentage->getEndDate();
- // Match the date against the percentage start/end dates.
- if (($start_date <= $date) && (!$end_date || $end_date > $date)) {
+ // Unlike DateTime, DrupalDateTime objects can't be compared directly.
+ // Convert them to timestamps, after discarding the time portion.
+ $time = $date->setTime(0, 0, 0)->format('U');
+ $start_time = $percentage->getStartDate()->setTime(0, 0, 0)->format('U');
+ $end_time = 0;
+ if ($end_date = $percentage->getEndDate()) {
+ $end_time = $end_date->setTime(0, 0, 0)->format('U');
+ }
+
+ if (($start_time <= $time) && (!$end_time || $end_time >= $time)) {
return $percentage;
}
}
diff --git a/modules/tax/tests/src/Kernel/TaxRateTest.php b/modules/tax/tests/src/Kernel/TaxRateTest.php
index 896f5e009c..ba7b837dba 100644
--- a/modules/tax/tests/src/Kernel/TaxRateTest.php
+++ b/modules/tax/tests/src/Kernel/TaxRateTest.php
@@ -3,6 +3,7 @@
namespace Drupal\Tests\commerce_tax\Kernel;
use Drupal\commerce_tax\TaxRate;
+use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase;
/**
@@ -51,7 +52,8 @@ public function testValid() {
'id' => 'standard',
'label' => 'Standard',
'percentages' => [
- ['number' => '0.23', 'start_date' => '2012-01-01'],
+ ['number' => '0.23', 'start_date' => '2012-01-01', 'end_date' => '2012-12-31'],
+ ['number' => '0.24', 'start_date' => '2013-01-01'],
],
'default' => TRUE,
];
@@ -60,11 +62,21 @@ public function testValid() {
$this->assertEquals($definition['id'], $rate->getId());
$this->assertEquals($definition['label'], $rate->getLabel());
$this->assertTrue($rate->isDefault());
- $this->assertCount(1, $rate->getPercentages());
+ $this->assertCount(2, $rate->getPercentages());
- $percentage = $rate->getPercentage();
+ $date = new DrupalDateTime('2012-06-30 12:00:00');
+ $percentage = $rate->getPercentage($date);
+ $this->assertEquals($percentage, $rate->getPercentages()[0]);
+ $this->assertEquals($definition['percentages'][0]['number'], $percentage->getNumber());
+
+ $date = new DrupalDateTime('2012-12-31 17:15:00');
+ $percentage = $rate->getPercentage($date);
$this->assertEquals($percentage, $rate->getPercentages()[0]);
$this->assertEquals($definition['percentages'][0]['number'], $percentage->getNumber());
+
+ $percentage = $rate->getPercentage();
+ $this->assertEquals($percentage, $rate->getPercentages()[1]);
+ $this->assertEquals($definition['percentages'][1]['number'], $percentage->getNumber());
}
}
diff --git a/src/BundleFieldDefinition.php b/src/BundleFieldDefinition.php
index 0f6c15486c..0690daebdc 100644
--- a/src/BundleFieldDefinition.php
+++ b/src/BundleFieldDefinition.php
@@ -2,11 +2,15 @@
namespace Drupal\commerce;
-use Drupal\entity\BundleFieldDefinition as BaseBundleFieldDefinition;
+@trigger_error('The ' . __NAMESPACE__ . '\BundleFieldDefinition is deprecated. Instead, use \Drupal\entity\BundleFieldDefinition', E_USER_DEPRECATED);
+
+use Drupal\entity\BundleFieldDefinition as EntityBundleFieldDefinition;
/**
* Provides a field definition class for bundle fields.
*
* Note: This code has moved to Entity API, see the parent class.
+ *
+ * @deprecated in Commerce 2.0
*/
-class BundleFieldDefinition extends BaseBundleFieldDefinition {}
+class BundleFieldDefinition extends EntityBundleFieldDefinition {}
diff --git a/src/BundlePluginInterface.php b/src/BundlePluginInterface.php
index 5270542a2b..5ea26edf33 100644
--- a/src/BundlePluginInterface.php
+++ b/src/BundlePluginInterface.php
@@ -2,11 +2,15 @@
namespace Drupal\commerce;
+@trigger_error('The ' . __NAMESPACE__ . '\BundlePluginInterface is deprecated. Instead, use \Drupal\entity\BundlePlugin\BundlePluginInterface', E_USER_DEPRECATED);
+
use Drupal\entity\BundlePlugin\BundlePluginInterface as BaseBundlePluginInterface;
/**
* Interface for plugins which act as entity bundles.
*
* Note: This code has moved to Entity API, see the parent class.
+ *
+ * @deprecated in Commerce 2.0
*/
interface BundlePluginInterface extends BaseBundlePluginInterface {}
diff --git a/src/CommerceBundleAccessControlHandler.php b/src/CommerceBundleAccessControlHandler.php
new file mode 100644
index 0000000000..6d7887d9c8
--- /dev/null
+++ b/src/CommerceBundleAccessControlHandler.php
@@ -0,0 +1,32 @@
+getEntityType()->getAdminPermission();
+ if ($operation === 'delete') {
+ if ($entity->isLocked()) {
+ return AccessResult::forbidden()->addCacheableDependency($entity);
+ }
+ else {
+ return AccessResult::allowedIfHasPermission($account, $admin_permission)->addCacheableDependency($entity);
+ }
+ }
+ return AccessResult::allowedIfHasPermission($account, $admin_permission);
+ }
+
+}
diff --git a/src/CommerceEntityViewsData.php b/src/CommerceEntityViewsData.php
new file mode 100644
index 0000000000..b52059d474
--- /dev/null
+++ b/src/CommerceEntityViewsData.php
@@ -0,0 +1,31 @@
+getName();
$entity_type_id = $field_definition->getTargetEntityTypeId();
$bundle = $field_definition->getTargetBundle();
@@ -86,7 +87,7 @@ public function createField(BundleFieldDefinition $field_definition, $lock = TRU
/**
* {@inheritdoc}
*/
- public function deleteField(BundleFieldDefinition $field_definition) {
+ public function deleteField(EntityBundleFieldDefinition $field_definition) {
$field_name = $field_definition->getName();
$entity_type_id = $field_definition->getTargetEntityTypeId();
$bundle = $field_definition->getTargetBundle();
@@ -105,7 +106,7 @@ public function deleteField(BundleFieldDefinition $field_definition) {
/**
* {@inheritdoc}
*/
- public function hasData(BundleFieldDefinition $field_definition) {
+ public function hasData(EntityBundleFieldDefinition $field_definition) {
$field_name = $field_definition->getName();
$entity_type_id = $field_definition->getTargetEntityTypeId();
$bundle = $field_definition->getTargetBundle();
diff --git a/src/ConfigurableFieldManagerInterface.php b/src/ConfigurableFieldManagerInterface.php
index bf38321714..15a0ff0be6 100644
--- a/src/ConfigurableFieldManagerInterface.php
+++ b/src/ConfigurableFieldManagerInterface.php
@@ -2,6 +2,8 @@
namespace Drupal\commerce;
+use Drupal\entity\BundleFieldDefinition as EntityBundleFieldDefinition;
+
/**
* Manages configurable fields based on field definitions.
*
@@ -12,7 +14,7 @@ interface ConfigurableFieldManagerInterface {
/**
* Creates a configurable field from the given field definition.
*
- * @param \Drupal\commerce\BundleFieldDefinition $field_definition
+ * @param \Drupal\entity\BundleFieldDefinition $field_definition
* The field definition.
* @param bool $lock
* Whether the created field should be locked.
@@ -23,12 +25,12 @@ interface ConfigurableFieldManagerInterface {
* @throws \RuntimeException
* Thrown when a field with the same name already exists.
*/
- public function createField(BundleFieldDefinition $field_definition, $lock = TRUE);
+ public function createField(EntityBundleFieldDefinition $field_definition, $lock = TRUE);
/**
* Deletes the configurable field created from the given field definition.
*
- * @param \Drupal\commerce\BundleFieldDefinition $field_definition
+ * @param \Drupal\entity\BundleFieldDefinition $field_definition
* The field definition.
*
* @throws \InvalidArgumentException
@@ -37,12 +39,12 @@ public function createField(BundleFieldDefinition $field_definition, $lock = TRU
* @throws \RuntimeException
* Thrown when no matching field was found.
*/
- public function deleteField(BundleFieldDefinition $field_definition);
+ public function deleteField(EntityBundleFieldDefinition $field_definition);
/**
* Checks whether the configurable field has data.
*
- * @param \Drupal\commerce\BundleFieldDefinition $field_definition
+ * @param \Drupal\entity\BundleFieldDefinition $field_definition
* The field definition.
*
* @return bool
@@ -54,6 +56,6 @@ public function deleteField(BundleFieldDefinition $field_definition);
* @throws \RuntimeException
* Thrown when no matching field was found.
*/
- public function hasData(BundleFieldDefinition $field_definition);
+ public function hasData(EntityBundleFieldDefinition $field_definition);
}
diff --git a/src/Context.php b/src/Context.php
index 2c4d77d223..4a78949de7 100644
--- a/src/Context.php
+++ b/src/Context.php
@@ -62,7 +62,7 @@ public function getCustomer() {
/**
* Gets the store.
*
- * @return \Drupal\commerce_store\Entity\Store
+ * @return \Drupal\commerce_store\Entity\StoreInterface
* The store.
*/
public function getStore() {
diff --git a/src/Entity/CommerceBundleEntityBase.php b/src/Entity/CommerceBundleEntityBase.php
index 8cd369a22e..98793ce6cb 100644
--- a/src/Entity/CommerceBundleEntityBase.php
+++ b/src/Entity/CommerceBundleEntityBase.php
@@ -30,6 +30,13 @@ class CommerceBundleEntityBase extends ConfigEntityBundleBase implements Commerc
*/
protected $traits = [];
+ /**
+ * Whether the bundle is locked, indicating that it cannot be deleted.
+ *
+ * @var bool
+ */
+ protected $locked = FALSE;
+
/**
* {@inheritdoc}
*/
@@ -52,4 +59,27 @@ public function hasTrait($trait) {
return in_array($trait, $this->traits);
}
+ /**
+ * {@inheritdoc}
+ */
+ public function isLocked() {
+ return (bool) $this->locked;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function lock() {
+ $this->locked = TRUE;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function unlock() {
+ $this->locked = FALSE;
+ return $this;
+ }
+
}
diff --git a/src/Entity/CommerceBundleEntityInterface.php b/src/Entity/CommerceBundleEntityInterface.php
index 1d8b8a72ad..53c52c08a6 100644
--- a/src/Entity/CommerceBundleEntityInterface.php
+++ b/src/Entity/CommerceBundleEntityInterface.php
@@ -39,4 +39,28 @@ public function setTraits(array $traits);
*/
public function hasTrait($trait);
+ /**
+ * Gets whether the bundle is locked.
+ *
+ * Locked bundles cannot be deleted.
+ *
+ * @return bool
+ * TRUE if the bundle is locked, FALSE otherwise.
+ */
+ public function isLocked();
+
+ /**
+ * Locks the bundle.
+ *
+ * @return $this
+ */
+ public function lock();
+
+ /**
+ * Unlocks the bundle.
+ *
+ * @return $this
+ */
+ public function unlock();
+
}
diff --git a/src/EntityAccessControlHandler.php b/src/EntityAccessControlHandler.php
index adf839bab0..23d6cb848f 100644
--- a/src/EntityAccessControlHandler.php
+++ b/src/EntityAccessControlHandler.php
@@ -2,11 +2,15 @@
namespace Drupal\commerce;
+@trigger_error('The ' . __NAMESPACE__ . '\EntityAccessControlHandler is deprecated. Instead, use \Drupal\entity\EntityAccessControlHandler', E_USER_DEPRECATED);
+
use Drupal\entity\EntityAccessControlHandler as BaseEntityAccessControlHandler;
/**
* Controls access based on the Commerce entity permissions.
*
* Note: This code has moved to Entity API, see the parent class.
+ *
+ * @deprecated in Commerce 2.0
*/
class EntityAccessControlHandler extends BaseEntityAccessControlHandler {}
diff --git a/src/EntityPermissionProvider.php b/src/EntityPermissionProvider.php
index 3d484b7a10..540d17fd8e 100644
--- a/src/EntityPermissionProvider.php
+++ b/src/EntityPermissionProvider.php
@@ -7,6 +7,8 @@
/**
* Provides Commerce entity permissions.
*
- * @see \Drupal\entity\EntityPermissionProvider
+ * Note: This code has moved to Entity API, see the parent class.
+ *
+ * @deprecated in Commerce 2.0
*/
class EntityPermissionProvider extends BaseEntityPermissionProvider {}
diff --git a/src/Form/CommercePluginEntityFormBase.php b/src/Form/CommercePluginEntityFormBase.php
index 421bb137a4..ee561d2f00 100644
--- a/src/Form/CommercePluginEntityFormBase.php
+++ b/src/Form/CommercePluginEntityFormBase.php
@@ -4,6 +4,9 @@
use Drupal\Core\Entity\EntityForm;
+/**
+ * @deprecated in Commerce 2.2. Set #disabled on the ID element directly.
+ */
abstract class CommercePluginEntityFormBase extends EntityForm {
/**
diff --git a/src/Plugin/Commerce/EntityTrait/EntityTraitInterface.php b/src/Plugin/Commerce/EntityTrait/EntityTraitInterface.php
index 144ecb95c3..cf8d954f48 100644
--- a/src/Plugin/Commerce/EntityTrait/EntityTraitInterface.php
+++ b/src/Plugin/Commerce/EntityTrait/EntityTraitInterface.php
@@ -38,7 +38,7 @@ public function getEntityTypeIds();
* THe provided field definitions will be created as configurable
* fields when the entity trait is installed for an entity type/bundle.
*
- * @return \Drupal\commerce\BundleFieldDefinition[]
+ * @return \Drupal\entity\BundleFieldDefinition[]
* An array of field definitions, keyed by field name.
*/
public function buildFieldDefinitions();
diff --git a/modules/promotion/src/Plugin/Field/FieldWidget/EndDateWidget.php b/src/Plugin/Field/FieldWidget/EndDateWidget.php
similarity index 97%
rename from modules/promotion/src/Plugin/Field/FieldWidget/EndDateWidget.php
rename to src/Plugin/Field/FieldWidget/EndDateWidget.php
index 505e13af08..df2bcd336b 100644
--- a/modules/promotion/src/Plugin/Field/FieldWidget/EndDateWidget.php
+++ b/src/Plugin/Field/FieldWidget/EndDateWidget.php
@@ -1,6 +1,6 @@