diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/Option.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/Option.php new file mode 100644 index 0000000000000..68968b6f3819a --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/Option.php @@ -0,0 +1,63 @@ +idEncoder = $idEncoder; + $this->valueFormatter = $valueFormatter; + } + + /** + * Format configurable product options according to the GraphQL schema + * + * @param Attribute $attribute + * @param array $optionIds + * @return array|null + */ + public function format(Attribute $attribute, array $optionIds): ?array + { + $optionValues = []; + + foreach ($attribute->getOptions() as $option) { + $optionValues[] = $this->valueFormatter->format($option, $attribute, $optionIds); + } + + return [ + 'uid' => $this->idEncoder->encode($attribute->getProductSuperAttributeId()), + 'attribute_code' => $attribute->getProductAttribute()->getAttributeCode(), + 'label' => $attribute->getLabel(), + 'values' => $optionValues, + ]; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/OptionValue.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/OptionValue.php new file mode 100644 index 0000000000000..5d721f13fbb9d --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/OptionValue.php @@ -0,0 +1,83 @@ +selectionUidFormatter = $selectionUidFormatter; + $this->stockRegistry = $stockRegistry; + } + + /** + * Format configurable product option values according to the GraphQL schema + * + * @param array $optionValue + * @param Attribute $attribute + * @param array $optionIds + * @return array + */ + public function format(array $optionValue, Attribute $attribute, array $optionIds): array + { + $valueIndex = (int)$optionValue['value_index']; + $attributeId = (int)$attribute->getAttributeId(); + + return [ + 'uid' => $this->selectionUidFormatter->encode( + $attributeId, + $valueIndex + ), + 'is_available' => $this->getIsAvailable($optionIds[$valueIndex] ?? []), + 'is_use_default' => (bool)$attribute->getIsUseDefault(), + 'label' => $optionValue['label'], + 'value_index' => $optionValue['value_index'] + ]; + } + + /** + * Get is variants available + * + * @param array $variantIds + * @return bool + */ + private function getIsAvailable(array $variantIds): bool + { + foreach ($variantIds as $variantId) { + if ($this->stockRegistry->getProductStockStatus($variantId)) { + return true; + } + } + + return false; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/Variant.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/Variant.php new file mode 100644 index 0000000000000..1d73ad6a19336 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Formatter/Variant.php @@ -0,0 +1,49 @@ + $selectedValue) { + if (!isset($options[$attributeId][$selectedValue])) { + throw new GraphQlInputException(__('configurableOptionValueUids values are incorrect')); + } + + $productIds = array_intersect($productIds, $options[$attributeId][$selectedValue]); + } + + if (count($productIds) === 1) { + $variantProduct = $variants[array_pop($productIds)]; + $variant = $variantProduct->getData(); + $variant['url_path'] = $variantProduct->getProductUrl(); + $variant['model'] = $variantProduct; + } + + return $variant; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/ConfigurableOptionsMetadata.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/ConfigurableOptionsMetadata.php new file mode 100644 index 0000000000000..ce81e970bcd58 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/ConfigurableOptionsMetadata.php @@ -0,0 +1,86 @@ +configurableProductHelper = $configurableProductHelper; + $this->configurableOptionsFormatter = $configurableOptionsFormatter; + } + + /** + * Load available selections from configurable options and variant. + * + * @param ProductInterface $product + * @param array $options + * @param array $selectedOptions + * @return array + */ + public function getAvailableSelections(ProductInterface $product, array $options, array $selectedOptions): array + { + $attributes = $this->getAttributes($product); + $availableSelections = []; + + foreach ($options as $attributeId => $option) { + if ($attributeId === 'index' || isset($selectedOptions[$attributeId])) { + continue; + } + + $availableSelections[] = $this->configurableOptionsFormatter->format( + $attributes[$attributeId], + $options[$attributeId] ?? [] + ); + } + + return $availableSelections; + } + + /** + * Retrieve configurable attributes for the product + * + * @param ProductInterface $product + * @return Attribute[] + */ + private function getAttributes(ProductInterface $product): array + { + $allowedAttributes = $this->configurableProductHelper->getAllowAttributes($product); + $attributes = []; + foreach ($allowedAttributes as $attribute) { + $attributes[$attribute->getAttributeId()] = $attribute; + } + + return $attributes; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/DataProvider/Variant.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/DataProvider/Variant.php index 80fbdc76bacb3..7b5a3fb806a5f 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/DataProvider/Variant.php +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/DataProvider/Variant.php @@ -10,6 +10,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\CatalogInventory\Model\ResourceModel\Stock\StatusFactory; use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Framework\Exception\LocalizedException; /** * Retrieve child products @@ -45,7 +46,7 @@ public function __construct( * @return ProductInterface[] * @throws \Magento\Framework\Exception\LocalizedException */ - public function getSalableVariantsByParent(ProductInterface $product) + public function getSalableVariantsByParent(ProductInterface $product): array { $collection = $this->configurableType->getUsedProductCollection($product); $collection @@ -62,6 +63,6 @@ public function getSalableVariantsByParent(ProductInterface $product) } $collection->clear(); - return $collection->getItems(); + return $collection->getItems() ?? []; } } diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/SelectionUidFormatter.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/SelectionUidFormatter.php index 1d13ad75489a1..8c82c9414763f 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/SelectionUidFormatter.php +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/SelectionUidFormatter.php @@ -3,8 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\ConfigurableProductGraphQl\Model\Options; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Uid; + /** * Handle option selection uid. */ @@ -20,6 +24,19 @@ class SelectionUidFormatter */ private const UID_SEPARATOR = '/'; + /** + * @var Uid + */ + private $idEncoder; + + /** + * @param Uid $idEncoder + */ + public function __construct(Uid $idEncoder) + { + $this->idEncoder = $idEncoder; + } + /** * Create uid and encode. * @@ -29,28 +46,22 @@ class SelectionUidFormatter */ public function encode(int $attributeId, int $indexId): string { - // phpcs:ignore Magento2.Functions.DiscouragedFunction - return base64_encode(implode(self::UID_SEPARATOR, [ - self::UID_PREFIX, - $attributeId, - $indexId - ])); + return $this->idEncoder->encode(implode(self::UID_SEPARATOR, [self::UID_PREFIX, $attributeId, $indexId])); } /** * Retrieve attribute and option index from uid. Array key is the id of attribute and value is the index of option * - * @param string $selectionUids + * @param array $selectionUids * @return array - * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @throws GraphQlInputException */ public function extract(array $selectionUids): array { $attributeOption = []; foreach ($selectionUids as $uid) { - // phpcs:ignore Magento2.Functions.DiscouragedFunction - $optionData = explode(self::UID_SEPARATOR, base64_decode($uid)); - if (count($optionData) == 3) { + $optionData = explode(self::UID_SEPARATOR, $this->idEncoder->decode($uid)); + if (count($optionData) === 3) { $attributeOption[(int)$optionData[1]] = (int)$optionData[2]; } } diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/OptionsSelectionMetadata.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/OptionsSelectionMetadata.php index 6aa3a9774b439..556aab7e39f7d 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/OptionsSelectionMetadata.php +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/OptionsSelectionMetadata.php @@ -3,17 +3,23 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\ConfigurableProductGraphQl\Model\Resolver; -use Magento\Catalog\Api\Data\ProductInterface; +use Magento\ConfigurableProduct\Helper\Data; +use Magento\ConfigurableProductGraphQl\Model\Formatter\Variant as VariantFormatter; +use Magento\ConfigurableProductGraphQl\Model\Options\ConfigurableOptionsMetadata; +use Magento\ConfigurableProductGraphQl\Model\Options\DataProvider\Variant; use Magento\ConfigurableProductGraphQl\Model\Options\Metadata; +use Magento\ConfigurableProductGraphQl\Model\Options\SelectionUidFormatter; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Config\Element\Field; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; /** - * Resolver class for option selection metadata. + * Resolver for options selection */ class OptionsSelectionMetadata implements ResolverInterface { @@ -22,13 +28,53 @@ class OptionsSelectionMetadata implements ResolverInterface */ private $configurableSelectionMetadata; + /** + * @var ConfigurableOptionsMetadata + */ + private $configurableOptionsMetadata; + + /** + * @var SelectionUidFormatter + */ + private $selectionUidFormatter; + + /** + * @var Variant + */ + private $variant; + + /** + * @var VariantFormatter + */ + private $variantFormatter; + + /** + * @var Data + */ + private $configurableProductHelper; + /** * @param Metadata $configurableSelectionMetadata + * @param ConfigurableOptionsMetadata $configurableOptionsMetadata + * @param SelectionUidFormatter $selectionUidFormatter + * @param Variant $variant + * @param VariantFormatter $variantFormatter + * @param Data $configurableProductHelper */ public function __construct( - Metadata $configurableSelectionMetadata + Metadata $configurableSelectionMetadata, + ConfigurableOptionsMetadata $configurableOptionsMetadata, + SelectionUidFormatter $selectionUidFormatter, + Variant $variant, + VariantFormatter $variantFormatter, + Data $configurableProductHelper ) { $this->configurableSelectionMetadata = $configurableSelectionMetadata; + $this->configurableOptionsMetadata = $configurableOptionsMetadata; + $this->selectionUidFormatter = $selectionUidFormatter; + $this->variant = $variant; + $this->variantFormatter = $variantFormatter; + $this->configurableProductHelper = $configurableProductHelper; } /** @@ -40,10 +86,31 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value throw new LocalizedException(__('"model" value should be specified')); } - $selectedOptions = $args['configurableOptionValueUids'] ?? []; - /** @var ProductInterface $product */ $product = $value['model']; - return $this->configurableSelectionMetadata->getAvailableSelections($product, $selectedOptions); + $selectionUids = $args['configurableOptionValueUids'] ?? []; + $selectedOptions = $this->selectionUidFormatter->extract($selectionUids); + + $variants = $this->variant->getSalableVariantsByParent($product); + $options = $this->configurableProductHelper->getOptions($product, $variants); + + $configurableOptions = $this->configurableOptionsMetadata->getAvailableSelections( + $product, + $options, + $selectedOptions + ); + + $optionsAvailableForSelection = $this->configurableSelectionMetadata->getAvailableSelections( + $product, + $args['configurableOptionValueUids'] ?? [] + ); + + return [ + 'configurable_options' => $configurableOptions, + 'variant' => $this->variantFormatter->format($options, $selectedOptions, $variants), + 'model' => $product, + 'options_available_for_selection' => $optionsAvailableForSelection['options_available_for_selection'], + 'availableSelectionProducts' => $optionsAvailableForSelection['availableSelectionProducts'] + ]; } } diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/SelectionMediaGallery.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/SelectionMediaGallery.php index 4aa322e66df60..972e4a9fd629a 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/SelectionMediaGallery.php +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/SelectionMediaGallery.php @@ -21,11 +21,11 @@ class SelectionMediaGallery implements ResolverInterface */ public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) { - if (!isset($value['product']) || !$value['product']) { + if (!isset($value['model']) || !$value['model']) { return null; } - $product = $value['product']; + $product = $value['model']; $availableSelectionProducts = $value['availableSelectionProducts']; $mediaGalleryEntries = []; $usedProducts = $product->getTypeInstance()->getUsedProducts($product, null); diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls b/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls index 3e84a428765fc..d9ba786c84354 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls +++ b/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls @@ -7,7 +7,7 @@ type Mutation { type ConfigurableProduct implements ProductInterface, PhysicalProductInterface, CustomizableProductInterface @doc(description: "ConfigurableProduct defines basic features of a configurable product and its simple product variants") { variants: [ConfigurableVariant] @doc(description: "An array of variants of products") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\ConfigurableVariant") configurable_options: [ConfigurableProductOptions] @doc(description: "An array of linked simple product items") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\Options") - configurable_product_options_selection(configurableOptionValueUids: [ID!]): ConfigurableProductOptionsSelection @doc(description: "Metadata for the specified configurable options selection") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\OptionsSelectionMetadata") + configurable_product_options_selection(configurableOptionValueUids: [ID!]): ConfigurableProductOptionsSelection @doc(description: "Specified configurable product options selection") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\OptionsSelectionMetadata") } type ConfigurableVariant @doc(description: "An array containing all the simple product variants of a configurable product") { @@ -83,6 +83,7 @@ type ConfigurableWishlistItem implements WishlistItemInterface @doc(description: type ConfigurableProductOptionsSelection @doc(description: "Metadata corresponding to the configurable options selection.") { options_available_for_selection: [ConfigurableOptionAvailableForSelection!] @doc(description: "Configurable options available for further selection based on current selection.") + configurable_options: [ConfigurableProductOption!] @doc(description: "Configurable options available for further selection based on current selection.") media_gallery: [MediaGalleryInterface!] @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\SelectionMediaGallery") @doc(description: "Product images and videos corresponding to the specified configurable options selection.") variant: SimpleProduct @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\Variant\\Variant") @doc(description: "Variant represented by the specified configurable options selection. It is expected to be null, until selections are made for each configurable option.") } @@ -92,6 +93,20 @@ type ConfigurableOptionAvailableForSelection @doc(description: "Configurable opt attribute_code: String! @doc(description: "Attribute code that uniquely identifies configurable option.") } +type ConfigurableProductOption { + uid: ID! + attribute_code: String! + label: String! + values: [ConfigurableProductOptionValue!] +} + +type ConfigurableProductOptionValue { + uid: ID! + is_available: Boolean! + is_use_default: Boolean! + label: String! +} + type StoreConfig @doc(description: "The type contains information about a store config") { configurable_thumbnail_source : String @doc(description: "The configuration setting determines which thumbnail should be used in the cart for configurable products.") } diff --git a/app/code/Magento/SwatchesGraphQl/composer.json b/app/code/Magento/SwatchesGraphQl/composer.json index 1b98b4044a2ff..959f0f201d2b3 100644 --- a/app/code/Magento/SwatchesGraphQl/composer.json +++ b/app/code/Magento/SwatchesGraphQl/composer.json @@ -9,6 +9,9 @@ "magento/module-catalog": "*", "magento/module-catalog-graph-ql": "*" }, + "suggest": { + "magento/module-configurable-product-graph-ql": "*" + }, "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SwatchesGraphQl/etc/module.xml b/app/code/Magento/SwatchesGraphQl/etc/module.xml index 6689f13db754e..71c336a8cd257 100644 --- a/app/code/Magento/SwatchesGraphQl/etc/module.xml +++ b/app/code/Magento/SwatchesGraphQl/etc/module.xml @@ -10,6 +10,7 @@ + diff --git a/app/code/Magento/SwatchesGraphQl/etc/schema.graphqls b/app/code/Magento/SwatchesGraphQl/etc/schema.graphqls index c51468ccd2856..3491568108daf 100644 --- a/app/code/Magento/SwatchesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SwatchesGraphQl/etc/schema.graphqls @@ -47,3 +47,7 @@ type TextSwatchData implements SwatchDataInterface { type ColorSwatchData implements SwatchDataInterface { } + +type ConfigurableProductOptionValue { + swatch: SwatchDataInterface @resolver(class: "Magento\\SwatchesGraphQl\\Model\\Resolver\\Product\\Options\\SwatchData") +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php index 659720578de10..5811344e4defd 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php @@ -3,18 +3,20 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + declare(strict_types=1); namespace Magento\GraphQl\ConfigurableProduct; use Exception; +use Magento\CatalogInventory\Model\Configuration; use Magento\Config\Model\ResourceModel\Config; +use Magento\ConfigurableProductGraphQl\Model\Options\SelectionUidFormatter; use Magento\Framework\App\Config\ReinitableConfigInterface; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\TestCase\GraphQlAbstract; -use Magento\CatalogInventory\Model\Configuration; /** * Add configurable product to cart testcases @@ -41,6 +43,11 @@ class AddConfigurableProductToCartSingleMutationTest extends GraphQlAbstract */ private $reinitConfig; + /** + * @var SelectionUidFormatter + */ + private $selectionUidFormatter; + /** * @inheritdoc */ @@ -51,13 +58,14 @@ protected function setUp(): void $this->resourceConfig = $objectManager->get(Config::class); $this->scopeConfig = $objectManager->get(ScopeConfigInterface::class); $this->reinitConfig = $objectManager->get(ReinitableConfigInterface::class); + $this->selectionUidFormatter = $objectManager->get(SelectionUidFormatter::class); } /** * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable.php * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php */ - public function testAddConfigurableProductToCart() + public function testAddConfigurableProductToCart(): void { $product = $this->getConfigurableProductInfo(); $quantity = 2; @@ -77,7 +85,8 @@ public function testAddConfigurableProductToCart() ); $response = $this->graphQlMutation($query); - $expectedProductOptionsValueUid = $this->generateConfigurableSelectionUID($attributeId, $valueIndex); + + $expectedProductOptionsValueUid = $this->selectionUidFormatter->encode($attributeId, $valueIndex); $expectedProductOptionsUid = base64_encode("configurable/$productRowId/$attributeId"); $cartItem = current($response['addProductsToCart']['cart']['items']); self::assertEquals($quantity, $cartItem['quantity']); @@ -94,35 +103,11 @@ public function testAddConfigurableProductToCart() self::assertArrayHasKey('value_label', $option); } - /** - * Generates UID configurable product - * - * @param int $attributeId - * @param int $valueIndex - * @return string - */ - private function generateConfigurableSelectionUID(int $attributeId, int $valueIndex): string - { - return base64_encode("configurable/$attributeId/$valueIndex"); - } - - /** - * Generates UID for super configurable product super attributes - * - * @param int $attributeId - * @param int $valueIndex - * @return string - */ - private function generateSuperAttributesUIDQuery(int $attributeId, int $valueIndex): string - { - return 'selected_options: ["' . $this->generateConfigurableSelectionUID($attributeId, $valueIndex) . '"]'; - } - /** * @magentoApiDataFixture Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php */ - public function testAddConfigurableProductWithWrongSuperAttributes() + public function testAddConfigurableProductWithWrongSuperAttributes(): void { $product = $this->getConfigurableProductInfo(); $quantity = 2; @@ -150,7 +135,7 @@ public function testAddConfigurableProductWithWrongSuperAttributes() * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_sku.php * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php */ - public function testAddProductIfQuantityIsNotAvailable() + public function testAddProductIfQuantityIsNotAvailable(): void { $product = $this->getConfigurableProductInfo(); $parentSku = $product['sku']; @@ -179,7 +164,7 @@ public function testAddProductIfQuantityIsNotAvailable() * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_sku.php * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php */ - public function testAddNonExistentConfigurableProductParentToCart() + public function testAddNonExistentConfigurableProductParentToCart(): void { $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); $parentSku = 'configurable_no_exist'; @@ -203,7 +188,7 @@ public function testAddNonExistentConfigurableProductParentToCart() * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_zero_qty_first_child.php * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php */ - public function testOutOfStockVariationToCart() + public function testOutOfStockVariationToCart(): void { $showOutOfStock = $this->scopeConfig->getValue(Configuration::XML_PATH_SHOW_OUT_OF_STOCK); @@ -215,7 +200,7 @@ public function testOutOfStockVariationToCart() $attributeId = (int) $product['configurable_options'][0]['attribute_id']; $valueIndex = $product['configurable_options'][0]['values'][0]['value_index']; // Asserting that the first value is the right option we want to add to cart - $this->assertEquals( + self::assertEquals( $product['configurable_options'][0]['values'][0]['label'], 'Option 1' ); @@ -237,7 +222,7 @@ public function testOutOfStockVariationToCart() 'There are no source items with the in stock status', 'This product is out of stock.' ]; - $this->assertContains( + self::assertContains( $response['addProductsToCart']['user_errors'][0]['message'], $expectedErrorMessages ); @@ -312,6 +297,18 @@ private function getConfigurableProductInfo(): array return current($searchResponse['products']['items']); } + /** + * Generates UID for super configurable product super attributes + * + * @param int $attributeId + * @param int $valueIndex + * @return string + */ + private function generateSuperAttributesUIDQuery(int $attributeId, int $valueIndex): string + { + return 'selected_options: ["' . $this->selectionUidFormatter->encode($attributeId, $valueIndex) . '"]'; + } + /** * Returns GraphQl query for fetching configurable product information * @@ -354,10 +351,27 @@ private function getFetchProductQuery(string $term): string attribute_code option_value_uids } + configurable_options { + uid + attribute_code + label + values { + uid + is_available + is_use_default + label + } + } variant { uid - name - attribute_set_id + sku + url_key + url_path + } + media_gallery { + url + label + disabled } } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/ConfigurableOptionsSelectionTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/ConfigurableOptionsSelectionTest.php new file mode 100644 index 0000000000000..ac252acfcaa2b --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/ConfigurableOptionsSelectionTest.php @@ -0,0 +1,379 @@ +attributeRepository = Bootstrap::getObjectManager()->create(AttributeRepository::class); + $this->selectionUidFormatter = Bootstrap::getObjectManager()->create(SelectionUidFormatter::class); + $this->indexerFactory = Bootstrap::getObjectManager()->create(IndexerFactory::class); + $this->idEncoder = Bootstrap::getObjectManager()->create(Uid::class); + } + + /** + * Test the first option of the first attribute selected + * + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testSelectedFirstAttributeFirstOption(): void + { + $attribute = $this->getFirstConfigurableAttribute(); + $options = $attribute->getOptions(); + $sku = 'configurable_12345'; + $firstOptionUid = $this->selectionUidFormatter->encode( + (int)$attribute->getAttributeId(), + (int)$options[1]->getValue() + ); + + $this->reindexAll(); + $response = $this->graphQlQuery($this->getQuery($sku, [$firstOptionUid])); + + self::assertNotEmpty($response['products']['items']); + $product = current($response['products']['items']); + self::assertEquals('ConfigurableProduct', $product['__typename']); + self::assertEquals($sku, $product['sku']); + self::assertNotEmpty($product['configurable_product_options_selection']['configurable_options']); + self::assertNull($product['configurable_product_options_selection']['variant']); + self::assertCount(1, $product['configurable_product_options_selection']['configurable_options']); + self::assertCount(4, $product['configurable_product_options_selection']['configurable_options'][0]['values']); + + $secondAttributeOptions = $this->getSecondConfigurableAttribute()->getOptions(); + $this->assertAvailableOptionUids( + $this->getSecondConfigurableAttribute()->getAttributeId(), + $secondAttributeOptions, + $this->getOptionsUids( + $product['configurable_product_options_selection']['configurable_options'][0]['values'] + ) + ); + + $this->assertMediaGallery($product); + } + + /** + * Test selected variant + * + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testSelectedVariant(): void + { + $firstAttribute = $this->getFirstConfigurableAttribute(); + $firstOptions = $firstAttribute->getOptions(); + $firstAttributeFirstOptionUid = $this->selectionUidFormatter->encode( + (int)$firstAttribute->getAttributeId(), + (int)$firstOptions[1]->getValue() + ); + $secondAttribute = $this->getSecondConfigurableAttribute(); + $secondOptions = $secondAttribute->getOptions(); + $secondAttributeFirstOptionUid = $this->selectionUidFormatter->encode( + (int)$secondAttribute->getAttributeId(), + (int)$secondOptions[1]->getValue() + ); + + $sku = 'configurable_12345'; + + $this->reindexAll(); + $response = $this->graphQlQuery( + $this->getQuery($sku, [$firstAttributeFirstOptionUid, $secondAttributeFirstOptionUid]) + ); + + self::assertNotEmpty($response['products']['items']); + $product = current($response['products']['items']); + self::assertEquals('ConfigurableProduct', $product['__typename']); + self::assertEquals($sku, $product['sku']); + self::assertEmpty($product['configurable_product_options_selection']['configurable_options']); + self::assertNotNull($product['configurable_product_options_selection']['variant']); + + $variantId = $this->idEncoder->decode($product['configurable_product_options_selection']['variant']['uid']); + self::assertIsNumeric($variantId); + self::assertIsString($product['configurable_product_options_selection']['variant']['sku']); + $urlKey = 'configurable-option-first-option-1-second-option-1'; + self::assertEquals($urlKey, $product['configurable_product_options_selection']['variant']['url_key']); + + $this->assertMediaGallery($product); + } + + /** + * Test without selected options + * + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testWithoutSelectedOption(): void + { + $sku = 'configurable_12345'; + $this->reindexAll(); + $response = $this->graphQlQuery($this->getQuery($sku)); + + self::assertNotEmpty($response['products']['items']); + $product = current($response['products']['items']); + self::assertEquals('ConfigurableProduct', $product['__typename']); + self::assertEquals($sku, $product['sku']); + + self::assertNotEmpty($product['configurable_product_options_selection']['configurable_options']); + self::assertNull($product['configurable_product_options_selection']['variant']); + self::assertCount(2, $product['configurable_product_options_selection']['configurable_options']); + self::assertCount(4, $product['configurable_product_options_selection']['configurable_options'][0]['values']); + self::assertCount(4, $product['configurable_product_options_selection']['configurable_options'][1]['values']); + + $firstAttributeOptions = $this->getFirstConfigurableAttribute()->getOptions(); + $this->assertAvailableOptionUids( + $this->getFirstConfigurableAttribute()->getAttributeId(), + $firstAttributeOptions, + $this->getOptionsUids( + $product['configurable_product_options_selection']['configurable_options'][0]['values'] + ) + ); + + $secondAttributeOptions = $this->getSecondConfigurableAttribute()->getOptions(); + $this->assertAvailableOptionUids( + $this->getSecondConfigurableAttribute()->getAttributeId(), + $secondAttributeOptions, + $this->getOptionsUids( + $product['configurable_product_options_selection']['configurable_options'][1]['values'] + ) + ); + + $this->assertMediaGallery($product); + } + + /** + * Test with wrong selected options + * + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php + */ + public function testWithWrongSelectedOptions(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('configurableOptionValueUids values are incorrect'); + + $attribute = $this->getFirstConfigurableAttribute(); + $options = $attribute->getOptions(); + $sku = 'configurable_12345'; + $firstOptionUid = $this->selectionUidFormatter->encode( + (int)$attribute->getAttributeId(), + $options[1]->getValue() + 100 + ); + + $this->reindexAll(); + $this->graphQlQuery($this->getQuery($sku, [$firstOptionUid])); + } + + /** + * Get GraphQL query to test configurable product options selection + * + * @param string $productSku + * @param array $optionValueUids + * @param int $pageSize + * @param int $currentPage + * @return string + */ + private function getQuery( + string $productSku, + array $optionValueUids = [], + int $pageSize = 20, + int $currentPage = 1 + ): string { + if (empty($optionValueUids)) { + $configurableOptionValueUids = ''; + } else { + $configurableOptionValueUids = '(configurableOptionValueUids: ['; + foreach ($optionValueUids as $configurableOptionValueUid) { + $configurableOptionValueUids .= '"' . $configurableOptionValueUid . '",'; + } + $configurableOptionValueUids .= '])'; + } + + return <<firstConfigurableAttribute) { + $this->firstConfigurableAttribute = $this->attributeRepository->get( + 'catalog_product', + 'test_configurable_first' + ); + } + + return $this->firstConfigurableAttribute; + } + + /** + * Get second configurable attribute. + * + * @return AttributeInterface + * @throws NoSuchEntityException + */ + private function getSecondConfigurableAttribute(): AttributeInterface + { + if (!$this->secondConfigurableAttribute) { + $this->secondConfigurableAttribute = $this->attributeRepository->get( + 'catalog_product', + 'test_configurable_second' + ); + } + + return $this->secondConfigurableAttribute; + } + + /** + * Assert option uid. + * + * @param $attributeId + * @param $expectedOptions + * @param $selectedOptions + */ + private function assertAvailableOptionUids($attributeId, $expectedOptions, $selectedOptions): void + { + unset($expectedOptions[0]); + foreach ($expectedOptions as $option) { + self::assertContains( + $this->selectionUidFormatter->encode((int)$attributeId, (int)$option->getValue()), + $selectedOptions + ); + } + } + + /** + * Make fulltext catalog search reindex + * + * @return void + * @throws \Throwable + */ + private function reindexAll(): void + { + $indexLists = [ + 'catalog_category_product', + 'catalog_product_attribute', + 'cataloginventory_stock', + 'catalogsearch_fulltext', + ]; + + foreach ($indexLists as $indexerId) { + $indexer = $this->indexerFactory->create(); + $indexer->load($indexerId)->reindexAll(); + } + } + + /** + * Retrieve options UIDs + * + * @param array $options + * @return array + */ + private function getOptionsUids(array $options): array + { + $uids = []; + foreach ($options as $option) { + $uids[] = $option['uid']; + } + return $uids; + } + + /** + * Assert media gallery fields + * + * @param array $product + */ + private function assertMediaGallery(array $product): void + { + self::assertNotEmpty($product['configurable_product_options_selection']['media_gallery']); + $image = current($product['configurable_product_options_selection']['media_gallery']); + self::assertIsString($image['url']); + self::assertEquals(false, $image['disabled']); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSwatchDataTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSwatchDataTest.php index ae34ea31f0d51..713a16a6bfaa9 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSwatchDataTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Swatches/ProductSwatchDataTest.php @@ -84,12 +84,12 @@ public function testVisualSwatchDataValues() $color = '#000000'; $query = <<swatchMediaHelper->getSwatchAttributeImage(Swatch::SWATCH_THUMBNAIL_NAME, $imageName) ); + + $configurableProductOptionsSelection = + $product['configurable_product_options_selection']['configurable_options'][0]; + + $this->assertArrayHasKey('values', $configurableProductOptionsSelection); + $this->assertEquals($color, $configurableProductOptionsSelection['values'][0]['swatch']['value']); + $this->assertStringContainsString( + $configurableProductOptionsSelection['values'][1]['swatch']['value'], + $this->swatchMediaHelper->getSwatchAttributeImage(Swatch::SWATCH_IMAGE_NAME, $imageName) + ); + $this->assertEquals( + $configurableProductOptionsSelection['values'][1]['swatch']['thumbnail'], + $this->swatchMediaHelper->getSwatchAttributeImage(Swatch::SWATCH_THUMBNAIL_NAME, $imageName) + ); } } diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php index 24e6010275bac..f26e39ca8e2a2 100644 --- a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/_files/configurable_products_with_two_attributes_combination.php @@ -3,10 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Indexer\Product\Price\Processor as PriceIndexerProcessor; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Media\Config; use Magento\Catalog\Model\Product\Type; use Magento\Catalog\Model\Product\Visibility; use Magento\Catalog\Setup\CategorySetup; @@ -15,8 +17,11 @@ use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\Eav\Api\Data\AttributeOptionInterface; use Magento\Framework\Api\Data\ImageContentInterface; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\Workaround\Override\Fixture\Resolver; +use Magento\Framework\App\Filesystem\DirectoryList; Resolver::getInstance()->requireDataFixture( 'Magento/ConfigurableProduct/_files/configurable_attribute_first.php' @@ -25,18 +30,31 @@ 'Magento/ConfigurableProduct/_files/configurable_attribute_second.php' ); +$objectManager = Bootstrap::getObjectManager(); + /** @var ProductRepositoryInterface $productRepository */ -$productRepository = Bootstrap::getObjectManager() - ->get(ProductRepositoryInterface::class); +$productRepository = $objectManager->get(ProductRepositoryInterface::class); /** @var $installer CategorySetup */ -$installer = Bootstrap::getObjectManager()->create(CategorySetup::class); +$installer = $objectManager->create(CategorySetup::class); /** @var \Magento\Eav\Model\Config $eavConfig */ -$eavConfig = Bootstrap::getObjectManager()->get(\Magento\Eav\Model\Config::class); +$eavConfig = $objectManager->get(\Magento\Eav\Model\Config::class); $firstAttribute = $eavConfig->getAttribute(Product::ENTITY, 'test_configurable_first'); $secondAttribute = $eavConfig->getAttribute(Product::ENTITY, 'test_configurable_second'); +/** @var Config $config */ +$config = $objectManager->get(Config::class); + +/** @var Filesystem $filesystem */ +$filesystem = $objectManager->get(Filesystem::class); + +/** @var WriteInterface $mediaDirectory */ +$mediaDirectory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); +$mediaPath = $mediaDirectory->getAbsolutePath(); +$baseTmpMediaPath = $config->getBaseTmpMediaPath(); +$mediaDirectory->create($baseTmpMediaPath); + /* Create simple products per each option value*/ /** @var AttributeOptionInterface[] $firstAttributeOptions */ $firstAttributeOptions = $firstAttribute->getOptions(); @@ -48,6 +66,8 @@ $firstAttributeValues = []; $secondAttributeValues = []; $testImagePath = __DIR__ . '/magento_image.jpg'; +$mediaImage = $mediaPath . '/' . $baseTmpMediaPath . '/magento_image.jpg'; +copy($testImagePath, $mediaImage); array_shift($firstAttributeOptions); array_shift($secondAttributeOptions); @@ -65,7 +85,10 @@ $qty = 100; $isInStock = 1; } - $product = Bootstrap::getObjectManager()->create(Product::class); + + $image = '/m/a/magento_image.jpg'; + + $product = $objectManager->create(Product::class); $product->setTypeId(Type::TYPE_SIMPLE) ->setAttributeSetId($attributeSetId) ->setWebsiteIds([1]) @@ -79,15 +102,15 @@ ->setStockData( ['use_config_manage_stock' => 1, 'qty' => $qty, 'is_qty_decimal' => 0, 'is_in_stock' => $isInStock] ) - ->setImage('/m/a/magento_image.jpg') - ->setSmallImage('/m/a/magento_image.jpg') - ->setThumbnail('/m/a/magento_image.jpg') + ->setImage($image) + ->setSmallImage($image) + ->setThumbnail($image) ->setData( 'media_gallery', [ 'images' => [ [ - 'file' => '/m/a/magento_image.jpg', + 'file' => $image, 'position' => 1, 'label' => 'Image Alt Text', 'disabled' => 0, @@ -113,11 +136,12 @@ foreach ($customAttributes as $attributeCode => $attributeValue) { $product->setCustomAttributes($customAttributes); } + $product = $productRepository->save($product); $associatedProductIds[] = $product->getId(); - /** @var \Magento\CatalogInventory\Model\Stock\Item $stockItem */ - $stockItem = Bootstrap::getObjectManager()->create(Item::class); + /** @var Item $stockItem */ + $stockItem = $objectManager->create(Item::class); $stockItem->load($product->getId(), 'product_id'); if (!$stockItem->getProductId()) { @@ -135,17 +159,16 @@ 'value_index' => $secondAttributeOption->getValue(), ]; } - } -$indexerProcessor = Bootstrap::getObjectManager()->get(PriceIndexerProcessor::class); +$indexerProcessor = $objectManager->get(PriceIndexerProcessor::class); $indexerProcessor->reindexList($associatedProductIds, true); /** @var $product Product */ -$product = Bootstrap::getObjectManager()->create(Product::class); +$product = $objectManager->create(Product::class); /** @var Factory $optionsFactory */ -$optionsFactory = Bootstrap::getObjectManager()->create(Factory::class); +$optionsFactory = $objectManager->create(Factory::class); $configurableAttributesData = [ [ @@ -180,9 +203,15 @@ ->setSku('configurable_12345') ->setVisibility(Visibility::VISIBILITY_BOTH) ->setStatus(Status::STATUS_ENABLED) - ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]); + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) + ->addImageToMediaGallery( + $mediaImage, + ['image', 'small_image', 'thumbnail'], + false, + false + ); $productRepository->cleanCache(); $product = $productRepository->save($product); -$indexerProcessor = Bootstrap::getObjectManager()->get(PriceIndexerProcessor::class); +$indexerProcessor = $objectManager->get(PriceIndexerProcessor::class); $indexerProcessor->reindexRow($product->getId(), true);