diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/PriceRange.php b/app/code/Magento/BundleGraphQl/Model/Resolver/PriceRange.php new file mode 100644 index 0000000000000..0129c7ae65138 --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/PriceRange.php @@ -0,0 +1,81 @@ +priceProviderPool = $priceProviderPool; + $this->discount = $discount; + $this->productDataProvider = $productDataProvider + ?? ObjectManager::getInstance()->get(ProductDataProvider::class); + $this->priceRangeDataProvider = $priceRangeDataProvider + ?? ObjectManager::getInstance()->get(PriceRangeDataProvider::class); + } + + /** + * @inheritDoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + $this->productDataProvider->addProductSku($value['sku']); + $productData = $this->productDataProvider->getProductBySku($value['sku']); + $value['model'] = $productData['model']; + + return $this->priceRangeDataProvider->prepare($context, $info, $value); + } +} diff --git a/app/code/Magento/BundleGraphQl/etc/schema.graphqls b/app/code/Magento/BundleGraphQl/etc/schema.graphqls index 9f447d7fc7118..9820bf108f325 100644 --- a/app/code/Magento/BundleGraphQl/etc/schema.graphqls +++ b/app/code/Magento/BundleGraphQl/etc/schema.graphqls @@ -55,6 +55,7 @@ type BundleItem @doc(description: "Defines an individual item within a bundle pr type: String @doc(description: "The input type that the customer uses to select the item. Examples include radio button and checkbox.") position: Int @doc(description: "A number indicating the sequence order of this item compared to the other bundle items.") sku: String @doc(description: "The SKU of the bundle product.") + price_range: PriceRange! @doc(description: "The range of prices for the product") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\PriceRange") options: [BundleItemOption] @doc(description: "An array of additional options for this bundle item.") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\BundleItemLinks") } diff --git a/app/code/Magento/Catalog/view/base/web/images/category/placeholder/image.jpg b/app/code/Magento/Catalog/view/base/web/images/category/placeholder/image.jpg new file mode 100644 index 0000000000000..3526efa91f55a Binary files /dev/null and b/app/code/Magento/Catalog/view/base/web/images/category/placeholder/image.jpg differ diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php index 4f4d9355cff13..e8c43b437f0c5 100644 --- a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php @@ -8,16 +8,15 @@ namespace Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder; use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory; -use Magento\CatalogGraphQl\DataProvider\CategoryAttributesMapper; use Magento\CatalogGraphQl\DataProvider\Category\Query\CategoryAttributeQuery; +use Magento\CatalogGraphQl\DataProvider\CategoryAttributesMapper; +use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Formatter\LayerFormatter; use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\LayerBuilderInterface; use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\RootCategoryProvider; use Magento\Framework\Api\Search\AggregationInterface; use Magento\Framework\Api\Search\AggregationValueInterface; use Magento\Framework\Api\Search\BucketInterface; use Magento\Framework\App\ResourceConnection; -use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Formatter\LayerFormatter; -use Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder\Aggregations; /** * Category layer builder @@ -36,7 +35,7 @@ class Category implements LayerBuilderInterface */ private static $bucketMap = [ self::CATEGORY_BUCKET => [ - 'request_name' => 'category_id', + 'request_name' => 'category_uid', 'label' => 'Category' ], ]; @@ -44,37 +43,37 @@ class Category implements LayerBuilderInterface /** * @var CategoryAttributeQuery */ - private $categoryAttributeQuery; + private CategoryAttributeQuery $categoryAttributeQuery; /** * @var CategoryAttributesMapper */ - private $attributesMapper; + private CategoryAttributesMapper $attributesMapper; /** * @var ResourceConnection */ - private $resourceConnection; + private ResourceConnection $resourceConnection; /** * @var RootCategoryProvider */ - private $rootCategoryProvider; + private RootCategoryProvider $rootCategoryProvider; /** * @var LayerFormatter */ - private $layerFormatter; + private LayerFormatter $layerFormatter; /** * @var CollectionFactory */ - private $categoryCollectionFactory; + private CollectionFactory $categoryCollectionFactory; /** * @var Aggregations\Category\IncludeDirectChildrenOnly */ - private $includeDirectChildrenOnly; + private Aggregations\Category\IncludeDirectChildrenOnly $includeDirectChildrenOnly; /** * @param CategoryAttributeQuery $categoryAttributeQuery @@ -152,7 +151,7 @@ function (AggregationValueInterface $value) { foreach ($bucket->getValues() as $value) { $categoryId = $value->getValue(); if (!\in_array($categoryId, $categoryIds, true)) { - continue ; + continue; } $result['options'][] = $this->layerFormatter->buildItem( $categoryLabels[$categoryId] ?? $categoryId, diff --git a/app/code/Magento/CatalogGraphQl/Model/PriceRangeDataProvider.php b/app/code/Magento/CatalogGraphQl/Model/PriceRangeDataProvider.php new file mode 100644 index 0000000000000..8139481729e1e --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/PriceRangeDataProvider.php @@ -0,0 +1,185 @@ +priceProviderPool = $priceProviderPool; + $this->discount = $discount; + } + + /** + * Prepare Query object based on search text + * + * @param ContextInterface $context + * @param ResolveInfo $info + * @param array $value + * @throws Exception + * @return mixed|Value + */ + public function prepare(ContextInterface $context, ResolveInfo $info, array $value): array + { + if (!isset($value['model'])) { + throw new LocalizedException(__('"model" value should be specified')); + } + /** @var StoreInterface $store */ + $store = $context->getExtensionAttributes()->getStore(); + + /** @var Product $product */ + $product = $value['model']; + $product->unsetData('minimal_price'); + // add store filter for the product + $product->setData(self::STORE_FILTER_CACHE_KEY, $store); + + if ($context) { + $customerGroupId = $context->getExtensionAttributes()->getCustomerGroupId(); + if ($customerGroupId !== null) { + $product->setCustomerGroupId($customerGroupId); + } + } + + $requestedFields = $info->getFieldSelection(10); + $returnArray = []; + + $returnArray['minimum_price'] = ($requestedFields['minimum_price'] ?? 0) ? ($this->canShowPrice($product) ? + $this->getMinimumProductPrice($product, $store) : $this->formatEmptyResult()) : $this->formatEmptyResult(); + $returnArray['maximum_price'] = ($requestedFields['maximum_price'] ?? 0) ? ($this->canShowPrice($product) ? + $this->getMaximumProductPrice($product, $store) : $this->formatEmptyResult()) : $this->formatEmptyResult(); + + return $returnArray; + } + + /** + * Get formatted minimum product price + * + * @param SaleableInterface $product + * @param StoreInterface $store + * @return array + */ + private function getMinimumProductPrice(SaleableInterface $product, StoreInterface $store): array + { + $priceProvider = $this->priceProviderPool->getProviderByProductType($product->getTypeId()); + $minPriceArray = $this->formatPrice( + (float)$priceProvider->getMinimalRegularPrice($product)->getValue(), + (float)$priceProvider->getMinimalFinalPrice($product)->getValue(), + $store + ); + $minPriceArray['model'] = $product; + + return $minPriceArray; + } + + /** + * Get formatted maximum product price + * + * @param SaleableInterface $product + * @param StoreInterface $store + * @return array + */ + private function getMaximumProductPrice(SaleableInterface $product, StoreInterface $store): array + { + $priceProvider = $this->priceProviderPool->getProviderByProductType($product->getTypeId()); + $maxPriceArray = $this->formatPrice( + (float)$priceProvider->getMaximalRegularPrice($product)->getValue(), + (float)$priceProvider->getMaximalFinalPrice($product)->getValue(), + $store + ); + $maxPriceArray['model'] = $product; + + return $maxPriceArray; + } + + /** + * Format price for GraphQl output + * + * @param float $regularPrice + * @param float $finalPrice + * @param StoreInterface $store + * @return array + */ + private function formatPrice(float $regularPrice, float $finalPrice, StoreInterface $store): array + { + return [ + 'regular_price' => [ + 'value' => $regularPrice, + 'currency' => $store->getCurrentCurrencyCode(), + ], + 'final_price' => [ + 'value' => $finalPrice, + 'currency' => $store->getCurrentCurrencyCode(), + ], + 'discount' => $this->discount->getDiscountByDifference($regularPrice, $finalPrice), + ]; + } + + /** + * Check if the product is allowed to show price + * + * @param ProductInterface $product + * @return bool + */ + private function canShowPrice(ProductInterface $product): bool + { + return $product->hasData('can_show_price') ? $product->getData('can_show_price') : true; + } + + /** + * Format empty result + * + * @return array + */ + private function formatEmptyResult(): array + { + return [ + 'regular_price' => [ + 'value' => null, + 'currency' => null, + ], + 'final_price' => [ + 'value' => null, + 'currency' => null, + ], + 'discount' => null, + ]; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Image.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Image.php index 549b1311000ec..34b9fff3d9026 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Image.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Image.php @@ -9,6 +9,8 @@ use Magento\Catalog\Model\Category; use Magento\Catalog\Model\Category\FileInfo; +use Magento\Framework\App\Area; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Filesystem\DirectoryList; use Magento\Framework\GraphQl\Config\Element\Field; @@ -16,7 +18,9 @@ use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\Framework\UrlInterface; +use Magento\Framework\View\Asset\Repository; use Magento\Store\Api\Data\StoreInterface; +use Psr\Log\LoggerInterface; /** * Resolve category image to a fully qualified URL @@ -29,16 +33,32 @@ class Image implements ResolverInterface /** @var FileInfo */ private $fileInfo; + /** + * @var Repository + */ + private Repository $assetRepo; + + /** + * @var LoggerInterface + */ + private LoggerInterface $logger; + /** * @param DirectoryList $directoryList * @param FileInfo $fileInfo + * @param Repository|null $assetRepo + * @param LoggerInterface|null $logger */ public function __construct( DirectoryList $directoryList, - FileInfo $fileInfo + FileInfo $fileInfo, + Repository $assetRepo = null, + LoggerInterface $logger = null ) { $this->directoryList = $directoryList; $this->fileInfo = $fileInfo; + $this->assetRepo = $assetRepo ?? ObjectManager::getInstance()->get(Repository::class); + $this->logger = $logger ?? ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -64,11 +84,14 @@ public function resolve( $store = $context->getExtensionAttributes()->getStore(); $baseUrl = $store->getBaseUrl(UrlInterface::URL_TYPE_WEB); - $filenameWithMedia = $this->fileInfo->isBeginsWithMediaDirectoryPath($imagePath) + $filenameWithMedia = $this->fileInfo->isBeginsWithMediaDirectoryPath($imagePath) ? $imagePath : $this->formatFileNameWithMediaCategoryFolder($imagePath); if (!$this->fileInfo->isExist($filenameWithMedia)) { - throw new GraphQlInputException(__('Category image not found.')); + $this->logger->error(__('Category image not found')); + return $this->assetRepo + ->createAsset('Magento_Catalog::images/category/placeholder/image.jpg', ['area' => Area::AREA_FRONTEND]) + ->getUrl(); } // return full url diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/PriceRange.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/PriceRange.php index cdea4a35ca6d2..25db5207af285 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/PriceRange.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/PriceRange.php @@ -7,42 +7,48 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Product; -use Magento\Catalog\Api\Data\ProductInterface; +use Magento\CatalogGraphQl\Model\PriceRangeDataProvider; use Magento\CatalogGraphQl\Model\Resolver\Product\Price\Discount; use Magento\CatalogGraphQl\Model\Resolver\Product\Price\ProviderPool as PriceProviderPool; +use Magento\Framework\App\ObjectManager; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Config\Element\Field; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; -use Magento\Catalog\Model\Product; -use Magento\Framework\Exception\LocalizedException; -use Magento\Framework\Pricing\SaleableInterface; -use Magento\Store\Api\Data\StoreInterface; /** * Format product's pricing information for price_range field */ class PriceRange implements ResolverInterface { - private const STORE_FILTER_CACHE_KEY = '_cache_instance_store_filter'; - /** * @var Discount */ - private $discount; + private Discount $discount; /** * @var PriceProviderPool */ - private $priceProviderPool; + private PriceProviderPool $priceProviderPool; + + /** + * @var PriceRangeDataProvider + */ + private PriceRangeDataProvider $priceRangeDataProvider; /** * @param PriceProviderPool $priceProviderPool * @param Discount $discount + * @param PriceRangeDataProvider|null $priceRangeDataProvider */ - public function __construct(PriceProviderPool $priceProviderPool, Discount $discount) - { + public function __construct( + PriceProviderPool $priceProviderPool, + Discount $discount, + PriceRangeDataProvider $priceRangeDataProvider = null + ) { $this->priceProviderPool = $priceProviderPool; $this->discount = $discount; + $this->priceRangeDataProvider = $priceRangeDataProvider + ?? ObjectManager::getInstance()->get(PriceRangeDataProvider::class); } /** @@ -55,128 +61,6 @@ public function resolve( array $value = null, array $args = null ) { - if (!isset($value['model'])) { - throw new LocalizedException(__('"model" value should be specified')); - } - /** @var StoreInterface $store */ - $store = $context->getExtensionAttributes()->getStore(); - - /** @var Product $product */ - $product = $value['model']; - $product->unsetData('minimal_price'); - // add store filter for the product - $product->setData(self::STORE_FILTER_CACHE_KEY, $store); - - if ($context) { - $customerGroupId = $context->getExtensionAttributes()->getCustomerGroupId(); - if ($customerGroupId !== null) { - $product->setCustomerGroupId($customerGroupId); - } - } - - $requestedFields = $info->getFieldSelection(10); - $returnArray = []; - - if (isset($requestedFields['minimum_price'])) { - $returnArray['minimum_price'] = $this->canShowPrice($product) ? - $this->getMinimumProductPrice($product, $store) : $this->formatEmptyResult(); - } - if (isset($requestedFields['maximum_price'])) { - $returnArray['maximum_price'] = $this->canShowPrice($product) ? - $this->getMaximumProductPrice($product, $store) : $this->formatEmptyResult(); - } - return $returnArray; - } - - /** - * Get formatted minimum product price - * - * @param SaleableInterface $product - * @param StoreInterface $store - * @return array - */ - private function getMinimumProductPrice(SaleableInterface $product, StoreInterface $store): array - { - $priceProvider = $this->priceProviderPool->getProviderByProductType($product->getTypeId()); - $regularPrice = $priceProvider->getMinimalRegularPrice($product)->getValue(); - $finalPrice = $priceProvider->getMinimalFinalPrice($product)->getValue(); - $minPriceArray = $this->formatPrice((float) $regularPrice, (float) $finalPrice, $store); - $minPriceArray['model'] = $product; - return $minPriceArray; - } - - /** - * Get formatted maximum product price - * - * @param SaleableInterface $product - * @param StoreInterface $store - * @return array - */ - private function getMaximumProductPrice(SaleableInterface $product, StoreInterface $store): array - { - $priceProvider = $this->priceProviderPool->getProviderByProductType($product->getTypeId()); - $regularPrice = $priceProvider->getMaximalRegularPrice($product)->getValue(); - $finalPrice = $priceProvider->getMaximalFinalPrice($product)->getValue(); - $maxPriceArray = $this->formatPrice((float) $regularPrice, (float) $finalPrice, $store); - $maxPriceArray['model'] = $product; - return $maxPriceArray; - } - - /** - * Format price for GraphQl output - * - * @param float $regularPrice - * @param float $finalPrice - * @param StoreInterface $store - * @return array - */ - private function formatPrice(float $regularPrice, float $finalPrice, StoreInterface $store): array - { - return [ - 'regular_price' => [ - 'value' => $regularPrice, - 'currency' => $store->getCurrentCurrencyCode() - ], - 'final_price' => [ - 'value' => $finalPrice, - 'currency' => $store->getCurrentCurrencyCode() - ], - 'discount' => $this->discount->getDiscountByDifference($regularPrice, $finalPrice), - ]; - } - - /** - * Check if the product is allowed to show price - * - * @param ProductInterface $product - * @return bool - */ - private function canShowPrice($product): bool - { - if ($product->hasData('can_show_price') && $product->getData('can_show_price') === false) { - return false; - } - - return true; - } - - /** - * Format empty result - * - * @return array - */ - private function formatEmptyResult(): array - { - return [ - 'regular_price' => [ - 'value' => null, - 'currency' => null - ], - 'final_price' => [ - 'value' => null, - 'currency' => null - ], - 'discount' => null - ]; + return $this->priceRangeDataProvider->prepare($context, $info, $value); } } diff --git a/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Category/ImageTest.php b/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Category/ImageTest.php new file mode 100644 index 0000000000000..4aac601a3386e --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Category/ImageTest.php @@ -0,0 +1,205 @@ +fileInfoMock = $this->createMock(FileInfo::class); + $this->directoryListMock = $this->createMock(DirectoryList::class); + $this->fieldMock = $this->createMock(Field::class); + $this->resolveInfoMock = $this->createMock(ResolveInfo::class); + $this->contextMock = $this->createMock(Context::class); + $this->categoryMock = $this->createMock(Category::class); + $this->assetRepoMock = $this->createMock(Repository::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->image = new Image( + $this->directoryListMock, + $this->fileInfoMock, + $this->assetRepoMock, + $this->loggerMock + ); + } + + public function testResolve(): void + { + $this->valueMock = ['model' => $this->categoryMock]; + $contextExtensionInterfaceMock = $this->getMockBuilder(ContextExtensionInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getStore']) + ->getMockForAbstractClass(); + $storeMock = $this->createMock(Store::class); + $this->categoryMock + ->expects($this->once()) + ->method('getData') + ->with('image') + ->willReturn('/media/catalog/category/test.jpg'); + $this->contextMock + ->expects($this->once()) + ->method('getExtensionAttributes') + ->willReturn($contextExtensionInterfaceMock); + $contextExtensionInterfaceMock + ->expects($this->once()) + ->method('getStore') + ->willReturn($storeMock); + $storeMock + ->expects($this->once()) + ->method('getBaseUrl') + ->willReturn('https://magento.url'); + $this->fileInfoMock + ->expects($this->once()) + ->method('isBeginsWithMediaDirectoryPath') + ->willReturn('fileName'); + $this->fileInfoMock + ->expects($this->once()) + ->method('isExist') + ->willReturn(true); + + $this->assertEquals( + 'https://magento.url/media/catalog/category/test.jpg', + $this->image->resolve( + $this->fieldMock, + $this->contextMock, + $this->resolveInfoMock, + $this->valueMock + ) + ); + } + + public function testResolveWithoutModelInValueParameter(): void + { + $this->expectException(LocalizedException::class); + $this->expectExceptionMessage('"model" value should be specified'); + $this->image->resolve($this->fieldMock, $this->contextMock, $this->resolveInfoMock, $this->valueMock); + } + + public function testResolveWhenImageFileDoesntExist(): void + { + $this->valueMock = ['model' => $this->categoryMock]; + $contextExtensionInterfaceMock = $this->getMockBuilder(ContextExtensionInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getStore']) + ->getMockForAbstractClass(); + $storeMock = $this->createMock(Store::class); + $this->categoryMock + ->expects($this->once()) + ->method('getData') + ->with('image') + ->willReturn('/media/catalog/category/test.jpg'); + $this->contextMock + ->expects($this->once()) + ->method('getExtensionAttributes') + ->willReturn($contextExtensionInterfaceMock); + $contextExtensionInterfaceMock + ->expects($this->once()) + ->method('getStore') + ->willReturn($storeMock); + $storeMock + ->expects($this->once()) + ->method('getBaseUrl') + ->willReturn('https://magento.url'); + $this->fileInfoMock + ->expects($this->once()) + ->method('isBeginsWithMediaDirectoryPath') + ->willReturn('fileName'); + $this->fileInfoMock + ->expects($this->once()) + ->method('isExist') + ->willReturn(false); + $assetFileMock = $this->createMock(File::class); + $assetFileMock + ->expects($this->once()) + ->method('getUrl') + ->willReturn('https://magento.url/Magento_Catalog/images/category/placeholder/image.jpg'); + $this->assetRepoMock + ->expects($this->once()) + ->method('createAsset') + ->with('Magento_Catalog::images/category/placeholder/image.jpg', ['area' => 'frontend']) + ->willReturn($assetFileMock); + $this->assertEquals( + 'https://magento.url/Magento_Catalog/images/category/placeholder/image.jpg', + $this->image->resolve( + $this->fieldMock, + $this->contextMock, + $this->resolveInfoMock, + $this->valueMock + ) + ); + } +} diff --git a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls index 0817987a383da..98d895a10c264 100644 --- a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls +++ b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls @@ -105,8 +105,8 @@ interface ProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\ image: ProductImage @doc(description: "The relative path to the main image on the product page.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductImage") small_image: ProductImage @doc(description: "The relative path to the small image, which is used on catalog pages.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductImage") thumbnail: ProductImage @doc(description: "The relative path to the product's thumbnail image.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductImage") - new_from_date: String @deprecated(reason: "The field should not be used on the storefront.") @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\NewFromTo") - new_to_date: String @deprecated(reason: "The field should not be used on the storefront.") @doc(description: "The end date for new product listings.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\NewFromTo") + new_from_date: String @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\NewFromTo") + new_to_date: String @doc(description: "The end date for new product listings.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\NewFromTo") tier_price: Float @deprecated(reason: "Use `price_tiers` for product tier price information.") @doc(description: "The price when tier pricing is in effect and the items purchased threshold has been reached.") options_container: String @doc(description: "If the product has multiple options, determines where they appear on the product page.") created_at: String @deprecated(reason: "The field should not be used on the storefront.") @doc(description: "Timestamp indicating when the product was created.") diff --git a/app/code/Magento/CmsUrlRewriteGraphQl/Model/Resolver/UrlRewrite/HomePageUrlLocator.php b/app/code/Magento/CmsUrlRewriteGraphQl/Model/Resolver/UrlRewrite/HomePageUrlLocator.php index 6cc669e46d080..2cb31d3dadb1b 100644 --- a/app/code/Magento/CmsUrlRewriteGraphQl/Model/Resolver/UrlRewrite/HomePageUrlLocator.php +++ b/app/code/Magento/CmsUrlRewriteGraphQl/Model/Resolver/UrlRewrite/HomePageUrlLocator.php @@ -7,26 +7,25 @@ namespace Magento\CmsUrlRewriteGraphQl\Model\Resolver\UrlRewrite; -use Magento\UrlRewriteGraphQl\Model\Resolver\UrlRewrite\CustomUrlLocatorInterface; -use Magento\Store\Model\ScopeInterface; use Magento\Cms\Helper\Page; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Store\Model\ScopeInterface; +use Magento\UrlRewriteGraphQl\Model\Resolver\UrlRewrite\CustomUrlLocatorInterface; -/** - * Home page URL locator. - */ class HomePageUrlLocator implements CustomUrlLocatorInterface { + public const HOME_PAGE_URL_DELIMITER = '|'; + /** - * @var \Magento\Framework\App\Config\ScopeConfigInterface + * @var ScopeConfigInterface */ - private $scopeConfig; + private ScopeConfigInterface $scopeConfig; /** - * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig + * @param ScopeConfigInterface $scopeConfig */ - public function __construct( - \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig - ) { + public function __construct(ScopeConfigInterface $scopeConfig) + { $this->scopeConfig = $scopeConfig; } @@ -40,8 +39,10 @@ public function locateUrl($urlKey): ?string Page::XML_PATH_HOME_PAGE, ScopeInterface::SCOPE_STORE ); - return $homePageUrl; + + return strtok($homePageUrl, self::HOME_PAGE_URL_DELIMITER); } + return null; } } diff --git a/app/code/Magento/CmsUrlRewriteGraphQl/Test/Unit/Model/Resolver/UrlRewrite/HomePageUrlLocatorTest.php b/app/code/Magento/CmsUrlRewriteGraphQl/Test/Unit/Model/Resolver/UrlRewrite/HomePageUrlLocatorTest.php new file mode 100644 index 0000000000000..5624f9fa365f8 --- /dev/null +++ b/app/code/Magento/CmsUrlRewriteGraphQl/Test/Unit/Model/Resolver/UrlRewrite/HomePageUrlLocatorTest.php @@ -0,0 +1,61 @@ +scopeConfigInterfaceMock = $this->createMock(ScopeConfigInterface::class); + $this->homePageUrlLocator = new HomePageUrlLocator($this->scopeConfigInterfaceMock); + } + + public function testLocateUrl(): void + { + $this->scopeConfigInterfaceMock + ->expects($this->once()) + ->method('getValue') + ->with(Page::XML_PATH_HOME_PAGE, ScopeInterface::SCOPE_STORE) + ->willReturn('home'); + $this->assertEquals('home', $this->homePageUrlLocator->locateUrl($this->homePageUrlKey)); + } + + public function testLocateUrlWhenMultipleStoresHaveSameHomePageUrl(): void + { + $this->scopeConfigInterfaceMock + ->expects($this->once()) + ->method('getValue') + ->with(Page::XML_PATH_HOME_PAGE, ScopeInterface::SCOPE_STORE) + ->willReturn('home|8'); + $this->assertEquals('home', $this->homePageUrlLocator->locateUrl($this->homePageUrlKey)); + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls b/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls index 5dc05f43af183..126fd44e024fc 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls +++ b/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls @@ -62,7 +62,7 @@ input ConfigurableProductCartItemInput { } type ConfigurableCartItem implements CartItemInterface @doc(description: "An implementation for configurable product cart items.") { - customizable_options: [SelectedCustomizableOption] @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\CustomizableOptions") @doc(description: "An array containing the customizable options the shopper selected.") + customizable_options: [SelectedCustomizableOption]! @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\CustomizableOptions") @doc(description: "An array containing the customizable options the shopper selected.") configurable_options: [SelectedConfigurableOption!]! @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\ConfigurableCartItemOptions") @doc(description: "An array containing the configuranle options the shopper selected.") configured_variant: ProductInterface! @doc(description: "Product details of the cart item.") @resolver(class: "\\Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\ProductResolver") } diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/AvailablePaymentMethods.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/AvailablePaymentMethods.php index 907d778550593..ff1228b4a2ff9 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/AvailablePaymentMethods.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/AvailablePaymentMethods.php @@ -10,26 +10,41 @@ use Magento\Checkout\Api\PaymentInformationManagementInterface; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\Quote\Api\Data\CartInterface; +use Magento\Quote\Api\ShippingMethodManagementInterface; /** * Get list of active payment methods resolver. */ class AvailablePaymentMethods implements ResolverInterface { + public const FREE_SHIPPING_METHOD = 'freeshipping'; + + public const FREE_PAYMENT_METHOD_CODE = 'free'; + /** * @var PaymentInformationManagementInterface */ - private $informationManagement; + private PaymentInformationManagementInterface $informationManagement; + + /** + * @var ShippingMethodManagementInterface + */ + private ShippingMethodManagementInterface $informationShipping; /** * @param PaymentInformationManagementInterface $informationManagement + * @param ShippingMethodManagementInterface $informationShipping */ - public function __construct(PaymentInformationManagementInterface $informationManagement) - { + public function __construct( + PaymentInformationManagementInterface $informationManagement, + ShippingMethodManagementInterface $informationShipping + ) { $this->informationManagement = $informationManagement; + $this->informationShipping = $informationShipping; } /** @@ -50,19 +65,55 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value * * @param CartInterface $cart * @return array + * @throws GraphQlInputException */ private function getPaymentMethodsData(CartInterface $cart): array { $paymentInformation = $this->informationManagement->getPaymentInformation($cart->getId()); $paymentMethods = $paymentInformation->getPaymentMethods(); - + $shippingData = $this->getShippingData($cart->getId()); + $carrierCode = $shippingData['carrier_code'] ?? null; + $grandTotal = $shippingData['grand_total'] ?? 0; $paymentMethodsData = []; foreach ($paymentMethods as $paymentMethod) { - $paymentMethodsData[] = [ - 'title' => $paymentMethod->getTitle(), - 'code' => $paymentMethod->getCode(), - ]; + /** + * Checking payment method and shipping method for zero price product + */ + if ((int)$grandTotal === 0 && $carrierCode === self::FREE_SHIPPING_METHOD + && $paymentMethod->getCode() === self::FREE_PAYMENT_METHOD_CODE) { + $paymentMethodsData[] = [ + 'title' => $paymentMethod->getTitle(), + 'code' => $paymentMethod->getCode(), + ]; + } elseif ((int)$grandTotal >= 0 + && $carrierCode !== self::FREE_SHIPPING_METHOD) { + $paymentMethodsData[] = [ + 'title' => $paymentMethod->getTitle(), + 'code' => $paymentMethod->getCode(), + ]; + } } return $paymentMethodsData; } + + /** + * Retrieve selected shipping method + * + * @param string $cartId + * @return array + */ + private function getShippingData(string $cartId): array + { + $shippingData = []; + try { + $shippingMethod = $this->informationShipping->get($cartId); + if ($shippingMethod) { + $shippingData['carrier_code'] = $shippingMethod->getCarrierCode(); + $shippingData['grand_total'] = $shippingMethod->getBaseAmount(); + } + } catch (LocalizedException $exception) { + $shippingData = []; + } + return $shippingData; + } } diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php index d992db1e3b53e..dfbc20bf7abd4 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php @@ -63,6 +63,10 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value 'currency' => $currencyCode, 'value' => $cartItem->getCalculationPrice(), ], + 'price_including_tax' => [ + 'currency' => $currencyCode, + 'value' => $cartItem->getPriceInclTax(), + ], 'row_total' => [ 'currency' => $currencyCode, 'value' => $cartItem->getRowTotal(), diff --git a/app/code/Magento/QuoteGraphQl/etc/di.xml b/app/code/Magento/QuoteGraphQl/etc/di.xml index 8dd35ab7f300b..73724e03bcf57 100644 --- a/app/code/Magento/QuoteGraphQl/etc/di.xml +++ b/app/code/Magento/QuoteGraphQl/etc/di.xml @@ -41,4 +41,9 @@ + + + Magento\Quote\Api\ShippingMethodManagementInterface + + diff --git a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls index 29afff169e637..1dc66531fbac6 100644 --- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls @@ -360,6 +360,7 @@ type Discount @doc(description:"Defines an individual discount. A discount can b type CartItemPrices @doc(description: "Contains details about the price of the item, including taxes and discounts.") { price: Money! @doc(description: "The price of the item before any discounts were applied. The price that might include tax, depending on the configured display settings for cart.") + price_including_tax: Money! @doc(description: "The price of the item before any discounts were applied. The price that might include tax, depending on the configured display settings for cart.") row_total: Money! @doc(description: "The value of the price multiplied by the quantity of the item.") row_total_including_tax: Money! @doc(description: "The value of `row_total` plus the tax applied to the item.") discounts: [Discount] @doc(description: "An array of discounts to be applied to the cart item.") diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductViewTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductViewTest.php index 5c2fecc6d98e1..bc492fc0cef8b 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductViewTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductViewTest.php @@ -21,8 +21,8 @@ */ class BundleProductViewTest extends GraphQlAbstract { - const KEY_PRICE_TYPE_FIXED = 'FIXED'; - const KEY_PRICE_TYPE_DYNAMIC = 'DYNAMIC'; + private const KEY_PRICE_TYPE_FIXED = 'FIXED'; + private const KEY_PRICE_TYPE_DYNAMIC = 'DYNAMIC'; /** * @magentoApiDataFixture Magento/Bundle/_files/product_1.php @@ -56,6 +56,20 @@ public function testAllFieldsBundleProducts() type position sku + price_range{ + maximum_price { + final_price { + currency + value + } + } + minimum_price { + final_price { + currency + value + } + } + } options { id quantity @@ -95,6 +109,9 @@ public function testAllFieldsBundleProducts() $this->assertEquals('PRICE_RANGE', $response['products']['items'][0]['price_view']); } $this->assertBundleBaseFields($bundleProduct, $response['products']['items'][0]); + $product = $response['products']['items'][0]['items'][0]; + $this->assertEquals(10, $product['price_range']['maximum_price']['final_price']['value']); + $this->assertEquals(10, $product['price_range']['minimum_price']['final_price']['value']); $this->assertBundleProductOptions($bundleProduct, $response['products']['items'][0]); $this->assertNotEmpty( diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoryTreeTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoryTreeTest.php index bccc3cc312d43..23f71ecc8a2b5 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoryTreeTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoriesQuery/CategoryTreeTest.php @@ -347,6 +347,8 @@ public function testCategoryProducts() } } name + new_from_date + new_to_date options_container price { minimalPrice { @@ -621,6 +623,48 @@ public function testCategoryImage(?string $imagePrefix) $this->assertEquals($expectedImageUrl, $childCategory['image']); } + /** + * Test categories query when category image is not found or missing. + * + * @magentoApiDataFixture Magento/Catalog/_files/catalog_category_with_missing_image.php + */ + public function testCategoriesQueryWhenCategoryImageIsMissing(): void + { + /** @var CategoryCollection $categoryCollection */ + $categoryCollection = $this->objectManager->get(CategoryCollection::class); + $categoryModel = $categoryCollection + ->addAttributeToSelect('image') + ->addAttributeToFilter('name', ['eq' => 'Parent Image Category']) + ->getFirstItem(); + $categoryId = $categoryModel->getId(); + $query = <<graphQlQuery($query); + $this->assertArrayNotHasKey('errors', $response); + $this->assertNotEmpty($response['categories']); + $categories = current($response['categories']['items']); + $this->assertEquals($categoryId, $categories['id']); + $this->assertEquals('Parent Image Category', $categories['name']); + $this->assertStringEndsWith('Magento_Catalog/images/category/placeholder/image.jpg', $categories['image']); + } + /** * @magentoApiDataFixture Magento/Catalog/_files/categories.php */ @@ -725,6 +769,8 @@ private function assertAttributes($actualResponse) 'short_description', 'country_of_manufacture', 'gift_message_available', + 'new_from_date', + 'new_to_date', 'options_container', 'special_price', 'special_to_date', diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php index 81f27dfafd332..5a1e547f7050a 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/CategoryTest.php @@ -411,6 +411,8 @@ public function testCategoryProducts() } } name + new_from_date + new_to_date options_container price { minimalPrice { @@ -846,6 +848,8 @@ private function assertAttributes($actualResponse) 'short_description', 'country_of_manufacture', 'gift_message_available', + 'new_from_date', + 'new_to_date', 'options_container', 'special_price' ]; diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchCategoryAggregationsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchCategoryAggregationsTest.php index 90dd8e343b2ce..33b22909a92ab 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchCategoryAggregationsTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchCategoryAggregationsTest.php @@ -57,7 +57,7 @@ private function getCategoryAggregation(array $result) : ?array return array_filter( $result['products']['aggregations'], function ($a) { - return $a['attribute_code'] == 'category_id'; + return $a['attribute_code'] == 'category_uid'; } ); } @@ -89,7 +89,7 @@ private function aggregationCategoryTesting(string $filterValue, string $include $categoryAggregation = array_filter( $result['products']['aggregations'], function ($a) { - return $a['attribute_code'] == 'category_id'; + return $a['attribute_code'] == 'category_uid'; } ); $this->assertNotEmpty($categoryAggregation); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php index 38214c2b6bd8e..57394503b4ad4 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php @@ -7,28 +7,26 @@ namespace Magento\GraphQl\Catalog; +use Magento\Catalog\Api\CategoryLinkManagementInterface; use Magento\Catalog\Api\CategoryRepositoryInterface; use Magento\Catalog\Api\Data\CategoryInterface; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Category; -use Magento\Catalog\Model\CategoryLinkManagement; use Magento\Catalog\Model\Indexer\Product\Category\Processor; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\ResourceModel\Category\Collection; +use Magento\Config\Model\ResourceModel\Config; use Magento\Eav\Api\Data\AttributeOptionInterface; use Magento\Eav\Model\Config as eavConfig; +use Magento\Framework\App\Cache; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\DataObject; +use Magento\Framework\ObjectManagerInterface; use Magento\TestFramework\Catalog\Model\GetCategoryByName; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\Helper\CacheCleaner; -use Magento\TestFramework\ObjectManager; use Magento\TestFramework\TestCase\GraphQlAbstract; -use Magento\Catalog\Api\CategoryLinkManagementInterface; -use Magento\Config\Model\ResourceModel\Config; -use Magento\Framework\App\Cache; -use Magento\Framework\ObjectManagerInterface; /** * @SuppressWarnings(PHPMD.TooManyPublicMethods) @@ -235,8 +233,9 @@ private function compareFilterNames(array $a, array $b) } /** - * Layered navigation for Configurable products with out of stock options - * Two configurable products each having two variations and one of the child products of one Configurable set to OOS + * Layered navigation for Configurable products with out of stock options + * Two configurable products each having two variations and one of the child products + * of one Configurable set to OOS * * @magentoApiDataFixture Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php * @magentoApiDataFixture Magento/Indexer/_files/reindex_all_invalid.php @@ -257,7 +256,11 @@ public function testLayeredNavigationForConfigurableProducts() $this->assertEquals(2, $response['products']['total_count']); $this->assertNotEmpty($response['products']['aggregations']); $this->assertNotEmpty($response['products']['filters'], 'Filters is empty'); - $this->assertCount(2, $response['products']['aggregations'], 'Aggregation count does not match'); + $this->assertCount( + 2, + $response['products']['aggregations'], + 'Aggregation count does not match' + ); // Custom attribute filter layer data $this->assertResponseFields( @@ -402,9 +405,20 @@ public function testFilterProductsByDropDownCustomAttribute() $filteredProducts = [$product3, $product2, $product1]; $countOfFilteredProducts = count($filteredProducts); $response = $this->graphQlQuery($query); - $this->assertEquals(3, $response['products']['total_count'], 'Number of products returned is incorrect'); - $this->assertTrue(count($response['products']['filters']) > 0, 'Product filters is not empty'); - $this->assertCount(3, $response['products']['aggregations'], 'Incorrect count of aggregations'); + $this->assertEquals( + 3, + $response['products']['total_count'], + 'Number of products returned is incorrect' + ); + $this->assertTrue( + count($response['products']['filters']) > 0, + 'Product filters is not empty' + ); + $this->assertCount( + 3, + $response['products']['aggregations'], + 'Incorrect count of aggregations' + ); $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); for ($itemIndex = 0; $itemIndex < $countOfFilteredProducts; $itemIndex++) { @@ -670,7 +684,7 @@ public function testSearchAndFilterByCustomAttribute() $this->assertResponseFields( $response['products']['aggregations'][1], [ - 'attribute_code' => 'category_id', + 'attribute_code' => 'category_uid', 'count' => 7, 'label' => 'Category' ] @@ -803,7 +817,11 @@ public function testFilterByCategoryIdAndCustomAttribute() // presort expected and actual results as different search engines have different orders usort($expectedCategoryInAggregations, [$this, 'compareLabels']); usort($actualCategoriesFromResponse, [$this, 'compareLabels']); - $categoryInAggregations = array_map(null, $expectedCategoryInAggregations, $actualCategoriesFromResponse); + $categoryInAggregations = array_map( + null, + $expectedCategoryInAggregations, + $actualCategoriesFromResponse + ); //Validate the categories and sub-categories data in the filter layer foreach ($categoryInAggregations as $index => $categoryAggregationsData) { @@ -1184,7 +1202,10 @@ public function testFilterWithinSpecificPriceRangeSortedByNameDesc() public function testSortByPosition() { // Get category ID for filtering - $category = $this->categoryCollection->addFieldToFilter('name', 'Category 999')->getFirstItem(); + $category = $this->categoryCollection->addFieldToFilter( + 'name', + 'Category 999' + )->getFirstItem(); $categoryId = $category->getId(); $queryAsc = <<categoryCollection->addFieldToFilter('name', 'Category 999')->getFirstItem(); + $category = $this->categoryCollection->addFieldToFilter( + 'name', + 'Category 999' + )->getFirstItem(); $categoryId = (int) $category->getId(); $expectedProductsAsc = ['simple1000', 'simple1001', 'simple1002']; @@ -1533,7 +1557,7 @@ public function testFilterProductsForExactMatchingName() ] ], [ - 'attribute_code' => 'category_id', + 'attribute_code' => 'category_uid', 'count' => 1, 'label' => 'Category', 'options' => [ @@ -1642,7 +1666,11 @@ public function testFilterProductsBySingleCategoryId(string $fieldName, string $ QUERY; $response = $this->graphQlQuery($query); - $this->assertEquals(2, $response['products']['total_count'], 'Incorrect count of products returned'); + $this->assertEquals( + 2, + $response['products']['total_count'], + 'Incorrect count of products returned' + ); $links = $this->categoryLinkManagement->getAssignedProducts( is_numeric($queryCategoryId) ? $queryCategoryId : base64_decode($queryCategoryId) ); @@ -1749,12 +1777,18 @@ public function testSearchAndSortByRelevance() $responseAsc = $this->graphQlQuery(sprintf($query, 'ASC')); $this->assertEquals(3, $responseDesc['products']['total_count']); $this->assertNotEmpty($responseDesc['products']['filters'], 'Filters should have the Category layer'); - $this->assertEquals('Colorful Category', $responseDesc['products']['filters'][0]['filter_items'][0]['label']); + $this->assertEquals( + 'Colorful Category', + $responseDesc['products']['filters'][0]['filter_items'][0]['label'] + ); $this->assertCount(2, $responseDesc['products']['aggregations']); $expectedProductsInResponse = ['Blue briefs', 'Navy Blue Striped Shoes', 'Grey shorts']; $namesDesc = array_column($responseDesc['products']['items'], 'name'); $this->assertEqualsCanonicalizing($expectedProductsInResponse, $namesDesc); - $this->assertEquals($namesDesc, array_reverse(array_column($responseAsc['products']['items'], 'name'))); + $this->assertEquals( + $namesDesc, + array_reverse(array_column($responseAsc['products']['items'], 'name')) + ); } /** diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductViewTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductViewTest.php index d573e2893e8f3..9f75e54612510 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductViewTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductViewTest.php @@ -86,6 +86,8 @@ public function testQueryAllFieldsSimpleProduct() } } name + new_from_date + new_to_date options_container ... on CustomizableProductInterface { options { @@ -337,6 +339,8 @@ public function testQueryMediaGalleryEntryFieldsSimpleProduct() } } name + new_from_date + new_to_date options_container ... on CustomizableProductInterface { field_options: options { @@ -893,9 +897,16 @@ private function assertEavAttributes($product, $actualResponse) * @param string $eavAttributeCode * @return string */ - private function eavAttributesToGraphQlSchemaFieldTranslator(string $eavAttributeCode) + private function eavAttributesToGraphQlSchemaFieldTranslator(string $eavAttributeCode): string { - return $eavAttributeCode; + switch ($eavAttributeCode) { + case 'news_from_date': + return 'new_from_date'; + case 'news_to_date': + return 'new_to_date'; + default: + return $eavAttributeCode; + } } /** diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogGraphQl/CategoryTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogGraphQl/CategoryTest.php new file mode 100644 index 0000000000000..3b106ab48be20 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/CatalogGraphQl/CategoryTest.php @@ -0,0 +1,51 @@ +getSearchQueryWithSCategoryUID(); + $response = $this->graphQlQuery($query); + $this->assertNotEmpty($response['products']); + $this->assertEquals(1, count($response['products']['aggregations'])); + $this->assertNotEmpty($response['products']['aggregations']); + $this->assertEquals('price', $response['products']['aggregations'][0]['attribute_code']); + } + + /** + * Prepare search query with suggestions + * + * @return string + */ + private function getSearchQueryWithSCategoryUID() : string + { + return <<getMaskedQuoteIdByReservedOrderId->execute('test_quote'); + $query = $this->getQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + self::assertArrayHasKey('cart', $response); + self::assertArrayHasKey('available_payment_methods', $response['cart']); + self::assertEquals('free', current($response['cart']['available_payment_methods'])['code']); + self::assertEquals( + 'No Payment Information Required', + current($response['cart']['available_payment_methods'])['title'] + ); + } + /** * _security * @magentoApiDataFixture Magento/Customer/_files/customer.php diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/CartTotalsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/CartTotalsTest.php index f2228900d1085..efbe163a12766 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/CartTotalsTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/CartTotalsTest.php @@ -62,6 +62,7 @@ public function testGetCartTotalsWithTaxApplied() $cartItem = $response['cart']['items'][0]; self::assertEquals(10, $cartItem['prices']['price']['value']); + self::assertEquals(10.75, $cartItem['prices']['price_including_tax']['value']); self::assertEquals(20, $cartItem['prices']['row_total']['value']); self::assertEquals(21.5, $cartItem['prices']['row_total_including_tax']['value']); @@ -129,6 +130,7 @@ public function testGetCartTotalsWithCatalogRuleApplied() $cartItem = $response['cart']['items'][0]; self::assertEquals(9, $cartItem['prices']['price']['value']); + self::assertEquals(9, $cartItem['prices']['price_including_tax']['value']); self::assertEquals(18, $cartItem['prices']['row_total']['value']); self::assertEquals(18, $cartItem['prices']['row_total_including_tax']['value']); @@ -157,6 +159,7 @@ public function testGetCartTotalsWithCatalogRuleAndTaxApplied() $cartItem = $response['cart']['items'][0]; self::assertEquals(9, $cartItem['prices']['price']['value']); + self::assertEquals(9.68, $cartItem['prices']['price_including_tax']['value']); self::assertEquals(18, $cartItem['prices']['row_total']['value']); self::assertEquals(19.35, $cartItem['prices']['row_total_including_tax']['value']); @@ -185,6 +188,7 @@ public function testGetCartTotalsWithCatalogRuleAndCartRuleApplied() $cartItem = $response['cart']['items'][0]; self::assertEquals(9, $cartItem['prices']['price']['value']); + self::assertEquals(9, $cartItem['prices']['price_including_tax']['value']); self::assertEquals(18, $cartItem['prices']['row_total']['value']); self::assertEquals(18, $cartItem['prices']['row_total_including_tax']['value']); self::assertEquals(9, $cartItem['prices']['total_item_discount']['value']); @@ -244,6 +248,7 @@ public function testGetTotalsWithNoTaxApplied() $cartItem = $response['cart']['items'][0]; self::assertEquals(10, $cartItem['prices']['price']['value']); + self::assertEquals(10, $cartItem['prices']['price_including_tax']['value']); self::assertEquals(20, $cartItem['prices']['row_total']['value']); self::assertEquals(20, $cartItem['prices']['row_total_including_tax']['value']); @@ -271,6 +276,7 @@ public function testGetCartTotalsWithNoAddressSet() $cartItem = $response['cart']['items'][0]; self::assertEquals(10, $cartItem['prices']['price']['value']); + self::assertEquals(10, $cartItem['prices']['price_including_tax']['value']); self::assertEquals(20, $cartItem['prices']['row_total']['value']); self::assertEquals(20, $cartItem['prices']['row_total_including_tax']['value']); @@ -321,6 +327,10 @@ private function getQuery(string $maskedQuoteId): string value currency } + price_including_tax { + value + currency + } row_total { value currency diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/GetAvailablePaymentMethodsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/GetAvailablePaymentMethodsTest.php index 5cd977aee6981..6a6e93ad515b7 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/GetAvailablePaymentMethodsTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Guest/GetAvailablePaymentMethodsTest.php @@ -49,6 +49,30 @@ public function testGetAvailablePaymentMethods() self::assertEquals('Check / Money order', $response['cart']['available_payment_methods'][0]['title']); } + /** + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product_with_zero_price.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/enable_offline_shipping_methods.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product_with_zero_price.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_new_shipping_address.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_new_billing_address.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_freeshipping_shipping_method.php + */ + public function testGetAvailablePaymentMethodsForZeroSubTotalCheckout():void + { + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); + $query = $this->getQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + self::assertArrayHasKey('cart', $response); + self::assertArrayHasKey('available_payment_methods', $response['cart']); + self::assertEquals('free', current($response['cart']['available_payment_methods'])['code']); + self::assertEquals( + 'No Payment Information Required', + current($response['cart']['available_payment_methods'])['title'] + ); + } + /** * _security * @magentoApiDataFixture Magento/Customer/_files/customer.php diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Weee/CartItemPricesWithFPTTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Weee/CartItemPricesWithFPTTest.php index 1d53bd03be3d4..f1fbe94aec947 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Weee/CartItemPricesWithFPTTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Weee/CartItemPricesWithFPTTest.php @@ -326,6 +326,10 @@ private function getQuery(string $maskedQuoteId): string value currency } + price_including_tax { + value + currency + } row_total { value currency diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_with_missing_image.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_with_missing_image.php new file mode 100644 index 0000000000000..5d2496f93d243 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_with_missing_image.php @@ -0,0 +1,37 @@ +get(\Magento\Framework\Filesystem::class)->getDirectoryWrite(DirectoryList::MEDIA); +$fileName = 'magento_small_image.jpg'; +$filePath = 'catalog/category/' . $fileName; +$mediaDirectory->create('catalog/category'); +$shortImageContent = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . $fileName); +$mediaDirectory->getDriver()->filePutContents($mediaDirectory->getAbsolutePath($filePath), $shortImageContent); + + +$filePath = 'catalog/category/magento_small_image.jpg'; +/** @var Category $category */ +$categoryParent = $objectManager->create(Category::class); +$categoryParent->setName('Parent Image Category') + ->setPath('1/2') + ->setLevel(2) + ->setImage($filePath) + ->setAvailableSortBy('name') + ->setDefaultSortBy('name') + ->setIsActive(true) + ->setPosition(1) + ->save(); + +$mediaDirectory->getDriver()->deleteFile($mediaDirectory->getAbsolutePath($filePath)); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_with_missing_image_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_with_missing_image_rollback.php new file mode 100644 index 0000000000000..4800d78e220ba --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/catalog_category_with_missing_image_rollback.php @@ -0,0 +1,27 @@ +get(Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var Collection $collection */ +$collection = $objectManager->create(Collection::class); +$collection + ->addAttributeToFilter('name', ['in' => ['Parent Image Category']]) + ->load() + ->delete(); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Catalog/_files/simple_product_with_zero_price.php b/dev/tests/integration/testsuite/Magento/GraphQl/Catalog/_files/simple_product_with_zero_price.php new file mode 100644 index 0000000000000..e2b125fa15573 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Catalog/_files/simple_product_with_zero_price.php @@ -0,0 +1,45 @@ +get(ProductInterfaceFactory::class); +/** @var DataObjectHelper $dataObjectHelper */ +$dataObjectHelper = Bootstrap::getObjectManager()->get(DataObjectHelper::class); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); + +$product = $productFactory->create(); +$productData = [ + ProductInterface::TYPE_ID => Type::TYPE_SIMPLE, + ProductInterface::ATTRIBUTE_SET_ID => 4, + ProductInterface::SKU => 'simple_product_with_zero_price', + ProductInterface::NAME => 'Simple Product With Zero Price', + ProductInterface::PRICE => 0, + ProductInterface::VISIBILITY => Visibility::VISIBILITY_BOTH, + ProductInterface::STATUS => Status::STATUS_ENABLED, +]; +$dataObjectHelper->populateWithArray($product, $productData, ProductInterface::class); +/** Out of interface */ +$product + ->setWebsiteIds([1]) + ->setStockData([ + 'qty' => 85, + 'is_in_stock' => true, + 'manage_stock' => true, + 'is_qty_decimal' => true, + ]); +$productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Catalog/_files/simple_product_with_zero_price_rollback.php b/dev/tests/integration/testsuite/Magento/GraphQl/Catalog/_files/simple_product_with_zero_price_rollback.php new file mode 100644 index 0000000000000..17d2811f33e29 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Catalog/_files/simple_product_with_zero_price_rollback.php @@ -0,0 +1,32 @@ +get(ProductRepositoryInterface::class); +/** @var Registry $registry */ +$registry = $objectManager->get(Registry::class); + +$currentArea = $registry->registry('isSecureArea'); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +try { + $productRepository->deleteById('simple_product_with_zero_price'); +} catch (NoSuchEntityException $e) { + /** + * Tests which are wrapped with MySQL transaction clear all data by transaction rollback. + */ +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', $currentArea); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Quote/_files/add_simple_product_with_zero_price.php b/dev/tests/integration/testsuite/Magento/GraphQl/Quote/_files/add_simple_product_with_zero_price.php new file mode 100644 index 0000000000000..41e5d6e726295 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Quote/_files/add_simple_product_with_zero_price.php @@ -0,0 +1,28 @@ +get(ProductRepositoryInterface::class); +/** @var QuoteFactory $quoteFactory */ +$quoteFactory = Bootstrap::getObjectManager()->get(QuoteFactory::class); +/** @var QuoteResource $quoteResource */ +$quoteResource = Bootstrap::getObjectManager()->get(QuoteResource::class); +/** @var CartRepositoryInterface $cartRepository */ +$cartRepository = Bootstrap::getObjectManager()->get(CartRepositoryInterface::class); + +$product = $productRepository->get('simple_product_with_zero_price'); + +$quote = $quoteFactory->create(); +$quoteResource->load($quote, 'test_quote', 'reserved_order_id'); +$quote->addProduct($product, 2); +$cartRepository->save($quote); diff --git a/dev/tests/integration/testsuite/Magento/Sales/Model/Order/BackOrderTest.php b/dev/tests/integration/testsuite/Magento/Sales/Model/Order/BackOrderTest.php new file mode 100644 index 0000000000000..985c49dcdbba0 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Model/Order/BackOrderTest.php @@ -0,0 +1,76 @@ +checkQuoteItemQty call + * returns a quantity to be backordered, that this value is added to the QuoteItem + * and saved into the OrderItem + */ +class BackOrderTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var QuoteIdMaskFactory + */ + private $quoteIdMaskFactory; + + protected function setUp(): void + { + parent::setUp(); + $this->objectManager = Bootstrap::getObjectManager(); + $this->quoteIdMaskFactory = $this->objectManager->get(QuoteIdMaskFactory::class); + } + + /** + * @magentoDataFixture Magento/Sales/_files/quote_with_backorder.php + * @return void + */ + public function testCreateOrderWithBackorders() + { + /** @var Quote $quote */ + $quote = $this->objectManager->create(Quote::class); + $quote->load('test01', 'reserved_order_id'); + + /** @var CheckoutSession $checkoutSession */ + $checkoutSession = $this->objectManager->get(CheckoutSession::class); + $checkoutSession->setQuoteId($quote->getId()); + + /** @var QuoteIdMask $quoteIdMask */ + $quoteIdMask = $this->quoteIdMaskFactory->create(); + $quoteIdMask->load($quote->getId(), 'quote_id'); + $cartId = $quoteIdMask->getMaskedId(); + + /** @var GuestCartManagementInterface $cartManagement */ + $cartManagement = $this->objectManager->get(GuestCartManagementInterface::class); + $orderId = $cartManagement->placeOrder($cartId); + + //The order should have 10 backordered items + /** @var Order $order */ + $order = $this->objectManager->get(OrderRepository::class)->get($orderId); + $this->assertNotNull($order); + $orderitem = $order->getAllItems()[0]; + $this->assertEquals($orderitem->getQtyBackordered(), 10); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_backorder.php b/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_backorder.php new file mode 100644 index 0000000000000..d5de9af85df80 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_backorder.php @@ -0,0 +1,74 @@ +loadArea('frontend'); + +$storeManager = Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->get(\Magento\Store\Model\StoreManagerInterface::class); + +$product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); +$product->setTypeId('simple') + ->setId(1) + ->setAttributeSetId(4) + ->setName('Simple Product') + ->setSku('simple') + ->setPrice(10) + ->setTaxClassId(0) + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setStockData( + [ + 'qty' => 0, + 'is_in_stock' => 1, + 'manage_stock' => 1, + 'backorders' => 2 + ] + ) + ->setWebsiteIds([$storeManager->getStore()->getWebsiteId()]) + ->save(); + +$productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); +$product = $productRepository->get('simple'); + +$addressData = include __DIR__ . '/address_data.php'; +$billingAddress = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + \Magento\Quote\Model\Quote\Address::class, + ['data' => $addressData] +); +$billingAddress->setAddressType('billing'); +$shippingAddress = clone $billingAddress; +$shippingAddress->setId(null)->setAddressType('shipping'); +$store = $storeManager->getStore(); + +/** @var \Magento\Quote\Model\Quote $quote */ +$quote = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Quote\Model\Quote::class); +$quote->setCustomerIsGuest(true) + ->setStoreId($store->getId()) + ->setReservedOrderId('test01') + ->setBillingAddress($billingAddress) + ->setShippingAddress($shippingAddress) + ->addProduct($product, 10); +$quote->getPayment()->setMethod('checkmo'); +$quote->setIsMultiShipping('0'); +$quote->getShippingAddress()->setShippingMethod('flatrate_flatrate')->setCollectShippingRates(true); +$quote->collectTotals(); + +$quoteRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->get(\Magento\Quote\Api\CartRepositoryInterface::class); +$quoteRepository->save($quote); + +/** @var \Magento\Quote\Model\QuoteIdMask $quoteIdMask */ +$quoteIdMask = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->create(\Magento\Quote\Model\QuoteIdMaskFactory::class) + ->create(); +$quoteIdMask->setQuoteId($quote->getId()); +$quoteIdMask->setDataChanges(true); +$quoteIdMask->save(); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_backorder_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_backorder_rollback.php new file mode 100644 index 0000000000000..94d2e8c3c1e33 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_backorder_rollback.php @@ -0,0 +1,37 @@ +get(\Magento\Framework\Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var $quote \Magento\Quote\Model\Quote */ +$quote = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(\Magento\Quote\Model\Quote::class); +$quote->load('test01', 'reserved_order_id'); +if ($quote->getId()) { + $quote->delete(); +} + +/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ +$productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); + +try { + $product = $productRepository->get('simple', false, null, true); + $productRepository->delete($product); +} catch (\Magento\Framework\Exception\NoSuchEntityException $exception) { + //Product already removed +} + +/** @var \Magento\CatalogInventory\Model\StockRegistryStorage $stockRegistryStorage */ +$stockRegistryStorage = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->get(\Magento\CatalogInventory\Model\StockRegistryStorage::class); +$stockRegistryStorage->removeStockItem(1); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/lib/internal/Magento/Framework/Data/Argument/Interpreter/ArrayType.php b/lib/internal/Magento/Framework/Data/Argument/Interpreter/ArrayType.php index 9a881ccf515ab..1a914a8f9b0d1 100644 --- a/lib/internal/Magento/Framework/Data/Argument/Interpreter/ArrayType.php +++ b/lib/internal/Magento/Framework/Data/Argument/Interpreter/ArrayType.php @@ -5,7 +5,9 @@ */ namespace Magento\Framework\Data\Argument\Interpreter; +use InvalidArgumentException; use Magento\Framework\Data\Argument\InterpreterInterface; +use Magento\Framework\ObjectManager\Helper\SortItems as SortItemsHelper; /** * Interpreter of array data type that supports arrays of unlimited depth @@ -17,107 +19,39 @@ class ArrayType implements InterpreterInterface * * @var InterpreterInterface */ - private $itemInterpreter; + private InterpreterInterface $itemInterpreter; + + /** + * @var SortItemsHelper + */ + private SortItemsHelper $sortItemsHelper; /** * @param InterpreterInterface $itemInterpreter + * @param SortItemsHelper|null $sortItemsHelper */ - public function __construct(InterpreterInterface $itemInterpreter) + public function __construct(InterpreterInterface $itemInterpreter, SortItemsHelper $sortItemsHelper = null) { $this->itemInterpreter = $itemInterpreter; + $this->sortItemsHelper = $sortItemsHelper ?: new \Magento\Framework\ObjectManager\Helper\SortItems(); } /** * @inheritdoc * @return array - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ - public function evaluate(array $data) + public function evaluate(array $data): array { - $items = isset($data['item']) ? $data['item'] : []; + $items = $data['item'] ?? []; if (!is_array($items)) { - throw new \InvalidArgumentException('Array items are expected.'); + throw new InvalidArgumentException('Array items are expected.'); } $result = []; - $items = $this->sortItems($items); + $items = $this->sortItemsHelper->sortItems($items); foreach ($items as $itemKey => $itemData) { $result[$itemKey] = $this->itemInterpreter->evaluate($itemData); } return $result; } - - /** - * Sort items by sort order attribute. - * - * @param array $items - * @return array - */ - private function sortItems($items) - { - $sortOrderDefined = $this->isSortOrderDefined($items); - if ($sortOrderDefined) { - $indexedItems = []; - foreach ($items as $key => $item) { - $indexedItems[] = ['key' => $key, 'item' => $item]; - } - uksort( - $indexedItems, - function ($firstItemKey, $secondItemKey) use ($indexedItems) { - return $this->compareItems($firstItemKey, $secondItemKey, $indexedItems); - } - ); - // Convert array of sorted items back to initial format - $items = []; - foreach ($indexedItems as $indexedItem) { - $items[$indexedItem['key']] = $indexedItem['item']; - } - } - return $items; - } - - /** - * Compare sortOrder of item - * - * @param mixed $firstItemKey - * @param mixed $secondItemKey - * @param array $indexedItems - * @return int - */ - private function compareItems($firstItemKey, $secondItemKey, $indexedItems) - { - $firstItem = $indexedItems[$firstItemKey]['item']; - $secondItem = $indexedItems[$secondItemKey]['item']; - $firstValue = 0; - $secondValue = 0; - if (isset($firstItem['sortOrder'])) { - $firstValue = (int)$firstItem['sortOrder']; - } - - if (isset($secondItem['sortOrder'])) { - $secondValue = (int)$secondItem['sortOrder']; - } - - if ($firstValue == $secondValue) { - // These keys reflect initial relative position of items. - // Allows stable sort for items with equal 'sortOrder' - return $firstItemKey < $secondItemKey ? -1 : 1; - } - return $firstValue < $secondValue ? -1 : 1; - } - - /** - * Determine if a sort order exists for any of the items. - * - * @param array $items - * @return bool - */ - private function isSortOrderDefined($items) - { - foreach ($items as $itemData) { - if (isset($itemData['sortOrder'])) { - return true; - } - } - return false; - } } diff --git a/lib/internal/Magento/Framework/Data/Argument/Interpreter/DataObject.php b/lib/internal/Magento/Framework/Data/Argument/Interpreter/DataObject.php index 3ff27f6aa89eb..25af622482abd 100644 --- a/lib/internal/Magento/Framework/Data/Argument/Interpreter/DataObject.php +++ b/lib/internal/Magento/Framework/Data/Argument/Interpreter/DataObject.php @@ -28,13 +28,16 @@ public function __construct(BooleanUtils $booleanUtils) * Compute and return effective value of an argument * * @param array $data - * @return mixed + * @return array * @throws \InvalidArgumentException * @throws \UnexpectedValueException */ - public function evaluate(array $data) + public function evaluate(array $data): array { $result = ['instance' => $data['value']]; + if (array_key_exists('sortOrder', $data)) { + $result['sortOrder'] = $data['sortOrder']; + } if (isset($data['shared'])) { $result['shared'] = $this->booleanUtils->toBoolean($data['shared']); } diff --git a/lib/internal/Magento/Framework/ObjectManager/Config/Config.php b/lib/internal/Magento/Framework/ObjectManager/Config/Config.php index df74eedf463a4..861a6de2c0995 100644 --- a/lib/internal/Magento/Framework/ObjectManager/Config/Config.php +++ b/lib/internal/Magento/Framework/ObjectManager/Config/Config.php @@ -5,12 +5,14 @@ */ namespace Magento\Framework\ObjectManager\Config; -use Magento\Framework\Serialize\SerializerInterface; use Magento\Framework\ObjectManager\ConfigCacheInterface; +use Magento\Framework\ObjectManager\ConfigInterface; use Magento\Framework\ObjectManager\DefinitionInterface; +use Magento\Framework\ObjectManager\Helper\SortItems as SortItemsHelper; use Magento\Framework\ObjectManager\RelationsInterface; +use Magento\Framework\Serialize\SerializerInterface; -class Config implements \Magento\Framework\ObjectManager\ConfigInterface +class Config implements ConfigInterface { /** * Config cache @@ -22,12 +24,11 @@ class Config implements \Magento\Framework\ObjectManager\ConfigInterface /** * Class definitions * - * @var \Magento\Framework\ObjectManager\DefinitionInterface + * @var DefinitionInterface */ protected $_definitions; /** - * Current cache key * * @var string */ @@ -41,7 +42,6 @@ class Config implements \Magento\Framework\ObjectManager\ConfigInterface protected $_preferences = []; /** - * Virtual types * * @var array */ @@ -76,18 +76,28 @@ class Config implements \Magento\Framework\ObjectManager\ConfigInterface protected $_mergedArguments; /** - * @var \Magento\Framework\Serialize\SerializerInterface + * @var SerializerInterface */ private $serializer; /** - * @param RelationsInterface $relations - * @param DefinitionInterface $definitions + * @var SortItemsHelper */ - public function __construct(RelationsInterface $relations = null, DefinitionInterface $definitions = null) - { + private SortItemsHelper $sortItemsHelper; + + /** + * @param RelationsInterface|null $relations + * @param DefinitionInterface|null $definitions + * @param SortItemsHelper|null $sortItemsHelper + */ + public function __construct( + RelationsInterface $relations = null, + DefinitionInterface $definitions = null, + SortItemsHelper $sortItemsHelper = null + ) { $this->_relations = $relations ?: new \Magento\Framework\ObjectManager\Relations\Runtime(); $this->_definitions = $definitions ?: new \Magento\Framework\ObjectManager\Definition\Runtime(); + $this->sortItemsHelper = $sortItemsHelper ?: new \Magento\Framework\ObjectManager\Helper\SortItems(); } /** @@ -185,11 +195,12 @@ public function getPreference($type) * @return array * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - protected function _collectConfiguration($type) + protected function _collectConfiguration($type): array { if (!isset($this->_mergedArguments[$type])) { if (isset($this->_virtualTypes[$type])) { $arguments = $this->_collectConfiguration($this->_virtualTypes[$type]); + $arguments = $this->sortItemsHelper->sortItems($arguments); } elseif ($this->_relations->has($type)) { $relations = $this->_relations->getParents($type); $arguments = []; @@ -198,6 +209,7 @@ protected function _collectConfiguration($type) $relationArguments = $this->_collectConfiguration($relation); if ($relationArguments) { $arguments = array_replace($arguments, $relationArguments); + $arguments = $this->sortItemsHelper->sortItems($arguments); } } } @@ -208,6 +220,7 @@ protected function _collectConfiguration($type) if (isset($this->_arguments[$type])) { if ($arguments && count($arguments)) { $arguments = array_replace_recursive($arguments, $this->_arguments[$type]); + $arguments = $this->sortItemsHelper->sortItems($arguments); } else { $arguments = $this->_arguments[$type]; } @@ -339,7 +352,7 @@ public function getPreferences() /** * Get serializer * - * @return \Magento\Framework\Serialize\SerializerInterface + * @return SerializerInterface * @deprecated 101.0.0 */ private function getSerializer() diff --git a/lib/internal/Magento/Framework/ObjectManager/Helper/SortItems.php b/lib/internal/Magento/Framework/ObjectManager/Helper/SortItems.php new file mode 100644 index 0000000000000..c2f45e340b44f --- /dev/null +++ b/lib/internal/Magento/Framework/ObjectManager/Helper/SortItems.php @@ -0,0 +1,141 @@ +isSortOrderDefined($items); + if ($sortOrderDefined) { + if (!$this->isMultiSortOrder($items)) { + $indexedItems = []; + foreach ($items as $key => $item) { + $indexedItems[] = ['key' => $key, 'item' => $item]; + } + uksort( + $indexedItems, + function ($firstItemKey, $secondItemKey) use ($indexedItems) { + return $this->compareItems($firstItemKey, $secondItemKey, $indexedItems); + } + ); + // Convert array of sorted items back to initial format + $items = []; + foreach ($indexedItems as $indexedItem) { + $items[$indexedItem['key']] = $indexedItem['item']; + } + } else { + $indexedItem = []; + foreach ($items as $key => $itemData) { + foreach ($itemData as $itemKey => $item) { + $indexedItem[] = ['parent'=>$key, 'key' => $itemKey, 'item' => $item]; + } + } + + uksort( + $indexedItem, + function ($firstItemKey, $secondItemKey) use ($indexedItem) { + return $this->compareItems($firstItemKey, $secondItemKey, $indexedItem); + } + ); + $items = []; + foreach ($indexedItem as $iItem) { + $items[$iItem['parent']][$iItem['key']] = $iItem['item']; + } + } + } + + return $items; + } + + /** + * Compare sortOrder of item + * + * @param mixed $firstItemKey + * @param mixed $secondItemKey + * @param array $indexedItems + * @return int + */ + private function compareItems($firstItemKey, $secondItemKey, array $indexedItems): int + { + $firstItem = $indexedItems[$firstItemKey]['item']; + $secondItem = $indexedItems[$secondItemKey]['item']; + $firstValue = 0; + $secondValue = 0; + if (isset($firstItem['sortOrder'])) { + $firstValue = (int)$firstItem['sortOrder']; + } + + if (isset($secondItem['sortOrder'])) { + $secondValue = (int)$secondItem['sortOrder']; + } + + if ($firstValue == $secondValue) { + // These keys reflect initial relative position of items. + // Allows stable sort for items with equal 'sortOrder' + return $firstValue <=> $secondValue; + } + return $firstValue <=> $secondValue; + } + + /** + * Determine if a sort order exists for any of the items. + * + * @param array $items + * @return bool + */ + private function isSortOrderDefined(array $items): bool + { + $isSortOrder = false; + + array_walk($items, function ($value) use (&$isSortOrder) { + if (!!is_array($value)) { + if (isset($value['sortOrder'])) { + $isSortOrder = true; + } else { + array_walk($value, function ($valueData) use (&$isSortOrder) { + if (isset($valueData['sortOrder'])) { + $isSortOrder = true; + } + }); + } + } + }); + + return $isSortOrder; + } + /** + * Determine if a sort order exists for any of the items. + * + * @param array $items + * @return bool + */ + private function isMultiSortOrder(array $items): bool + { + $isMultiSortOrder = false; + + array_walk($items, function ($value) use (&$isMultiSortOrder) { + if (!!is_array($value)) { + array_walk($value, function ($valueData) use (&$isMultiSortOrder) { + $isMultiSortOrder = isset($valueData['sortOrder']); + }); + } + }); + + return $isMultiSortOrder; + } +} diff --git a/lib/internal/Magento/Framework/ObjectManager/Test/Unit/Config/ConfigTest.php b/lib/internal/Magento/Framework/ObjectManager/Test/Unit/Config/ConfigTest.php index 419fd482d89ed..0852a3017a549 100644 --- a/lib/internal/Magento/Framework/ObjectManager/Test/Unit/Config/ConfigTest.php +++ b/lib/internal/Magento/Framework/ObjectManager/Test/Unit/Config/ConfigTest.php @@ -10,6 +10,7 @@ use Magento\Framework\ObjectManager\Config\Config; use Magento\Framework\ObjectManager\ConfigCacheInterface; use Magento\Framework\ObjectManager\DefinitionInterface; +use Magento\Framework\ObjectManager\Helper\SortItems; use Magento\Framework\Serialize\SerializerInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use PHPUnit\Framework\TestCase; @@ -60,7 +61,16 @@ public function testExtendWithCacheMock() $serializerMock->expects($this->atLeast(2)) ->method('serialize') ->willReturn('[[],[],[],[]]'); - $config = new Config(null, $definitions, $serializerMock); + + $sortItemsMock = $this->getMockForAbstractClass(SortItems::class); + $config =$this->objectManagerHelper->getObject( + Config::class, + [ + 'relations' => null, + 'definitions' => $definitions, + 'sortItemsHelper' => $sortItemsMock, + ] + ); $this->objectManagerHelper->setBackwardCompatibleProperty( $config, 'serializer', diff --git a/lib/internal/Magento/Framework/ObjectManager/Test/Unit/Helper/SortItemsTest.php b/lib/internal/Magento/Framework/ObjectManager/Test/Unit/Helper/SortItemsTest.php new file mode 100644 index 0000000000000..44646946bafb7 --- /dev/null +++ b/lib/internal/Magento/Framework/ObjectManager/Test/Unit/Helper/SortItemsTest.php @@ -0,0 +1,143 @@ +_model = new SortItemsHelper(); + } + + /** + * @param array $input + * @param array $expected + * + * @dataProvider evaluateDataProvider + */ + public function testSortItems(array $input, array $expected): void + { + $actual = $this->_model->sortItems($input); + $this->assertSame($expected, $actual); + } + + /** + * @return array + */ + public function evaluateDataProvider(): array + { + return [ + 'empty array items' => [ + [], + [], + ], + 'absent array items' => [ + [], + [], + ], + 'present array items' => [ + [ + 'key1' => ['value' => 'value 1'], + 'key2' => ['value' => 'value 2'], + 'key3' => ['value' => 'value 3'], + ], + [ + 'key1' => ['value' => 'value 1'], + 'key2' => ['value' => 'value 2'], + 'key3' => ['value' => 'value 3'], + ], + ], + 'sorted array items' => [ + [ + 'key1' => ['value' => 'value 1', 'sortOrder' => 50], + 'key2' => ['value' => 'value 2'], + 'key3' => ['value' => 'value 3', 'sortOrder' => 10], + 'key4' => ['value' => 'value 4'], + ], + [ + 'key2' => ['value' => 'value 2'], + 'key4' => ['value' => 'value 4'], + 'key3' => ['value' => 'value 3', 'sortOrder' => 10], + 'key1' => ['value' => 'value 1', 'sortOrder' => 50], + ], + ], + 'multi sorted array items' => [ + [ + 'key1' => ['value' => 'value 1'], + 'key2' => ['value' => 'value 2'], + 'item1' => [ + 'key3'=>['value' => 'value 3', 'sortOrder' => 30], + 'key4'=>['value' => 'value 4', 'sortOrder' => 10], + 'key5'=>['value' => 'value 5', 'sortOrder' => 20], + ], + ], + [ + 'key1' => ['value' => 'value 1'], + 'key2' => ['value' => 'value 2'], + 'item1' => [ + 'key4'=>['value' => 'value 4', 'sortOrder' => 10], + 'key5'=>['value' => 'value 5', 'sortOrder' => 20], + 'key3'=>['value' => 'value 3', 'sortOrder' => 30], + ], + + ], + ], + 'pre-sorted array items' => [ + [ + 'item' => [ + 'key1' => ['value' => 'value 1'], + 'key4' => ['value' => 'value 4'], + 'key3' => ['value' => 'value 3'], + 'key2' => ['value' => 'value 2', 'sortOrder' => 10], + ] + ], + [ + 'item' => [ + 'key1' => ['value' => 'value 1'], + 'key4' => ['value' => 'value 4'], + 'key3' => ['value' => 'value 3'], + 'key2' => ['value' => 'value 2', 'sortOrder' => 10], + ] + ], + ], + 'sort order edge case values' => [ + [ + 'item' => [ + 'key1' => ['value' => 'value 1', 'sortOrder' => 101], + 'key4' => ['value' => 'value 4'], + 'key2' => ['value' => 'value 2', 'sortOrder' => -10], + 'key3' => ['value' => 'value 3'], + 'key5' => ['value' => 'value 5', 'sortOrder' => 20], + ], + ], + [ + 'item' => [ + 'key2' => ['value' => 'value 2', 'sortOrder' => -10], + 'key4' => ['value' => 'value 4'], + 'key3' => ['value' => 'value 3'], + 'key5' => ['value' => 'value 5', 'sortOrder' => 20], + 'key1' => ['value' => 'value 1', 'sortOrder' => 101], + ] + ], + ], + ]; + } +}