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 %}
- {{ adjustment.label }} {{ adjustment.total|commerce_price_format }} + {{ adjustment.label }} {{ adjustment.amount|commerce_price_format }}
{% endfor %}
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 @@