diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php
index ca80261a467d3..cc469654ccd23 100644
--- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php
+++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php
@@ -12,6 +12,7 @@
use Magento\Catalog\Api\Data\ProductAttributeInterface;
use Magento\Catalog\Controller\Adminhtml\Product\Attribute;
use Magento\Catalog\Helper\Product;
+use Magento\Catalog\Model\Product\Attribute\FilterableAllowedInputTypes;
use Magento\Catalog\Model\Product\Attribute\Frontend\Inputtype\Presentation;
use Magento\Framework\Serialize\Serializer\FormData;
use Magento\Catalog\Model\Product\AttributeSet\BuildFactory;
@@ -38,41 +39,6 @@
*/
class Save extends Attribute implements HttpPostActionInterface
{
- /**
- * @var BuildFactory
- */
- protected $buildFactory;
-
- /**
- * @var FilterManager
- */
- protected $filterManager;
-
- /**
- * @var Product
- */
- protected $productHelper;
-
- /**
- * @var AttributeFactory
- */
- protected $attributeFactory;
-
- /**
- * @var ValidatorFactory
- */
- protected $validatorFactory;
-
- /**
- * @var CollectionFactory
- */
- protected $groupCollectionFactory;
-
- /**
- * @var LayoutFactory
- */
- private $layoutFactory;
-
/**
* @var Presentation
*/
@@ -83,6 +49,11 @@ class Save extends Attribute implements HttpPostActionInterface
*/
private $formDataSerializer;
+ /**
+ * @var FilterableAllowedInputTypes
+ */
+ private $filterableAllowedInputTypes;
+
/**
* @param Context $context
* @param FrontendInterface $attributeLabelCache
@@ -97,6 +68,7 @@ class Save extends Attribute implements HttpPostActionInterface
* @param LayoutFactory $layoutFactory
* @param Presentation|null $presentation
* @param FormData|null $formDataSerializer
+ * @param FilterableAllowedInputTypes|null $filterableAllowedInputTypes
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*/
public function __construct(
@@ -104,27 +76,23 @@ public function __construct(
FrontendInterface $attributeLabelCache,
Registry $coreRegistry,
PageFactory $resultPageFactory,
- BuildFactory $buildFactory,
- AttributeFactory $attributeFactory,
- ValidatorFactory $validatorFactory,
- CollectionFactory $groupCollectionFactory,
- FilterManager $filterManager,
- Product $productHelper,
- LayoutFactory $layoutFactory,
+ protected BuildFactory $buildFactory,
+ protected AttributeFactory $attributeFactory,
+ protected ValidatorFactory $validatorFactory,
+ protected CollectionFactory $groupCollectionFactory,
+ protected FilterManager $filterManager,
+ protected Product $productHelper,
+ private LayoutFactory $layoutFactory,
?Presentation $presentation = null,
- ?FormData $formDataSerializer = null
+ ?FormData $formDataSerializer = null,
+ ?FilterableAllowedInputTypes $filterableAllowedInputTypes = null
) {
parent::__construct($context, $attributeLabelCache, $coreRegistry, $resultPageFactory);
- $this->buildFactory = $buildFactory;
- $this->filterManager = $filterManager;
- $this->productHelper = $productHelper;
- $this->attributeFactory = $attributeFactory;
- $this->validatorFactory = $validatorFactory;
- $this->groupCollectionFactory = $groupCollectionFactory;
- $this->layoutFactory = $layoutFactory;
$this->presentation = $presentation ?: ObjectManager::getInstance()->get(Presentation::class);
$this->formDataSerializer = $formDataSerializer
?: ObjectManager::getInstance()->get(FormData::class);
+ $this->filterableAllowedInputTypes = $filterableAllowedInputTypes
+ ?: ObjectManager::getInstance()->get(FilterableAllowedInputTypes::class);
}
/**
@@ -254,6 +222,11 @@ public function execute()
$data += ['is_filterable' => 0, 'is_filterable_in_search' => 0];
+ $filterableValidationResult = $this->validateFilterableFlags($data, $attributeId);
+ if ($filterableValidationResult) {
+ return $filterableValidationResult;
+ }
+
$defaultValueField = $model->getDefaultValueByInput($data['frontend_input']);
if ($defaultValueField) {
$data['default_value'] = $this->getRequest()->getParam($defaultValueField);
@@ -356,6 +329,34 @@ public function execute()
return $this->returnResult('catalog/*/', [], ['error' => true]);
}
+ /**
+ * Reject layered-navigation flags for input types the admin form does not allow.
+ *
+ * @param array $data
+ * @param mixed $attributeId
+ * @return Json|Redirect|null
+ */
+ private function validateFilterableFlags(array $data, $attributeId)
+ {
+ if ($this->filterableAllowedInputTypes->isAllowed($data['frontend_input'] ?? null)) {
+ return null;
+ }
+
+ if ((int)($data['is_filterable'] ?? 0) || (int)($data['is_filterable_in_search'] ?? 0)) {
+ $this->messageManager->addErrorMessage(
+ __('Can be used only with catalog input type Yes/No, Dropdown, Multiple Select and Price.')
+ );
+ $this->_session->setAttributeData($data);
+ return $this->returnResult(
+ 'catalog/*/edit',
+ ['attribute_id' => $attributeId, '_current' => true],
+ ['error' => true]
+ );
+ }
+
+ return null;
+ }
+
/**
* Provides an initialized Result object.
*
diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/FilterableAllowedInputTypes.php b/app/code/Magento/Catalog/Model/Product/Attribute/FilterableAllowedInputTypes.php
new file mode 100644
index 0000000000000..02f9ea53b2038
--- /dev/null
+++ b/app/code/Magento/Catalog/Model/Product/Attribute/FilterableAllowedInputTypes.php
@@ -0,0 +1,38 @@
+inputTypes = $inputTypes;
+ }
+
+ /**
+ * Check whether the catalog input type may be used in layered navigation.
+ *
+ * @param mixed $frontendInput
+ * @return bool
+ */
+ public function isAllowed(mixed $frontendInput): bool
+ {
+ return in_array((string)$frontendInput, $this->inputTypes, true);
+ }
+}
diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/Repository.php b/app/code/Magento/Catalog/Model/Product/Attribute/Repository.php
index 89aeb40c232b5..9f37fda5e0c3c 100644
--- a/app/code/Magento/Catalog/Model/Product/Attribute/Repository.php
+++ b/app/code/Magento/Catalog/Model/Product/Attribute/Repository.php
@@ -7,9 +7,18 @@
use Laminas\Validator\Regex;
use Magento\Catalog\Api\Data\EavAttributeInterface;
+use Magento\Catalog\Helper\Product;
+use Magento\Catalog\Model\ResourceModel\Attribute as AttributeResource;
+use Magento\Eav\Api\AttributeRepositoryInterface;
+use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\ValidatorFactory;
+use Magento\Eav\Model\Config;
use Magento\Eav\Model\Entity\Attribute;
+use Magento\Eav\Model\Validator\Attribute\Code;
+use Magento\Framework\Api\SearchCriteriaBuilder;
+use Magento\Framework\App\ObjectManager;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Exception\NoSuchEntityException;
+use Magento\Framework\Filter\FilterManager;
/**
* Product attribute repository
@@ -18,76 +27,43 @@
*/
class Repository implements \Magento\Catalog\Api\ProductAttributeRepositoryInterface
{
- private const FILTERABLE_ALLOWED_INPUT_TYPES = ['date', 'datetime', 'text', 'textarea', 'texteditor'];
-
- /**
- * @var \Magento\Catalog\Model\ResourceModel\Attribute
- */
- protected $attributeResource;
-
- /**
- * @var \Magento\Eav\Model\AttributeRepository
- */
- protected $eavAttributeRepository;
-
- /**
- * @var \Magento\Eav\Model\Config
- */
- protected $eavConfig;
-
/**
- * @var \Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\ValidatorFactory
+ * @var ValidatorFactory
+ * @deprecated
+ * @see $validatorFactory
*/
protected $inputtypeValidatorFactory;
/**
- * @var \Magento\Catalog\Helper\Product
- */
- protected $productHelper;
-
- /**
- * @var \Magento\Framework\Filter\FilterManager
- */
- protected $filterManager;
-
- /**
- * @var \Magento\Framework\Api\SearchCriteriaBuilder
- */
- protected $searchCriteriaBuilder;
-
- /**
- * @var \Magento\Eav\Model\Validator\Attribute\Code
+ * @var FilterableAllowedInputTypes
*/
- protected $attributeCodeValidator;
+ private FilterableAllowedInputTypes $filterableAllowedInputTypes;
/**
- * @param \Magento\Catalog\Model\ResourceModel\Attribute $attributeResource
- * @param \Magento\Catalog\Helper\Product $productHelper
- * @param \Magento\Framework\Filter\FilterManager $filterManager
- * @param \Magento\Eav\Api\AttributeRepositoryInterface $eavAttributeRepository
- * @param \Magento\Eav\Model\Config $eavConfig
- * @param \Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\ValidatorFactory $validatorFactory
- * @param \Magento\Framework\Api\SearchCriteriaBuilder $searchCriteriaBuilder
- * @param \Magento\Eav\Model\Validator\Attribute\Code $attributeCodeValidator
+ * @param AttributeResource $attributeResource
+ * @param Product $productHelper
+ * @param FilterManager $filterManager
+ * @param AttributeRepositoryInterface $eavAttributeRepository
+ * @param Config $eavConfig
+ * @param ValidatorFactory $validatorFactory
+ * @param SearchCriteriaBuilder $searchCriteriaBuilder
+ * @param Code $attributeCodeValidator
+ * @param FilterableAllowedInputTypes|null $filterableAllowedInputTypes
*/
public function __construct(
- \Magento\Catalog\Model\ResourceModel\Attribute $attributeResource,
- \Magento\Catalog\Helper\Product $productHelper,
- \Magento\Framework\Filter\FilterManager $filterManager,
- \Magento\Eav\Api\AttributeRepositoryInterface $eavAttributeRepository,
- \Magento\Eav\Model\Config $eavConfig,
- \Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\ValidatorFactory $validatorFactory,
- \Magento\Framework\Api\SearchCriteriaBuilder $searchCriteriaBuilder,
- \Magento\Eav\Model\Validator\Attribute\Code $attributeCodeValidator
+ protected AttributeResource $attributeResource,
+ protected Product $productHelper,
+ protected FilterManager $filterManager,
+ protected AttributeRepositoryInterface $eavAttributeRepository,
+ protected Config $eavConfig,
+ protected ValidatorFactory $validatorFactory,
+ protected SearchCriteriaBuilder $searchCriteriaBuilder,
+ protected Code $attributeCodeValidator,
+ ?FilterableAllowedInputTypes $filterableAllowedInputTypes = null
) {
- $this->attributeResource = $attributeResource;
- $this->productHelper = $productHelper;
- $this->filterManager = $filterManager;
- $this->eavAttributeRepository = $eavAttributeRepository;
- $this->eavConfig = $eavConfig;
$this->inputtypeValidatorFactory = $validatorFactory;
- $this->searchCriteriaBuilder = $searchCriteriaBuilder;
- $this->attributeCodeValidator = $attributeCodeValidator;
+ $this->filterableAllowedInputTypes = $filterableAllowedInputTypes
+ ?? ObjectManager::getInstance()->get(FilterableAllowedInputTypes::class);
}
/**
@@ -120,7 +96,7 @@ public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCr
*/
public function save(\Magento\Catalog\Api\Data\ProductAttributeInterface $attribute)
{
- if (in_array($attribute->getFrontendInput(), self::FILTERABLE_ALLOWED_INPUT_TYPES)) {
+ if (!$this->filterableAllowedInputTypes->isAllowed($attribute->getFrontendInput())) {
if ($attribute->getIsFilterable()) {
throw InputException::invalidFieldValue(
EavAttributeInterface::IS_FILTERABLE,
@@ -312,7 +288,7 @@ protected function validateCode($code)
protected function validateFrontendInput($frontendInput)
{
/** @var \Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\Validator $validator */
- $validator = $this->inputtypeValidatorFactory->create();
+ $validator = $this->validatorFactory->create();
if (!$validator->isValid($frontendInput)) {
throw InputException::invalidFieldValue('frontend_input', $frontendInput);
}
diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php
index 4367ba30d8623..0b01e1227ca2f 100644
--- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php
+++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php
@@ -12,6 +12,7 @@
use Magento\Catalog\Api\Data\ProductAttributeInterface;
use Magento\Catalog\Controller\Adminhtml\Product\Attribute\Save;
use Magento\Catalog\Helper\Product as ProductHelper;
+use Magento\Catalog\Model\Product\Attribute\FilterableAllowedInputTypes;
use Magento\Catalog\Model\Entity\Attribute;
use Magento\Catalog\Model\Product\Attribute\Frontend\Inputtype\Presentation;
use Magento\Catalog\Model\Product\AttributeSet\Build;
@@ -134,9 +135,13 @@ class SaveTest extends AttributeTest
/**
* @var Session|MockObject
*/
-
private $sessionMock;
+ /**
+ * @var FilterableAllowedInputTypes
+ */
+ private $filterableAllowedInputTypes;
+
protected function setUp(): void
{
parent::setUp();
@@ -157,6 +162,9 @@ protected function setUp(): void
$this->redirectMock = $this->createMock(ResultRedirect::class);
$this->jsonResultMock = $this->createMock(ResultJson::class);
$this->productAttributeMock = $this->createMock(Attribute::class);
+ $this->filterableAllowedInputTypes = new FilterableAllowedInputTypes(
+ ['boolean', 'select', 'multiselect', 'price']
+ );
$this->buildFactoryMock->expects($this->any())
->method('create')
@@ -189,6 +197,7 @@ protected function getModel()
'formDataSerializer' => $this->formDataSerializerMock,
'attributeCodeValidator' => $this->attributeCodeValidatorMock,
'presentation' => $this->presentationMock,
+ 'filterableAllowedInputTypes' => $this->filterableAllowedInputTypes,
'_session' => $this->sessionMock
]);
}
@@ -259,6 +268,9 @@ public function testConstructorFallbackUsesGlobalObjectManagerForFormDataSeriali
if ($type === Presentation::class) {
return $this->presentationMock;
}
+ if ($type === FilterableAllowedInputTypes::class) {
+ return $this->filterableAllowedInputTypes;
+ }
return null;
});
ObjectManager::setInstance($objectManagerMock);
@@ -279,6 +291,7 @@ public function testConstructorFallbackUsesGlobalObjectManagerForFormDataSeriali
// Intentionally omit 'formDataSerializer' to trigger fallback
'formDataSerializer' => null,
'presentation' => $this->presentationMock,
+ 'filterableAllowedInputTypes' => $this->filterableAllowedInputTypes,
'_session' => $this->sessionMock
]);
@@ -631,6 +644,7 @@ public function testEntityTypeCheckFailsWhenBackendModelProvided()
'layoutFactory' => $this->layoutFactoryMock,
'formDataSerializer' => $this->formDataSerializerMock,
'presentation' => $this->presentationMock,
+ 'filterableAllowedInputTypes' => $this->filterableAllowedInputTypes,
'_session' => $this->sessionMock
]);
@@ -960,6 +974,121 @@ public function testNewAttributeSetGenericExceptionAddsExceptionMessage()
$this->assertInstanceOf(ResultRedirect::class, $this->getModel()->execute());
}
+ public function testExecuteRejectsFilterableForUnsupportedInputType()
+ {
+ $data = [
+ 'frontend_input' => 'text',
+ 'is_filterable' => '1',
+ ];
+
+ $this->requestMock->expects($this->any())
+ ->method('getParam')
+ ->willReturnMap([
+ ['isAjax', null, null],
+ ['serialized_options', '[]', ''],
+ ['attribute_code', null, 'issue_33055_text'],
+ ]);
+ $this->formDataSerializerMock->expects($this->once())->method('unserialize')->with('')->willReturn([]);
+ $this->requestMock->expects($this->once())->method('getPostValue')->willReturn($data);
+ $this->inputTypeValidatorMock->method('isValid')->with('text')->willReturn(true);
+ $this->presentationMock->method('convertPresentationDataToInputType')->willReturnCallback(function ($arg) {
+ return $arg;
+ });
+ $this->productHelperMock->method('getAttributeSourceModelByInputType')->with('text')->willReturn(null);
+ $this->productHelperMock->method('getAttributeBackendModelByInputType')->with('text')->willReturn(null);
+ $this->productAttributeMock->expects($this->never())->method('getDefaultValueByInput');
+ $this->productAttributeMock->expects($this->never())->method('save');
+ $this->messageManager->expects($this->once())
+ ->method('addErrorMessage')
+ ->with(
+ $this->callback(static function ($message) {
+ return (string)$message ===
+ 'Can be used only with catalog input type Yes/No, Dropdown, Multiple Select and Price.';
+ })
+ );
+ $this->resultFactoryMock->expects($this->once())
+ ->method('create')
+ ->with(ResultFactory::TYPE_REDIRECT)
+ ->willReturn($this->redirectMock);
+ $this->redirectMock->expects($this->once())
+ ->method('setPath')
+ ->with('catalog/*/edit', [
+ 'attribute_id' => null,
+ '_current' => true,
+ ])
+ ->willReturnSelf();
+
+ $this->assertInstanceOf(ResultRedirect::class, $this->getModel()->execute());
+ }
+
+ public function testExecuteRejectsFilterableInSearchForUnsupportedInputType()
+ {
+ $data = [
+ 'frontend_input' => 'textarea',
+ 'is_filterable_in_search' => '1',
+ ];
+
+ $this->requestMock->expects($this->any())
+ ->method('getParam')
+ ->willReturnMap([
+ ['isAjax', null, null],
+ ['serialized_options', '[]', ''],
+ ['attribute_code', null, 'issue_33055_textarea'],
+ ]);
+ $this->formDataSerializerMock->expects($this->once())->method('unserialize')->with('')->willReturn([]);
+ $this->requestMock->expects($this->once())->method('getPostValue')->willReturn($data);
+ $this->inputTypeValidatorMock->method('isValid')->with('textarea')->willReturn(true);
+ $this->presentationMock->method('convertPresentationDataToInputType')->willReturnCallback(function ($arg) {
+ return $arg;
+ });
+ $this->productHelperMock->method('getAttributeSourceModelByInputType')->with('textarea')->willReturn(null);
+ $this->productHelperMock->method('getAttributeBackendModelByInputType')->with('textarea')->willReturn(null);
+ $this->productAttributeMock->expects($this->never())->method('save');
+ $this->messageManager->expects($this->once())->method('addErrorMessage');
+ $this->resultFactoryMock->expects($this->once())
+ ->method('create')
+ ->with(ResultFactory::TYPE_REDIRECT)
+ ->willReturn($this->redirectMock);
+ $this->redirectMock->expects($this->any())->method('setPath')->willReturnSelf();
+
+ $this->assertInstanceOf(ResultRedirect::class, $this->getModel()->execute());
+ }
+
+ public function testExecuteAllowsFilterableForSupportedInputType()
+ {
+ $data = [
+ 'frontend_input' => 'select',
+ 'is_filterable' => '1',
+ ];
+
+ $this->requestMock->expects($this->any())
+ ->method('getParam')
+ ->willReturnMap([
+ ['isAjax', null, null],
+ ['serialized_options', '[]', ''],
+ ['attribute_code', null, 'filterable_select'],
+ ]);
+ $this->formDataSerializerMock->expects($this->once())->method('unserialize')->with('')->willReturn([]);
+ $this->requestMock->expects($this->once())->method('getPostValue')->willReturn($data);
+ $this->inputTypeValidatorMock->method('isValid')->with('select')->willReturn(true);
+ $this->presentationMock->method('convertPresentationDataToInputType')->willReturnCallback(function ($arg) {
+ return $arg;
+ });
+ $this->productHelperMock->method('getAttributeSourceModelByInputType')->with('select')->willReturn(null);
+ $this->productHelperMock->method('getAttributeBackendModelByInputType')->with('select')->willReturn(null);
+ $this->productAttributeMock->method('getDefaultValueByInput')->with('select')->willReturn(null);
+ $this->productAttributeMock->expects($this->once())
+ ->method('addData')
+ ->with($this->callback(static function ($arg) {
+ return (int)$arg['is_filterable'] === 1;
+ }));
+ $this->messageManager->expects($this->never())->method('addErrorMessage');
+ $this->resultFactoryMock->expects($this->any())->method('create')->willReturn($this->redirectMock);
+ $this->redirectMock->expects($this->any())->method('setPath')->willReturnSelf();
+
+ $this->assertInstanceOf(ResultRedirect::class, $this->getModel()->execute());
+ }
+
private function createAttributeFactoryForGroupCollectionTest()
{
$attributeModel = $this->createPartialMock(
@@ -1032,6 +1161,7 @@ private function createControllerWithAttributeFactory($localAttributeFactory)
'layoutFactory' => $this->layoutFactoryMock,
'formDataSerializer' => $this->formDataSerializerMock,
'presentation' => $this->presentationMock,
+ 'filterableAllowedInputTypes' => $this->filterableAllowedInputTypes,
'_session' => $this->sessionMock
]);
}
diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/FilterableAllowedInputTypesTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/FilterableAllowedInputTypesTest.php
new file mode 100644
index 0000000000000..02b304f068db1
--- /dev/null
+++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/FilterableAllowedInputTypesTest.php
@@ -0,0 +1,47 @@
+assertSame($expected, $model->isAllowed($frontendInput));
+ }
+
+ /**
+ * @return array
+ */
+ public static function isAllowedDataProvider(): array
+ {
+ return [
+ 'select' => ['select', true],
+ 'boolean' => ['boolean', true],
+ 'multiselect' => ['multiselect', true],
+ 'price' => ['price', true],
+ 'text' => ['text', false],
+ 'textarea' => ['textarea', false],
+ 'media_image' => ['media_image', false],
+ 'gallery' => ['gallery', false],
+ 'empty' => ['', false],
+ 'null' => [null, false],
+ ];
+ }
+}
diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/RepositoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/RepositoryTest.php
index edc6c31967550..bf301902cf5a1 100644
--- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/RepositoryTest.php
+++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/RepositoryTest.php
@@ -11,6 +11,7 @@
use Magento\Catalog\Api\Data\ProductAttributeInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Helper\Product;
+use Magento\Catalog\Model\Product\Attribute\FilterableAllowedInputTypes;
use Magento\Catalog\Model\Product\Attribute\Repository;
use Magento\Catalog\Model\ResourceModel\Eav\Attribute;
use Magento\Eav\Api\AttributeRepositoryInterface;
@@ -133,7 +134,8 @@ protected function setUp(): void
$this->eavConfigMock,
$this->validatorFactoryMock,
$this->searchCriteriaBuilderMock,
- $this->attributeCodeValidatorMock
+ $this->attributeCodeValidatorMock,
+ new FilterableAllowedInputTypes(['boolean', 'select', 'multiselect', 'price'])
);
}
@@ -330,6 +332,23 @@ public static function filterableDataProvider(): array
];
}
+ /**
+ * @return void
+ */
+ public function testSaveInputExceptionInvalidIsFilterableForMediaImage(): void
+ {
+ $this->expectException('Magento\Framework\Exception\InputException');
+ $this->expectExceptionMessage('Invalid value of "1" provided for the is_filterable field.');
+ $attributeMock = $this->createPartialMock(
+ Attribute::class,
+ ['getFrontendInput', 'getIsFilterable']
+ );
+ $attributeMock->expects($this->atLeastOnce())->method('getFrontendInput')->willReturn('media_image');
+ $attributeMock->expects($this->atLeastOnce())->method('getIsFilterable')->willReturn(1);
+
+ $this->model->save($attributeMock);
+ }
+
public function testSaveInputExceptionInvalidFieldValue()
{
$this->expectException('Magento\Framework\Exception\InputException');
diff --git a/app/code/Magento/Catalog/etc/di.xml b/app/code/Magento/Catalog/etc/di.xml
index e5ff924a414eb..4e13dac61d6ad 100644
--- a/app/code/Magento/Catalog/etc/di.xml
+++ b/app/code/Magento/Catalog/etc/di.xml
@@ -77,6 +77,16 @@
+
+
+
+ - boolean
+ - select
+ - multiselect
+ - price
+
+
+
diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save/FilterableInputTypeTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save/FilterableInputTypeTest.php
new file mode 100644
index 0000000000000..159da782cdfe0
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save/FilterableInputTypeTest.php
@@ -0,0 +1,120 @@
+createAttributeUsingDataWithErrorAndAssert($attributePostData, $errorMessage);
+ }
+
+ /**
+ * @param array $attributePostData
+ * @param array $checkArray
+ * @return void
+ */
+ #[DataProvider('allowedFilterableDataProvider')]
+ public function testCreateAttributeWithLayeredNavigationIsAllowed(
+ array $attributePostData,
+ array $checkArray
+ ): void {
+ $this->createAttributeUsingDataAndAssert($attributePostData, $checkArray);
+ }
+
+ /**
+ * @return array
+ */
+ public static function disallowedFilterableDataProvider(): array
+ {
+ $message = (string)__(
+ 'Can be used only with catalog input type Yes/No, Dropdown, Multiple Select and Price.'
+ );
+
+ $cases = [];
+ foreach (['text', 'textarea', 'texteditor', 'date', 'datetime', 'media_image'] as $frontendInput) {
+ $cases[$frontendInput . '_is_filterable'] = [
+ self::postData($frontendInput, 'filterable_' . $frontendInput, '1', '0'),
+ $message,
+ ];
+ }
+
+ $cases['text_is_filterable_in_search'] = [
+ self::postData('text', 'filterable_text_search', '0', '1'),
+ $message,
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @return array
+ */
+ public static function allowedFilterableDataProvider(): array
+ {
+ $cases = [];
+ foreach (['boolean', 'price'] as $frontendInput) {
+ $attributeCode = 'allowed_filterable_' . $frontendInput;
+ $cases[$frontendInput . '_is_filterable'] = [
+ self::postData($frontendInput, $attributeCode, '1', '0'),
+ [
+ 'attribute_code' => $attributeCode,
+ 'frontend_input' => $frontendInput,
+ 'is_filterable' => 1,
+ ],
+ ];
+ }
+
+ return $cases;
+ }
+
+ /**
+ * @param string $frontendInput
+ * @param string $attributeCode
+ * @param string $isFilterable
+ * @param string $isFilterableInSearch
+ * @return array
+ */
+ private static function postData(
+ string $frontendInput,
+ string $attributeCode,
+ string $isFilterable,
+ string $isFilterableInSearch
+ ): array {
+ return [
+ 'frontend_label' => [
+ Store::DEFAULT_STORE_ID => 'Test attribute name',
+ ],
+ 'frontend_input' => $frontendInput,
+ 'is_required' => '0',
+ 'attribute_code' => $attributeCode,
+ 'is_global' => '1',
+ 'is_unique' => '0',
+ 'is_searchable' => $isFilterableInSearch === '1' ? '1' : '0',
+ 'is_filterable' => $isFilterable,
+ 'is_filterable_in_search' => $isFilterableInSearch,
+ ];
+ }
+}
diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Attribute/RepositoryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Attribute/RepositoryTest.php
index 6a83f629a1b29..1c893ea448a74 100644
--- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Attribute/RepositoryTest.php
+++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Attribute/RepositoryTest.php
@@ -12,6 +12,7 @@
use Magento\Catalog\Api\ProductAttributeRepositoryInterface;
use Magento\Catalog\Setup\CategorySetup;
use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface;
+use Magento\Eav\Model\Validator\Attribute\Code;
use Magento\Framework\Exception\InputException;
use Magento\Framework\ObjectManagerInterface;
use Magento\TestFramework\Helper\Bootstrap;
@@ -49,6 +50,17 @@ protected function setUp(): void
$this->objectManager = Bootstrap::getObjectManager();
$this->repository = $this->objectManager->get(ProductAttributeRepositoryInterface::class);
$this->attributeFactory = $this->objectManager->get(ProductAttributeInterfaceFactory::class);
+ $this->clearAttributeCodeValidatorMessages();
+ }
+
+ /**
+ * Shared attribute-code validator keeps messages between calls.
+ */
+ private function clearAttributeCodeValidatorMessages(): void
+ {
+ $validator = $this->objectManager->get(Code::class);
+ $method = new \ReflectionMethod($validator, '_clearMessages');
+ $method->invoke($validator);
}
/**
@@ -112,6 +124,88 @@ public static function errorProvider(): array
];
}
+ /**
+ * @param string $frontendInput
+ * @param string $field
+ * @param int $value
+ * @return void
+ */
+ #[DataProvider('invalidFilterableInputTypeProvider')]
+ public function testSaveRejectsFilterableForUnsupportedInputType(
+ string $frontendInput,
+ string $field,
+ int $value
+ ): void {
+ $this->expectExceptionObject(InputException::invalidFieldValue($field, $value));
+ $this->createdAttribute = $this->saveAttributeWithData(
+ $this->hydrateData(
+ [
+ 'attribute_code' => 'rej_' . $frontendInput . '_' . $field,
+ 'frontend_input' => $frontendInput,
+ 'frontend_label' => 'Rejected ' . $frontendInput,
+ $field => $value,
+ ]
+ )
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public static function invalidFilterableInputTypeProvider(): array
+ {
+ return [
+ 'text_is_filterable' => ['text', ProductAttributeInterface::IS_FILTERABLE, 1],
+ 'textarea_is_filterable' => ['textarea', ProductAttributeInterface::IS_FILTERABLE, 1],
+ 'date_is_filterable' => ['date', ProductAttributeInterface::IS_FILTERABLE, 1],
+ 'datetime_is_filterable' => ['datetime', ProductAttributeInterface::IS_FILTERABLE, 1],
+ 'media_image_is_filterable' => ['media_image', ProductAttributeInterface::IS_FILTERABLE, 1],
+ 'text_is_filterable_in_search' => ['text', ProductAttributeInterface::IS_FILTERABLE_IN_SEARCH, 1],
+ ];
+ }
+
+ /**
+ * @return void
+ */
+ public function testSaveAllowsFilterableForSupportedInputType(): void
+ {
+ $this->createdAttribute = $this->saveAttributeWithData(
+ $this->hydrateData(
+ [
+ 'attribute_code' => 'repo_filt_bool',
+ 'frontend_input' => 'boolean',
+ 'frontend_label' => 'Allowed Filterable Boolean',
+ ProductAttributeInterface::IS_FILTERABLE => 1,
+ ]
+ )
+ );
+
+ $this->assertSame(1, (int)$this->createdAttribute->getIsFilterable());
+ }
+
+ /**
+ * @return void
+ */
+ public function testSaveRejectsEnablingFilterableOnExistingTextAttribute(): void
+ {
+ $this->createdAttribute = $this->saveAttributeWithData(
+ $this->hydrateData(
+ [
+ 'attribute_code' => 'repo_txt_then_filt',
+ 'frontend_input' => 'text',
+ 'frontend_label' => 'Text Then Filterable',
+ ProductAttributeInterface::IS_FILTERABLE => 0,
+ ]
+ )
+ );
+ $this->createdAttribute->setIsFilterable(1);
+
+ $this->expectExceptionObject(
+ InputException::invalidFieldValue(ProductAttributeInterface::IS_FILTERABLE, 1)
+ );
+ $this->repository->save($this->createdAttribute);
+ }
+
/**
* Save product attribute with data
*