-
Notifications
You must be signed in to change notification settings - Fork 9.4k
[GraphQl] Single mutation for adding different types of products to the shopping cart #27914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b9e28fd
13d0942
a7ded07
aa5bd2b
05bb308
3da33f5
87407fa
b391029
75f531d
62d4146
2315faf
4c08148
040663f
f5f6b59
52fb7a7
85fd270
01e6312
11e23d6
1521ac2
9be928c
bf83c7f
354fcbd
8cbce5c
b26a7e5
633ef00
38504fd
394dd7e
06e3c7b
0b75992
c24c12b
089ef94
b46af1c
f91b685
ec99fef
330e763
f6cfcff
b1159bf
8b925df
4c5403d
84ceb30
5f86828
762a3c1
6f62556
473b8d8
2518eb1
4391235
a556345
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,225 @@ | ||||||
<?php | ||||||
/** | ||||||
* Copyright © Magento, Inc. All rights reserved. | ||||||
* See COPYING.txt for license details. | ||||||
*/ | ||||||
declare(strict_types=1); | ||||||
|
||||||
namespace Magento\Quote\Model\Cart; | ||||||
|
||||||
use Magento\Catalog\Api\ProductRepositoryInterface; | ||||||
use Magento\Framework\Exception\NoSuchEntityException; | ||||||
use Magento\Quote\Api\CartRepositoryInterface; | ||||||
use Magento\Quote\Api\Data\CartInterface; | ||||||
use Magento\Quote\Model\Cart\BuyRequest\BuyRequestBuilder; | ||||||
use Magento\Quote\Model\Cart\Data\AddProductsToCartOutput; | ||||||
use Magento\Quote\Model\MaskedQuoteIdToQuoteIdInterface; | ||||||
use Magento\Quote\Model\Quote; | ||||||
use Magento\Framework\Message\MessageInterface; | ||||||
|
||||||
/** | ||||||
* Unified approach to add products to the Shopping Cart. | ||||||
* Client code must validate, that customer is eligible to call service with provided {cartId} and {cartItems} | ||||||
*/ | ||||||
class AddProductsToCart | ||||||
{ | ||||||
/**#@+ | ||||||
* Error message codes | ||||||
*/ | ||||||
private const ERROR_PRODUCT_NOT_FOUND = 'PRODUCT_NOT_FOUND'; | ||||||
private const ERROR_INSUFFICIENT_STOCK = 'INSUFFICIENT_STOCK'; | ||||||
private const ERROR_NOT_SALABLE = 'NOT_SALABLE'; | ||||||
private const ERROR_UNDEFINED = 'UNDEFINED'; | ||||||
/**#@-*/ | ||||||
|
||||||
/** | ||||||
* List of error messages and codes. | ||||||
*/ | ||||||
private const MESSAGE_CODES = [ | ||||||
'Could not find a product with SKU' => self::ERROR_PRODUCT_NOT_FOUND, | ||||||
'The required options you selected are not available' => self::ERROR_NOT_SALABLE, | ||||||
'Product that you are trying to add is not available.' => self::ERROR_NOT_SALABLE, | ||||||
'This product is out of stock' => self::ERROR_INSUFFICIENT_STOCK, | ||||||
'There are no source items' => self::ERROR_NOT_SALABLE, | ||||||
'The fewest you may purchase is' => self::ERROR_INSUFFICIENT_STOCK, | ||||||
'The most you may purchase is' => self::ERROR_INSUFFICIENT_STOCK, | ||||||
'The requested qty is not available' => self::ERROR_INSUFFICIENT_STOCK, | ||||||
]; | ||||||
|
||||||
/** | ||||||
* @var ProductRepositoryInterface | ||||||
*/ | ||||||
private $productRepository; | ||||||
|
||||||
/** | ||||||
* @var array | ||||||
*/ | ||||||
private $errors = []; | ||||||
|
||||||
/** | ||||||
* @var CartRepositoryInterface | ||||||
*/ | ||||||
private $cartRepository; | ||||||
|
||||||
/** | ||||||
* @var MaskedQuoteIdToQuoteIdInterface | ||||||
*/ | ||||||
private $maskedQuoteIdToQuoteId; | ||||||
|
||||||
/** | ||||||
* @var BuyRequestBuilder | ||||||
*/ | ||||||
private $requestBuilder; | ||||||
|
||||||
/** | ||||||
* @param ProductRepositoryInterface $productRepository | ||||||
* @param CartRepositoryInterface $cartRepository | ||||||
* @param MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId | ||||||
* @param BuyRequestBuilder $requestBuilder | ||||||
*/ | ||||||
public function __construct( | ||||||
ProductRepositoryInterface $productRepository, | ||||||
CartRepositoryInterface $cartRepository, | ||||||
MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId, | ||||||
BuyRequestBuilder $requestBuilder | ||||||
) { | ||||||
$this->productRepository = $productRepository; | ||||||
$this->cartRepository = $cartRepository; | ||||||
$this->maskedQuoteIdToQuoteId = $maskedQuoteIdToQuoteId; | ||||||
$this->requestBuilder = $requestBuilder; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Add cart items to the cart | ||||||
* | ||||||
* @param string $maskedCartId | ||||||
* @param Data\CartItem[] $cartItems | ||||||
* @return AddProductsToCartOutput | ||||||
* @throws NoSuchEntityException Could not find a Cart with provided $maskedCartId | ||||||
*/ | ||||||
public function execute(string $maskedCartId, array $cartItems): AddProductsToCartOutput | ||||||
{ | ||||||
$cartId = $this->maskedQuoteIdToQuoteId->execute($maskedCartId); | ||||||
$cart = $this->cartRepository->get($cartId); | ||||||
|
||||||
foreach ($cartItems as $cartItemPosition => $cartItem) { | ||||||
$this->addItemToCart($cart, $cartItem, $cartItemPosition); | ||||||
} | ||||||
|
||||||
if ($cart->getData('has_error')) { | ||||||
$errors = $cart->getErrors(); | ||||||
|
||||||
/** @var MessageInterface $error */ | ||||||
foreach ($errors as $error) { | ||||||
$this->addError($error->getText()); | ||||||
} | ||||||
} | ||||||
|
||||||
if (count($this->errors) !== 0) { | ||||||
/* Revert changes introduced by add to cart processes in case of an error */ | ||||||
$cart->getItemsCollection()->clear(); | ||||||
} | ||||||
|
||||||
return $this->prepareErrorOutput($cart); | ||||||
} | ||||||
|
||||||
/** | ||||||
* Adds a particular item to the shopping cart | ||||||
* | ||||||
* @param CartInterface|Quote $cart | ||||||
* @param Data\CartItem $cartItem | ||||||
* @param int $cartItemPosition | ||||||
*/ | ||||||
private function addItemToCart(CartInterface $cart, Data\CartItem $cartItem, int $cartItemPosition): void | ||||||
{ | ||||||
$sku = $cartItem->getSku(); | ||||||
|
||||||
if ($cartItem->getQuantity() <= 0) { | ||||||
$this->addError(__('The product quantity should be greater than 0')->render()); | ||||||
|
||||||
return; | ||||||
} | ||||||
|
||||||
try { | ||||||
$product = $this->productRepository->get($sku, false, null, true); | ||||||
} catch (NoSuchEntityException $e) { | ||||||
$this->addError( | ||||||
__('Could not find a product with SKU "%sku"', ['sku' => $sku])->render(), | ||||||
$cartItemPosition | ||||||
); | ||||||
|
||||||
return; | ||||||
} | ||||||
|
||||||
try { | ||||||
$result = $cart->addProduct($product, $this->requestBuilder->build($cartItem)); | ||||||
$this->cartRepository->save($cart); | ||||||
} catch (\Throwable $e) { | ||||||
$this->addError( | ||||||
__($e->getMessage())->render(), | ||||||
$cartItemPosition | ||||||
); | ||||||
$cart->setHasError(false); | ||||||
|
||||||
return; | ||||||
} | ||||||
|
||||||
if (is_string($result)) { | ||||||
$errors = array_unique(explode("\n", $result)); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For complex errors we should be able utilize composite There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @lenaorobei. Thank you for your suggestion. In case of GraphQl modules, this approach will work. However, in this particular case, we work with the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @lenaorobei this ugly approach comes from \Magento\Quote\Model\Quote::addProduct and due to we need to provide error message(s) we need to "parse" it in that way, as was "designed" in Quote::addProduct
|
||||||
foreach ($errors as $error) { | ||||||
$this->addError(__($error)->render(), $cartItemPosition); | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
/** | ||||||
* Add order line item error | ||||||
* | ||||||
* @param string $message | ||||||
* @param int $cartItemPosition | ||||||
* @return void | ||||||
*/ | ||||||
private function addError(string $message, int $cartItemPosition = 0): void | ||||||
{ | ||||||
$this->errors[] = new Data\Error( | ||||||
$message, | ||||||
$this->getErrorCode($message), | ||||||
$cartItemPosition | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good catch. The problem is it's a default system behavior introduced to the core a couple of months ago. magento2/app/code/Magento/Quote/Model/Quote.php Line 1684 in faced71
I'm not sure about the best way to overcome it. @prabhuram93 maybe you have ideas, thank you. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey @rogyar. This took me a while to figure it out. Apparently this is happening only on the new mutation, not the existing ones. Even though the cart item is being deleted in core, the cart is not saved. But I see in this PR the cart is being saved here even when there are errors. app/code/Magento/Quote/Model/Cart/AddProductsToCart.php:116. I don't see any to reason to do that, please correct me if I am wrong. So fixing that should fix this issue. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @prabhuram93. That's a good point. But unfortunately, without explicit cart saving on You may test it in the following way:
So I'm still thinking that the approach of removing item if it has an error magento2/app/code/Magento/Quote/Model/Quote.php Line 1684 in faced71
Does the dirty thing. Thank you! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey @rogyar. I understand that cart is not being saved anywhere else. But I don't see any reason to save the cart when there are errors. So in the scenario you mentioned I wouldn't just comment out the line in step 2, rather we can handle it to save the cart when only there are no errors. That way when there are errors just the error objects will be updated for that particular mutation and the cart will not be saved. When there are no errors cart will be saved. I managed to try it out and it works for both the cases you mentioned. Let me know if this is still unclear. Thanks! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Got it, thank you. We may adjust it. The only thing that will be lost in this case, described in the following scenario.
... but, it's another story :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey @rogyar, I don't see a use case where we add 3 different items to the cart at the same point. We might do it one after the other, but not together. So we are good on that front. |
||||||
); | ||||||
} | ||||||
|
||||||
/** | ||||||
* Get message error code. | ||||||
* | ||||||
* TODO: introduce a separate class for getting error code from a message | ||||||
* | ||||||
* @param string $message | ||||||
* @return string | ||||||
*/ | ||||||
private function getErrorCode(string $message): string | ||||||
{ | ||||||
foreach (self::MESSAGE_CODES as $codeMessage => $code) { | ||||||
if (false !== stripos($message, $codeMessage)) { | ||||||
return $code; | ||||||
} | ||||||
} | ||||||
|
||||||
/* If no code was matched, return the default one */ | ||||||
return self::ERROR_UNDEFINED; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Creates a new output from existing errors | ||||||
* | ||||||
* @param CartInterface $cart | ||||||
* @return AddProductsToCartOutput | ||||||
*/ | ||||||
private function prepareErrorOutput(CartInterface $cart): AddProductsToCartOutput | ||||||
{ | ||||||
$output = new AddProductsToCartOutput($cart, $this->errors); | ||||||
$this->errors = []; | ||||||
$cart->setHasError(false); | ||||||
|
||||||
return $output; | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
<?php | ||
/** | ||
* Copyright © Magento, Inc. All rights reserved. | ||
* See COPYING.txt for license details. | ||
*/ | ||
declare(strict_types=1); | ||
|
||
namespace Magento\Quote\Model\Cart\BuyRequest; | ||
|
||
use Magento\Framework\DataObject; | ||
use Magento\Framework\DataObjectFactory; | ||
use Magento\Quote\Model\Cart\Data\CartItem; | ||
|
||
/** | ||
* Build buy request for adding products to cart | ||
*/ | ||
class BuyRequestBuilder | ||
{ | ||
/** | ||
* @var BuyRequestDataProviderInterface[] | ||
*/ | ||
private $providers; | ||
|
||
/** | ||
* @var DataObjectFactory | ||
*/ | ||
private $dataObjectFactory; | ||
|
||
/** | ||
* @param DataObjectFactory $dataObjectFactory | ||
* @param array $providers | ||
*/ | ||
public function __construct( | ||
DataObjectFactory $dataObjectFactory, | ||
array $providers = [] | ||
) { | ||
$this->dataObjectFactory = $dataObjectFactory; | ||
$this->providers = $providers; | ||
} | ||
|
||
/** | ||
* Build buy request for adding product to cart | ||
* | ||
* @see \Magento\Quote\Model\Quote::addProduct | ||
* @param CartItem $cartItem | ||
* @return DataObject | ||
*/ | ||
public function build(CartItem $cartItem): DataObject | ||
{ | ||
$requestData = [ | ||
['qty' => $cartItem->getQuantity()] | ||
]; | ||
|
||
/** @var BuyRequestDataProviderInterface $provider */ | ||
foreach ($this->providers as $provider) { | ||
$requestData[] = $provider->execute($cartItem); | ||
} | ||
|
||
return $this->dataObjectFactory->create(['data' => array_merge(...$requestData)]); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
<?php | ||
/** | ||
* Copyright © Magento, Inc. All rights reserved. | ||
* See COPYING.txt for license details. | ||
*/ | ||
declare(strict_types=1); | ||
|
||
namespace Magento\Quote\Model\Cart\BuyRequest; | ||
|
||
use Magento\Quote\Model\Cart\Data\CartItem; | ||
|
||
/** | ||
* Provides data for buy request for different types of products | ||
*/ | ||
interface BuyRequestDataProviderInterface | ||
{ | ||
/** | ||
* Provide buy request data from add to cart item request | ||
* | ||
* @param CartItem $cartItem | ||
* @return array | ||
*/ | ||
public function execute(CartItem $cartItem): array; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please, add a comment that this is an ad-hoc solution to get an error code related to some message. And maybe we can introduce a new class to provide error code based on error message to avoid logic duplicate: \Magento\Sales\Model\Reorder\Reorder::getErrorCode
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Totally agree. I was thinking about that but then came up with the conclusion that we will need this new class on the framework level (since we will need to reuse it in different independent modules). As long as we plan to deliver this PR as a part of 2.4.0 release, I don't want to introduce new interfaces or implementations on the framework level. So, let's leave a comment for now, and then, once this PR is delivered, I will introduce a new class and refactor the existing logic.
Thank you.