Skip to content

Commit b63dd05

Browse files
committed
Merge remote-tracking branch 'origin/ACP2E-4353' into PR_2025_12_03_chittima
2 parents 6dd9756 + 4847058 commit b63dd05

File tree

3 files changed

+239
-46
lines changed

3 files changed

+239
-46
lines changed

app/code/Magento/CatalogWidget/Block/Product/ProductsList.php

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Magento\Catalog\Pricing\Price\FinalPrice;
1919
use Magento\Catalog\ViewModel\Product\OptionsData;
2020
use Magento\CatalogWidget\Model\Rule;
21+
use Magento\CatalogWidget\Model\Rule\Condition\Product\CategoryConditionProcessor;
2122
use Magento\Framework\App\ActionInterface;
2223
use Magento\Framework\App\Http\Context as HttpContext;
2324
use Magento\Framework\App\ObjectManager;
@@ -137,6 +138,11 @@ class ProductsList extends AbstractProduct implements BlockInterface, IdentityIn
137138
*/
138139
private OptionsData $optionsData;
139140

141+
/**
142+
* @var CategoryConditionProcessor
143+
*/
144+
private CategoryConditionProcessor $categoryConditionProcessor;
145+
140146
/**
141147
* @param Context $context
142148
* @param CollectionFactory $productCollectionFactory
@@ -151,6 +157,7 @@ class ProductsList extends AbstractProduct implements BlockInterface, IdentityIn
151157
* @param EncoderInterface|null $urlEncoder
152158
* @param CategoryRepositoryInterface|null $categoryRepository
153159
* @param OptionsData|null $optionsData
160+
* @param CategoryConditionProcessor|null $categoryConditionProcessor
154161
*
155162
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
156163
*/
@@ -167,7 +174,8 @@ public function __construct(
167174
?LayoutFactory $layoutFactory = null,
168175
?EncoderInterface $urlEncoder = null,
169176
?CategoryRepositoryInterface $categoryRepository = null,
170-
?OptionsData $optionsData = null
177+
?OptionsData $optionsData = null,
178+
?CategoryConditionProcessor $categoryConditionProcessor = null
171179
) {
172180
$this->productCollectionFactory = $productCollectionFactory;
173181
$this->catalogProductVisibility = $catalogProductVisibility;
@@ -181,6 +189,8 @@ public function __construct(
181189
$this->categoryRepository = $categoryRepository ?? ObjectManager::getInstance()
182190
->get(CategoryRepositoryInterface::class);
183191
$this->optionsData = $optionsData ?: ObjectManager::getInstance()->get(OptionsData::class);
192+
$this->categoryConditionProcessor = $categoryConditionProcessor ?: ObjectManager::getInstance()
193+
->get(CategoryConditionProcessor::class);
184194
parent::__construct(
185195
$context,
186196
$data
@@ -390,34 +400,6 @@ public function getBaseCollection(): Collection
390400
return $collection;
391401
}
392402

393-
/**
394-
* Update conditions if the category is an anchor category
395-
*
396-
* @param array $condition
397-
* @return array
398-
*/
399-
private function updateAnchorCategoryConditions(array $condition): array
400-
{
401-
if (array_key_exists('value', $condition)) {
402-
$categoryId = $condition['value'];
403-
404-
try {
405-
$category = $this->categoryRepository->get($categoryId, $this->_storeManager->getStore()->getId());
406-
} catch (NoSuchEntityException $e) {
407-
return $condition;
408-
}
409-
410-
$children = $category->getIsAnchor() ? $category->getChildren(true) : [];
411-
if ($children) {
412-
$children = explode(',', $children);
413-
$condition['operator'] = "()";
414-
$condition['value'] = array_merge([$categoryId], $children);
415-
}
416-
}
417-
418-
return $condition;
419-
}
420-
421403
/**
422404
* Get conditions
423405
*
@@ -440,7 +422,10 @@ protected function getConditions()
440422
}
441423

442424
if ($condition['attribute'] == 'category_ids') {
443-
$conditions[$key] = $this->updateAnchorCategoryConditions($condition);
425+
$conditions[$key] = $this->categoryConditionProcessor->process(
426+
$condition,
427+
$this->_storeManager->getStore()->getId()
428+
);
444429
}
445430
}
446431
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace Magento\CatalogWidget\Model\Rule\Condition\Product;
10+
11+
use Magento\Catalog\Api\CategoryRepositoryInterface;
12+
use Magento\Framework\Exception\NoSuchEntityException;
13+
14+
/**
15+
* Process category condition to include child categories if the category is anchor
16+
*/
17+
class CategoryConditionProcessor
18+
{
19+
/**
20+
* @param CategoryRepositoryInterface $categoryRepository
21+
*/
22+
public function __construct(
23+
private readonly CategoryRepositoryInterface $categoryRepository
24+
) {
25+
}
26+
27+
/**
28+
* Process category condition to include child categories if the category is anchor
29+
*
30+
* @param array $condition
31+
* @param int|null $storeId
32+
* @return array
33+
*/
34+
public function process(array $condition, ?int $storeId = null): array
35+
{
36+
if (!empty($condition['value'])) {
37+
$condition['value'] = $this->getCategoriesWithChildren(
38+
!is_array($condition['value']) ? $this->toArray((string) $condition['value']) : $condition['value'],
39+
$storeId
40+
);
41+
}
42+
return $condition;
43+
}
44+
45+
/**
46+
* Get category IDs including children of anchor categories
47+
*
48+
* @param array $categoryIds
49+
* @param int|null $storeId
50+
* @return array
51+
*/
52+
private function getCategoriesWithChildren(array $categoryIds, ?int $storeId = null): array
53+
{
54+
$allCategoryIds = [];
55+
foreach ($categoryIds as $categoryId) {
56+
try {
57+
$category = $this->categoryRepository->get($categoryId, $storeId);
58+
} catch (NoSuchEntityException $e) {
59+
continue;
60+
}
61+
62+
$allCategoryIds[] = $categoryId;
63+
$children = $category->getIsAnchor() ? $category->getChildren(true) : '';
64+
if ($children) {
65+
array_push($allCategoryIds, ...$this->toArray((string) $children));
66+
}
67+
}
68+
69+
return $allCategoryIds;
70+
}
71+
72+
/**
73+
* Convert comma or semicolon separated string to array
74+
*
75+
* @param string $value
76+
* @return array
77+
*/
78+
private function toArray(string $value): array
79+
{
80+
return $value ? preg_split('#\s*[,;]\s*#', $value, -1, PREG_SPLIT_NO_EMPTY) : [];
81+
}
82+
}

dev/tests/integration/testsuite/Magento/CatalogWidget/Block/Product/ProductsListTest.php

Lines changed: 142 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Magento\Catalog\Model\Indexer\Product\Eav\Processor;
1616
use Magento\Catalog\Model\ResourceModel\Category\Collection as CategoryCollection;
1717
use Magento\Catalog\Model\ResourceModel\Eav\Attribute;
18+
use Magento\Framework\Exception\LocalizedException;
1819
use Magento\Framework\ObjectManagerInterface;
1920
use Magento\TestFramework\Fixture\DataFixture;
2021
use Magento\TestFramework\Fixture\DataFixtureStorage;
@@ -235,44 +236,95 @@ public function testProductListWithDateAttribute()
235236
/**
236237
* Make sure CatalogWidget would display anchor category products recursively from children categories.
237238
*
238-
* 1. Create an anchor root category and a sub category inside it
239-
* 2. Create 2 new products and assign them to the sub categories
240-
* 3. Create product list widget condition to display products from the anchor root category
241-
* 4. Load collection for product list widget and make sure that number of loaded products is correct
239+
* @param string $operator
240+
* @param string $value
241+
* @param array $expectedProducts
242+
* @throws LocalizedException
242243
*/
243244
#[
244-
DataFixture('Magento/Catalog/_files/product_in_nested_anchor_categories.php'),
245+
DataProvider('createAnchorCollectionDataProvider'),
246+
// level 1 categories
247+
DataFixture(CategoryFixture::class, ['is_anchor' => 1], 'category1'),
248+
DataFixture(CategoryFixture::class, ['is_anchor' => 1], 'category2'),
249+
DataFixture(CategoryFixture::class, ['is_anchor' => 0], 'category3'),
250+
// level 2 categories
251+
DataFixture(CategoryFixture::class, ['parent_id' => '$category1.id$'], 'category11'),
252+
DataFixture(CategoryFixture::class, ['parent_id' => '$category2.id$'], 'category21'),
253+
DataFixture(CategoryFixture::class, ['parent_id' => '$category3.id$'], 'category31'),
254+
// level 3 categories
255+
DataFixture(CategoryFixture::class, ['parent_id' => '$category11.id$'], 'category111'),
256+
// products assigned to level 1 categories
257+
DataFixture(ProductFixture::class, ['category_ids' => ['$category1.id$']], as: 'product1'),
258+
DataFixture(ProductFixture::class, ['category_ids' => ['$category2.id$']], as: 'product2'),
259+
DataFixture(ProductFixture::class, ['category_ids' => ['$category3.id$']], as: 'product3'),
260+
// unassigned product
261+
DataFixture(ProductFixture::class, as: 'product4'),
262+
// products assigned to level 2 categories
263+
DataFixture(ProductFixture::class, ['category_ids' => ['$category11.id$']], as: 'product11'),
264+
DataFixture(ProductFixture::class, ['category_ids' => ['$category21.id$']], as: 'product21'),
265+
DataFixture(ProductFixture::class, ['category_ids' => ['$category31.id$']], as: 'product31'),
266+
// products assigned to level 3 categories
267+
DataFixture(ProductFixture::class, ['category_ids' => ['$category111.id$']], as: 'product111'),
245268
]
246-
public function testCreateAnchorCollection()
247-
{
269+
public function testCreateAnchorCollection(
270+
string $operator,
271+
string $value,
272+
array $expectedProducts
273+
): void {
248274
// Reindex EAV attributes to enable products filtration by created multiselect attribute
249275
/** @var Processor $eavIndexerProcessor */
250276
$eavIndexerProcessor = $this->objectManager->get(
251277
Processor::class
252278
);
253279
$eavIndexerProcessor->reindexAll();
280+
$fixtures = DataFixtureStorageManager::getStorage();
254281

255282
$this->categoryCollection->addNameToResult()->load();
256-
$rootCategoryId = $this
257-
->categoryCollection
258-
->getItemByColumnValue('name', 'Default Category')
259-
->getId();
283+
$value = preg_replace_callback(
284+
'/(category\d+)/',
285+
function ($matches) use ($fixtures) {
286+
return $fixtures->get($matches[1])->getId();
287+
},
288+
$value
289+
);
260290

261291
$encodedConditions = '^[`1`:^[`type`:`Magento||CatalogWidget||Model||Rule||Condition||Combine`,
262292
`aggregator`:`all`,`value`:`1`,`new_child`:``^],
263293
`1--1`:^[`type`:`Magento||CatalogWidget||Model||Rule||Condition||Product`,
264294
`attribute`:`category_ids`,
265-
`operator`:`==`,`value`:`' . $rootCategoryId . '`^]^]';
295+
`operator`:`' . $operator . '`,`value`:`' . $value . '`^]^]';
266296

267297
$this->block->setData('conditions_encoded', $encodedConditions);
268298

269299
$productCollection = $this->block->createCollection();
270300
$productCollection->load();
271301

272-
$this->assertEquals(
273-
2,
274-
$productCollection->count(),
275-
"Anchor root category does not contain products of it's children."
302+
$allProducts = [
303+
'product1',
304+
'product2',
305+
'product3',
306+
'product4',
307+
'product11',
308+
'product21',
309+
'product31',
310+
'product111',
311+
];
312+
$allProducts = array_combine(
313+
array_map(fn ($productKey) => $fixtures->get($productKey)->getSku(), $allProducts),
314+
$allProducts,
315+
);
316+
317+
$actualProducts = $productCollection->getColumnValues('sku');
318+
319+
$this->assertEqualsCanonicalizing(
320+
$expectedProducts,
321+
array_map(
322+
fn ($sku) => $allProducts[$sku],
323+
array_intersect(
324+
$actualProducts,
325+
array_keys($allProducts)
326+
)
327+
)
276328
);
277329
}
278330

@@ -534,4 +586,78 @@ public static function collectionResultWithMultiselectAttributeDataProvider(): a
534586
]
535587
];
536588
}
589+
590+
/**
591+
* @return array[]
592+
*/
593+
public static function createAnchorCollectionDataProvider(): array
594+
{
595+
return [
596+
'is - category1,category2' => [
597+
'==',
598+
'category1,category2',
599+
['product111', 'product21', 'product11', 'product2', 'product1']
600+
],
601+
'is not - category1,category2' => [
602+
'!=',
603+
'category1,category2',
604+
['product31', 'product4', 'product3']
605+
],
606+
'contains - category1,category2' => [
607+
'{}',
608+
'category1,category2',
609+
['product111', 'product21', 'product11', 'product2', 'product1']
610+
],
611+
'does not contain - category1,category2' => [
612+
'!{}',
613+
'category1,category2',
614+
['product31', 'product4', 'product3']
615+
],
616+
'is one of - category1,category2' => [
617+
'()',
618+
'category1,category2',
619+
['product111', 'product21', 'product11', 'product2', 'product1']
620+
],
621+
'is not one of - category1,category2' => [
622+
'!()',
623+
'category1,category2',
624+
['product31', 'product4', 'product3']
625+
],
626+
// single anchor category
627+
'is - category1' => [
628+
'==',
629+
'category1',
630+
['product111', 'product11', 'product1']
631+
],
632+
'is not - category1' => [
633+
'!=',
634+
'category1',
635+
['product31', 'product21', 'product4', 'product3', 'product2']
636+
],
637+
// single non-anchor category
638+
'is - category3' => [
639+
'==',
640+
'category3',
641+
['product3']
642+
],
643+
'is not - category3' => [
644+
'!=',
645+
'category3',
646+
['product111', 'product31', 'product21', 'product11', 'product4', 'product2', 'product1']
647+
],
648+
// anchor and non-anchor category
649+
'is - category1,category3' => [
650+
'==',
651+
// spaces are intentional to check trimming functionality
652+
'category1 , category3',
653+
['product111', 'product11', 'product3', 'product1']
654+
],
655+
'is not - category1,category3' => [
656+
'!=',
657+
// spaces are intentional to check trimming functionality
658+
'category1 , category3',
659+
['product31', 'product21', 'product4', 'product2']
660+
],
661+
];
662+
}
537663
}

0 commit comments

Comments
 (0)