diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2839ac5ee9d32..dae954a0970b7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,34 +1,34 @@ # Contributing to Magento 2 code Contributions to the Magento 2 codebase are done using the fork & pull model. -This contribution model has contributors maintaining their own copy of the forked codebase (which can easily be synced with the main copy). The forked repository is then used to submit a request to the base repository to “pull” a set of changes (hence the phrase “pull request”). +This contribution model has contributors maintaining their own copy of the forked codebase (which can easily be synced with the main copy). The forked repository is then used to submit a request to the base repository to “pull” a set of changes. For more information on pull requests please refer to [GitHub Help](https://help.github.com/articles/about-pull-requests/). -Contributions can take the form of new components/features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes, optimizations or just good suggestions. +Contributions can take the form of new components or features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes or optimizations. -The Magento 2 development team will review all issues and contributions submitted by the community of developers in the first in, first out order. During the review we might require clarifications from the contributor. If there is no response from the contributor for two weeks, the issue is closed. +The Magento 2 development team will review all issues and contributions submitted by the community of developers in the first in, first out order. During the review we might require clarifications from the contributor. If there is no response from the contributor within two weeks, the pull request will be closed. ## Contribution requirements -1. Contributions must adhere to [Magento coding standards](http://devdocs.magento.com/guides/v2.0/coding-standards/bk-coding-standards.html). -2. Pull requests (PRs) must be accompanied by a meaningful description of their purpose. Comprehensive descriptions increase the chances of a pull request to be merged quickly and without additional clarification requests. -3. Commits must be accompanied by meaningful commit messages. -4. PRs which include bug fixing, must be accompanied with step-by-step description of how to reproduce the bug. +1. Contributions must adhere to the [Magento coding standards](https://devdocs.magento.com/guides/v2.2/coding-standards/bk-coding-standards.html). +2. Pull requests (PRs) must be accompanied by a meaningful description of their purpose. Comprehensive descriptions increase the chances of a pull request being merged quickly and without additional clarification requests. +3. Commits must be accompanied by meaningful commit messages. Please see the [Magento Pull Request Template](https://github.com/magento/magento2/blob/2.2-develop/.github/PULL_REQUEST_TEMPLATE.md) for more information. +4. PRs which include bug fixes must be accompanied with a step-by-step description of how to reproduce the bug. 3. PRs which include new logic or new features must be submitted along with: -* Unit/integration test coverage (we will be releasing more information on writing test coverage in the near future). -* Proposed [documentation](http://devdocs.magento.com) update. Documentation contributions can be submitted [here](https://github.com/magento/devdocs). -4. For large features or changes, please [open an issue](https://github.com/magento/magento2/issues) and discuss first. This may prevent duplicate or unnecessary effort, and it may gain you some additional contributors. -5. All automated tests are passed successfully (all builds on [Travis CI](https://travis-ci.org/magento/magento2) must be green). +* Unit/integration test coverage +* Proposed [documentation](http://devdocs.magento.com) updates. Documentation contributions can be submitted via the [devdocs GitHub](https://github.com/magento/devdocs). +4. For larger features or changes, please [open an issue](https://github.com/magento/magento2/issues) to discuss the proposed changes prior to development. This may prevent duplicate or unnecessary effort and allow other contributors to provide input. +5. All automated tests must pass (all builds on [Travis CI](https://travis-ci.org/magento/magento2) must be green). ## Contribution process -If you are a new GitHub user, we recommend that you create your own [free github account](https://github.com/signup/free). By doing that, you will be able to collaborate with the Magento 2 development team, “fork” the Magento 2 project and be able to easily send “pull requests”. +If you are a new GitHub user, we recommend that you create your own [free github account](https://github.com/signup/free). This will allow you to collaborate with the Magento 2 development team, fork the Magento 2 project and send pull requests. 1. Search current [listed issues](https://github.com/magento/magento2/issues) (open or closed) for similar proposals of intended contribution before starting work on a new contribution. 2. Review the [Contributor License Agreement](https://magento.com/legaldocuments/mca) if this is your first time contributing. 3. Create and test your work. -4. Fork the Magento 2 repository according to [Fork a repository instructions](http://devdocs.magento.com/guides/v2.0/contributor-guide/contributing.html#fork) and when you are ready to send us a pull request – follow [Create a pull request instructions](http://devdocs.magento.com/guides/v2.0/contributor-guide/contributing.html#pull_request). -5. Once your contribution is received, Magento 2 development team will review the contribution and collaborate with you as needed to improve the quality of the contribution. +4. Fork the Magento 2 repository according to the [Fork A Repository instructions](http://devdocs.magento.com/guides/v2.2/contributor-guide/contributing.html#fork) and when you are ready to send us a pull request – follow the [Create A Pull Request instructions](http://devdocs.magento.com/guides/v2.2/contributor-guide/contributing.html#pull_request). +5. Once your contribution is received the Magento 2 development team will review the contribution and collaborate with you as needed. ## Code of Conduct diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 3ac68076d4353..12ad4e452b1c7 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,24 +1,35 @@ - - + ### Preconditions - - + 1. 2. ### Steps to reproduce - + 1. 2. 3. ### Expected result -1. +1. [Screenshots, logs or description] ### Actual result -1. [Screenshot, logs] - - +1. [Screenshots, logs or description] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d1f01ba9f2640..5b0b9d74e453b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,32 @@ - + + + ### Description - + ### Fixed Issues (if relevant) - + 1. magento/magento2#: Issue title 2. ... ### Manual testing scenarios - + 1. ... 2. ... diff --git a/.htaccess b/.htaccess index 6247830fa8d14..d22b5a1395cae 100644 --- a/.htaccess +++ b/.htaccess @@ -355,6 +355,15 @@ Require all denied + + + order allow,deny + deny from all + + = 2.4> + Require all denied + + # For 404s and 403s that aren't handled by the application, show plain 404 response ErrorDocument 404 /pub/errors/404.php diff --git a/.htaccess.sample b/.htaccess.sample index 3c412725f2134..c9ddff2cca4cf 100644 --- a/.htaccess.sample +++ b/.htaccess.sample @@ -332,6 +332,15 @@ Require all denied + + + order allow,deny + deny from all + + = 2.4> + Require all denied + + # For 404s and 403s that aren't handled by the application, show plain 404 response ErrorDocument 404 /pub/errors/404.php diff --git a/.php_cs.dist b/.php_cs.dist index 0f254c63283bd..84a5f88bf4355 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -5,9 +5,9 @@ */ /** - * Pre-commit hook installation: - * vendor/bin/static-review.php hook:install dev/tools/Magento/Tools/StaticReview/pre-commit .git/hooks/pre-commit + * PHP Coding Standards fixer configuration */ + $finder = PhpCsFixer\Finder::create() ->name('*.phtml') ->exclude('dev/tests/functional/generated') diff --git a/.travis.yml b/.travis.yml index dcd00f39bb810..cc730ca5a2cd4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,15 +11,18 @@ addons: firefox: "46.0" hosts: - magento2.travis +services: + - rabbitmq + - elasticsearch language: php php: - - 7.0 - 7.1 + - 7.2 env: global: - COMPOSER_BIN_DIR=~/bin - INTEGRATION_SETS=3 - - NODE_JS_VERSION=6 + - NODE_JS_VERSION=8 - MAGENTO_HOST_NAME="magento2.travis" matrix: - TEST_SUITE=unit @@ -32,13 +35,13 @@ env: - TEST_SUITE=functional matrix: exclude: - - php: 7.0 + - php: 7.1 env: TEST_SUITE=static - - php: 7.0 + - php: 7.1 env: TEST_SUITE=js GRUNT_COMMAND=spec - - php: 7.0 + - php: 7.1 env: TEST_SUITE=js GRUNT_COMMAND=static - - php: 7.0 + - php: 7.1 env: TEST_SUITE=functional cache: apt: true @@ -47,7 +50,9 @@ cache: - $HOME/.nvm - $HOME/node_modules - $HOME/yarn.lock -before_install: ./dev/travis/before_install.sh +before_install: + - curl -O https://download.elastic.co/elasticsearch/release/org/elasticsearch/distribution/deb/elasticsearch/2.3.0/elasticsearch-2.3.0.deb && sudo dpkg -i --force-confnew elasticsearch-2.3.0.deb && sudo service elasticsearch restart + - ./dev/travis/before_install.sh install: composer install --no-interaction before_script: ./dev/travis/before_script.sh script: diff --git a/COPYING.txt b/COPYING.txt index 2ba7d78d58a25..040bdd5f3ce72 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,4 +1,4 @@ -Copyright © 2013-2018 Magento, Inc. +Copyright © 2013-present Magento, Inc. Each Magento source file included in this distribution is licensed under OSL 3.0 or the Magento Enterprise Edition (MEE) license diff --git a/README.md b/README.md index c72357db26d16..a2cf536bb6520 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -[![Build Status](https://travis-ci.org/magento/magento2.svg?branch=develop)](https://travis-ci.org/magento/magento2) +[![Build Status](https://travis-ci.org/magento/magento2.svg?branch=2.3-develop)](https://travis-ci.org/magento/magento2) +[![Open Source Helpers](https://www.codetriage.com/magento/magento2/badges/users.svg)](https://www.codetriage.com/magento/magento2) [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/magento/magento2?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/magento-2/localized.png)](https://crowdin.com/project/magento-2)

Welcome

-Welcome to Magento 2 installation! We're glad you chose to install Magento 2, a cutting edge, feature-rich eCommerce solution that gets results. +Welcome to Magento 2 installation! We're glad you chose to install Magento 2, a cutting-edge, feature-rich eCommerce solution that gets results. ## Magento system requirements -[Magento system requirements](http://devdocs.magento.com/guides/v2.2/install-gde/system-requirements2.html) +[Magento system requirements](http://devdocs.magento.com/guides/v2.3/install-gde/system-requirements2.html) ## Install Magento To install Magento, see either: -* [Magento DevBox](https://magento.com/tech-resources/download), the easiest way to get started with Magento. -* [Installation guide](http://devdocs.magento.com/guides/v2.2/install-gde/bk-install-guide.html) +* [Installation guide](http://devdocs.magento.com/guides/v2.3/install-gde/bk-install-guide.html)

Contributing to the Magento 2 code base

Contributions can take the form of new components or features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes, optimizations, or just good suggestions. @@ -22,11 +22,24 @@ To learn about issues, click [here][2]. To open an issue, click [here][3]. To suggest documentation improvements, click [here][4]. -[1]: -[2]: +[1]: +[2]: [3]: [4]: +

Community Maintainers

+The members of this team have been recognized for their outstanding commitment to maintaining and improving Magento. Magento has granted them permission to accept, merge, and reject pull requests, as well as review issues, and thanks these Community Maintainers for their valuable contributions. + + + + + +

Top Contributors

+Magento is thankful for any contribution that can improve our code base, documentation or increase test coverage. We always recognize our most active members, as their contributions are the foundation of the Magento Open Source platform. + + + +

Labels applied by the Magento team

| Label | Description | diff --git a/app/code/Magento/AdminNotification/Block/System/Messages.php b/app/code/Magento/AdminNotification/Block/System/Messages.php index e95d68663bf04..b950f5583e599 100644 --- a/app/code/Magento/AdminNotification/Block/System/Messages.php +++ b/app/code/Magento/AdminNotification/Block/System/Messages.php @@ -16,24 +16,34 @@ class Messages extends \Magento\Backend\Block\Template /** * @var \Magento\Framework\Json\Helper\Data + * @deprecated */ protected $jsonHelper; + /** + * @var \Magento\Framework\Serialize\Serializer\Json + */ + private $serializer; + /** * @param \Magento\Backend\Block\Template\Context $context * @param \Magento\AdminNotification\Model\ResourceModel\System\Message\Collection\Synchronized $messages * @param \Magento\Framework\Json\Helper\Data $jsonHelper * @param array $data + * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer */ public function __construct( \Magento\Backend\Block\Template\Context $context, \Magento\AdminNotification\Model\ResourceModel\System\Message\Collection\Synchronized $messages, \Magento\Framework\Json\Helper\Data $jsonHelper, - array $data = [] + array $data = [], + \Magento\Framework\Serialize\Serializer\Json $serializer = null ) { $this->jsonHelper = $jsonHelper; parent::__construct($context, $data); $this->_messages = $messages; + $this->serializer = $serializer ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Framework\Serialize\Serializer\Json::class); } /** @@ -117,7 +127,7 @@ protected function _getMessagesUrl() */ public function getSystemMessageDialogJson() { - return $this->jsonHelper->jsonEncode( + return $this->serializer->serialize( [ 'systemMessageDialog' => [ 'buttons' => [], diff --git a/app/code/Magento/AdminNotification/Block/System/Messages/UnreadMessagePopup.php b/app/code/Magento/AdminNotification/Block/System/Messages/UnreadMessagePopup.php index 7ea0062581467..2d4c7f279f707 100644 --- a/app/code/Magento/AdminNotification/Block/System/Messages/UnreadMessagePopup.php +++ b/app/code/Magento/AdminNotification/Block/System/Messages/UnreadMessagePopup.php @@ -77,9 +77,8 @@ public function getPopupTitle() $messageCount = count($this->_messages->getUnread()); if ($messageCount > 1) { return __('You have %1 new system messages', $messageCount); - } else { - return __('You have %1 new system message', $messageCount); } + return __('You have %1 new system message', $messageCount); } /** diff --git a/app/code/Magento/AdminNotification/Block/Window.php b/app/code/Magento/AdminNotification/Block/Window.php index b80e12a8674db..9563626ee2577 100644 --- a/app/code/Magento/AdminNotification/Block/Window.php +++ b/app/code/Magento/AdminNotification/Block/Window.php @@ -98,10 +98,9 @@ protected function _getLatestItem() { if ($this->_latestItem == null) { $items = array_values($this->_criticalCollection->getItems()); + $this->_latestItem = false; if (count($items)) { $this->_latestItem = $items[0]; - } else { - $this->_latestItem = false; } } return $this->_latestItem; diff --git a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MarkAsRead.php b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MarkAsRead.php index 79f69ab5da88d..6b5e0681139cf 100644 --- a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MarkAsRead.php +++ b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MarkAsRead.php @@ -28,11 +28,11 @@ public function execute() )->markAsRead( $notificationId ); - $this->messageManager->addSuccess(__('The message has been marked as Read.')); + $this->messageManager->addSuccessMessage(__('The message has been marked as Read.')); } catch (\Magento\Framework\Exception\LocalizedException $e) { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { - $this->messageManager->addException( + $this->messageManager->addExceptionMessage( $e, __("We couldn't mark the notification as Read because of an error.") ); diff --git a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassMarkAsRead.php b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassMarkAsRead.php index 9e61b8ff4b83c..9ae4a7cdac0b9 100644 --- a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassMarkAsRead.php +++ b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassMarkAsRead.php @@ -23,7 +23,7 @@ public function execute() { $ids = $this->getRequest()->getParam('notification'); if (!is_array($ids)) { - $this->messageManager->addError(__('Please select messages.')); + $this->messageManager->addErrorMessage(__('Please select messages.')); } else { try { foreach ($ids as $id) { @@ -32,13 +32,13 @@ public function execute() $model->setIsRead(1)->save(); } } - $this->messageManager->addSuccess( + $this->messageManager->addSuccessMessage( __('A total of %1 record(s) have been marked as Read.', count($ids)) ); } catch (\Magento\Framework\Exception\LocalizedException $e) { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { - $this->messageManager->addException( + $this->messageManager->addExceptionMessage( $e, __("We couldn't mark the notification as Read because of an error.") ); diff --git a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassRemove.php b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassRemove.php index 6c0dfd1db7d16..06659b8452cab 100644 --- a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassRemove.php +++ b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/MassRemove.php @@ -23,7 +23,7 @@ public function execute() { $ids = $this->getRequest()->getParam('notification'); if (!is_array($ids)) { - $this->messageManager->addError(__('Please select messages.')); + $this->messageManager->addErrorMessage(__('Please select messages.')); } else { try { foreach ($ids as $id) { @@ -32,13 +32,16 @@ public function execute() $model->setIsRemove(1)->save(); } } - $this->messageManager->addSuccess(__('Total of %1 record(s) have been removed.', count($ids))); + $this->messageManager->addSuccessMessage(__('Total of %1 record(s) have been removed.', count($ids))); } catch (\Magento\Framework\Exception\LocalizedException $e) { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { - $this->messageManager->addException($e, __("We couldn't remove the messages because of an error.")); + $this->messageManager->addExceptionMessage( + $e, + __("We couldn't remove the messages because of an error.") + ); } } - $this->getResponse()->setRedirect($this->_redirect->getRedirectUrl($this->getUrl('*'))); + $this->_redirect('adminhtml/*/'); } } diff --git a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/Remove.php b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/Remove.php index 17f911339cb61..f0724a9587c50 100644 --- a/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/Remove.php +++ b/app/code/Magento/AdminNotification/Controller/Adminhtml/Notification/Remove.php @@ -31,11 +31,14 @@ public function execute() try { $model->setIsRemove(1)->save(); - $this->messageManager->addSuccess(__('The message has been removed.')); + $this->messageManager->addSuccessMessage(__('The message has been removed.')); } catch (\Magento\Framework\Exception\LocalizedException $e) { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { - $this->messageManager->addException($e, __("We couldn't remove the messages because of an error.")); + $this->messageManager->addExceptionMessage( + $e, + __("We couldn't remove the messages because of an error.") + ); } $this->_redirect('adminhtml/*/'); diff --git a/app/code/Magento/AdminNotification/Controller/Adminhtml/System/Message/ListAction.php b/app/code/Magento/AdminNotification/Controller/Adminhtml/System/Message/ListAction.php index c332440276083..d58a7ec31f77d 100644 --- a/app/code/Magento/AdminNotification/Controller/Adminhtml/System/Message/ListAction.php +++ b/app/code/Magento/AdminNotification/Controller/Adminhtml/System/Message/ListAction.php @@ -6,6 +6,8 @@ */ namespace Magento\AdminNotification\Controller\Adminhtml\System\Message; +use Magento\Framework\Controller\ResultFactory; + class ListAction extends \Magento\Backend\App\AbstractAction { /** @@ -15,6 +17,7 @@ class ListAction extends \Magento\Backend\App\AbstractAction /** * @var \Magento\Framework\Json\Helper\Data + * @deprecated */ protected $jsonHelper; @@ -41,7 +44,7 @@ public function __construct( } /** - * @return void + * @return \Magento\Framework\Controller\Result\Json */ public function execute() { @@ -59,10 +62,15 @@ public function execute() if (empty($result)) { $result[] = [ 'severity' => (string)\Magento\Framework\Notification\MessageInterface::SEVERITY_NOTICE, - 'text' => 'You have viewed and resolved all recent system notices. ' - . 'Please refresh the web page to clear the notice alert.', + 'text' => __( + 'You have viewed and resolved all recent system notices. ' + . 'Please refresh the web page to clear the notice alert.' + ) ]; } - $this->getResponse()->representJson($this->jsonHelper->jsonEncode($result)); + /** @var \Magento\Framework\Controller\Result\Json $resultJson */ + $resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON); + $resultJson->setData($result); + return $resultJson; } } diff --git a/app/code/Magento/AdminNotification/composer.json b/app/code/Magento/AdminNotification/composer.json index b8dba6f899645..e5cf487908cd7 100644 --- a/app/code/Magento/AdminNotification/composer.json +++ b/app/code/Magento/AdminNotification/composer.json @@ -5,16 +5,15 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "lib-libxml": "*", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-ui": "100.3.*" + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-media-storage": "*", + "magento/module-store": "*", + "magento/module-ui": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/AdminNotification/etc/db_schema.xml b/app/code/Magento/AdminNotification/etc/db_schema.xml index 6d969b3f0090a..35e6045b607d1 100644 --- a/app/code/Magento/AdminNotification/etc/db_schema.xml +++ b/app/code/Magento/AdminNotification/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> diff --git a/app/code/Magento/AdminNotification/i18n/en_US.csv b/app/code/Magento/AdminNotification/i18n/en_US.csv index 16c5abb9db0d2..db5a4c9254814 100644 --- a/app/code/Magento/AdminNotification/i18n/en_US.csv +++ b/app/code/Magento/AdminNotification/i18n/en_US.csv @@ -48,3 +48,4 @@ Severity,Severity "Date Added","Date Added" Message,Message Actions,Actions +"You have viewed and resolved all recent system notices. Please refresh the web page to clear the notice alert.","You have viewed and resolved all recent system notices. Please refresh the web page to clear the notice alert." diff --git a/app/code/Magento/AdvancedPricingImportExport/Model/Export/AdvancedPricing.php b/app/code/Magento/AdvancedPricingImportExport/Model/Export/AdvancedPricing.php index 7ddd5e3bb2a36..a92df095036f3 100644 --- a/app/code/Magento/AdvancedPricingImportExport/Model/Export/AdvancedPricing.php +++ b/app/code/Magento/AdvancedPricingImportExport/Model/Export/AdvancedPricing.php @@ -5,6 +5,7 @@ */ namespace Magento\AdvancedPricingImportExport\Model\Export; +use Magento\ImportExport\Model\Export; use Magento\Store\Model\Store; use Magento\CatalogImportExport\Model\Import\Product as ImportProduct; use Magento\AdvancedPricingImportExport\Model\Import\AdvancedPricing as ImportAdvancedPricing; @@ -79,6 +80,11 @@ class AdvancedPricing extends \Magento\CatalogImportExport\Model\Export\Product ImportAdvancedPricing::COL_TIER_PRICE_TYPE => '' ]; + /** + * @var string[] + */ + private $websiteCodesMap = []; + /** * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate * @param \Magento\Eav\Model\Config $config @@ -255,36 +261,131 @@ public function filterAttributeCollection(\Magento\Eav\Model\ResourceModel\Entit */ protected function getExportData() { + if ($this->_passTierPrice) { + return []; + } + $exportData = []; try { - $rawData = $this->collectRawData(); - $productIds = array_keys($rawData); - if (isset($productIds)) { - if (!$this->_passTierPrice) { - $exportData = array_merge( - $exportData, - $this->getTierPrices($productIds, ImportAdvancedPricing::TABLE_TIER_PRICE) - ); + $productsByStores = $this->loadCollection(); + if (!empty($productsByStores)) { + $linkField = $this->getProductEntityLinkField(); + $productLinkIds = []; + + foreach ($productsByStores as $product) { + $productLinkIds[array_pop($product)[$linkField]] = true; + } + $productLinkIds = array_keys($productLinkIds); + $tierPricesData = $this->fetchTierPrices($productLinkIds); + $exportData = $this->prepareExportData( + $productsByStores, + $tierPricesData + ); + if (!empty($exportData)) { + asort($exportData); } } - if ($exportData) { - $exportData = $this->correctExportData($exportData); - } - if (isset($exportData)) { - asort($exportData); - } - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->_logger->critical($e); } + return $exportData; } + /** + * Creating export-formatted row from tier price. + * + * @param array $tierPriceData Tier price information. + * + * @return array Formatted for export tier price information. + */ + private function createExportRow(array $tierPriceData): array + { + //List of columns to display in export row. + $exportRow = $this->templateExportData; + + foreach (array_keys($exportRow) as $keyTemplate) { + if (array_key_exists($keyTemplate, $tierPriceData)) { + if (in_array($keyTemplate, $this->_priceWebsite)) { + //If it's website column then getting website code. + $exportRow[$keyTemplate] = $this->_getWebsiteCode( + $tierPriceData[$keyTemplate] + ); + } elseif (in_array($keyTemplate, $this->_priceCustomerGroup)) { + //If it's customer group column then getting customer + //group name by ID. + $exportRow[$keyTemplate] = $this->_getCustomerGroupById( + $tierPriceData[$keyTemplate], + $tierPriceData[ImportAdvancedPricing::VALUE_ALL_GROUPS] + ); + unset($exportRow[ImportAdvancedPricing::VALUE_ALL_GROUPS]); + } elseif ($keyTemplate + === ImportAdvancedPricing::COL_TIER_PRICE + ) { + //If it's price column then getting value and type + //of tier price. + $exportRow[$keyTemplate] + = $tierPriceData[ImportAdvancedPricing::COL_TIER_PRICE_PERCENTAGE_VALUE] + ? $tierPriceData[ImportAdvancedPricing::COL_TIER_PRICE_PERCENTAGE_VALUE] + : $tierPriceData[ImportAdvancedPricing::COL_TIER_PRICE]; + $exportRow[ImportAdvancedPricing::COL_TIER_PRICE_TYPE] + = $this->tierPriceTypeValue($tierPriceData); + } else { + //Any other column just goes as is. + $exportRow[$keyTemplate] = $tierPriceData[$keyTemplate]; + } + } + } + + return $exportRow; + } + + /** + * Prepare data for export. + * + * @param array $productsData Products to export. + * @param array $tierPricesData Their tier prices. + * + * @return array Export rows to display. + */ + private function prepareExportData( + array $productsData, + array $tierPricesData + ): array { + //Assigning SKUs to tier prices data. + $productLinkIdToSkuMap = []; + foreach ($productsData as $productData) { + $productLinkIdToSkuMap[$productData[Store::DEFAULT_STORE_ID][$this->getProductEntityLinkField()]] + = $productData[Store::DEFAULT_STORE_ID]['sku']; + } + + //Adding products' SKUs to tier price data. + $linkedTierPricesData = []; + foreach ($tierPricesData as $tierPriceData) { + $sku = $productLinkIdToSkuMap[$tierPriceData['product_link_id']]; + $linkedTierPricesData[] = array_merge( + $tierPriceData, + [ImportAdvancedPricing::COL_SKU => $sku] + ); + } + + //Formatting data for export. + $customExportData = []; + foreach ($linkedTierPricesData as $row) { + $customExportData[] = $this->createExportRow($row); + } + + return $customExportData; + } + /** * Correct export data. * * @param array $exportData * @return array * @SuppressWarnings(PHPMD.UnusedLocalVariable) + * @deprecated + * @see prepareExportData */ protected function correctExportData($exportData) { @@ -327,16 +428,83 @@ protected function correctExportData($exportData) /** * Check type for tier price. * - * @param string $tierPricePercentage + * @param array $tierPriceData * @return string */ - private function tierPriceTypeValue($tierPricePercentage) + private function tierPriceTypeValue(array $tierPriceData): string { - return $tierPricePercentage + return $tierPriceData[ImportAdvancedPricing::COL_TIER_PRICE_PERCENTAGE_VALUE] ? ImportAdvancedPricing::TIER_PRICE_TYPE_PERCENT : ImportAdvancedPricing::TIER_PRICE_TYPE_FIXED; } + /** + * Load tier prices for given products. + * + * @param string[] $productIds Link IDs of products to find tier prices for. + * + * @return array Tier prices data. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + private function fetchTierPrices(array $productIds): array + { + if (empty($productIds)) { + throw new \InvalidArgumentException( + 'Can only load tier prices for specific products' + ); + } + + $pricesTable = ImportAdvancedPricing::TABLE_TIER_PRICE; + $exportFilter = null; + $priceFromFilter = null; + $priceToFilter = null; + if (isset($this->_parameters[Export::FILTER_ELEMENT_GROUP])) { + $exportFilter = $this->_parameters[Export::FILTER_ELEMENT_GROUP]; + } + $productEntityLinkField = $this->getProductEntityLinkField(); + $selectFields = [ + ImportAdvancedPricing::COL_TIER_PRICE_WEBSITE => 'ap.website_id', + ImportAdvancedPricing::VALUE_ALL_GROUPS => 'ap.all_groups', + ImportAdvancedPricing::COL_TIER_PRICE_CUSTOMER_GROUP => 'ap.customer_group_id', + ImportAdvancedPricing::COL_TIER_PRICE_QTY => 'ap.qty', + ImportAdvancedPricing::COL_TIER_PRICE => 'ap.value', + ImportAdvancedPricing::COL_TIER_PRICE_PERCENTAGE_VALUE => 'ap.percentage_value', + 'product_link_id' => 'ap.' .$productEntityLinkField, + ]; + if ($exportFilter && array_key_exists('tier_price', $exportFilter)) { + if (!empty($exportFilter['tier_price'][0])) { + $priceFromFilter = $exportFilter['tier_price'][0]; + } + if (!empty($exportFilter['tier_price'][1])) { + $priceToFilter = $exportFilter['tier_price'][1]; + } + } + + $select = $this->_connection->select() + ->from( + ['ap' => $this->_resource->getTableName($pricesTable)], + $selectFields + ) + ->where( + 'ap.'.$productEntityLinkField.' IN (?)', + $productIds + ); + + if ($priceFromFilter !== null) { + $select->where('ap.value >= ?', $priceFromFilter); + } + if ($priceToFilter !== null) { + $select->where('ap.value <= ?', $priceToFilter); + } + if ($priceFromFilter || $priceToFilter) { + $select->orWhere('ap.percentage_value IS NOT NULL'); + } + + return $this->_connection->fetchAll($select); + } + /** * Get tier prices. * @@ -345,6 +513,8 @@ private function tierPriceTypeValue($tierPricePercentage) * @return array|bool * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @deprecated + * @see fetchTierPrices */ protected function getTierPrices(array $listSku, $table) { @@ -413,41 +583,51 @@ protected function getTierPrices(array $listSku, $table) } /** - * Get Website code + * Get Website code. * * @param int $websiteId + * * @return string */ - protected function _getWebsiteCode($websiteId) + protected function _getWebsiteCode(int $websiteId): string { - $storeName = ($websiteId == 0) - ? ImportAdvancedPricing::VALUE_ALL_WEBSITES - : $this->_storeManager->getWebsite($websiteId)->getCode(); - $currencyCode = ''; - if ($websiteId == 0) { - $currencyCode = $this->_storeManager->getWebsite($websiteId)->getBaseCurrencyCode(); - } - if ($storeName && $currencyCode) { - return $storeName . ' [' . $currencyCode . ']'; - } else { - return $storeName; + if (!array_key_exists($websiteId, $this->websiteCodesMap)) { + $storeName = ($websiteId == 0) + ? ImportAdvancedPricing::VALUE_ALL_WEBSITES + : $this->_storeManager->getWebsite($websiteId)->getCode(); + $currencyCode = ''; + if ($websiteId == 0) { + $currencyCode = $this->_storeManager->getWebsite($websiteId) + ->getBaseCurrencyCode(); + } + + if ($storeName && $currencyCode) { + $code = $storeName.' ['.$currencyCode.']'; + } else { + $code = $storeName; + } + $this->websiteCodesMap[$websiteId] = $code; } + + return $this->websiteCodesMap[$websiteId]; } /** - * Get Customer Group By Id + * Get Customer Group By Id. + * + * @param int $groupId + * @param int $allGroups * - * @param int $customerGroupId - * @param null $allGroups * @return string */ - protected function _getCustomerGroupById($customerGroupId, $allGroups = null) - { - if ($allGroups) { + protected function _getCustomerGroupById( + int $groupId, + int $allGroups = 0 + ): string { + if ($allGroups !== 0) { return ImportAdvancedPricing::VALUE_ALL_GROUPS; - } else { - return $this->_groupRepository->getById($customerGroupId)->getCode(); } + return $this->_groupRepository->getById($groupId)->getCode(); } /** diff --git a/app/code/Magento/AdvancedPricingImportExport/Model/Import/AdvancedPricing.php b/app/code/Magento/AdvancedPricingImportExport/Model/Import/AdvancedPricing.php index 0e8acb37104e6..4663aea7a7dfc 100644 --- a/app/code/Magento/AdvancedPricingImportExport/Model/Import/AdvancedPricing.php +++ b/app/code/Magento/AdvancedPricingImportExport/Model/Import/AdvancedPricing.php @@ -482,9 +482,8 @@ protected function deleteProductTierPrices(array $listSku, $table) $this->addRowError(ValidatorInterface::ERROR_SKU_IS_EMPTY, 0); return false; } - } else { - return false; } + return false; } /** diff --git a/app/code/Magento/AdvancedPricingImportExport/composer.json b/app/code/Magento/AdvancedPricingImportExport/composer.json index 1660104953504..12e1d9938f4bd 100644 --- a/app/code/Magento/AdvancedPricingImportExport/composer.json +++ b/app/code/Magento/AdvancedPricingImportExport/composer.json @@ -5,18 +5,17 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-import-export": "100.3.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-import-export": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-catalog-import-export": "*", + "magento/module-catalog-inventory": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-import-export": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Edit.php b/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Edit.php new file mode 100644 index 0000000000000..403a4d12cc17b --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Edit.php @@ -0,0 +1,31 @@ + + * @since 100.0.2 + */ +class Edit extends \Magento\Backend\Block\Widget\Grid\Container +{ + /** + * Enable grid container + * + * @return void + */ + protected function _construct() + { + $this->_blockGroup = 'Magento_AdvancedSearch'; + $this->_controller = 'adminhtml_search'; + $this->_headerText = __('Related Search Terms'); + $this->_addButtonLabel = __('Add New Search Term'); + parent::_construct(); + $this->buttonList->remove('add'); + } +} diff --git a/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Grid.php b/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Grid.php new file mode 100644 index 0000000000000..6bdfd3b0dd143 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Block/Adminhtml/Search/Grid.php @@ -0,0 +1,113 @@ + + * @since 100.0.2 + */ +class Grid extends \Magento\Backend\Block\Widget\Grid +{ + /** + * @var \Magento\AdvancedSearch\Model\Adminhtml\Search\Grid\Options + */ + protected $_options; + + /** + * @var \Magento\Framework\Registry + */ + protected $_registryManager; + + /** + * @var \Magento\Framework\Json\Helper\Data + */ + protected $jsonHelper; + + /** + * @param \Magento\Backend\Block\Template\Context $context + * @param \Magento\Backend\Helper\Data $backendHelper + * @param \Magento\AdvancedSearch\Model\Adminhtml\Search\Grid\Options $options + * @param \Magento\Framework\Registry $registry + * @param \Magento\Framework\Json\Helper\Data $jsonHelper + * @param array $data + */ + public function __construct( + \Magento\Backend\Block\Template\Context $context, + \Magento\Backend\Helper\Data $backendHelper, + \Magento\AdvancedSearch\Model\Adminhtml\Search\Grid\Options $options, + \Magento\Framework\Registry $registry, + \Magento\Framework\Json\Helper\Data $jsonHelper, + array $data = [] + ) { + $this->jsonHelper = $jsonHelper; + parent::__construct($context, $backendHelper, $data); + $this->_options = $options; + $this->_registryManager = $registry; + $this->setDefaultFilter(['query_id_selected' => 1]); + } + + /** + * Retrieve a value from registry by a key + * + * @return mixed + */ + public function getQuery() + { + return $this->_registryManager->registry('current_catalog_search'); + } + + /** + * Add column filter to collection + * + * @param \Magento\Backend\Block\Widget\Grid\Column $column + * @return $this + */ + protected function _addColumnFilterToCollection($column) + { + // Set custom filter for query selected flag + if ($column->getId() == 'query_id_selected' && $this->getQuery()->getId()) { + $selectedIds = $this->getSelectedQueries(); + if (empty($selectedIds)) { + $selectedIds = 0; + } + if ($column->getFilter()->getValue()) { + $this->getCollection()->addFieldToFilter('query_id', ['in' => $selectedIds]); + } elseif (!empty($selectedIds)) { + $this->getCollection()->addFieldToFilter('query_id', ['nin' => $selectedIds]); + } + } else { + parent::_addColumnFilterToCollection($column); + } + return $this; + } + + /** + * Retrieve selected related queries from grid + * + * @return array + */ + public function getSelectedQueries() + { + return $this->_options->toOptionArray(); + } + + /** + * Get queries json + * + * @return string + */ + public function getQueriesJson() + { + $queries = array_flip($this->getSelectedQueries()); + if (!empty($queries)) { + return $this->jsonHelper->jsonEncode($queries); + } + return '{}'; + } +} diff --git a/app/code/Magento/AdvancedSearch/Block/Adminhtml/System/Config/TestConnection.php b/app/code/Magento/AdvancedSearch/Block/Adminhtml/System/Config/TestConnection.php new file mode 100644 index 0000000000000..a546cfb126ba7 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Block/Adminhtml/System/Config/TestConnection.php @@ -0,0 +1,74 @@ +setTemplate('Magento_AdvancedSearch::system/config/testconnection.phtml'); + return $this; + } + + /** + * Unset some non-related element parameters + * + * @param \Magento\Framework\Data\Form\Element\AbstractElement $element + * @return string + * @since 100.1.0 + */ + public function render(\Magento\Framework\Data\Form\Element\AbstractElement $element) + { + $element = clone $element; + $element->unsScope()->unsCanUseWebsiteValue()->unsCanUseDefaultValue(); + return parent::render($element); + } + + /** + * Get the button and scripts contents + * + * @param \Magento\Framework\Data\Form\Element\AbstractElement $element + * @return string + * @since 100.1.0 + */ + protected function _getElementHtml(\Magento\Framework\Data\Form\Element\AbstractElement $element) + { + $originalData = $element->getOriginalData(); + $this->addData( + [ + 'button_label' => __($originalData['button_label']), + 'html_id' => $element->getHtmlId(), + 'ajax_url' => $this->_urlBuilder->getUrl('catalog/search_system_config/testconnection'), + 'field_mapping' => str_replace('"', '\\"', json_encode($this->_getFieldMapping())) + ] + ); + + return $this->_toHtml(); + } + + /** + * Returns configuration fields required to perform the ping request + * + * @return array + * @since 100.1.0 + */ + protected function _getFieldMapping() + { + return ['engine' => 'catalog_search_engine']; + } +} diff --git a/app/code/Magento/AdvancedSearch/Block/Recommendations.php b/app/code/Magento/AdvancedSearch/Block/Recommendations.php new file mode 100644 index 0000000000000..1a23ea554bd91 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Block/Recommendations.php @@ -0,0 +1,14 @@ +searchDataProvider = $searchDataProvider; + $this->query = $queryFactory->get(); + $this->title = $title; + parent::__construct($context, $data); + } + + /** + * {@inheritdoc} + */ + public function getItems() + { + return $this->searchDataProvider->getItems($this->query); + } + + /** + * {@inheritdoc} + */ + public function isShowResultsCount() + { + return $this->searchDataProvider->isResultsCountEnabled(); + } + + /** + * {@inheritdoc} + */ + public function getLink($queryText) + { + return $this->getUrl('*/*/') . '?q=' . urlencode($queryText); + } + + /** + * {@inheritdoc} + */ + public function getTitle() + { + return __($this->title); + } +} diff --git a/app/code/Magento/AdvancedSearch/Block/SearchDataInterface.php b/app/code/Magento/AdvancedSearch/Block/SearchDataInterface.php new file mode 100644 index 0000000000000..299e68e558ad5 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Block/SearchDataInterface.php @@ -0,0 +1,36 @@ +clientResolver = $clientResolver; + $this->resultJsonFactory = $resultJsonFactory; + $this->tagFilter = $tagFilter; + } + + /** + * Check for connection to server + * + * @return \Magento\Framework\Controller\Result\Json + */ + public function execute() + { + $result = [ + 'success' => false, + 'errorMessage' => '', + ]; + $options = $this->getRequest()->getParams(); + + try { + if (empty($options['engine'])) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Missing search engine parameter.') + ); + } + $response = $this->clientResolver->create($options['engine'], $options)->testConnection(); + if ($response) { + $result['success'] = true; + } + } catch (\Magento\Framework\Exception\LocalizedException $e) { + $result['errorMessage'] = $e->getMessage(); + } catch (\Exception $e) { + $message = __($e->getMessage()); + $result['errorMessage'] = $this->tagFilter->filter($message); + } + + /** @var \Magento\Framework\Controller\Result\Json $resultJson */ + $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); + } +} diff --git a/app/code/Magento/AdvancedSearch/LICENSE.txt b/app/code/Magento/AdvancedSearch/LICENSE.txt new file mode 100644 index 0000000000000..49525fd99da9c --- /dev/null +++ b/app/code/Magento/AdvancedSearch/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/app/design/frontend/Magento/rush/LICENSE_AFL.txt b/app/code/Magento/AdvancedSearch/LICENSE_AFL.txt similarity index 100% rename from app/design/frontend/Magento/rush/LICENSE_AFL.txt rename to app/code/Magento/AdvancedSearch/LICENSE_AFL.txt diff --git a/app/code/Magento/AdvancedSearch/Model/Adapter/DataMapper/AdditionalFieldsProvider.php b/app/code/Magento/AdvancedSearch/Model/Adapter/DataMapper/AdditionalFieldsProvider.php new file mode 100644 index 0000000000000..ef1f9890e02d1 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/Adapter/DataMapper/AdditionalFieldsProvider.php @@ -0,0 +1,39 @@ + [field name1 => value1, ...], ...] + */ +class AdditionalFieldsProvider implements AdditionalFieldsProviderInterface +{ + /** + * @var AdditionalFieldsProviderInterface[] + */ + private $fieldsProviders; + + /** + * @param AdditionalFieldsProviderInterface[] $fieldsProviders + */ + public function __construct(array $fieldsProviders) + { + $this->fieldsProviders = $fieldsProviders; + } + + /** + * {@inheritdoc} + */ + public function getFields(array $productIds, $storeId) + { + $fields = []; + foreach ($this->fieldsProviders as $fieldsProvider) { + $fields[] = $fieldsProvider->getFields($productIds, $storeId); + } + + return array_replace_recursive(...$fields); + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/Adapter/DataMapper/AdditionalFieldsProviderInterface.php b/app/code/Magento/AdvancedSearch/Model/Adapter/DataMapper/AdditionalFieldsProviderInterface.php new file mode 100644 index 0000000000000..d7151236c6170 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/Adapter/DataMapper/AdditionalFieldsProviderInterface.php @@ -0,0 +1,25 @@ + [field name1 => value1, ...], ...] + * @api + * @since 100.2.0 + */ +interface AdditionalFieldsProviderInterface +{ + /** + * Get additional fields for data mapper during search indexer based on product ids and store id. + * + * @param array $productIds + * @param int $storeId + * @return array + * @since 100.2.0 + */ + public function getFields(array $productIds, $storeId); +} diff --git a/app/code/Magento/AdvancedSearch/Model/Adminhtml/Search/Grid/Options.php b/app/code/Magento/AdvancedSearch/Model/Adminhtml/Search/Grid/Options.php new file mode 100644 index 0000000000000..b139689dbc234 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/Adminhtml/Search/Grid/Options.php @@ -0,0 +1,60 @@ +_request = $request; + $this->_registryManager = $registry; + $this->_searchResourceModel = $searchResourceModel; + } + + /** + * {@inheritdoc} + */ + public function toOptionArray() + { + $queries = $this->_request->getPost('selected_queries'); + + $currentQueryId = $this->_registryManager->registry('current_catalog_search')->getId(); + $queryIds = []; + if ($queries === null && !empty($currentQueryId)) { + $queryIds = $this->_searchResourceModel->getRelatedQueries($currentQueryId); + } + return $queryIds; + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/Client/ClientFactory.php b/app/code/Magento/AdvancedSearch/Model/Client/ClientFactory.php new file mode 100644 index 0000000000000..05eb513d68399 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/Client/ClientFactory.php @@ -0,0 +1,47 @@ +objectManager = $objectManager; + $this->clientClass = $clientClass; + } + + /** + * Return search client + * + * @param array $options + * @return ClientInterface + */ + public function create(array $options = []) + { + return $this->objectManager->create( + $this->clientClass, + ['options' => $options] + ); + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/Client/ClientFactoryInterface.php b/app/code/Magento/AdvancedSearch/Model/Client/ClientFactoryInterface.php new file mode 100644 index 0000000000000..acacbb1c093fa --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/Client/ClientFactoryInterface.php @@ -0,0 +1,22 @@ +objectManager = $objectManager; + $this->clientFactoryPool = $clientFactories; + $this->clientOptionsPool = $clientOptions; + $this->engineResolver = $engineResolver; + } + + /** + * Returns configured search engine + * + * @return string + * @since 100.1.0 + */ + public function getCurrentEngine() + { + return $this->engineResolver->getCurrentSearchEngine(); + } + + /** + * Create client instance + * + * @param string $engine + * @param array $data + * @return ClientInterface + * @since 100.1.0 + */ + public function create($engine = '', array $data = []) + { + $engine = $engine ?: $this->getCurrentEngine(); + + if (!isset($this->clientFactoryPool[$engine])) { + throw new \LogicException( + 'There is no such client factory: ' . $engine + ); + } + $factoryClass = $this->clientFactoryPool[$engine]; + $factory = $this->objectManager->create($factoryClass); + if (!($factory instanceof ClientFactoryInterface)) { + throw new \InvalidArgumentException( + 'Client factory must implement \Magento\AdvancedSearch\Model\Client\ClientFactoryInterface' + ); + } + + $optionsClass = $this->clientOptionsPool[$engine]; + $clientOptions = $this->objectManager->create($optionsClass); + if (!($clientOptions instanceof ClientOptionsInterface)) { + throw new \InvalidArgumentException( + 'Client options must implement \Magento\AdvancedSearch\Model\Client\ClientInterface' + ); + } + + $client = $factory->create($clientOptions->prepareClientOptions($data)); + + return $client; + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/DataProvider/Suggestions.php b/app/code/Magento/AdvancedSearch/Model/DataProvider/Suggestions.php new file mode 100644 index 0000000000000..c76811c854514 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/DataProvider/Suggestions.php @@ -0,0 +1,28 @@ +clientOptions = $clientOptions; + $this->engineResolver = $engineResolver; + } + + /** + * Invalidate indexer on customer group save + * + * @param Group $subject + * @param \Closure $proceed + * @param AbstractModel $group + * @return Attribute + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function aroundSave( + Group $subject, + \Closure $proceed, + AbstractModel $group + ) { + $needInvalidation = + ($this->engineResolver->getCurrentSearchEngine() != EngineResolver::CATALOG_SEARCH_MYSQL_ENGINE) + && ($group->isObjectNew() || $group->dataHasChangedFor('tax_class_id')); + $result = $proceed($group); + if ($needInvalidation) { + $this->indexerRegistry->get(Fulltext::INDEXER_ID)->invalidate(); + } + return $result; + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/Recommendations/DataProvider.php b/app/code/Magento/AdvancedSearch/Model/Recommendations/DataProvider.php new file mode 100644 index 0000000000000..546983bb5e5a8 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/Recommendations/DataProvider.php @@ -0,0 +1,149 @@ +scopeConfig = $scopeConfig; + $this->searchLayer = $layerResolver->get(); + $this->recommendationsFactory = $recommendationsFactory; + $this->queryResultFactory = $queryResultFactory; + } + + /** + * @return bool + */ + public function isResultsCountEnabled() + { + return (bool)$this->scopeConfig->getValue( + self::CONFIG_RESULTS_COUNT_ENABLED, + ScopeInterface::SCOPE_STORE + ); + } + + /** + * {@inheritdoc} + */ + public function getItems(QueryInterface $query) + { + $recommendations = []; + + if (!$this->isSearchRecommendationsEnabled()) { + return []; + } + + foreach ($this->getSearchRecommendations($query) as $recommendation) { + $recommendations[] = $this->queryResultFactory->create( + [ + 'queryText' => $recommendation['query_text'], + 'resultsCount' => $recommendation['num_results'], + ] + ); + } + return $recommendations; + } + + /** + * @param QueryInterface $query + * @return array + */ + private function getSearchRecommendations(\Magento\Search\Model\QueryInterface $query) + { + $recommendations = []; + + if ($this->isSearchRecommendationsEnabled()) { + $productCollection = $this->searchLayer->getProductCollection(); + $params = ['store_id' => $productCollection->getStoreId()]; + + /** @var \Magento\AdvancedSearch\Model\ResourceModel\Recommendations $recommendationsResource */ + $recommendationsResource = $this->recommendationsFactory->create(); + $recommendations = $recommendationsResource->getRecommendationsByQuery( + $query->getQueryText(), + $params, + $this->getSearchRecommendationsCount() + ); + } + + return $recommendations; + } + + /** + * @return bool + */ + private function isSearchRecommendationsEnabled() + { + return (bool)$this->scopeConfig->getValue( + self::CONFIG_IS_ENABLED, + ScopeInterface::SCOPE_STORE + ); + } + + /** + * @return int + */ + private function getSearchRecommendationsCount() + { + return (int)$this->scopeConfig->getValue( + self::CONFIG_RESULTS_COUNT, + ScopeInterface::SCOPE_STORE + ); + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/Recommendations/SaveSearchQueryRelationsObserver.php b/app/code/Magento/AdvancedSearch/Model/Recommendations/SaveSearchQueryRelationsObserver.php new file mode 100644 index 0000000000000..5f5d4122d97a5 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/Recommendations/SaveSearchQueryRelationsObserver.php @@ -0,0 +1,48 @@ +recommendationsFactory = $recommendationsFactory; + } + + /** + * Save search query relations after save search query + * + * @param EventObserver $observer + * @return void + */ + public function execute(EventObserver $observer) + { + $searchQueryModel = $observer->getEvent()->getDataObject(); + $queryId = $searchQueryModel->getId(); + $relatedQueries = $searchQueryModel->getSelectedQueriesGrid(); + + if (strlen($relatedQueries) == 0) { + $relatedQueries = []; + } else { + $relatedQueries = explode('&', $relatedQueries); + } + + $this->recommendationsFactory->create()->saveRelatedQueries($queryId, $relatedQueries); + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php new file mode 100644 index 0000000000000..c2379e9dff062 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php @@ -0,0 +1,189 @@ +storeManager = $storeManager; + $this->metadataPool = $metadataPool; + $this->tableResolver = $tableResolver ?: ObjectManager::getInstance()->get(TableResolver::class); + } + + /** + * Implementation of abstract construct + * @return void + * @since 100.1.0 + */ + protected function _construct() + { + } + + /** + * Return array of price data per customer and website by products + * + * @param null|array $productIds + * @return array + * @since 100.1.0 + */ + protected function _getCatalogProductPriceData($productIds = null) + { + $connection = $this->getConnection(); + + $select = $connection->select()->from( + $this->getTable('catalog_product_index_price'), + ['entity_id', 'customer_group_id', 'website_id', 'min_price'] + ); + + if ($productIds) { + $select->where('entity_id IN (?)', $productIds); + } + + $result = []; + foreach ($connection->fetchAll($select) as $row) { + $result[$row['website_id']][$row['entity_id']][$row['customer_group_id']] = round($row['min_price'], 2); + } + + return $result; + } + + /** + * Retrieve price data for product + * + * @param null|array $productIds + * @param int $storeId + * @return array + * @since 100.1.0 + */ + public function getPriceIndexData($productIds, $storeId) + { + $priceProductsIndexData = $this->_getCatalogProductPriceData($productIds); + + $websiteId = $this->storeManager->getStore($storeId)->getWebsiteId(); + if (!isset($priceProductsIndexData[$websiteId])) { + return []; + } + + return $priceProductsIndexData[$websiteId]; + } + + /** + * Prepare system index data for products. + * + * @param int $storeId + * @param null|array $productIds + * @return array + * @since 100.1.0 + */ + public function getCategoryProductIndexData($storeId = null, $productIds = null) + { + $connection = $this->getConnection(); + + $catalogCategoryProductDimension = new Dimension(\Magento\Store\Model\Store::ENTITY, $storeId); + + $catalogCategoryProductTableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); + + $select = $connection->select()->from( + [$catalogCategoryProductTableName], + ['category_id', 'product_id', 'position', 'store_id'] + )->where( + 'store_id = ?', + $storeId + ); + + if ($productIds) { + $select->where('product_id IN (?)', $productIds); + } + + $result = []; + foreach ($connection->fetchAll($select) as $row) { + $result[$row['product_id']][$row['category_id']] = $row['position']; + } + + return $result; + } + + /** + * Retrieve moved categories product ids + * + * @param int $categoryId + * @return array + * @since 100.1.0 + */ + public function getMovedCategoryProductIds($categoryId) + { + $connection = $this->getConnection(); + + $identifierField = $this->metadataPool->getMetadata(CategoryInterface::class)->getIdentifierField(); + + $select = $connection->select()->distinct()->from( + ['c_p' => $this->getTable('catalog_category_product')], + ['product_id'] + )->join( + ['c_e' => $this->getTable('catalog_category_entity')], + 'c_p.category_id = c_e.' . $identifierField, + [] + )->where( + $connection->quoteInto('c_e.path LIKE ?', '%/' . $categoryId . '/%') + )->orWhere( + 'c_p.category_id = ?', + $categoryId + ); + + return $connection->fetchCol($select); + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Recommendations.php b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Recommendations.php new file mode 100644 index 0000000000000..c19c1d67d81f7 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Recommendations.php @@ -0,0 +1,227 @@ + + * @api + * @since 100.0.2 + */ +class Recommendations extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb +{ + + /** + * Search query model + * + * @var \Magento\Search\Model\Query + */ + protected $_searchQueryModel; + + /** + * Construct + * + * @param \Magento\Framework\Model\ResourceModel\Db\Context $context + * @param \Magento\Search\Model\QueryFactory $queryFactory + * @param string $connectionName + */ + public function __construct( + \Magento\Framework\Model\ResourceModel\Db\Context $context, + \Magento\Search\Model\QueryFactory $queryFactory, + $connectionName = null + ) { + parent::__construct($context, $connectionName); + $this->_searchQueryModel = $queryFactory->create(); + } + + /** + * Init main table + * + * @return void + */ + protected function _construct() + { + $this->_init('catalogsearch_recommendations', 'id'); + } + + /** + * Save search relations + * + * @param int $queryId + * @param array $relatedQueries + * @return $this + */ + public function saveRelatedQueries($queryId, $relatedQueries = []) + { + $connection = $this->getConnection(); + $whereOr = []; + if (count($relatedQueries) > 0) { + $whereOr[] = implode( + ' AND ', + [ + $connection->quoteInto('query_id=?', $queryId), + $connection->quoteInto('relation_id NOT IN(?)', $relatedQueries) + ] + ); + $whereOr[] = implode( + ' AND ', + [ + $connection->quoteInto('relation_id = ?', $queryId), + $connection->quoteInto('query_id NOT IN(?)', $relatedQueries) + ] + ); + } else { + $whereOr[] = $connection->quoteInto('query_id = ?', $queryId); + $whereOr[] = $connection->quoteInto('relation_id = ?', $queryId); + } + $whereCond = '(' . implode(') OR (', $whereOr) . ')'; + $connection->delete($this->getMainTable(), $whereCond); + + $existsRelatedQueries = $this->getRelatedQueries($queryId); + $neededRelatedQueries = array_diff($relatedQueries, $existsRelatedQueries); + foreach ($neededRelatedQueries as $relationId) { + $connection->insert($this->getMainTable(), ["query_id" => $queryId, "relation_id" => $relationId]); + } + return $this; + } + + /** + * Retrieve related search queries + * + * @param int|array $queryId + * @param bool $limit + * @param bool $order + * @return array + */ + public function getRelatedQueries($queryId, $limit = false, $order = false) + { + $collection = $this->_searchQueryModel->getResourceCollection(); + $connection = $this->getConnection(); + + $queryIdCond = $connection->quoteInto('main_table.query_id IN (?)', $queryId); + + $collection->getSelect()->join( + ['sr' => $collection->getTable('catalogsearch_recommendations')], + '(sr.query_id=main_table.query_id OR sr.relation_id=main_table.query_id) AND ' . $queryIdCond + )->reset( + \Magento\Framework\DB\Select::COLUMNS + )->columns( + [ + 'rel_id' => $connection->getCheckSql( + 'main_table.query_id=sr.query_id', + 'sr.relation_id', + 'sr.query_id' + ), + ] + ); + if (!empty($limit)) { + $collection->getSelect()->limit($limit); + } + if (!empty($order)) { + $collection->getSelect()->order($order); + } + + $queryIds = $connection->fetchCol($collection->getSelect()); + return $queryIds; + } + + /** + * Retrieve related search queries by single query + * + * @param string $query + * @param array $params + * @param int $searchRecommendationsCount + * @return array + */ + public function getRecommendationsByQuery($query, $params, $searchRecommendationsCount) + { + $this->_searchQueryModel->loadByQueryText($query); + + if (isset($params['store_id'])) { + $this->_searchQueryModel->setStoreId($params['store_id']); + } + $relatedQueriesIds = $this->loadByQuery($query, $searchRecommendationsCount); + $relatedQueries = []; + if (count($relatedQueriesIds)) { + $connection = $this->getConnection(); + $mainTable = $this->_searchQueryModel->getResourceCollection()->getMainTable(); + $select = $connection->select()->from( + ['main_table' => $mainTable], + ['query_text', 'num_results'] + )->where( + 'query_id IN(?)', + $relatedQueriesIds + )->where( + 'num_results > 0' + ); + $relatedQueries = $connection->fetchAll($select); + } + + return $relatedQueries; + } + + /** + * Retrieve search terms which are started with $queryWords + * + * @param string $query + * @param int $searchRecommendationsCount + * @return array + */ + protected function loadByQuery($query, $searchRecommendationsCount) + { + $connection = $this->getConnection(); + $queryId = $this->_searchQueryModel->getId(); + $relatedQueries = $this->getRelatedQueries($queryId, $searchRecommendationsCount, 'num_results DESC'); + if ($searchRecommendationsCount - count($relatedQueries) < 1) { + return $relatedQueries; + } + + $queryWords = [$query]; + if (strpos($query, ' ') !== false) { + $queryWords = array_unique(array_merge($queryWords, explode(' ', $query))); + foreach ($queryWords as $key => $word) { + $queryWords[$key] = trim($word); + if (strlen($word) < 3) { + unset($queryWords[$key]); + } + } + } + + $likeCondition = []; + foreach ($queryWords as $word) { + $likeCondition[] = $connection->quoteInto('query_text LIKE ?', $word . '%'); + } + $likeCondition = implode(' OR ', $likeCondition); + + $select = $connection->select()->from( + $this->_searchQueryModel->getResource()->getMainTable(), + ['query_id'] + )->where( + new \Zend_Db_Expr($likeCondition) + )->where( + 'store_id=?', + $this->_searchQueryModel->getStoreId() + )->order( + 'num_results DESC' + )->limit( + $searchRecommendationsCount + 1 + ); + $ids = $connection->fetchCol($select); + + if (!is_array($ids)) { + $ids = []; + } + + $key = array_search($queryId, $ids); + if ($key !== false) { + unset($ids[$key]); + } + $ids = array_unique(array_merge($relatedQueries, $ids)); + $ids = array_slice($ids, 0, $searchRecommendationsCount); + return $ids; + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Search/Grid/Collection.php b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Search/Grid/Collection.php new file mode 100644 index 0000000000000..59263f308117c --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Search/Grid/Collection.php @@ -0,0 +1,80 @@ +_registryManager = $registry; + parent::__construct( + $entityFactory, + $logger, + $fetchStrategy, + $eventManager, + $storeManager, + $resourceHelper, + $connection, + $resource + ); + } + + /** + * Initialize select + * + * @return $this + */ + protected function _initSelect() + { + parent::_initSelect(); + $queryId = $this->getQuery()->getId(); + if ($queryId) { + $this->addFieldToFilter('query_id', ['nin' => $queryId]); + } + return $this; + } + + /** + * Retrieve a value from registry by a key + * + * @return \Magento\Search\Model\Query + */ + public function getQuery() + { + return $this->_registryManager->registry('current_catalog_search'); + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/SuggestedQueries.php b/app/code/Magento/AdvancedSearch/Model/SuggestedQueries.php new file mode 100644 index 0000000000000..60f76682fc164 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/SuggestedQueries.php @@ -0,0 +1,88 @@ +engineResolver = $engineResolver; + $this->objectManager = $objectManager; + $this->data = $data; + } + + /** + * {@inheritdoc} + */ + public function isResultsCountEnabled() + { + return $this->getDataProvider()->isResultsCountEnabled(); + } + + /** + * {@inheritdoc} + */ + public function getItems(QueryInterface $query) + { + return $this->getDataProvider()->getItems($query); + } + + /** + * Returns DataProvider for SuggestedQueries + * + * @return SuggestedQueriesInterface|SuggestedQueriesInterface[] + * @throws \Exception + */ + private function getDataProvider() + { + if (empty($this->dataProvider)) { + $currentEngine = $this->engineResolver->getCurrentSearchEngine(); + $this->dataProvider = $this->objectManager->create($this->data[$currentEngine]); + if (!$this->dataProvider instanceof SuggestedQueriesInterface) { + throw new \InvalidArgumentException( + 'Data provider must implement \Magento\AdvancedSearch\Model\SuggestedQueriesInterface' + ); + } + } + return $this->dataProvider; + } +} diff --git a/app/code/Magento/AdvancedSearch/Model/SuggestedQueriesInterface.php b/app/code/Magento/AdvancedSearch/Model/SuggestedQueriesInterface.php new file mode 100644 index 0000000000000..64ab45ceb145e --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Model/SuggestedQueriesInterface.php @@ -0,0 +1,42 @@ +dataProvider = $this->getMockBuilder(\Magento\AdvancedSearch\Model\SuggestedQueriesInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getItems', 'isResultsCountEnabled']) + ->getMockForAbstractClass(); + + $this->searchQuery = $this->getMockBuilder(\Magento\Search\Model\QueryInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getQueryText']) + ->getMockForAbstractClass(); + $this->queryFactory = $this->getMockBuilder(\Magento\Search\Model\QueryFactoryInterface::class) + ->disableOriginalConstructor() + ->setMethods(['get']) + ->getMockForAbstractClass(); + $this->queryFactory->expects($this->once()) + ->method('get') + ->will($this->returnValue($this->searchQuery)); + $this->context = $this->getMockBuilder(\Magento\Framework\View\Element\Template\Context::class) + ->disableOriginalConstructor() + ->getMock(); + $this->block = $this->getMockBuilder(\Magento\AdvancedSearch\Block\SearchData::class)->setConstructorArgs( + [ + $this->context, + $this->dataProvider, + $this->queryFactory, + 'Test Title', + [], + ] + ) + ->setMethods(['getUrl']) + ->getMockForAbstractClass(); + } + + public function testGetSuggestions() + { + $value = [1, 2, 3, 100500]; + + $this->dataProvider->expects($this->once()) + ->method('getItems') + ->with($this->searchQuery) + ->will($this->returnValue($value)); + $actualValue = $this->block->getItems(); + $this->assertEquals($value, $actualValue); + } + + public function testGetLink() + { + $searchQuery = 'Some test search query'; + $expectedResult = '?q=Some+test+search+query'; + $actualResult = $this->block->getLink($searchQuery); + $this->assertEquals($expectedResult, $actualResult); + } + + public function testIsShowResultsCount() + { + $value = 'qwertyasdfzxcv'; + $this->dataProvider->expects($this->once()) + ->method('isResultsCountEnabled') + ->will($this->returnValue($value)); + $this->assertEquals($value, $this->block->isShowResultsCount()); + } +} diff --git a/app/code/Magento/AdvancedSearch/Test/Unit/Controller/Adminhtml/Search/System/Config/TestConnectionTest.php b/app/code/Magento/AdvancedSearch/Test/Unit/Controller/Adminhtml/Search/System/Config/TestConnectionTest.php new file mode 100644 index 0000000000000..6215d79fc41ee --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Test/Unit/Controller/Adminhtml/Search/System/Config/TestConnectionTest.php @@ -0,0 +1,169 @@ +requestMock = $this->createPartialMock(\Magento\Framework\App\Request\Http::class, ['getParams']); + $responseMock = $this->createMock(\Magento\Framework\App\Response\Http::class); + + $context = $this->getMockBuilder(\Magento\Backend\App\Action\Context::class) + ->setMethods(['getRequest', 'getResponse', 'getMessageManager', 'getSession']) + ->setConstructorArgs( + $helper->getConstructArguments( + \Magento\Backend\App\Action\Context::class, + [ + 'request' => $this->requestMock + ] + ) + ) + ->getMock(); + $context->expects($this->once())->method('getRequest')->will($this->returnValue($this->requestMock)); + $context->expects($this->once())->method('getResponse')->will($this->returnValue($responseMock)); + + $this->clientResolverMock = $this->getMockBuilder(\Magento\AdvancedSearch\Model\Client\ClientResolver::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + + $this->clientMock = $this->createMock(\Magento\AdvancedSearch\Model\Client\ClientInterface::class); + + $this->resultJson = $this->getMockBuilder(\Magento\Framework\Controller\Result\Json::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->resultJsonFactory = $this->getMockBuilder(\Magento\Framework\Controller\Result\JsonFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + + $this->tagFilterMock = $this->getMockBuilder(\Magento\Framework\Filter\StripTags::class) + ->disableOriginalConstructor() + ->setMethods(['filter']) + ->getMock(); + + $this->controller = new TestConnection( + $context, + $this->clientResolverMock, + $this->resultJsonFactory, + $this->tagFilterMock + ); + } + + public function testExecuteEmptyEngine() + { + $this->requestMock->expects($this->once())->method('getParams') + ->will($this->returnValue(['engine' => ''])); + + $this->resultJsonFactory->expects($this->once())->method('create') + ->will($this->returnValue($this->resultJson)); + + $result = ['success' => false, 'errorMessage' => 'Missing search engine parameter.']; + + $this->resultJson->expects($this->once())->method('setData') + ->with($this->equalTo($result)); + + $this->controller->execute(); + } + + public function testExecute() + { + $this->requestMock->expects($this->once())->method('getParams') + ->will($this->returnValue(['engine' => 'engineName'])); + + $this->clientResolverMock->expects($this->once())->method('create') + ->with($this->equalTo('engineName')) + ->will($this->returnValue($this->clientMock)); + + $this->clientMock->expects($this->once())->method('testConnection') + ->will($this->returnValue(true)); + + $this->resultJsonFactory->expects($this->once())->method('create') + ->will($this->returnValue($this->resultJson)); + + $result = ['success' => true, 'errorMessage' => '']; + + $this->resultJson->expects($this->once())->method('setData') + ->with($this->equalTo($result)); + + $this->controller->execute(); + } + + public function testExecutePingFailed() + { + $this->requestMock->expects($this->once())->method('getParams') + ->will($this->returnValue(['engine' => 'engineName'])); + + $this->clientResolverMock->expects($this->once())->method('create') + ->with($this->equalTo('engineName')) + ->will($this->returnValue($this->clientMock)); + + $this->clientMock->expects($this->once())->method('testConnection') + ->will($this->returnValue(false)); + + $this->resultJsonFactory->expects($this->once())->method('create') + ->will($this->returnValue($this->resultJson)); + + $result = ['success' => false, 'errorMessage' => '']; + + $this->resultJson->expects($this->once())->method('setData') + ->with($this->equalTo($result)); + + $this->controller->execute(); + } +} diff --git a/app/code/Magento/AdvancedSearch/Test/Unit/Model/Client/ClientResolverTest.php b/app/code/Magento/AdvancedSearch/Test/Unit/Model/Client/ClientResolverTest.php new file mode 100644 index 0000000000000..0cad0a2e8301c --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Test/Unit/Model/Client/ClientResolverTest.php @@ -0,0 +1,108 @@ +engineResolverMock = $this->getMockBuilder(EngineResolverInterface::class) + ->getMockForAbstractClass(); + + $this->objectManager = $this->createMock(ObjectManagerInterface::class); + + $this->model = new ClientResolver( + $this->objectManager, + ['engineName' => 'engineFactoryClass'], + ['engineName' => 'engineOptionClass'], + $this->engineResolverMock + ); + } + + public function testCreate() + { + $this->engineResolverMock->expects($this->once())->method('getCurrentSearchEngine') + ->will($this->returnValue('engineName')); + + $factoryMock = $this->createMock(ClientFactoryInterface::class); + + $clientMock = $this->createMock(ClientInterface::class); + + $clientOptionsMock = $this->createMock(ClientOptionsInterface::class); + + $this->objectManager->expects($this->exactly(2))->method('create') + ->withConsecutive( + [$this->equalTo('engineFactoryClass')], + [$this->equalTo('engineOptionClass')] + ) + ->willReturnOnConsecutiveCalls( + $factoryMock, + $clientOptionsMock + ); + + $clientOptionsMock->expects($this->once())->method('prepareClientOptions') + ->with([]) + ->will($this->returnValue(['parameters'])); + + $factoryMock->expects($this->once())->method('create') + ->with($this->equalTo(['parameters'])) + ->will($this->returnValue($clientMock)); + + $result = $this->model->create(); + $this->assertInstanceOf(ClientInterface::class, $result); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testCreateExceptionThrown() + { + $this->objectManager->expects($this->once())->method('create') + ->with($this->equalTo('engineFactoryClass')) + ->will($this->returnValue('t')); + + $this->model->create('engineName'); + } + + /** + * @expectedException LogicException + */ + public function testCreateLogicException() + { + $this->model->create('input'); + } + + public function testGetCurrentEngine() + { + $this->engineResolverMock->expects($this->once())->method('getCurrentSearchEngine') + ->will($this->returnValue('engineName')); + + $this->assertEquals('engineName', $this->model->getCurrentEngine()); + } +} diff --git a/app/code/Magento/AdvancedSearch/Test/Unit/Model/Indexer/Fulltext/Plugin/CustomerGroupTest.php b/app/code/Magento/AdvancedSearch/Test/Unit/Model/Indexer/Fulltext/Plugin/CustomerGroupTest.php new file mode 100644 index 0000000000000..e6de135aab473 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Test/Unit/Model/Indexer/Fulltext/Plugin/CustomerGroupTest.php @@ -0,0 +1,131 @@ +subjectMock = $this->createMock(\Magento\Customer\Model\ResourceModel\Group::class); + $this->customerOptionsMock = $this->createMock( + \Magento\AdvancedSearch\Model\Client\ClientOptionsInterface::class + ); + $this->indexerMock = $this->getMockForAbstractClass( + \Magento\Framework\Indexer\IndexerInterface::class, + [], + '', + false, + false, + true, + ['getId', 'getState', '__wakeup'] + ); + $this->indexerRegistryMock = $this->createPartialMock( + \Magento\Framework\Indexer\IndexerRegistry::class, + ['get'] + ); + $this->engineResolverMock = $this->createPartialMock( + \Magento\Search\Model\EngineResolver::class, + ['getCurrentSearchEngine'] + ); + $this->model = new CustomerGroup( + $this->indexerRegistryMock, + $this->customerOptionsMock, + $this->engineResolverMock + ); + } + + /** + * @param string $searchEngine + * @param bool $isObjectNew + * @param bool $isTaxClassIdChanged + * @param int $invalidateCounter + * @return void + * @dataProvider aroundSaveDataProvider + */ + public function testAroundSave($searchEngine, $isObjectNew, $isTaxClassIdChanged, $invalidateCounter) + { + $this->engineResolverMock->expects($this->once()) + ->method('getCurrentSearchEngine') + ->will($this->returnValue($searchEngine)); + + $groupMock = $this->createPartialMock( + \Magento\Customer\Model\Group::class, + ['dataHasChangedFor', 'isObjectNew', '__wakeup'] + ); + $groupMock->expects($this->any())->method('isObjectNew')->will($this->returnValue($isObjectNew)); + $groupMock->expects($this->any()) + ->method('dataHasChangedFor') + ->with('tax_class_id') + ->will($this->returnValue($isTaxClassIdChanged)); + + $closureMock = function (\Magento\Customer\Model\Group $object) use ($groupMock) { + $this->assertEquals($object, $groupMock); + return $this->subjectMock; + }; + + $this->indexerMock->expects($this->exactly($invalidateCounter))->method('invalidate'); + $this->indexerRegistryMock->expects($this->exactly($invalidateCounter)) + ->method('get') + ->with(\Magento\CatalogSearch\Model\Indexer\Fulltext::INDEXER_ID) + ->will($this->returnValue($this->indexerMock)); + + $this->assertEquals( + $this->subjectMock, + $this->model->aroundSave($this->subjectMock, $closureMock, $groupMock) + ); + } + + /** + * @return array + */ + public function aroundSaveDataProvider() + { + return [ + ['mysql', false, false, 0], + ['mysql', false, true, 0], + ['mysql', true, false, 0], + ['mysql', true, true, 0], + ['custom', false, false, 0], + ['custom', false, true, 1], + ['custom', true, false, 1], + ['custom', true, true, 1], + ]; + } +} diff --git a/app/code/Magento/AdvancedSearch/Test/Unit/Model/ResourceModel/IndexTest.php b/app/code/Magento/AdvancedSearch/Test/Unit/Model/ResourceModel/IndexTest.php new file mode 100644 index 0000000000000..185e932406e5b --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Test/Unit/Model/ResourceModel/IndexTest.php @@ -0,0 +1,84 @@ +storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->resourceContextMock = $this->createMock(Context::class); + $this->resourceConnectionMock = $this->createMock(ResourceConnection::class); + $this->resourceContextMock->expects($this->any()) + ->method('getResources') + ->willReturn($this->resourceConnectionMock); + $this->adapterMock = $this->createMock(AdapterInterface::class); + $this->resourceConnectionMock->expects($this->any())->method('getConnection')->willReturn($this->adapterMock); + $this->metadataPoolMock = $this->createMock(MetadataPool::class); + + $this->model = new Index( + $this->resourceContextMock, + $this->storeManagerMock, + $this->metadataPoolMock + ); + } + + public function testGetPriceIndexDataUsesFrontendPriceIndexerTable() + { + $storeId = 1; + $storeMock = $this->createMock(StoreInterface::class); + $storeMock->expects($this->any())->method('getId')->willReturn($storeId); + $this->storeManagerMock->expects($this->once())->method('getStore')->with($storeId)->willReturn($storeMock); + + $selectMock = $this->createMock(Select::class); + $selectMock->expects($this->any())->method('from')->willReturnSelf(); + $selectMock->expects($this->any())->method('where')->willReturnSelf(); + $this->adapterMock->expects($this->once())->method('select')->willReturn($selectMock); + $this->adapterMock->expects($this->once())->method('fetchAll')->with($selectMock)->willReturn([]); + + $this->assertEmpty($this->model->getPriceIndexData([1], $storeId)); + } +} diff --git a/app/code/Magento/AdvancedSearch/Test/Unit/Model/SuggestedQueriesTest.php b/app/code/Magento/AdvancedSearch/Test/Unit/Model/SuggestedQueriesTest.php new file mode 100644 index 0000000000000..d349ed3e3ce93 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/Test/Unit/Model/SuggestedQueriesTest.php @@ -0,0 +1,131 @@ +engineResolverMock = $this->getMockBuilder(\Magento\Search\Model\EngineResolver::class) + ->setMethods(['getCurrentSearchEngine']) + ->disableOriginalConstructor() + ->getMock(); + $this->engineResolverMock->expects($this->any()) + ->method('getCurrentSearchEngine') + ->willReturn('my_engine'); + + /** + * @var \Magento\AdvancedSearch\Model\SuggestedQueriesInterface| + * \PHPUnit_Framework_MockObject_MockObject + */ + $suggestedQueriesMock = $this->createMock(\Magento\AdvancedSearch\Model\SuggestedQueriesInterface::class); + $suggestedQueriesMock->expects($this->any()) + ->method('isResultsCountEnabled') + ->willReturn(true); + $suggestedQueriesMock->expects($this->any()) + ->method('getItems') + ->willReturn([]); + + $this->objectManagerMock = $this->getMockBuilder(\Magento\Framework\ObjectManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->objectManagerMock->expects($this->any()) + ->method('create') + ->with('search_engine') + ->willReturn($suggestedQueriesMock); + + $this->objectManagerHelper = new ObjectManagerHelper($this); + $this->model = $this->objectManagerHelper->getObject( + \Magento\AdvancedSearch\Model\SuggestedQueries::class, + [ + 'engineResolver' => $this->engineResolverMock, + 'objectManager' => $this->objectManagerMock, + 'data' => ['my_engine' => 'search_engine'] + ] + ); + } + + /** + * Test isResultsCountEnabled method. + * + * @return void + */ + public function testIsResultsCountEnabled() + { + $result = $this->model->isResultsCountEnabled(); + $this->assertTrue($result); + } + + /** + * Test isResultsCountEnabled() method failure. + * @expectedException \InvalidArgumentException + * + * @return void + */ + public function testIsResultsCountEnabledException() + { + $objectManagerMock = $this->getMockBuilder(\Magento\Framework\ObjectManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $objectManagerMock->expects($this->once()) + ->method('create') + ->willReturn(null); + + $objectManagerHelper = new ObjectManagerHelper($this); + /* @var $model \Magento\AdvancedSearch\Model\SuggestedQueries */ + $model = $objectManagerHelper->getObject( + \Magento\AdvancedSearch\Model\SuggestedQueries::class, + [ + 'engineResolver' => $this->engineResolverMock, + 'objectManager' => $objectManagerMock, + 'data' => ['my_engine' => 'search_engine'] + ] + ); + $model->isResultsCountEnabled(); + } + + /** + * Test testGetItems() method. + * + * @return void + */ + public function testGetItems() + { + /** @var $queryInterfaceMock \Magento\Search\Model\QueryInterface */ + $queryInterfaceMock = $this->createMock(\Magento\Search\Model\QueryInterface::class); + $result = $this->model->getItems($queryInterfaceMock); + $this->assertEquals([], $result); + } +} diff --git a/app/code/Magento/AdvancedSearch/composer.json b/app/code/Magento/AdvancedSearch/composer.json new file mode 100644 index 0000000000000..a224a1001cd01 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/composer.json @@ -0,0 +1,30 @@ +{ + "name": "magento/module-advanced-search", + "description": "N/A", + "config": { + "sort-packages": true + }, + "require": { + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-search": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-search": "*", + "magento/module-store": "*", + "php": "~7.1.3||~7.2.0" + }, + "type": "magento2-module", + "license": [ + "proprietary" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\AdvancedSearch\\": "" + } + } +} diff --git a/app/code/Magento/AdvancedSearch/etc/adminhtml/events.xml b/app/code/Magento/AdvancedSearch/etc/adminhtml/events.xml new file mode 100644 index 0000000000000..b4d0f63a2bab4 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/etc/adminhtml/events.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/code/Magento/AdvancedSearch/etc/adminhtml/routes.xml b/app/code/Magento/AdvancedSearch/etc/adminhtml/routes.xml new file mode 100644 index 0000000000000..286d1537d40cc --- /dev/null +++ b/app/code/Magento/AdvancedSearch/etc/adminhtml/routes.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/AdvancedSearch/etc/adminhtml/system.xml b/app/code/Magento/AdvancedSearch/etc/adminhtml/system.xml new file mode 100644 index 0000000000000..fa7774f5cec1d --- /dev/null +++ b/app/code/Magento/AdvancedSearch/etc/adminhtml/system.xml @@ -0,0 +1,74 @@ + + + + +
+ + + + When you enable this option your site may slow down. + Magento\Config\Model\Config\Source\Yesno + + + + validate-digits + + 1 + + + + + Magento\Config\Model\Config\Source\Yesno + + 1 + + + + + + When you enable this option your site may slow down. + Magento\Config\Model\Config\Source\Yesno + + + + + 1 + + + + + Magento\Config\Model\Config\Source\Yesno + When you enable this option your site may slow down. + + 1 + + + + +
+
+
diff --git a/app/code/Magento/AdvancedSearch/etc/config.xml b/app/code/Magento/AdvancedSearch/etc/config.xml new file mode 100644 index 0000000000000..a4affbccdbc4e --- /dev/null +++ b/app/code/Magento/AdvancedSearch/etc/config.xml @@ -0,0 +1,21 @@ + + + + + + + 1 + 2 + 0 + 1 + 5 + 0 + + + + diff --git a/app/code/Magento/AdvancedSearch/etc/db_schema.xml b/app/code/Magento/AdvancedSearch/etc/db_schema.xml new file mode 100644 index 0000000000000..9fae40411098c --- /dev/null +++ b/app/code/Magento/AdvancedSearch/etc/db_schema.xml @@ -0,0 +1,27 @@ + + + +
+ + + + + + + + +
+
diff --git a/app/code/Magento/AdvancedSearch/etc/db_schema_whitelist.json b/app/code/Magento/AdvancedSearch/etc/db_schema_whitelist.json new file mode 100644 index 0000000000000..eaf7f3d616736 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/etc/db_schema_whitelist.json @@ -0,0 +1,14 @@ +{ + "catalogsearch_recommendations": { + "column": { + "id": true, + "query_id": true, + "relation_id": true + }, + "constraint": { + "PRIMARY": true, + "CATALOGSEARCH_RECOMMENDATIONS_QUERY_ID_SEARCH_QUERY_QUERY_ID": true, + "CATALOGSEARCH_RECOMMENDATIONS_RELATION_ID_SEARCH_QUERY_QUERY_ID": true + } + } +} \ No newline at end of file diff --git a/app/code/Magento/AdvancedSearch/etc/di.xml b/app/code/Magento/AdvancedSearch/etc/di.xml new file mode 100644 index 0000000000000..9ec75f56bbf7b --- /dev/null +++ b/app/code/Magento/AdvancedSearch/etc/di.xml @@ -0,0 +1,30 @@ + + + + + + Magento\AdvancedSearch\Model\Recommendations\DataProvider + Related search terms + + + + + Magento\AdvancedSearch\Model\SuggestedQueries + Did you mean + + + + + + Magento\AdvancedSearch\Model\DataProvider\Suggestions + + + + + diff --git a/app/code/Magento/AdvancedSearch/etc/module.xml b/app/code/Magento/AdvancedSearch/etc/module.xml new file mode 100644 index 0000000000000..cc0c97f43d542 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/etc/module.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/app/code/Magento/AdvancedSearch/i18n/en_US.csv b/app/code/Magento/AdvancedSearch/i18n/en_US.csv new file mode 100644 index 0000000000000..f8210d58888ce --- /dev/null +++ b/app/code/Magento/AdvancedSearch/i18n/en_US.csv @@ -0,0 +1,26 @@ +"Related Search Terms","Related Search Terms" +"Add New Search Term","Add New Search Term" +button_label,button_label +"Missing search engine parameter.","Missing search engine parameter." +"Successful! Test again?","Successful! Test again?" +"Connection failed! Test again?","Connection failed! Test again?" +"Enable Search Recommendations","Enable Search Recommendations" +"When you enable this option your site may slow down.","When you enable this option your site may slow down." +"Search Recommendations Count","Search Recommendations Count" +"Show Results Count for Each Recommendation","Show Results Count for Each Recommendation" +"Enable Search Suggestions","Enable Search Suggestions" +"Search Suggestions Count","Search Suggestions Count" +"Show Results Count for Each Suggestion","Show Results Count for Each Suggestion" +"Related search terms","Related search terms" +"Did you mean","Did you mean" +ID,ID +"Search Query","Search Query" +Store,Store +Results,Results +Uses,Uses +"Redirect URL","Redirect URL" +"Suggested Term","Suggested Term" +Yes,Yes +No,No +Action,Action +Edit,Edit diff --git a/app/code/Magento/AdvancedSearch/registration.php b/app/code/Magento/AdvancedSearch/registration.php new file mode 100644 index 0000000000000..c82ffa8e7e4d6 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/registration.php @@ -0,0 +1,9 @@ + + + + + + + + catalog_search_grid + Magento\AdvancedSearch\Model\ResourceModel\Search\Grid\Collection + name + ASC + 1 + 1 + + 1 + + + + + + */*/edit + + getId + + + + + + query_id + query_id_selected + checkbox + query_id_selected + + + + + + ID + query_id + col-id + col-id + + + + + Search Query + query_text + + + + + Store + store_id + store + 1 + 0 + + + + + Results + num_results + number + + + + + Uses + popularity + number + + + + + Redirect URL + redirect + + + + + Suggested Term + 1 + display_in_terms + options + + + 1 + Yes + + + 0 + No + + + + + + + Action + catalog + action + getId + 0 + 0 + + + Edit + + */*/edit + + id + + + + + + + + + diff --git a/app/code/Magento/AdvancedSearch/view/adminhtml/layout/catalog_search_edit.xml b/app/code/Magento/AdvancedSearch/view/adminhtml/layout/catalog_search_edit.xml new file mode 100644 index 0000000000000..5e6774b1b5c6b --- /dev/null +++ b/app/code/Magento/AdvancedSearch/view/adminhtml/layout/catalog_search_edit.xml @@ -0,0 +1,28 @@ + + + + + + + + + + search.edit.grid + getSelectedQueries + selected_queries_grid + selected_queries_grid + + + + edit_form + + + + + + diff --git a/app/code/Magento/AdvancedSearch/view/adminhtml/layout/catalog_search_relatedgrid.xml b/app/code/Magento/AdvancedSearch/view/adminhtml/layout/catalog_search_relatedgrid.xml new file mode 100644 index 0000000000000..4187ba9127369 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/view/adminhtml/layout/catalog_search_relatedgrid.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/app/code/Magento/AdvancedSearch/view/adminhtml/requirejs-config.js b/app/code/Magento/AdvancedSearch/view/adminhtml/requirejs-config.js new file mode 100644 index 0000000000000..80369c99b8995 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/view/adminhtml/requirejs-config.js @@ -0,0 +1,12 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +var config = { + map: { + '*': { + testConnection: 'Magento_AdvancedSearch/js/testconnection' + } + } +}; diff --git a/app/code/Magento/AdvancedSearch/view/adminhtml/templates/system/config/testconnection.phtml b/app/code/Magento/AdvancedSearch/view/adminhtml/templates/system/config/testconnection.phtml new file mode 100644 index 0000000000000..ae202cbfaf442 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/view/adminhtml/templates/system/config/testconnection.phtml @@ -0,0 +1,15 @@ + + diff --git a/app/code/Magento/AdvancedSearch/view/adminhtml/web/js/testconnection.js b/app/code/Magento/AdvancedSearch/view/adminhtml/web/js/testconnection.js new file mode 100644 index 0000000000000..e28f1b4d07d94 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/view/adminhtml/web/js/testconnection.js @@ -0,0 +1,73 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/** + * @api + */ +define([ + 'jquery', + 'Magento_Ui/js/modal/alert', + 'jquery/ui' +], function ($, alert) { + 'use strict'; + + $.widget('mage.testConnection', { + options: { + url: '', + elementId: '', + successText: '', + failedText: '', + fieldMapping: '' + }, + + /** + * Bind handlers to events + */ + _create: function () { + this._on({ + 'click': $.proxy(this._connect, this) + }); + }, + + /** + * Method triggers an AJAX request to check search engine connection + * @private + */ + _connect: function () { + var result = this.options.failedText, + element = $('#' + this.options.elementId), + self = this, + params = {}, + msg = ''; + + element.removeClass('success').addClass('fail'); + $.each($.parseJSON(this.options.fieldMapping), function (key, el) { + params[key] = $('#' + el).val(); + }); + $.ajax({ + url: this.options.url, + showLoader: true, + data: params + }).done(function (response) { + if (response.success) { + element.removeClass('fail').addClass('success'); + result = self.options.successText; + } else { + msg = response.errorMessage; + + if (msg) { + alert({ + content: msg + }); + } + } + }).always(function () { + $('#' + self.options.elementId + '_result').text(result); + }); + } + }); + + return $.mage.testConnection; +}); diff --git a/app/code/Magento/AdvancedSearch/view/frontend/layout/catalogsearch_result_index.xml b/app/code/Magento/AdvancedSearch/view/frontend/layout/catalogsearch_result_index.xml new file mode 100644 index 0000000000000..bf27fe73711e3 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/view/frontend/layout/catalogsearch_result_index.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/app/code/Magento/AdvancedSearch/view/frontend/templates/search_data.phtml b/app/code/Magento/AdvancedSearch/view/frontend/templates/search_data.phtml new file mode 100644 index 0000000000000..6e660555053a1 --- /dev/null +++ b/app/code/Magento/AdvancedSearch/view/frontend/templates/search_data.phtml @@ -0,0 +1,27 @@ + +getItems(); +if (count($data)):?> +
+
getTitle()) ?>
+ +
+ escapeHtml($additionalInfo->getQueryText()) ?> + isShowResultsCount()): ?> + getResultsCount() ?> + +
+ +
+ diff --git a/app/code/Magento/Amqp/LICENSE.txt b/app/code/Magento/Amqp/LICENSE.txt new file mode 100644 index 0000000000000..49525fd99da9c --- /dev/null +++ b/app/code/Magento/Amqp/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/app/code/Magento/Amqp/LICENSE_AFL.txt b/app/code/Magento/Amqp/LICENSE_AFL.txt new file mode 100644 index 0000000000000..f39d641b18a19 --- /dev/null +++ b/app/code/Magento/Amqp/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/app/code/Magento/Amqp/Model/Config.php b/app/code/Magento/Amqp/Model/Config.php new file mode 100644 index 0000000000000..9ec9780317a9f --- /dev/null +++ b/app/code/Magento/Amqp/Model/Config.php @@ -0,0 +1,16 @@ +getPublisherConfig(), + $this->getResponseQueueNameBuilder(), + $communicationConfig, + $rpcConnectionTimeout + ); + } + + /** + * Get publisher config. + * + * @return PublisherConfig + * + * @deprecated 100.2.0 + */ + private function getPublisherConfig() + { + return \Magento\Framework\App\ObjectManager::getInstance()->get(PublisherConfig::class); + } + + /** + * Get response queue name builder. + * + * @return ResponseQueueNameBuilder + * + * @deprecated 100.2.0 + */ + private function getResponseQueueNameBuilder() + { + return \Magento\Framework\App\ObjectManager::getInstance()->get(ResponseQueueNameBuilder::class); + } +} diff --git a/app/code/Magento/Amqp/Model/Queue.php b/app/code/Magento/Amqp/Model/Queue.php new file mode 100644 index 0000000000000..ffef398352bc7 --- /dev/null +++ b/app/code/Magento/Amqp/Model/Queue.php @@ -0,0 +1,16 @@ +get(TopologyConfig::class), + \Magento\Framework\App\ObjectManager::getInstance()->get(ExchangeInstaller::class), + \Magento\Framework\App\ObjectManager::getInstance()->get(ConfigPool::class), + \Magento\Framework\App\ObjectManager::getInstance()->get(QueueInstaller::class), + \Magento\Framework\App\ObjectManager::getInstance()->get(ConnectionTypeResolver::class), + $logger + ); + } +} diff --git a/app/code/Magento/Amqp/README.md b/app/code/Magento/Amqp/README.md new file mode 100644 index 0000000000000..a21624031d619 --- /dev/null +++ b/app/code/Magento/Amqp/README.md @@ -0,0 +1,3 @@ +# Amqp + +**Amqp** provides functionality to publish/consume messages with Amqp. diff --git a/app/code/Magento/Amqp/Setup/ConfigOptionsList.php b/app/code/Magento/Amqp/Setup/ConfigOptionsList.php new file mode 100644 index 0000000000000..7b857dc2bcc2d --- /dev/null +++ b/app/code/Magento/Amqp/Setup/ConfigOptionsList.php @@ -0,0 +1,228 @@ +connectionValidator = $connectionValidator; + } + + /** + * {@inheritdoc} + */ + public function getOptions() + { + return [ + new TextConfigOption( + self::INPUT_KEY_QUEUE_AMQP_HOST, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH_QUEUE_AMQP_HOST, + 'Amqp server host', + self::DEFAULT_AMQP_HOST + ), + new TextConfigOption( + self::INPUT_KEY_QUEUE_AMQP_PORT, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH_QUEUE_AMQP_PORT, + 'Amqp server port', + self::DEFAULT_AMQP_PORT + ), + new TextConfigOption( + self::INPUT_KEY_QUEUE_AMQP_USER, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH_QUEUE_AMQP_USER, + 'Amqp server username', + self::DEFAULT_AMQP_USER + ), + new TextConfigOption( + self::INPUT_KEY_QUEUE_AMQP_PASSWORD, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH_QUEUE_AMQP_PASSWORD, + 'Amqp server password', + self::DEFAULT_AMQP_PASSWORD + ), + new TextConfigOption( + self::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH_QUEUE_AMQP_VIRTUAL_HOST, + 'Amqp virtualhost', + self::DEFAULT_AMQP_VIRTUAL_HOST + ), + new TextConfigOption( + self::INPUT_KEY_QUEUE_AMQP_SSL, + TextConfigOption::FRONTEND_WIZARD_TEXT, + self::CONFIG_PATH_QUEUE_AMQP_SSL, + 'Amqp SSL', + self::DEFAULT_AMQP_SSL + ), + new TextConfigOption( + self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS, + TextConfigOption::FRONTEND_WIZARD_TEXTAREA, + self::CONFIG_PATH_QUEUE_AMQP_SSL_OPTIONS, + 'Amqp SSL Options (JSON)', + self::DEFAULT_AMQP_SSL + ), + ]; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function createConfig(array $data, DeploymentConfig $deploymentConfig) + { + $configData = new ConfigData(ConfigFilePool::APP_ENV); + + if (!$this->isDataEmpty($data, self::INPUT_KEY_QUEUE_AMQP_HOST)) { + $configData->set(self::CONFIG_PATH_QUEUE_AMQP_HOST, $data[self::INPUT_KEY_QUEUE_AMQP_HOST]); + if (!$this->isDataEmpty($data, self::INPUT_KEY_QUEUE_AMQP_PORT)) { + $configData->set(self::CONFIG_PATH_QUEUE_AMQP_PORT, $data[self::INPUT_KEY_QUEUE_AMQP_PORT]); + } + if (!$this->isDataEmpty($data, self::INPUT_KEY_QUEUE_AMQP_USER)) { + $configData->set(self::CONFIG_PATH_QUEUE_AMQP_USER, $data[self::INPUT_KEY_QUEUE_AMQP_USER]); + } + if (!$this->isDataEmpty($data, self::INPUT_KEY_QUEUE_AMQP_PASSWORD)) { + $configData->set(self::CONFIG_PATH_QUEUE_AMQP_PASSWORD, $data[self::INPUT_KEY_QUEUE_AMQP_PASSWORD]); + } + if (!$this->isDataEmpty($data, self::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST)) { + $configData->set( + self::CONFIG_PATH_QUEUE_AMQP_VIRTUAL_HOST, + $data[self::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST] + ); + } + if (!$this->isDataEmpty($data, self::INPUT_KEY_QUEUE_AMQP_SSL)) { + $configData->set(self::CONFIG_PATH_QUEUE_AMQP_SSL, $data[self::INPUT_KEY_QUEUE_AMQP_SSL]); + } + if (!$this->isDataEmpty( + $data, + self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS + )) { + $options = json_decode( + $data[self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS], + true + ); + if ($options !== null) { + $configData->set( + self::CONFIG_PATH_QUEUE_AMQP_SSL_OPTIONS, + $options + ); + } + } + } + + return [$configData]; + } + + /** + * {@inheritdoc} + */ + public function validate(array $options, DeploymentConfig $deploymentConfig) + { + $errors = []; + + if (isset($options[self::INPUT_KEY_QUEUE_AMQP_HOST]) + && $options[self::INPUT_KEY_QUEUE_AMQP_HOST] !== '') { + if (!$this->isDataEmpty( + $options, + self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS + )) { + $sslOptions = json_decode( + $options[self::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS], + true + ); + } else { + $sslOptions = null; + } + $isSslEnabled = !empty($options[self::INPUT_KEY_QUEUE_AMQP_SSL]) + && $options[self::INPUT_KEY_QUEUE_AMQP_SSL] !== 'false'; + + $result = $this->connectionValidator->isConnectionValid( + $options[self::INPUT_KEY_QUEUE_AMQP_HOST], + $options[self::INPUT_KEY_QUEUE_AMQP_PORT], + $options[self::INPUT_KEY_QUEUE_AMQP_USER], + $options[self::INPUT_KEY_QUEUE_AMQP_PASSWORD], + $options[self::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST], + $isSslEnabled, + $sslOptions + ); + + if (!$result) { + $errors[] = "Could not connect to the Amqp Server."; + } + } + + return $errors; + } + + /** + * Check if data ($data) with key ($key) is empty + * + * @param array $data + * @param string $key + * @return bool + */ + private function isDataEmpty(array $data, $key) + { + if (isset($data[$key]) && $data[$key] !== '') { + return false; + } + + return true; + } +} diff --git a/app/code/Magento/Amqp/Setup/ConnectionValidator.php b/app/code/Magento/Amqp/Setup/ConnectionValidator.php new file mode 100644 index 0000000000000..55a11286c7c43 --- /dev/null +++ b/app/code/Magento/Amqp/Setup/ConnectionValidator.php @@ -0,0 +1,72 @@ +connectionFactory = $connectionFactory; + } + + /** + * Checks Amqp Connection + * + * @param string $host + * @param string $port + * @param string $user + * @param string $password + * @param string $virtualHost + * @param bool $ssl + * @param string[]|null $sslOptions + * @return bool true if the connection succeeded, false otherwise + */ + public function isConnectionValid( + $host, + $port, + $user, + $password = '', + $virtualHost = '', + bool $ssl = false, + array $sslOptions = null + ) { + try { + $options = new FactoryOptions(); + $options->setHost($host); + $options->setPort($port); + $options->setUsername($user); + $options->setPassword($password); + $options->setVirtualHost($virtualHost); + $options->setSslEnabled($ssl); + + if ($sslOptions) { + $options->setSslOptions($sslOptions); + } + + $connection = $this->connectionFactory->create($options); + + $connection->close(); + } catch (\Exception $e) { + return false; + } + + return true; + } +} diff --git a/app/code/Magento/Amqp/Setup/Recurring.php b/app/code/Magento/Amqp/Setup/Recurring.php new file mode 100644 index 0000000000000..cc1951d84e3d0 --- /dev/null +++ b/app/code/Magento/Amqp/Setup/Recurring.php @@ -0,0 +1,38 @@ +topologyInstaller = $topologyInstaller; + } + + /** + * {@inheritdoc} + */ + public function install(SchemaSetupInterface $setup, ModuleContextInterface $context) + { + $this->topologyInstaller->install(); + } +} diff --git a/app/code/Magento/Amqp/Test/Unit/Setup/ConfigOptionsListTest.php b/app/code/Magento/Amqp/Test/Unit/Setup/ConfigOptionsListTest.php new file mode 100644 index 0000000000000..8db9ae73034a2 --- /dev/null +++ b/app/code/Magento/Amqp/Test/Unit/Setup/ConfigOptionsListTest.php @@ -0,0 +1,231 @@ +options = [ + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_HOST => 'host', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PORT => 'port', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_USER => 'user', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PASSWORD => 'password', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST => 'virtual host', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL => 'ssl', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS => '{"ssl_option":"test"}', + ]; + + $this->objectManager = new ObjectManager($this); + $this->connectionValidatorMock = $this->getMockBuilder(\Magento\Amqp\Setup\ConnectionValidator::class) + ->disableOriginalConstructor() + ->setMethods([]) + ->getMock(); + + $this->deploymentConfigMock = $this->getMockBuilder(\Magento\Framework\App\DeploymentConfig::class) + ->disableOriginalConstructor() + ->setMethods([]) + ->getMock(); + + $this->model = $this->objectManager->getObject( + \Magento\Amqp\Setup\ConfigOptionsList::class, + [ + 'connectionValidator' => $this->connectionValidatorMock, + ] + ); + } + + public function testGetOptions() + { + $expectedOptions = [ + new TextConfigOption( + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_HOST, + TextConfigOption::FRONTEND_WIZARD_TEXT, + ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_HOST, + 'Amqp server host', + ConfigOptionsList::DEFAULT_AMQP_HOST + ), + new TextConfigOption( + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PORT, + TextConfigOption::FRONTEND_WIZARD_TEXT, + ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_PORT, + 'Amqp server port', + ConfigOptionsList::DEFAULT_AMQP_PORT + ), + new TextConfigOption( + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_USER, + TextConfigOption::FRONTEND_WIZARD_TEXT, + ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_USER, + 'Amqp server username', + ConfigOptionsList::DEFAULT_AMQP_USER + ), + new TextConfigOption( + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PASSWORD, + TextConfigOption::FRONTEND_WIZARD_TEXT, + ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_PASSWORD, + 'Amqp server password', + ConfigOptionsList::DEFAULT_AMQP_PASSWORD + ), + new TextConfigOption( + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST, + TextConfigOption::FRONTEND_WIZARD_TEXT, + ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_VIRTUAL_HOST, + 'Amqp virtualhost', + ConfigOptionsList::DEFAULT_AMQP_VIRTUAL_HOST + ), + new TextConfigOption( + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL, + TextConfigOption::FRONTEND_WIZARD_TEXT, + ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_SSL, + 'Amqp SSL', + ConfigOptionsList::DEFAULT_AMQP_SSL + ), + new TextConfigOption( + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS, + TextConfigOption::FRONTEND_WIZARD_TEXTAREA, + ConfigOptionsList::CONFIG_PATH_QUEUE_AMQP_SSL_OPTIONS, + 'Amqp SSL Options (JSON)', + ConfigOptionsList::DEFAULT_AMQP_SSL + ), + ]; + $this->assertEquals($expectedOptions, $this->model->getOptions()); + } + + /** + * @param array $options + * @param array $expectedConfigData + * @dataProvider getCreateConfigDataProvider + */ + public function testCreateConfig($options, $expectedConfigData) + { + $result = $this->model->createConfig($options, $this->deploymentConfigMock); + $this->assertInternalType('array', $result); + $this->assertNotEmpty($result); + /** @var \Magento\Framework\Config\Data\ConfigData $configData */ + $configData = $result[0]; + $this->assertInstanceOf(\Magento\Framework\Config\Data\ConfigData::class, $configData); + $this->assertEquals($expectedConfigData, $configData->getData()); + } + + public function testValidateInvalidConnection() + { + $expectedResult = ['Could not connect to the Amqp Server.']; + $this->connectionValidatorMock->expects($this->once())->method('isConnectionValid')->willReturn(false); + $this->assertEquals($expectedResult, $this->model->validate($this->options, $this->deploymentConfigMock)); + } + + public function testValidateValidConnection() + { + $expectedResult = []; + $this->connectionValidatorMock->expects($this->once())->method('isConnectionValid')->willReturn(true); + $this->assertEquals($expectedResult, $this->model->validate($this->options, $this->deploymentConfigMock)); + } + + public function testValidateNoOptions() + { + $expectedResult = []; + $options = []; + $this->connectionValidatorMock->expects($this->never())->method('isConnectionValid'); + $this->assertEquals($expectedResult, $this->model->validate($options, $this->deploymentConfigMock)); + } + + public function getCreateConfigDataProvider() + { + return [ + [ + [ + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_HOST => 'host', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PORT => 'port', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_USER => 'user', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PASSWORD => 'password', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST => 'virtual host', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL => 'ssl', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS => '{"ssl_option":"test"}', + ], + ['queue' => + ['amqp' => + [ + 'host' => 'host', + 'port' => 'port', + 'user' => 'user', + 'password' => 'password', + 'virtualhost' => 'virtual host', + 'ssl' => 'ssl', + 'ssl_options' => ['ssl_option' => 'test'], + ] + ] + ], + ], + [ + [ + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_HOST => 'host', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PORT => ConfigOptionsList::DEFAULT_AMQP_PORT, + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_USER => 'user', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PASSWORD => 'password', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST => 'virtual host', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL => 'ssl', + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL_OPTIONS => '{"ssl_option":"test"}', + ], + ['queue' => + ['amqp' => + [ + 'host' => 'host', + 'port' => ConfigOptionsList::DEFAULT_AMQP_PORT, + 'user' => 'user', + 'password' => 'password', + 'virtualhost' => 'virtual host', + 'ssl' => 'ssl', + 'ssl_options' => ['ssl_option' => 'test'], + ] + ] + ], + ], + [ + [ + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_HOST => ConfigOptionsList::DEFAULT_AMQP_HOST, + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PORT => ConfigOptionsList::DEFAULT_AMQP_PORT, + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_USER => ConfigOptionsList::DEFAULT_AMQP_USER, + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_PASSWORD => ConfigOptionsList::DEFAULT_AMQP_PASSWORD, + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_VIRTUAL_HOST => + ConfigOptionsList::DEFAULT_AMQP_VIRTUAL_HOST, + ConfigOptionsList::INPUT_KEY_QUEUE_AMQP_SSL => ConfigOptionsList::DEFAULT_AMQP_SSL, + ], + [], + ], + ]; + } +} diff --git a/app/code/Magento/Amqp/composer.json b/app/code/Magento/Amqp/composer.json new file mode 100644 index 0000000000000..23130dfb01a4e --- /dev/null +++ b/app/code/Magento/Amqp/composer.json @@ -0,0 +1,25 @@ +{ + "name": "magento/module-amqp", + "description": "N/A", + "config": { + "sort-packages": true + }, + "require": { + "magento/framework": "*", + "magento/framework-amqp": "*", + "magento/framework-message-queue": "*", + "php": "~7.1.3||~7.2.0" + }, + "type": "magento2-module", + "license": [ + "proprietary" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\Amqp\\": "" + } + } +} diff --git a/app/code/Magento/Amqp/etc/di.xml b/app/code/Magento/Amqp/etc/di.xml new file mode 100644 index 0000000000000..920bb72261ef9 --- /dev/null +++ b/app/code/Magento/Amqp/etc/di.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + Magento\Framework\MessageQueue\Publisher + + + Magento\Framework\MessageQueue\Rpc\Publisher + + + + + + + + + Magento\Framework\MessageQueue\Bulk\Publisher + + + Magento\Framework\MessageQueue\Bulk\Rpc\Publisher + + + + + + + + Magento\Framework\Amqp\ConnectionTypeResolver + + + + + + + Magento\Framework\Amqp\ExchangeFactory + + + + + + + Magento\Framework\Amqp\Bulk\ExchangeFactory + + + + + + + Magento\Framework\Amqp\QueueFactory + + + + + + \Magento\Framework\Amqp\Bulk\Exchange + + + diff --git a/app/code/Magento/Amqp/etc/module.xml b/app/code/Magento/Amqp/etc/module.xml new file mode 100644 index 0000000000000..1768a9b121c81 --- /dev/null +++ b/app/code/Magento/Amqp/etc/module.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/code/Magento/Amqp/registration.php b/app/code/Magento/Amqp/registration.php new file mode 100644 index 0000000000000..17d8382c698e8 --- /dev/null +++ b/app/code/Magento/Amqp/registration.php @@ -0,0 +1,9 @@ +localeResolver = $localeResolver ?: + ObjectManager::getInstance()->get(\Magento\Framework\Locale\ResolverInterface::class); + parent::__construct($context, $data); + } + + /** + * Add current time zone to comment, properly translated according to locale * * @param \Magento\Framework\Data\Form\Element\AbstractElement $element * @return string @@ -19,7 +41,9 @@ class CollectionTimeLabel extends \Magento\Config\Block\System\Config\Form\Field public function render(\Magento\Framework\Data\Form\Element\AbstractElement $element) { $timeZoneCode = $this->_localeDate->getConfigTimezone(); - $getLongTimeZoneName = \IntlTimeZone::createTimeZone($timeZoneCode)->getDisplayName(); + $locale = $this->localeResolver->getLocale(); + $getLongTimeZoneName = \IntlTimeZone::createTimeZone($timeZoneCode) + ->getDisplayName(false, \IntlTimeZone::DISPLAY_LONG, $locale); $element->setData( 'comment', sprintf("%s (%s)", $getLongTimeZoneName, $timeZoneCode) diff --git a/app/code/Magento/Analytics/Model/Config/Backend/Enabled.php b/app/code/Magento/Analytics/Model/Config/Backend/Enabled.php index ac97f2a843e61..a5d885c80c3fc 100644 --- a/app/code/Magento/Analytics/Model/Config/Backend/Enabled.php +++ b/app/code/Magento/Analytics/Model/Config/Backend/Enabled.php @@ -67,12 +67,7 @@ public function afterSave() try { if ($this->isValueChanged()) { $enabled = $this->getData('value'); - - if ($enabled) { - $this->subscriptionHandler->processEnabled(); - } else { - $this->subscriptionHandler->processDisabled(); - } + $enabled ? $this->subscriptionHandler->processEnabled() : $this->subscriptionHandler->processDisabled(); } } catch (\Exception $e) { $this->_logger->error($e->getMessage()); diff --git a/app/code/Magento/Analytics/Setup/Patch/Data/PrepareInitialConfig.php b/app/code/Magento/Analytics/Setup/Patch/Data/PrepareInitialConfig.php index 609dadc511436..a352854a8b77b 100644 --- a/app/code/Magento/Analytics/Setup/Patch/Data/PrepareInitialConfig.php +++ b/app/code/Magento/Analytics/Setup/Patch/Data/PrepareInitialConfig.php @@ -7,10 +7,9 @@ namespace Magento\Analytics\Setup\Patch\Data; use Magento\Analytics\Model\Config\Backend\Enabled\SubscriptionHandler; -use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Initial patch. diff --git a/app/code/Magento/Analytics/Test/Unit/Block/Adminhtml/System/Config/CollectionTimeLabelTest.php b/app/code/Magento/Analytics/Test/Unit/Block/Adminhtml/System/Config/CollectionTimeLabelTest.php index a652cf6b3d548..462b3c909a7fd 100644 --- a/app/code/Magento/Analytics/Test/Unit/Block/Adminhtml/System/Config/CollectionTimeLabelTest.php +++ b/app/code/Magento/Analytics/Test/Unit/Block/Adminhtml/System/Config/CollectionTimeLabelTest.php @@ -9,6 +9,7 @@ use Magento\Backend\Block\Template\Context; use Magento\Framework\Data\Form; use Magento\Framework\Data\Form\Element\AbstractElement; +use Magento\Framework\Locale\ResolverInterface; use Magento\Framework\Stdlib\DateTime\TimezoneInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; @@ -34,6 +35,11 @@ class CollectionTimeLabelTest extends \PHPUnit\Framework\TestCase */ private $abstractElementMock; + /** + * @var ResolverInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $localeResolver; + protected function setUp() { $this->abstractElementMock = $this->getMockBuilder(AbstractElement::class) @@ -53,12 +59,17 @@ protected function setUp() $this->contextMock->expects($this->any()) ->method('getLocaleDate') ->willReturn($this->timeZoneMock); + $this->localeResolver = $this->getMockBuilder(ResolverInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getLocale']) + ->getMockForAbstractClass(); $objectManager = new ObjectManager($this); $this->collectionTimeLabel = $objectManager->getObject( CollectionTimeLabel::class, [ - 'context' => $this->contextMock + 'context' => $this->contextMock, + 'localeResolver' => $this->localeResolver ] ); } @@ -73,6 +84,9 @@ public function testRender() $this->abstractElementMock->expects($this->any()) ->method('getComment') ->willReturn('Eastern Standard Time (America/New_York)'); + $this->localeResolver->expects($this->once()) + ->method('getLocale') + ->willReturn('en_US'); $this->assertRegexp( "/Eastern Standard Time \(America\/New_York\)/", $this->collectionTimeLabel->render($this->abstractElementMock) diff --git a/app/code/Magento/Analytics/composer.json b/app/code/Magento/Analytics/composer.json index bdea53c445a34..88127f3c62a92 100644 --- a/app/code/Magento/Analytics/composer.json +++ b/app/code/Magento/Analytics/composer.json @@ -2,15 +2,14 @@ "name": "magento/module-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/module-backend": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-integration": "100.3.*", - "magento/module-store": "100.3.*", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/module-backend": "*", + "magento/module-config": "*", + "magento/module-integration": "*", + "magento/module-store": "*", + "magento/framework": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Analytics/etc/adminhtml/system.xml b/app/code/Magento/Analytics/etc/adminhtml/system.xml index 889517e629e04..4e21648d00ce8 100644 --- a/app/code/Magento/Analytics/etc/adminhtml/system.xml +++ b/app/code/Magento/Analytics/etc/adminhtml/system.xml @@ -17,14 +17,14 @@ Your reports can be accessed securely on a personalized dashboard outside of the admin panel by clicking on the "Go to Advanced Reporting" link.
For more information, see our terms and conditions.]]> - + Magento\Config\Model\Config\Source\Enabledisable Magento\Analytics\Model\Config\Backend\Enabled Magento\Analytics\Block\Adminhtml\System\Config\SubscriptionStatusLabel analytics/subscription/enabled - + Magento\Analytics\Block\Adminhtml\System\Config\CollectionTimeLabel Magento\Analytics\Model\Config\Backend\CollectionTime diff --git a/app/code/Magento/AsynchronousOperations/Api/BulkStatusInterface.php b/app/code/Magento/AsynchronousOperations/Api/BulkStatusInterface.php new file mode 100644 index 0000000000000..88db2d6d80141 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Api/BulkStatusInterface.php @@ -0,0 +1,36 @@ +urlBuilder = $urlBuilder; + } + + /** + * Retrieve button data + * + * @return array button configuration + */ + public function getButtonData() + { + return [ + 'label' => __('Back'), + 'on_click' => sprintf("location.href = '%s';", $this->urlBuilder->getUrl('*/')), + 'class' => 'back', + 'sort_order' => 10 + ]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Block/Adminhtml/Bulk/Details/DoneButton.php b/app/code/Magento/AsynchronousOperations/Block/Adminhtml/Bulk/Details/DoneButton.php new file mode 100644 index 0000000000000..5e30c20fd2fbf --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Block/Adminhtml/Bulk/Details/DoneButton.php @@ -0,0 +1,75 @@ +bulkStatus = $bulkStatus; + $this->request = $request; + } + + /** + * Retrieve button data + * + * @return array button configuration + */ + public function getButtonData() + { + $uuid = $this->request->getParam('uuid'); + $operationsCount = $this->bulkStatus->getOperationsCountByBulkIdAndStatus( + $uuid, + OperationInterface::STATUS_TYPE_RETRIABLY_FAILED + ); + $button = []; + + if ($this->request->getParam('buttons') && $operationsCount === 0) { + $button = [ + 'label' => __('Done'), + 'class' => 'primary', + 'sort_order' => 10, + 'on_click' => '', + 'data_attribute' => [ + 'mage-init' => [ + 'Magento_Ui/js/form/button-adapter' => [ + 'actions' => [ + [ + 'targetName' => 'notification_area.notification_area.modalContainer.modal', + 'actionName' => 'closeModal' + ], + ], + ], + ], + ], + ]; + } + + return $button; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Block/Adminhtml/Bulk/Details/RetryButton.php b/app/code/Magento/AsynchronousOperations/Block/Adminhtml/Bulk/Details/RetryButton.php new file mode 100644 index 0000000000000..9051f1ab9d428 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Block/Adminhtml/Bulk/Details/RetryButton.php @@ -0,0 +1,66 @@ +details = $details; + $this->request = $request; + $this->targetName = $targetName; + } + + /** + * {@inheritdoc} + */ + public function getButtonData() + { + $uuid = $this->request->getParam('uuid'); + $details = $this->details->getDetails($uuid); + if ($details['failed_retriable'] === 0) { + return []; + } + return [ + 'label' => __('Retry'), + 'class' => 'retry primary', + 'data_attribute' => [ + 'mage-init' => ['button' => ['event' => 'save']], + 'form-role' => 'save', + ], + ]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Bulk/Details.php b/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Bulk/Details.php new file mode 100644 index 0000000000000..9e9dbd3dd67c5 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Bulk/Details.php @@ -0,0 +1,71 @@ +resultPageFactory = $resultPageFactory; + $this->accessValidator = $accessValidator; + $this->menuId = $menuId; + parent::__construct($context); + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('Magento_Logging::system_magento_logging_bulk_operations') + && $this->accessValidator->isAllowed($this->getRequest()->getParam('uuid')); + } + + /** + * Bulk details action + * + * @return \Magento\Framework\View\Result\Page + */ + public function execute() + { + $bulkId = $this->getRequest()->getParam('uuid'); + $resultPage = $this->resultPageFactory->create(); + $resultPage->initLayout(); + $this->_setActiveMenu($this->menuId); + $resultPage->getConfig()->getTitle()->prepend(__('Action Details - #' . $bulkId)); + + return $resultPage; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Bulk/Retry.php b/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Bulk/Retry.php new file mode 100644 index 0000000000000..62e6b9ba4551b --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Bulk/Retry.php @@ -0,0 +1,99 @@ +bulkManagement = $bulkManagement; + $this->notificationManagement = $notificationManagement; + $this->accessValidator = $accessValidator; + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('Magento_Logging::system_magento_logging_bulk_operations') + && $this->accessValidator->isAllowed($this->getRequest()->getParam('uuid')); + } + + /** + * {@inheritdoc} + */ + public function execute() + { + $bulkUuid = $this->getRequest()->getParam('uuid'); + $isAjax = $this->getRequest()->getParam('isAjax'); + $operationsToRetry = (array)$this->getRequest()->getParam('operations_to_retry', []); + $errorCodes = []; + foreach ($operationsToRetry as $operationData) { + if (isset($operationData['error_code'])) { + $errorCodes[] = (int)$operationData['error_code']; + } + } + + $affectedOperations = $this->bulkManagement->retryBulk($bulkUuid, $errorCodes); + $this->notificationManagement->ignoreBulks([$bulkUuid]); + if (!$isAjax) { + $this->messageManager->addSuccessMessage( + __('%1 item(s) have been scheduled for update."', $affectedOperations) + ); + /** @var Redirect $result */ + $result = $this->resultRedirectFactory->create(); + $result->setPath('bulk/index'); + } else { + /** @var \Magento\Framework\Controller\Result\Json $result */ + $result = $this->resultFactory->create(ResultFactory::TYPE_JSON); + $result->setHttpResponseCode(200); + $response = new \Magento\Framework\DataObject(); + $response->setError(0); + + $result->setData($response); + } + return $result; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Index/Index.php b/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Index/Index.php new file mode 100644 index 0000000000000..5a2b9c0a34e64 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Index/Index.php @@ -0,0 +1,65 @@ +resultPageFactory = $resultPageFactory; + $this->menuId = $menuId; + parent::__construct($context); + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + return parent::_isAllowed(); + } + + /** + * Bulk list action + * + * @return \Magento\Framework\View\Result\Page + */ + public function execute() + { + $resultPage = $this->resultPageFactory->create(); + $resultPage->initLayout(); + $this->_setActiveMenu($this->menuId); + $resultPage->getConfig()->getTitle()->prepend(__('Bulk Actions Log')); + return $resultPage; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Notification/Dismiss.php b/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Notification/Dismiss.php new file mode 100644 index 0000000000000..0a71c130fb20a --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Controller/Adminhtml/Notification/Dismiss.php @@ -0,0 +1,65 @@ +notificationManagement = $notificationManagement; + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('Magento_Logging::system_magento_logging_bulk_operations'); + } + + /** + * {@inheritdoc} + */ + public function execute() + { + $bulkUuids = []; + foreach ((array)$this->getRequest()->getParam('uuid', []) as $bulkUuid) { + $bulkUuids[] = (string)$bulkUuid; + } + + $isAcknowledged = $this->notificationManagement->acknowledgeBulks($bulkUuids); + + /** @var \Magento\Framework\Controller\Result\Json $result */ + $result = $this->resultFactory->create(ResultFactory::TYPE_JSON); + if (!$isAcknowledged) { + $result->setHttpResponseCode(400); + } + + return $result; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Cron/BulkCleanup.php b/app/code/Magento/AsynchronousOperations/Cron/BulkCleanup.php new file mode 100644 index 0000000000000..7c8da3c1c4236 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Cron/BulkCleanup.php @@ -0,0 +1,77 @@ +metadataPool = $metadataPool; + $this->resourceConnection = $resourceConnection; + $this->dateTime = $dateTime; + $this->scopeConfig = $scopeConfig; + $this->date = $time; + } + + /** + * Remove all expired bulks and corresponding operations + * + * @return void + */ + public function execute() + { + $metadata = $this->metadataPool->getMetadata(BulkSummaryInterface::class); + $connection = $this->resourceConnection->getConnectionByName($metadata->getEntityConnectionName()); + + $bulkLifetime = 3600 * 24 * (int)$this->scopeConfig->getValue('system/bulk/lifetime'); + $maxBulkStartTime = $this->dateTime->formatDate($this->date->gmtTimestamp() - $bulkLifetime); + $connection->delete($metadata->getEntityTable(), ['start_time <= ?' => $maxBulkStartTime]); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/AccessValidator.php b/app/code/Magento/AsynchronousOperations/Model/AccessValidator.php new file mode 100644 index 0000000000000..a14ec254cf897 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/AccessValidator.php @@ -0,0 +1,60 @@ +userContext = $userContext; + $this->entityManager = $entityManager; + $this->bulkSummaryFactory = $bulkSummaryFactory; + } + + /** + * Check if content allowed for current user + * + * @param int $bulkUuid + * @return bool + */ + public function isAllowed($bulkUuid) + { + /** @var \Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface $bulkSummary */ + $bulkSummary = $this->entityManager->load( + $this->bulkSummaryFactory->create(), + $bulkUuid + ); + return $bulkSummary->getUserId() === $this->userContext->getUserId(); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/AsyncResponse.php b/app/code/Magento/AsynchronousOperations/Model/AsyncResponse.php new file mode 100644 index 0000000000000..02a2e8de1fa64 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/AsyncResponse.php @@ -0,0 +1,81 @@ +getData(self::BULK_UUID); + } + + /** + * @inheritDoc + */ + public function setBulkUuid($bulkUuid) + { + return $this->setData(self::BULK_UUID, $bulkUuid); + } + + /** + * @inheritDoc + */ + public function getRequestItems() + { + return $this->getData(self::REQUEST_ITEMS); + } + + /** + * @inheritDoc + */ + public function setRequestItems($requestItems) + { + return $this->setData(self::REQUEST_ITEMS, $requestItems); + } + + /** + * @inheritdoc + */ + public function setErrors($isErrors = false) + { + return $this->setData(self::ERRORS, $isErrors); + } + + /** + * @inheritdoc + */ + public function isErrors() + { + return $this->getData(self::ERRORS); + } + + /** + * @inheritDoc + */ + public function getExtensionAttributes() + { + return $this->getData(self::EXTENSION_ATTRIBUTES_KEY); + } + + /** + * @inheritDoc + */ + public function setExtensionAttributes( + \Magento\AsynchronousOperations\Api\Data\AsyncResponseExtensionInterface $extensionAttributes + ) { + return $this->setData(self::EXTENSION_ATTRIBUTES_KEY, $extensionAttributes); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkDescription/Options.php b/app/code/Magento/AsynchronousOperations/Model/BulkDescription/Options.php new file mode 100644 index 0000000000000..08e1a863b259d --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkDescription/Options.php @@ -0,0 +1,64 @@ +bulkCollectionFactory = $bulkCollection; + $this->userContext = $userContext; + } + + /** + * {@inheritdoc} + */ + public function toOptionArray() + { + /** @var \Magento\AsynchronousOperations\Model\ResourceModel\Bulk\Collection $collection */ + $collection = $this->bulkCollectionFactory->create(); + + /** @var \Magento\Framework\DB\Select $select */ + $select = $collection->getSelect(); + $select->reset(); + $select->distinct(true); + $select->from($collection->getMainTable(), ['description']); + $select->where('user_id = ?', $this->userContext->getUserId()); + + $options = []; + + /** @var BulkSummaryInterface $item */ + foreach ($collection->getItems() as $item) { + $options[] = [ + 'value' => $item->getDescription(), + 'label' => $item->getDescription() + ]; + } + return $options; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkManagement.php b/app/code/Magento/AsynchronousOperations/Model/BulkManagement.php new file mode 100644 index 0000000000000..4f086ce8ac2ca --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkManagement.php @@ -0,0 +1,205 @@ +entityManager = $entityManager; + $this->bulkSummaryFactory= $bulkSummaryFactory; + $this->operationCollectionFactory = $operationCollectionFactory; + $this->metadataPool = $metadataPool; + $this->resourceConnection = $resourceConnection; + $this->publisher = $publisher; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function scheduleBulk($bulkUuid, array $operations, $description, $userId = null) + { + $metadata = $this->metadataPool->getMetadata(BulkSummaryInterface::class); + $connection = $this->resourceConnection->getConnectionByName($metadata->getEntityConnectionName()); + // save bulk summary and related operations + $connection->beginTransaction(); + try { + /** @var \Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface $bulkSummary */ + $bulkSummary = $this->bulkSummaryFactory->create(); + $this->entityManager->load($bulkSummary, $bulkUuid); + $bulkSummary->setBulkId($bulkUuid); + $bulkSummary->setDescription($description); + $bulkSummary->setUserId($userId); + $bulkSummary->setOperationCount((int)$bulkSummary->getOperationCount() + count($operations)); + + $this->entityManager->save($bulkSummary); + + $connection->commit(); + } catch (\Exception $exception) { + $connection->rollBack(); + $this->logger->critical($exception->getMessage()); + return false; + } + $this->publishOperations($operations); + + return true; + } + + /** + * Retry bulk operations that failed due to given errors. + * + * @param string $bulkUuid target bulk UUID + * @param array $errorCodes list of corresponding error codes + * @return int number of affected bulk operations + */ + public function retryBulk($bulkUuid, array $errorCodes) + { + $metadata = $this->metadataPool->getMetadata(BulkSummaryInterface::class); + $connection = $this->resourceConnection->getConnectionByName($metadata->getEntityConnectionName()); + + /** @var \Magento\AsynchronousOperations\Model\ResourceModel\Operation[] $retriablyFailedOperations */ + $retriablyFailedOperations = $this->operationCollectionFactory->create() + ->addFieldToFilter('error_code', ['in' => $errorCodes]) + ->addFieldToFilter('bulk_uuid', ['eq' => $bulkUuid]) + ->getItems(); + + // remove corresponding operations from database (i.e. move them to 'open' status) + $connection->beginTransaction(); + try { + $operationIds = []; + $currentBatchSize = 0; + $maxBatchSize = 10000; + /** @var OperationInterface $operation */ + foreach ($retriablyFailedOperations as $operation) { + if ($currentBatchSize === $maxBatchSize) { + $connection->delete( + $this->resourceConnection->getTableName('magento_operation'), + $connection->quoteInto('id IN (?)', $operationIds) + ); + $operationIds = []; + $currentBatchSize = 0; + } + $currentBatchSize++; + $operationIds[] = $operation->getId(); + // Rescheduled operations must be put in queue in 'open' state (i.e. without ID) + $operation->setId(null); + } + // remove operations from the last batch + if (!empty($operationIds)) { + $connection->delete( + $this->resourceConnection->getTableName('magento_operation'), + $connection->quoteInto('id IN (?)', $operationIds) + ); + } + + $connection->commit(); + } catch (\Exception $exception) { + $connection->rollBack(); + $this->logger->critical($exception->getMessage()); + return 0; + } + $this->publishOperations($retriablyFailedOperations); + + return count($retriablyFailedOperations); + } + + /** + * Publish list of operations to the corresponding message queues. + * + * @param array $operations + * @return void + */ + private function publishOperations(array $operations) + { + $operationsByTopics = []; + foreach ($operations as $operation) { + $operationsByTopics[$operation->getTopicName()][] = $operation; + } + foreach ($operationsByTopics as $topicName => $operations) { + $this->publisher->publish($topicName, $operations); + } + } + + /** + * @inheritDoc + */ + public function deleteBulk($bulkId) + { + return $this->entityManager->delete( + $this->entityManager->load( + $this->bulkSummaryFactory->create(), + $bulkId + ) + ); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkNotificationManagement.php b/app/code/Magento/AsynchronousOperations/Model/BulkNotificationManagement.php new file mode 100644 index 0000000000000..2ba7f7fe5e3ee --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkNotificationManagement.php @@ -0,0 +1,150 @@ +metadataPool = $metadataPool; + $this->resourceConnection = $resourceConnection; + $this->bulkCollectionFactory = $bulkCollectionFactory; + $this->logger = $logger; + } + + /** + * Mark given bulks as acknowledged. + * Notifications related to these bulks will not appear in notification area. + * + * @param array $bulkUuids + * @return bool true on success or false on failure + */ + public function acknowledgeBulks(array $bulkUuids) + { + $metadata = $this->metadataPool->getMetadata(BulkSummaryInterface::class); + $connection = $this->resourceConnection->getConnectionByName($metadata->getEntityConnectionName()); + + try { + $connection->insertArray( + $this->resourceConnection->getTableName('magento_acknowledged_bulk'), + ['bulk_uuid'], + $bulkUuids + ); + } catch (\Exception $exception) { + $this->logger->critical($exception->getMessage()); + return false; + } + return true; + } + + /** + * Remove given bulks from acknowledged list. + * Notifications related to these bulks will appear again in notification area. + * + * @param array $bulkUuids + * @return bool true on success or false on failure + */ + public function ignoreBulks(array $bulkUuids) + { + $metadata = $this->metadataPool->getMetadata(BulkSummaryInterface::class); + $connection = $this->resourceConnection->getConnectionByName($metadata->getEntityConnectionName()); + + try { + $connection->delete( + $this->resourceConnection->getTableName('magento_acknowledged_bulk'), + ['bulk_uuid IN(?)' => $bulkUuids] + ); + } catch (\Exception $exception) { + $this->logger->critical($exception->getMessage()); + return false; + } + return true; + } + + /** + * Retrieve all bulks that were acknowledged by given user. + * + * @param int $userId + * @return BulkSummaryInterface[] + */ + public function getAcknowledgedBulksByUser($userId) + { + $bulks = $this->bulkCollectionFactory->create() + ->join( + ['acknowledged_bulk' => $this->resourceConnection->getTableName('magento_acknowledged_bulk')], + 'main_table.uuid = acknowledged_bulk.bulk_uuid', + [] + )->addFieldToFilter('user_id', $userId) + ->addOrder('start_time', Collection::SORT_ORDER_DESC) + ->getItems(); + + return $bulks; + } + + /** + * Retrieve all bulks that were not acknowledged by given user. + * + * @param int $userId + * @return BulkSummaryInterface[] + */ + public function getIgnoredBulksByUser($userId) + { + /** @var \Magento\AsynchronousOperations\Model\ResourceModel\Bulk\Collection $bulkCollection */ + $bulkCollection = $this->bulkCollectionFactory->create(); + $bulkCollection->getSelect()->joinLeft( + ['acknowledged_bulk' => $this->resourceConnection->getTableName('magento_acknowledged_bulk')], + 'main_table.uuid = acknowledged_bulk.bulk_uuid', + ['acknowledged_bulk.bulk_uuid'] + ); + $bulks = $bulkCollection->addFieldToFilter('user_id', $userId) + ->addFieldToFilter('acknowledged_bulk.bulk_uuid', ['null' => true]) + ->addOrder('start_time', Collection::SORT_ORDER_DESC) + ->getItems(); + + return $bulks; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkOperationsStatus.php b/app/code/Magento/AsynchronousOperations/Model/BulkOperationsStatus.php new file mode 100644 index 0000000000000..5fc164ec833d8 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkOperationsStatus.php @@ -0,0 +1,150 @@ +operationCollectionFactory = $operationCollection; + $this->bulkStatus = $bulkStatus; + $this->bulkDetailedFactory = $bulkDetailedFactory; + $this->bulkShortFactory = $bulkShortFactory; + $this->entityManager = $entityManager; + } + + /** + * @inheritDoc + */ + public function getFailedOperationsByBulkId($bulkUuid, $failureType = null) + { + return $this->bulkStatus->getFailedOperationsByBulkId($bulkUuid, $failureType); + } + + /** + * @inheritDoc + */ + public function getOperationsCountByBulkIdAndStatus($bulkUuid, $status) + { + return $this->bulkStatus->getOperationsCountByBulkIdAndStatus($bulkUuid, $status); + } + + /** + * @inheritDoc + */ + public function getBulksByUser($userId) + { + return $this->bulkStatus->getBulksByUser($userId); + } + + /** + * @inheritDoc + */ + public function getBulkStatus($bulkUuid) + { + return $this->bulkStatus->getBulkStatus($bulkUuid); + } + + /** + * @inheritDoc + */ + public function getBulkDetailedStatus($bulkUuid) + { + $bulkSummary = $this->bulkDetailedFactory->create(); + + /** @var \Magento\AsynchronousOperations\Api\Data\DetailedBulkOperationsStatusInterface $bulk */ + $bulk = $this->entityManager->load($bulkSummary, $bulkUuid); + + if ($bulk->getBulkId() === null) { + throw new NoSuchEntityException( + __( + 'Bulk uuid %bulkUuid not exist', + ['bulkUuid' => $bulkUuid] + ) + ); + } + $operations = $this->operationCollectionFactory->create()->addFieldToFilter('bulk_uuid', $bulkUuid)->getItems(); + $bulk->setOperationsList($operations); + + return $bulk; + } + + /** + * @inheritDoc + */ + public function getBulkShortStatus($bulkUuid) + { + $bulkSummary = $this->bulkShortFactory->create(); + + /** @var \Magento\AsynchronousOperations\Api\Data\BulkOperationsStatusInterface $bulk */ + $bulk = $this->entityManager->load($bulkSummary, $bulkUuid); + if ($bulk->getBulkId() === null) { + throw new NoSuchEntityException( + __( + 'Bulk uuid %bulkUuid not exist', + ['bulkUuid' => $bulkUuid] + ) + ); + } + $operations = $this->operationCollectionFactory->create()->addFieldToFilter('bulk_uuid', $bulkUuid)->getItems(); + $bulk->setOperationsList($operations); + + return $bulk; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkStatus.php b/app/code/Magento/AsynchronousOperations/Model/BulkStatus.php new file mode 100644 index 0000000000000..c37ae0d23dd25 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkStatus.php @@ -0,0 +1,200 @@ +operationCollectionFactory = $operationCollection; + $this->bulkCollectionFactory = $bulkCollection; + $this->resourceConnection = $resourceConnection; + $this->calculatedStatusSql = $calculatedStatusSql; + $this->metadataPool = $metadataPool; + } + + /** + * @inheritDoc + */ + public function getFailedOperationsByBulkId($bulkUuid, $failureType = null) + { + $failureCodes = $failureType + ? [$failureType] + : [ + OperationInterface::STATUS_TYPE_RETRIABLY_FAILED, + OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED + ]; + $operations = $this->operationCollectionFactory->create() + ->addFieldToFilter('bulk_uuid', $bulkUuid) + ->addFieldToFilter('status', $failureCodes) + ->getItems(); + return $operations; + } + + /** + * @inheritDoc + */ + public function getOperationsCountByBulkIdAndStatus($bulkUuid, $status) + { + if ($status === OperationInterface::STATUS_TYPE_OPEN) { + /** + * Total number of operations that has been scheduled within the given bulk + */ + $allOperationsQty = $this->getOperationCount($bulkUuid); + + /** + * Number of operations that has been processed (i.e. operations with any status but 'open') + */ + $allProcessedOperationsQty = (int)$this->operationCollectionFactory->create() + ->addFieldToFilter('bulk_uuid', $bulkUuid) + ->getSize(); + + return $allOperationsQty - $allProcessedOperationsQty; + } + + /** @var \Magento\AsynchronousOperations\Model\ResourceModel\Operation\Collection $collection */ + $collection = $this->operationCollectionFactory->create(); + return $collection->addFieldToFilter('bulk_uuid', $bulkUuid) + ->addFieldToFilter('status', $status) + ->getSize(); + } + + /** + * @inheritDoc + */ + public function getBulksByUser($userId) + { + /** @var ResourceModel\Bulk\Collection $collection */ + $collection = $this->bulkCollectionFactory->create(); + $operationTableName = $this->resourceConnection->getTableName('magento_operation'); + $statusesArray = [ + OperationInterface::STATUS_TYPE_RETRIABLY_FAILED, + OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED, + BulkSummaryInterface::NOT_STARTED, + OperationInterface::STATUS_TYPE_OPEN, + OperationInterface::STATUS_TYPE_COMPLETE + ]; + $select = $collection->getSelect(); + $select->columns(['status' => $this->calculatedStatusSql->get($operationTableName)]) + ->order(new \Zend_Db_Expr('FIELD(status, ' . implode(',', $statusesArray) . ')')); + $collection->addFieldToFilter('user_id', $userId) + ->addOrder('start_time'); + + return $collection->getItems(); + } + + /** + * @inheritDoc + */ + public function getBulkStatus($bulkUuid) + { + /** + * Number of operations that has been processed (i.e. operations with any status but 'open') + */ + $allProcessedOperationsQty = (int)$this->operationCollectionFactory->create() + ->addFieldToFilter('bulk_uuid', $bulkUuid) + ->getSize(); + + if ($allProcessedOperationsQty == 0) { + return BulkSummaryInterface::NOT_STARTED; + } + + /** + * Total number of operations that has been scheduled within the given bulk + */ + $allOperationsQty = $this->getOperationCount($bulkUuid); + + /** + * Number of operations that has not been started yet (i.e. operations with status 'open') + */ + $allOpenOperationsQty = $allOperationsQty - $allProcessedOperationsQty; + + /** + * Number of operations that has been completed successfully + */ + $allCompleteOperationsQty = $this->operationCollectionFactory->create() + ->addFieldToFilter('bulk_uuid', $bulkUuid)->addFieldToFilter( + 'status', + OperationInterface::STATUS_TYPE_COMPLETE + )->getSize(); + + if ($allCompleteOperationsQty == $allOperationsQty) { + return BulkSummaryInterface::FINISHED_SUCCESSFULLY; + } + + if ($allOpenOperationsQty > 0 && $allOpenOperationsQty !== $allOperationsQty) { + return BulkSummaryInterface::IN_PROGRESS; + } + + return BulkSummaryInterface::FINISHED_WITH_FAILURE; + } + + /** + * Get total number of operations that has been scheduled within the given bulk. + * + * @param string $bulkUuid + * @return int + */ + private function getOperationCount($bulkUuid) + { + $metadata = $this->metadataPool->getMetadata(BulkSummaryInterface::class); + $connection = $this->resourceConnection->getConnectionByName($metadata->getEntityConnectionName()); + + return (int)$connection->fetchOne( + $connection->select() + ->from($metadata->getEntityTable(), 'operation_count') + ->where('uuid = ?', $bulkUuid) + ); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkStatus/CalculatedStatusSql.php b/app/code/Magento/AsynchronousOperations/Model/BulkStatus/CalculatedStatusSql.php new file mode 100644 index 0000000000000..7bdf8a5b7d400 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkStatus/CalculatedStatusSql.php @@ -0,0 +1,32 @@ +getData(self::OPERATIONS_LIST); + } + + /** + * @inheritDoc + */ + public function setOperationsList($operationStatusList) + { + return $this->setData(self::OPERATIONS_LIST, $operationStatusList); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkStatus/Options.php b/app/code/Magento/AsynchronousOperations/Model/BulkStatus/Options.php new file mode 100644 index 0000000000000..47c317138ec64 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkStatus/Options.php @@ -0,0 +1,39 @@ + BulkSummaryInterface::NOT_STARTED, + 'label' => 'Not Started' + ], + [ + 'value' => BulkSummaryInterface::IN_PROGRESS, + 'label' => 'In Progress' + ], + [ + 'value' => BulkSummaryInterface::FINISHED_SUCCESSFULLY, + 'label' => 'Finished Successfully' + ], + [ + 'value' => BulkSummaryInterface::FINISHED_WITH_FAILURE, + 'label' => 'Finished with Failure' + ] + ]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkStatus/Short.php b/app/code/Magento/AsynchronousOperations/Model/BulkStatus/Short.php new file mode 100644 index 0000000000000..c6aa99e67202b --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkStatus/Short.php @@ -0,0 +1,31 @@ +getData(self::OPERATIONS_LIST); + } + + /** + * @inheritDoc + */ + public function setOperationsList($operationStatusList) + { + return $this->setData(self::OPERATIONS_LIST, $operationStatusList); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/BulkSummary.php b/app/code/Magento/AsynchronousOperations/Model/BulkSummary.php new file mode 100644 index 0000000000000..e99233d076957 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/BulkSummary.php @@ -0,0 +1,118 @@ +getData(self::BULK_ID); + } + + /** + * @inheritDoc + */ + public function setBulkId($bulkUuid) + { + return $this->setData(self::BULK_ID, $bulkUuid); + } + + /** + * @inheritDoc + */ + public function getDescription() + { + return $this->getData(self::DESCRIPTION); + } + + /** + * @inheritDoc + */ + public function setDescription($description) + { + return $this->setData(self::DESCRIPTION, $description); + } + + /** + * @inheritDoc + */ + public function getStartTime() + { + return $this->getData(self::START_TIME); + } + + /** + * @inheritDoc + */ + public function setStartTime($timestamp) + { + return $this->setData(self::START_TIME, $timestamp); + } + + /** + * @inheritDoc + */ + public function getUserId() + { + return $this->getData(self::USER_ID); + } + + /** + * @inheritDoc + */ + public function setUserId($userId) + { + return $this->setData(self::USER_ID, $userId); + } + + /** + * @inheritDoc + */ + public function getOperationCount() + { + return $this->getData(self::OPERATION_COUNT); + } + + /** + * @inheritDoc + */ + public function setOperationCount($operationCount) + { + return $this->setData(self::OPERATION_COUNT, $operationCount); + } + + /** + * Retrieve existing extension attributes object. + * + * @return \Magento\AsynchronousOperations\Api\Data\BulkSummaryExtensionInterface|null + */ + public function getExtensionAttributes() + { + return $this->getData(self::EXTENSION_ATTRIBUTES_KEY); + } + + /** + * Set an extension attributes object. + * + * @param \Magento\AsynchronousOperations\Api\Data\BulkSummaryExtensionInterface $extensionAttributes + * @return $this + */ + public function setExtensionAttributes( + \Magento\AsynchronousOperations\Api\Data\BulkSummaryExtensionInterface $extensionAttributes + ) { + return $this->setData(self::EXTENSION_ATTRIBUTES_KEY, $extensionAttributes); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ConfigInterface.php b/app/code/Magento/AsynchronousOperations/Model/ConfigInterface.php new file mode 100644 index 0000000000000..de0f89a71650a --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ConfigInterface.php @@ -0,0 +1,60 @@ + self::SYSTEM_TOPIC_NAME, + CommunicationConfig::TOPIC_IS_SYNCHRONOUS => false, + CommunicationConfig::TOPIC_REQUEST => OperationInterface::class, + CommunicationConfig::TOPIC_REQUEST_TYPE => CommunicationConfig::TOPIC_REQUEST_TYPE_CLASS, + CommunicationConfig::TOPIC_RESPONSE => null, + CommunicationConfig::TOPIC_HANDLERS => [], + ]; + /**#@-*/ + + /** + * Get array of generated topics name and related to this topic service class and methods + * + * @return array + */ + public function getServices(); + + /** + * Get topic name from webapi_async_config services config array by route url and http method + * + * @param string $routeUrl + * @param string $httpMethod GET|POST|PUT|DELETE + * @return string + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function getTopicName($routeUrl, $httpMethod); +} diff --git a/app/code/Magento/AsynchronousOperations/Model/Entity/BulkSummaryMapper.php b/app/code/Magento/AsynchronousOperations/Model/Entity/BulkSummaryMapper.php new file mode 100644 index 0000000000000..4abbde4c3602b --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/Entity/BulkSummaryMapper.php @@ -0,0 +1,65 @@ +metadataPool = $metadataPool; + $this->resourceConnection = $resourceConnection; + } + + /** + * {@inheritdoc} + */ + public function entityToDatabase($entityType, $data) + { + // workaround for delete/update operations that are currently using only primary key as identifier + if (!empty($data['uuid'])) { + $metadata = $this->metadataPool->getMetadata($entityType); + $connection = $this->resourceConnection->getConnectionByName($metadata->getEntityConnectionName()); + $select = $connection->select()->from($metadata->getEntityTable(), 'id')->where("uuid = ?", $data['uuid']); + $identifier = $connection->fetchOne($select); + if ($identifier !== false) { + $data['id'] = $identifier; + } + } + return $data; + } + + /** + * {@inheritdoc} + * @codeCoverageIgnore + */ + public function databaseToEntity($entityType, $data) + { + return $data; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ItemStatus.php b/app/code/Magento/AsynchronousOperations/Model/ItemStatus.php new file mode 100644 index 0000000000000..b493e0bb663d3 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ItemStatus.php @@ -0,0 +1,103 @@ +getData(self::ENTITY_ID); + } + + /** + * @inheritDoc + */ + public function setId($entityId) + { + return $this->setData(self::ENTITY_ID, $entityId); + } + + /** + * @inheritDoc + */ + public function getDataHash() + { + return $this->getData(self::DATA_HASH); + } + + /** + * @inheritDoc + */ + public function setDataHash($hash) + { + return $this->setData(self::DATA_HASH, $hash); + } + + /** + * @inheritDoc + */ + public function getStatus() + { + return $this->getData(self::STATUS); + } + + /** + * @inheritDoc + */ + public function setStatus($status = self::STATUS_ACCEPTED) + { + return $this->setData(self::STATUS, $status); + } + + /** + * @inheritDoc + */ + public function getErrorMessage() + { + return $this->getData(self::ERROR_MESSAGE); + } + + /** + * @inheritDoc + */ + public function setErrorMessage($errorMessage = null) + { + if ($errorMessage instanceof \Exception) { + $errorMessage = $errorMessage->getMessage(); + } + + return $this->setData(self::ERROR_MESSAGE, $errorMessage); + } + + /** + * @inheritDoc + */ + public function getErrorCode() + { + return $this->getData(self::ERROR_CODE); + } + + /** + * @inheritDoc + */ + public function setErrorCode($errorCode = null) + { + if ($errorCode instanceof \Exception) { + $errorCode = $errorCode->getCode(); + } + + return $this->setData(self::ERROR_CODE, (int) $errorCode); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/MassConsumer.php b/app/code/Magento/AsynchronousOperations/Model/MassConsumer.php new file mode 100644 index 0000000000000..28bc8141a8e99 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/MassConsumer.php @@ -0,0 +1,145 @@ +invoker = $invoker; + $this->resource = $resource; + $this->messageController = $messageController; + $this->configuration = $configuration; + $this->operationProcessor = $operationProcessorFactory->create([ + 'configuration' => $configuration + ]); + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public function process($maxNumberOfMessages = null) + { + $queue = $this->configuration->getQueue(); + + if (!isset($maxNumberOfMessages)) { + $queue->subscribe($this->getTransactionCallback($queue)); + } else { + $this->invoker->invoke($queue, $maxNumberOfMessages, $this->getTransactionCallback($queue)); + } + } + + /** + * Get transaction callback. This handles the case of async. + * + * @param QueueInterface $queue + * @return \Closure + */ + private function getTransactionCallback(QueueInterface $queue) + { + return function (EnvelopeInterface $message) use ($queue) { + /** @var LockInterface $lock */ + $lock = null; + try { + $topicName = $message->getProperties()['topic_name']; + $lock = $this->messageController->lock($message, $this->configuration->getConsumerName()); + + $allowedTopics = $this->configuration->getTopicNames(); + if (in_array($topicName, $allowedTopics)) { + $this->operationProcessor->process($message->getBody()); + } else { + $queue->reject($message); + return; + } + $queue->acknowledge($message); + } catch (MessageLockException $exception) { + $queue->acknowledge($message); + } catch (ConnectionLostException $e) { + if ($lock) { + $this->resource->getConnection() + ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); + } + } catch (NotFoundException $e) { + $queue->acknowledge($message); + $this->logger->warning($e->getMessage()); + } catch (\Exception $e) { + $queue->reject($message, false, $e->getMessage()); + if ($lock) { + $this->resource->getConnection() + ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); + } + } + }; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/MassPublisher.php b/app/code/Magento/AsynchronousOperations/Model/MassPublisher.php new file mode 100644 index 0000000000000..5f0f8e28f9fe6 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/MassPublisher.php @@ -0,0 +1,106 @@ +exchangeRepository = $exchangeRepository; + $this->envelopeFactory = $envelopeFactory; + $this->messageEncoder = $messageEncoder; + $this->messageValidator = $messageValidator; + $this->publisherConfig = $publisherConfig; + $this->messageIdGenerator = $messageIdGenerator; + } + + /** + * {@inheritdoc} + */ + public function publish($topicName, $data) + { + $envelopes = []; + foreach ($data as $message) { + $this->messageValidator->validate(AsyncConfig::SYSTEM_TOPIC_NAME, $message); + $message = $this->messageEncoder->encode(AsyncConfig::SYSTEM_TOPIC_NAME, $message); + $envelopes[] = $this->envelopeFactory->create( + [ + 'body' => $message, + 'properties' => [ + 'delivery_mode' => 2, + 'message_id' => $this->messageIdGenerator->generate($topicName), + ] + ] + ); + } + $publisher = $this->publisherConfig->getPublisher($topicName); + $connectionName = $publisher->getConnection()->getName(); + $exchange = $this->exchangeRepository->getByConnectionName($connectionName); + $exchange->enqueue($topicName, $envelopes); + return null; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/MassSchedule.php b/app/code/Magento/AsynchronousOperations/Model/MassSchedule.php new file mode 100644 index 0000000000000..2d516e82f4016 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/MassSchedule.php @@ -0,0 +1,155 @@ +identityService = $identityService; + $this->itemStatusInterfaceFactory = $itemStatusInterfaceFactory; + $this->asyncResponseFactory = $asyncResponseFactory; + $this->bulkManagement = $bulkManagement; + $this->logger = $logger; + $this->operationRepository = $operationRepository; + } + + /** + * Schedule new bulk operation based on the list of entities + * + * @param $topicName + * @param $entitiesArray + * @param null $groupId + * @param null $userId + * @return AsyncResponseInterface + * @throws BulkException + * @throws LocalizedException + */ + public function publishMass($topicName, array $entitiesArray, $groupId = null, $userId = null) + { + $bulkDescription = __('Topic %1', $topicName); + + if ($groupId == null) { + $groupId = $this->identityService->generateId(); + + /** create new bulk without operations */ + if (!$this->bulkManagement->scheduleBulk($groupId, [], $bulkDescription, $userId)) { + throw new LocalizedException( + __('Something went wrong while processing the request.') + ); + } + } + + $operations = []; + $requestItems = []; + $bulkException = new BulkException(); + foreach ($entitiesArray as $key => $entityParams) { + /** @var \Magento\AsynchronousOperations\Api\Data\ItemStatusInterface $requestItem */ + $requestItem = $this->itemStatusInterfaceFactory->create(); + + try { + $operations[] = $this->operationRepository->createByTopic($topicName, $entityParams, $groupId); + $requestItem->setId($key); + $requestItem->setStatus(ItemStatusInterface::STATUS_ACCEPTED); + $requestItems[] = $requestItem; + } catch (\Exception $exception) { + $this->logger->error($exception); + $requestItem->setId($key); + $requestItem->setStatus(ItemStatusInterface::STATUS_REJECTED); + $requestItem->setErrorMessage($exception); + $requestItem->setErrorCode($exception); + $requestItems[] = $requestItem; + $bulkException->addException(new LocalizedException( + __('Error processing %key element of input data', ['key' => $key]), + $exception + )); + } + } + + if (!$this->bulkManagement->scheduleBulk($groupId, $operations, $bulkDescription, $userId)) { + throw new LocalizedException( + __('Something went wrong while processing the request.') + ); + } + /** @var AsyncResponseInterface $asyncResponse */ + $asyncResponse = $this->asyncResponseFactory->create(); + $asyncResponse->setBulkUuid($groupId); + $asyncResponse->setRequestItems($requestItems); + + if ($bulkException->wasErrorAdded()) { + $asyncResponse->setErrors(true); + $bulkException->addData($asyncResponse); + throw $bulkException; + } else { + $asyncResponse->setErrors(false); + } + + return $asyncResponse; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/Operation.php b/app/code/Magento/AsynchronousOperations/Model/Operation.php new file mode 100644 index 0000000000000..70cc9f0ebc575 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/Operation.php @@ -0,0 +1,166 @@ +getData(self::ID); + } + + /** + * @inheritDoc + */ + public function setId($id) + { + return $this->setData(self::ID, $id); + } + + /** + * @inheritDoc + */ + public function getBulkUuid() + { + return $this->getData(self::BULK_ID); + } + + /** + * @inheritDoc + */ + public function setBulkUuid($bulkId) + { + return $this->setData(self::BULK_ID, $bulkId); + } + + /** + * @inheritDoc + */ + public function getTopicName() + { + return $this->getData(self::TOPIC_NAME); + } + + /** + * @inheritDoc + */ + public function setTopicName($topic) + { + return $this->setData(self::TOPIC_NAME, $topic); + } + + /** + * @inheritDoc + */ + public function getSerializedData() + { + return $this->getData(self::SERIALIZED_DATA); + } + + /** + * @inheritDoc + */ + public function setSerializedData($serializedData) + { + return $this->setData(self::SERIALIZED_DATA, $serializedData); + } + + /** + * @inheritDoc + */ + public function getResultSerializedData() + { + return $this->getData(self::RESULT_SERIALIZED_DATA); + } + + /** + * @inheritDoc + */ + public function setResultSerializedData($resultSerializedData) + { + return $this->setData(self::RESULT_SERIALIZED_DATA, $resultSerializedData); + } + + /** + * @inheritDoc + */ + public function getStatus() + { + return $this->getData(self::STATUS); + } + + /** + * @inheritDoc + */ + public function setStatus($status) + { + return $this->setData(self::STATUS, $status); + } + + /** + * @inheritDoc + */ + public function getResultMessage() + { + return $this->getData(self::RESULT_MESSAGE); + } + + /** + * @inheritDoc + */ + public function setResultMessage($resultMessage) + { + return $this->setData(self::RESULT_MESSAGE, $resultMessage); + } + + /** + * @inheritDoc + */ + public function getErrorCode() + { + return $this->getData(self::ERROR_CODE); + } + + /** + * @inheritDoc + */ + public function setErrorCode($errorCode) + { + return $this->setData(self::ERROR_CODE, $errorCode); + } + + /** + * Retrieve existing extension attributes object. + * + * @return \Magento\AsynchronousOperations\Api\Data\OperationExtensionInterface|null + */ + public function getExtensionAttributes() + { + return $this->getData(self::EXTENSION_ATTRIBUTES_KEY); + } + + /** + * Set an extension attributes object. + * + * @param \Magento\AsynchronousOperations\Api\Data\OperationExtensionInterface $extensionAttributes + * @return $this + */ + public function setExtensionAttributes( + \Magento\AsynchronousOperations\Api\Data\OperationExtensionInterface $extensionAttributes + ) { + return $this->setData(self::EXTENSION_ATTRIBUTES_KEY, $extensionAttributes); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/Operation/Details.php b/app/code/Magento/AsynchronousOperations/Model/Operation/Details.php new file mode 100644 index 0000000000000..d248f9c3e9276 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/Operation/Details.php @@ -0,0 +1,164 @@ + 'operations_successful', + OperationInterface::STATUS_TYPE_RETRIABLY_FAILED => 'failed_retriable', + OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED => 'failed_not_retriable', + OperationInterface::STATUS_TYPE_OPEN => 'open', + OperationInterface::STATUS_TYPE_REJECTED => 'rejected', + ]; + + /** + * Init dependencies. + * + * @param \Magento\Framework\Bulk\BulkStatusInterface $bulkStatus + * @param null $bulkUuid + */ + public function __construct( + BulkStatusInterface $bulkStatus, + $bulkUuid = null + ) { + $this->bulkStatus = $bulkStatus; + $this->bulkUuid = $bulkUuid; + } + + /** + * Collect operations statistics for the bulk + * + * @param string $bulkUuid + * @return array + */ + public function getDetails($bulkUuid) + { + $details = [ + 'operations_total' => 0, + 'operations_successful' => 0, + 'operations_failed' => 0, + 'failed_retriable' => 0, + 'failed_not_retriable' => 0, + 'rejected' => 0, + ]; + + if (array_key_exists($bulkUuid, $this->operationCache)) { + return $this->operationCache[$bulkUuid]; + } + + foreach ($this->statusMap as $statusCode => $readableKey) { + $details[$readableKey] = $this->bulkStatus->getOperationsCountByBulkIdAndStatus( + $bulkUuid, + $statusCode + ); + } + + $details['operations_total'] = array_sum($details); + $details['operations_failed'] = $details['failed_retriable'] + $details['failed_not_retriable']; + $this->operationCache[$bulkUuid] = $details; + + return $details; + } + + /** + * @inheritDoc + */ + public function getOperationsTotal() + { + $this->getDetails($this->bulkUuid); + + return $this->operationCache[$this->bulkUuid]['operations_total']; + } + + /** + * @inheritDoc + */ + public function getOpen() + { + $this->getDetails($this->bulkUuid); + $statusKey = $this->statusMap[OperationInterface::STATUS_TYPE_OPEN]; + + return $this->operationCache[$this->bulkUuid][$statusKey]; + } + + /** + * @inheritDoc + */ + public function getOperationsSuccessful() + { + $this->getDetails($this->bulkUuid); + $statusKey = $this->statusMap[OperationInterface::STATUS_TYPE_COMPLETE]; + + return $this->operationCache[$this->bulkUuid][$statusKey]; + } + + /** + * @inheritDoc + */ + public function getTotalFailed() + { + $this->getDetails($this->bulkUuid); + + return $this->operationCache[$this->bulkUuid]['operations_failed']; + } + + /** + * @inheritDoc + */ + public function getFailedNotRetriable() + { + $statusKey = $this->statusMap[OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED]; + + return $this->operationCache[$this->bulkUuid][$statusKey]; + } + + /** + * @inheritDoc + */ + public function getFailedRetriable() + { + $this->getDetails($this->bulkUuid); + $statusKey = $this->statusMap[OperationInterface::STATUS_TYPE_RETRIABLY_FAILED]; + + return $this->operationCache[$this->bulkUuid][$statusKey]; + } + + /** + * @inheritDoc + */ + public function getRejected() + { + $this->getDetails($this->bulkUuid); + $statusKey = $this->statusMap[OperationInterface::STATUS_TYPE_REJECTED]; + + return $this->operationCache[$this->bulkUuid][$statusKey]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/OperationList.php b/app/code/Magento/AsynchronousOperations/Model/OperationList.php new file mode 100644 index 0000000000000..7de62107415b0 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/OperationList.php @@ -0,0 +1,34 @@ +items = $items; + } + + /** + * @inheritdoc + */ + public function getItems() + { + return $this->items; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/OperationManagement.php b/app/code/Magento/AsynchronousOperations/Model/OperationManagement.php new file mode 100644 index 0000000000000..ce780a4ba858d --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/OperationManagement.php @@ -0,0 +1,76 @@ +entityManager = $entityManager; + $this->operationFactory = $operationFactory; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function changeOperationStatus( + $operationId, + $status, + $errorCode = null, + $message = null, + $data = null, + $resultData = null + ) { + try { + $operationEntity = $this->operationFactory->create(); + $this->entityManager->load($operationEntity, $operationId); + $operationEntity->setErrorCode($errorCode); + $operationEntity->setStatus($status); + $operationEntity->setResultMessage($message); + $operationEntity->setSerializedData($data); + $operationEntity->setResultSerializedData($resultData); + $operationEntity->setResultSerializedData($resultData); + $this->entityManager->save($operationEntity); + } catch (\Exception $exception) { + $this->logger->critical($exception->getMessage()); + return false; + } + return true; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/OperationProcessor.php b/app/code/Magento/AsynchronousOperations/Model/OperationProcessor.php new file mode 100644 index 0000000000000..6826c34fd35f0 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/OperationProcessor.php @@ -0,0 +1,227 @@ +messageValidator = $messageValidator; + $this->messageEncoder = $messageEncoder; + $this->configuration = $configuration; + $this->jsonHelper = $jsonHelper; + $this->operationManagement = $operationManagement; + $this->logger = $logger; + $this->serviceOutputProcessor = $serviceOutputProcessor; + $this->communicationConfig = $communicationConfig; + } + + /** + * Process topic-based encoded message + * + * @param string $encodedMessage + * @return void + */ + public function process(string $encodedMessage) + { + $operation = $this->messageEncoder->decode(AsyncConfig::SYSTEM_TOPIC_NAME, $encodedMessage); + $this->messageValidator->validate(AsyncConfig::SYSTEM_TOPIC_NAME, $operation); + + $status = OperationInterface::STATUS_TYPE_COMPLETE; + $errorCode = null; + $messages = []; + $topicName = $operation->getTopicName(); + $handlers = $this->configuration->getHandlers($topicName); + try { + $data = $this->jsonHelper->unserialize($operation->getSerializedData()); + $entityParams = $this->messageEncoder->decode($topicName, $data['meta_information']); + $this->messageValidator->validate($topicName, $entityParams); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + $status = OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED; + $errorCode = $e->getCode(); + $messages[] = $e->getMessage(); + } + + $outputData = null; + if ($errorCode === null) { + foreach ($handlers as $callback) { + $result = $this->executeHandler($callback, $entityParams); + $status = $result['status']; + $errorCode = $result['error_code']; + $messages = array_merge($messages, $result['messages']); + $outputData = $result['output_data']; + } + } + + if (isset($outputData)) { + try { + $communicationConfig = $this->communicationConfig->getTopic($topicName); + $asyncHandler = + $communicationConfig[CommunicationConfig::TOPIC_HANDLERS][AsyncConfig::DEFAULT_HANDLER_NAME]; + $serviceClass = $asyncHandler[CommunicationConfig::HANDLER_TYPE]; + $serviceMethod = $asyncHandler[CommunicationConfig::HANDLER_METHOD]; + $outputData = $this->serviceOutputProcessor->process( + $outputData, + $serviceClass, + $serviceMethod + ); + $outputData = $this->jsonHelper->serialize($outputData); + } catch (\Exception $e) { + $messages[] = $e->getMessage(); + } + } + + $serializedData = (isset($errorCode)) ? $operation->getSerializedData() : null; + $this->operationManagement->changeOperationStatus( + $operation->getId(), + $status, + $errorCode, + implode('; ', $messages), + $serializedData, + $outputData + ); + } + + /** + * Execute topic handler + * + * @param $callback + * @param $entityParams + * @return array + */ + private function executeHandler($callback, $entityParams) + { + $result = [ + 'status' => OperationInterface::STATUS_TYPE_COMPLETE, + 'error_code' => null, + 'messages' => [], + 'output_data' => null + ]; + try { + $result['output_data'] = call_user_func_array($callback, $entityParams); + $result['messages'][] = sprintf('Service execution success %s::%s', get_class($callback[0]), $callback[1]); + } catch (\Zend_Db_Adapter_Exception $e) { + $this->logger->critical($e->getMessage()); + if ($e instanceof LockWaitException + || $e instanceof DeadlockException + || $e instanceof ConnectionException + ) { + $result['status'] = OperationInterface::STATUS_TYPE_RETRIABLY_FAILED; + $result['error_code'] = $e->getCode(); + $result['messages'][] = __($e->getMessage()); + } else { + $result['status'] = OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED; + $result['error_code'] = $e->getCode(); + $result['messages'][] = + __('Sorry, something went wrong during product prices update. Please see log for details.'); + } + } catch (NoSuchEntityException $e) { + $this->logger->error($e->getMessage()); + $result['status'] = ($e instanceof TemporaryStateExceptionInterface) ? + OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED : + OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED; + $result['error_code'] = $e->getCode(); + $result['messages'][] = $e->getMessage(); + } catch (LocalizedException $e) { + $this->logger->error($e->getMessage()); + $result['status'] = OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED; + $result['error_code'] = $e->getCode(); + $result['messages'][] = $e->getMessage(); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + $result['status'] = OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED; + $result['error_code'] = $e->getCode(); + $result['messages'][] = $e->getMessage(); + } + return $result; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/OperationStatus.php b/app/code/Magento/AsynchronousOperations/Model/OperationStatus.php new file mode 100644 index 0000000000000..5c975bd1a9a45 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/OperationStatus.php @@ -0,0 +1,52 @@ +getData(OperationInterface::ID); + } + + /** + * @inheritDoc + */ + public function getStatus() + { + return $this->getData(OperationInterface::STATUS); + } + + /** + * @inheritDoc + */ + public function getResultMessage() + { + return $this->getData(OperationInterface::RESULT_MESSAGE); + } + + /** + * @inheritDoc + */ + public function getErrorCode() + { + return $this->getData(OperationInterface::ERROR_CODE); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Bulk.php b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Bulk.php new file mode 100644 index 0000000000000..d56d54359ee9a --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Bulk.php @@ -0,0 +1,23 @@ +_init('magento_bulk', 'uuid'); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Bulk/Collection.php b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Bulk/Collection.php new file mode 100644 index 0000000000000..6dd997c5333ff --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Bulk/Collection.php @@ -0,0 +1,27 @@ +_init( + \Magento\AsynchronousOperations\Model\BulkSummary::class, + \Magento\AsynchronousOperations\Model\ResourceModel\Bulk::class + ); + $this->setMainTable('magento_bulk'); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation.php b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation.php new file mode 100644 index 0000000000000..061d0917e7ab0 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation.php @@ -0,0 +1,23 @@ +_init('magento_operation', 'id'); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/CheckIfExists.php b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/CheckIfExists.php new file mode 100644 index 0000000000000..46c4e4bc1c2bc --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/CheckIfExists.php @@ -0,0 +1,28 @@ +_init( + \Magento\AsynchronousOperations\Model\Operation::class, + \Magento\AsynchronousOperations\Model\ResourceModel\Operation::class + ); + $this->setMainTable('magento_operation'); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/Create.php b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/Create.php new file mode 100644 index 0000000000000..ce2357c6b2b4f --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/Create.php @@ -0,0 +1,85 @@ +metadataPool = $metadataPool; + $this->typeResolver = $typeResolver; + $this->resourceConnection = $resourceConnection; + } + + /** + * Save all operations from the list in one query. + * + * @param object $entity + * @param array $arguments + * @return object + * @throws \Exception + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function execute($entity, $arguments = []) + { + $entityType = $this->typeResolver->resolve($entity); + $metadata = $this->metadataPool->getMetadata($entityType); + $connection = $this->resourceConnection->getConnection($metadata->getEntityConnectionName()); + try { + $connection->beginTransaction(); + $data = []; + foreach ($entity->getItems() as $operation) { + $data[] = $operation->getData(); + } + $connection->insertOnDuplicate( + $metadata->getEntityTable(), + $data, + [ + 'status', + 'error_code', + 'result_message', + ] + ); + $connection->commit(); + } catch (\Exception $e) { + $connection->rollBack(); + throw $e; + } + return $entity; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/OperationRepository.php b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/OperationRepository.php new file mode 100644 index 0000000000000..54e65cc3470dd --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/Operation/OperationRepository.php @@ -0,0 +1,98 @@ +operationFactory = $operationFactory; + $this->jsonSerializer = $jsonSerializer; + $this->messageEncoder = $messageEncoder; + $this->messageValidator = $messageValidator; + $this->entityManager = $entityManager; + } + + /** + * @param $topicName + * @param $entityParams + * @param $groupId + * @return mixed + */ + public function createByTopic($topicName, $entityParams, $groupId) + { + $this->messageValidator->validate($topicName, $entityParams); + $encodedMessage = $this->messageEncoder->encode($topicName, $entityParams); + + $serializedData = [ + 'entity_id' => null, + 'entity_link' => '', + 'meta_information' => $encodedMessage, + ]; + $data = [ + 'data' => [ + OperationInterface::BULK_ID => $groupId, + OperationInterface::TOPIC_NAME => $topicName, + OperationInterface::SERIALIZED_DATA => $this->jsonSerializer->serialize($serializedData), + OperationInterface::STATUS => OperationInterface::STATUS_TYPE_OPEN, + ], + ]; + + /** @var \Magento\AsynchronousOperations\Api\Data\OperationInterface $operation */ + $operation = $this->operationFactory->create($data); + return $this->entityManager->save($operation); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/ResourceModel/System/Message/Collection/Synchronized/Plugin.php b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/System/Message/Collection/Synchronized/Plugin.php new file mode 100644 index 0000000000000..8457a641ed9a9 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/ResourceModel/System/Message/Collection/Synchronized/Plugin.php @@ -0,0 +1,171 @@ +messageFactory = $messageFactory; + $this->bulkStatus = $bulkStatus; + $this->userContext = $userContext; + $this->operationDetails = $operationDetails; + $this->bulkNotificationManagement = $bulkNotificationManagement; + $this->authorization = $authorization; + $this->statusMapper = $statusMapper; + } + + /** + * Adding bulk related messages to notification area + * + * @param \Magento\AdminNotification\Model\ResourceModel\System\Message\Collection\Synchronized $collection + * @param array $result + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterToArray( + \Magento\AdminNotification\Model\ResourceModel\System\Message\Collection\Synchronized $collection, + $result + ) { + if (!$this->authorization->isAllowed('Magento_Logging::system_magento_logging_bulk_operations')) { + return $result; + } + $userId = $this->userContext->getUserId(); + $userBulks = $this->bulkStatus->getBulksByUser($userId); + $acknowledgedBulks = $this->getAcknowledgedBulksUuid( + $this->bulkNotificationManagement->getAcknowledgedBulksByUser($userId) + ); + $bulkMessages = []; + foreach ($userBulks as $bulk) { + $bulkUuid = $bulk->getBulkId(); + if (!in_array($bulkUuid, $acknowledgedBulks)) { + $details = $this->operationDetails->getDetails($bulkUuid); + $text = $this->getText($details); + $bulkStatus = $this->statusMapper->operationStatusToBulkSummaryStatus($bulk->getStatus()); + if ($bulkStatus === \Magento\Framework\Bulk\BulkSummaryInterface::IN_PROGRESS) { + $text = __('%1 item(s) are currently being updated.', $details['operations_total']) . $text; + } + $data = [ + 'data' => [ + 'text' => __('Task "%1": ', $bulk->getDescription()) . $text, + 'severity' => \Magento\Framework\Notification\MessageInterface::SEVERITY_MAJOR, + 'identity' => md5('bulk' . $bulkUuid), + 'uuid' => $bulkUuid, + 'status' => $bulkStatus, + 'created_at' => $bulk->getStartTime() + ] + ]; + $bulkMessages[] = $this->messageFactory->create($data)->toArray(); + } + } + + if (!empty($bulkMessages)) { + $result['totalRecords'] += count($bulkMessages); + $bulkMessages = array_slice($bulkMessages, 0, 5); + $result['items'] = array_merge($bulkMessages, $result['items']); + } + return $result; + } + + /** + * Get Bulk notification message + * + * @param array $operationDetails + * @return \Magento\Framework\Phrase|string + */ + private function getText($operationDetails) + { + if (0 == $operationDetails['operations_successful'] && 0 == $operationDetails['operations_failed']) { + return __('%1 item(s) have been scheduled for update.', $operationDetails['operations_total']); + } + + $summaryReport = ''; + if ($operationDetails['operations_successful'] > 0) { + $summaryReport .= __( + '%1 item(s) have been successfully updated.', + $operationDetails['operations_successful'] + ); + } + + if ($operationDetails['operations_failed'] > 0) { + $summaryReport .= '' + . __('%1 item(s) failed to update', $operationDetails['operations_failed']) + . ''; + } + return $summaryReport; + } + + /** + * Get array with acknowledgedBulksUuid + * + * @param array $acknowledgedBulks + * @return array + */ + private function getAcknowledgedBulksUuid($acknowledgedBulks) + { + $acknowledgedBulksArray = []; + foreach ($acknowledgedBulks as $bulk) { + $acknowledgedBulksArray[] = $bulk->getBulkId(); + } + return $acknowledgedBulksArray; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Model/StatusMapper.php b/app/code/Magento/AsynchronousOperations/Model/StatusMapper.php new file mode 100644 index 0000000000000..e5aee6d2f59fa --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Model/StatusMapper.php @@ -0,0 +1,64 @@ + BulkSummaryInterface::FINISHED_WITH_FAILURE, + OperationInterface::STATUS_TYPE_RETRIABLY_FAILED => BulkSummaryInterface::FINISHED_WITH_FAILURE, + OperationInterface::STATUS_TYPE_REJECTED => BulkSummaryInterface::FINISHED_WITH_FAILURE, + OperationInterface::STATUS_TYPE_COMPLETE => BulkSummaryInterface::FINISHED_SUCCESSFULLY, + OperationInterface::STATUS_TYPE_OPEN => BulkSummaryInterface::IN_PROGRESS, + BulkSummaryInterface::NOT_STARTED => BulkSummaryInterface::NOT_STARTED + ]; + + if (isset($statusMapping[$operationStatus])) { + return $statusMapping[$operationStatus]; + } + return null; + } + + /** + * Map bulk summary status to operation status + * + * @param int $bulkStatus + * @return int|null + */ + public function bulkSummaryStatusToOperationStatus($bulkStatus) + { + $statusMapping = [ + BulkSummaryInterface::FINISHED_WITH_FAILURE => [ + OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED, + OperationInterface::STATUS_TYPE_RETRIABLY_FAILED, + OperationInterface::STATUS_TYPE_REJECTED + ], + BulkSummaryInterface::FINISHED_SUCCESSFULLY => OperationInterface::STATUS_TYPE_COMPLETE, + BulkSummaryInterface::IN_PROGRESS => OperationInterface::STATUS_TYPE_OPEN, + BulkSummaryInterface::NOT_STARTED => BulkSummaryInterface::NOT_STARTED + ]; + + if (isset($statusMapping[$bulkStatus])) { + return $statusMapping[$bulkStatus]; + } + return null; + } +} diff --git a/app/code/Magento/AsynchronousOperations/README.md b/app/code/Magento/AsynchronousOperations/README.md new file mode 100644 index 0000000000000..fb7d53df1b81c --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/README.md @@ -0,0 +1 @@ + This component is designed to provide response for client who launched the bulk operation as soon as possible and postpone handling of operations moving them to background handler. \ No newline at end of file diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/BackButtonTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/BackButtonTest.php new file mode 100644 index 0000000000000..d6d4c5e7479f9 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/BackButtonTest.php @@ -0,0 +1,46 @@ +urlBuilderMock = $this->getMockBuilder(\Magento\Framework\UrlInterface::class) + ->getMock(); + $this->block = new \Magento\AsynchronousOperations\Block\Adminhtml\Bulk\Details\BackButton( + $this->urlBuilderMock + ); + } + + public function testGetButtonData() + { + $backUrl = 'back url'; + $expectedResult = [ + 'label' => __('Back'), + 'on_click' => sprintf("location.href = '%s';", $backUrl), + 'class' => 'back', + 'sort_order' => 10 + ]; + + $this->urlBuilderMock->expects($this->once()) + ->method('getUrl') + ->with('*/') + ->willReturn($backUrl); + + $this->assertEquals($expectedResult, $this->block->getButtonData()); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/DoneButtonTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/DoneButtonTest.php new file mode 100644 index 0000000000000..10c9d898aa526 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/DoneButtonTest.php @@ -0,0 +1,91 @@ +bulkStatusMock = $this->createMock(\Magento\Framework\Bulk\BulkStatusInterface::class); + $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\RequestInterface::class) + ->getMock(); + $this->block = new \Magento\AsynchronousOperations\Block\Adminhtml\Bulk\Details\DoneButton( + $this->bulkStatusMock, + $this->requestMock + ); + } + + /** + * @param int $failedCount + * @param int $buttonsParam + * @param array $expectedResult + * @dataProvider getButtonDataProvider + */ + public function testGetButtonData($failedCount, $buttonsParam, $expectedResult) + { + $uuid = 'some standard uuid string'; + $this->requestMock->expects($this->exactly(2)) + ->method('getParam') + ->withConsecutive(['uuid'], ['buttons']) + ->willReturnOnConsecutiveCalls($uuid, $buttonsParam); + $this->bulkStatusMock->expects($this->once()) + ->method('getOperationsCountByBulkIdAndStatus') + ->with($uuid, OperationInterface::STATUS_TYPE_RETRIABLY_FAILED) + ->willReturn($failedCount); + + $this->assertEquals($expectedResult, $this->block->getButtonData()); + } + + /** + * @return array + */ + public function getButtonDataProvider() + { + return [ + [1, 0, []], + [0, 0, []], + [ + 0, + 1, + [ + 'label' => __('Done'), + 'class' => 'primary', + 'sort_order' => 10, + 'on_click' => '', + 'data_attribute' => [ + 'mage-init' => [ + 'Magento_Ui/js/form/button-adapter' => [ + 'actions' => [ + [ + 'targetName' => 'notification_area.notification_area.modalContainer.modal', + 'actionName' => 'closeModal' + ], + ], + ], + ], + ], + ] + ], + ]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/RetryButtonTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/RetryButtonTest.php new file mode 100644 index 0000000000000..b7c154be09d89 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Block/Adminhtml/Bulk/Details/RetryButtonTest.php @@ -0,0 +1,79 @@ +detailsMock = $this->getMockBuilder(\Magento\AsynchronousOperations\Model\Operation\Details::class) + ->disableOriginalConstructor() + ->getMock(); + $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\RequestInterface::class) + ->getMock(); + $this->block = new \Magento\AsynchronousOperations\Block\Adminhtml\Bulk\Details\RetryButton( + $this->detailsMock, + $this->requestMock + ); + } + + /** + * @param int $failedCount + * @param array $expectedResult + * @dataProvider getButtonDataProvider + */ + public function testGetButtonData($failedCount, $expectedResult) + { + $details = ['failed_retriable' => $failedCount]; + $uuid = 'some standard uuid string'; + $this->requestMock->expects($this->once()) + ->method('getParam') + ->with('uuid') + ->willReturn($uuid); + $this->detailsMock->expects($this->once()) + ->method('getDetails') + ->with($uuid) + ->willReturn($details); + + $this->assertEquals($expectedResult, $this->block->getButtonData()); + } + + /** + * @return array + */ + public function getButtonDataProvider() + { + return [ + [0, []], + [ + 20, + [ + 'label' => __('Retry'), + 'class' => 'retry primary', + 'data_attribute' => [ + 'mage-init' => ['button' => ['event' => 'save']], + 'form-role' => 'save', + ], + ] + ], + ]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Bulk/DetailsTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Bulk/DetailsTest.php new file mode 100644 index 0000000000000..ecd33d355c223 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Bulk/DetailsTest.php @@ -0,0 +1,78 @@ +viewMock = $this->createMock(\Magento\Framework\App\ViewInterface::class); + $this->requestMock = $this->createMock(\Magento\Framework\App\RequestInterface::class); + $this->resultFactoryMock = $this->createMock(\Magento\Framework\View\Result\PageFactory::class); + $this->model = $objectManager->getObject( + \Magento\AsynchronousOperations\Controller\Adminhtml\Bulk\Details::class, + [ + 'request' => $this->requestMock, + 'resultPageFactory' => $this->resultFactoryMock, + 'view' => $this->viewMock, + + ] + ); + } + + public function testExecute() + { + $id = '42'; + $parameterName = 'uuid'; + $itemId = 'Magento_AsynchronousOperations::system_magento_logging_bulk_operations'; + $layoutMock = $this->createMock(\Magento\Framework\View\LayoutInterface::class); + + $blockMock = $this->createPartialMock( + \Magento\Framework\View\Element\BlockInterface::class, + ['setActive', 'getMenuModel', 'toHtml'] + ); + $menuModelMock = $this->createMock(\Magento\Backend\Model\Menu::class); + $this->viewMock->expects($this->once())->method('getLayout')->willReturn($layoutMock); + $layoutMock->expects($this->once())->method('getBlock')->willReturn($blockMock); + $blockMock->expects($this->once())->method('setActive')->with($itemId); + $blockMock->expects($this->once())->method('getMenuModel')->willReturn($menuModelMock); + $menuModelMock->expects($this->once())->method('getParentItems')->willReturn([]); + $pageMock = $this->createMock(\Magento\Framework\View\Result\Page::class); + $pageConfigMock = $this->createMock(\Magento\Framework\View\Page\Config::class); + $titleMock = $this->createMock(\Magento\Framework\View\Page\Title::class); + $this->resultFactoryMock->expects($this->once())->method('create')->willReturn($pageMock); + $this->requestMock->expects($this->once())->method('getParam')->with($parameterName)->willReturn($id); + $pageMock->expects($this->once())->method('getConfig')->willReturn($pageConfigMock); + $pageConfigMock->expects($this->once())->method('getTitle')->willReturn($titleMock); + $titleMock->expects($this->once())->method('prepend')->with($this->stringContains($id)); + $pageMock->expects($this->once())->method('initLayout'); + $this->assertEquals($pageMock, $this->model->execute()); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Bulk/RetryTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Bulk/RetryTest.php new file mode 100644 index 0000000000000..ab5e117b0225f --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Bulk/RetryTest.php @@ -0,0 +1,166 @@ +bulkManagementMock = $this->createMock(BulkManagement::class); + $this->notificationManagementMock = $this->createMock(BulkNotificationManagement::class); + $this->requestMock = $this->createMock(RequestInterface::class); + $this->resultFactoryMock = $this->createPartialMock(ResultFactory::class, ['create']); + $this->jsonResultMock = $this->createMock(Json::class); + + $this->resultRedirectFactoryMock = $this->createPartialMock(RedirectFactory::class, ['create']); + $this->resultRedirectMock = $this->createMock(Redirect::class); + + $this->model = $objectManager->getObject( + Retry::class, + [ + 'bulkManagement' => $this->bulkManagementMock, + 'notificationManagement' => $this->notificationManagementMock, + 'request' => $this->requestMock, + 'resultRedirectFactory' => $this->resultRedirectFactoryMock, + 'resultFactory' => $this->resultFactoryMock, + ] + ); + } + + public function testExecute() + { + $bulkUuid = '49da7406-1ec3-4100-95ae-9654c83a6801'; + $operationsToRetry = [ + [ + 'key' => 'value', + 'error_code' => 1111, + ], + [ + 'error_code' => 2222, + ], + [ + 'error_code' => '3333', + ], + ]; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturnMap([ + ['uuid', null, $bulkUuid], + ['operations_to_retry', [], $operationsToRetry], + ['isAjax', null, false], + ]); + + $this->bulkManagementMock->expects($this->once()) + ->method('retryBulk') + ->with($bulkUuid, [1111, 2222, 3333]); + + $this->notificationManagementMock->expects($this->once()) + ->method('ignoreBulks') + ->with([$bulkUuid]) + ->willReturn(true); + + $this->resultRedirectFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->resultRedirectMock); + + $this->resultRedirectMock->expects($this->once()) + ->method('setPath') + ->with('bulk/index'); + + $this->model->execute(); + } + + public function testExecuteReturnsJsonResultWhenRequestIsSentViaAjax() + { + $bulkUuid = '49da7406-1ec3-4100-95ae-9654c83a6801'; + $operationsToRetry = [ + [ + 'key' => 'value', + 'error_code' => 1111, + ], + ]; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturnMap([ + ['uuid', null, $bulkUuid], + ['operations_to_retry', [], $operationsToRetry], + ['isAjax', null, true], + ]); + + $this->bulkManagementMock->expects($this->once()) + ->method('retryBulk') + ->with($bulkUuid, [1111]); + + $this->notificationManagementMock->expects($this->once()) + ->method('ignoreBulks') + ->with([$bulkUuid]) + ->willReturn(true); + + $this->resultFactoryMock->expects($this->once()) + ->method('create') + ->with(ResultFactory::TYPE_JSON, []) + ->willReturn($this->jsonResultMock); + + $this->jsonResultMock->expects($this->once()) + ->method('setHttpResponseCode') + ->with(200); + + $this->assertEquals($this->jsonResultMock, $this->model->execute()); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Index/IndexTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Index/IndexTest.php new file mode 100644 index 0000000000000..98d51d8b0fd46 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Index/IndexTest.php @@ -0,0 +1,79 @@ +viewMock = $this->createMock(\Magento\Framework\App\ViewInterface::class); + $this->requestMock = $this->createMock(\Magento\Framework\App\RequestInterface::class); + $this->resultFactoryMock = $this->createMock(\Magento\Framework\View\Result\PageFactory::class); + + $this->model = $objectManager->getObject( + \Magento\AsynchronousOperations\Controller\Adminhtml\Index\Index::class, + [ + 'request' => $this->requestMock, + 'view' => $this->viewMock, + 'resultPageFactory' => $this->resultFactoryMock + + ] + ); + } + + public function testExecute() + { + $itemId = 'Magento_AsynchronousOperations::system_magento_logging_bulk_operations'; + $prependText = 'Bulk Actions Log'; + $layoutMock = $this->createMock(\Magento\Framework\View\LayoutInterface::class); + $menuModelMock = $this->createMock(\Magento\Backend\Model\Menu::class); + $pageMock = $this->createMock(\Magento\Framework\View\Result\Page::class); + $pageConfigMock = $this->createMock(\Magento\Framework\View\Page\Config::class); + $titleMock = $this->createMock(\Magento\Framework\View\Page\Title::class); + $this->resultFactoryMock->expects($this->once())->method('create')->willReturn($pageMock); + + $blockMock = $this->createPartialMock( + \Magento\Framework\View\Element\BlockInterface::class, + ['setActive', 'getMenuModel', 'toHtml'] + ); + + $this->viewMock->expects($this->once())->method('getLayout')->willReturn($layoutMock); + $layoutMock->expects($this->once())->method('getBlock')->willReturn($blockMock); + $blockMock->expects($this->once())->method('setActive')->with($itemId); + $blockMock->expects($this->once())->method('getMenuModel')->willReturn($menuModelMock); + $menuModelMock->expects($this->once())->method('getParentItems')->willReturn([]); + + $pageMock->expects($this->once())->method('getConfig')->willReturn($pageConfigMock); + $pageConfigMock->expects($this->once())->method('getTitle')->willReturn($titleMock); + $titleMock->expects($this->once())->method('prepend')->with($prependText); + $pageMock->expects($this->once())->method('initLayout'); + $this->model->execute(); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Notification/DismissTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Notification/DismissTest.php new file mode 100644 index 0000000000000..8ec1ec4609aa9 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Adminhtml/Notification/DismissTest.php @@ -0,0 +1,108 @@ +notificationManagementMock = $this->createMock(BulkNotificationManagement::class); + $this->requestMock = $this->createMock(RequestInterface::class); + $this->resultFactoryMock = $this->createPartialMock(ResultFactory::class, ['create']); + + $this->jsonResultMock = $this->createMock(Json::class); + + $this->model = $objectManager->getObject( + Dismiss::class, + [ + 'notificationManagement' => $this->notificationManagementMock, + 'request' => $this->requestMock, + 'resultFactory' => $this->resultFactoryMock, + ] + ); + } + + public function testExecute() + { + $bulkUuids = ['49da7406-1ec3-4100-95ae-9654c83a6801']; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->with('uuid', []) + ->willReturn($bulkUuids); + + $this->notificationManagementMock->expects($this->once()) + ->method('acknowledgeBulks') + ->with($bulkUuids) + ->willReturn(true); + + $this->resultFactoryMock->expects($this->once()) + ->method('create') + ->with(ResultFactory::TYPE_JSON, []) + ->willReturn($this->jsonResultMock); + + $this->assertEquals($this->jsonResultMock, $this->model->execute()); + } + + public function testExecuteSetsBadRequestResponseStatusIfBulkWasNotAcknowledgedCorrectly() + { + $bulkUuids = ['49da7406-1ec3-4100-95ae-9654c83a6801']; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->with('uuid', []) + ->willReturn($bulkUuids); + + $this->resultFactoryMock->expects($this->once()) + ->method('create') + ->with(ResultFactory::TYPE_JSON, []) + ->willReturn($this->jsonResultMock); + + $this->notificationManagementMock->expects($this->once()) + ->method('acknowledgeBulks') + ->with($bulkUuids) + ->willReturn(false); + + $this->jsonResultMock->expects($this->once()) + ->method('setHttpResponseCode') + ->with(400); + + $this->assertEquals($this->jsonResultMock, $this->model->execute()); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Cron/BulkCleanupTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Cron/BulkCleanupTest.php new file mode 100644 index 0000000000000..be38e9181734a --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Controller/Cron/BulkCleanupTest.php @@ -0,0 +1,79 @@ +dateTimeMock = $this->createMock(\Magento\Framework\Stdlib\DateTime::class); + $this->scopeConfigMock = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $this->resourceConnectionMock = $this->createMock(\Magento\Framework\App\ResourceConnection::class); + $this->metadataPoolMock = $this->createMock(\Magento\Framework\EntityManager\MetadataPool::class); + $this->timeMock = $this->createMock(\Magento\Framework\Stdlib\DateTime\DateTime::class); + $this->model = new \Magento\AsynchronousOperations\Cron\BulkCleanup( + $this->metadataPoolMock, + $this->resourceConnectionMock, + $this->dateTimeMock, + $this->scopeConfigMock, + $this->timeMock + ); + } + + public function testExecute() + { + $entityType = 'BulkSummaryInterface'; + $connectionName = 'Connection'; + $bulkLifetimeMultiplier = 10; + $bulkLifetime = 3600 * 24 * $bulkLifetimeMultiplier; + + $adapterMock = $this->createMock(\Magento\Framework\DB\Adapter\AdapterInterface::class); + $entityMetadataMock = $this->createMock(\Magento\Framework\EntityManager\EntityMetadataInterface::class); + + $this->metadataPoolMock->expects($this->once())->method('getMetadata')->with($this->stringContains($entityType)) + ->willReturn($entityMetadataMock); + $entityMetadataMock->expects($this->once())->method('getEntityConnectionName')->willReturn($connectionName); + $this->resourceConnectionMock->expects($this->once())->method('getConnectionByName')->with($connectionName) + ->willReturn($adapterMock); + $this->scopeConfigMock->expects($this->once())->method('getValue')->with($this->stringContains('bulk/lifetime')) + ->willReturn($bulkLifetimeMultiplier); + $this->timeMock->expects($this->once())->method('gmtTimestamp')->willReturn($bulkLifetime*10); + $this->dateTimeMock->expects($this->once())->method('formatDate')->with($bulkLifetime*9); + $adapterMock->expects($this->once())->method('delete'); + + $this->model->execute(); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/AccessValidatorTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/AccessValidatorTest.php new file mode 100644 index 0000000000000..8eb8778a384b0 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/AccessValidatorTest.php @@ -0,0 +1,80 @@ +userContextMock = $this->createMock(\Magento\Authorization\Model\UserContextInterface::class); + $this->entityManagerMock = $this->createMock(\Magento\Framework\EntityManager\EntityManager::class); + $this->bulkSummaryFactoryMock = $this->createPartialMock( + \Magento\AsynchronousOperations\Api\Data\BulkSummaryInterfaceFactory::class, + ['create'] + ); + + $this->model = new \Magento\AsynchronousOperations\Model\AccessValidator( + $this->userContextMock, + $this->entityManagerMock, + $this->bulkSummaryFactoryMock + ); + } + + /** + * @dataProvider summaryDataProvider + * @param string $bulkUserId + * @param bool $expectedResult + */ + public function testIsAllowed($bulkUserId, $expectedResult) + { + $adminId = 1; + $uuid = 'test-001'; + $bulkSummaryMock = $this->createMock(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class); + + $this->bulkSummaryFactoryMock->expects($this->once())->method('create')->willReturn($bulkSummaryMock); + $this->entityManagerMock->expects($this->once()) + ->method('load') + ->with($bulkSummaryMock, $uuid) + ->willReturn($bulkSummaryMock); + + $bulkSummaryMock->expects($this->once())->method('getUserId')->willReturn($bulkUserId); + $this->userContextMock->expects($this->once())->method('getUserId')->willReturn($adminId); + + $this->assertEquals($this->model->isAllowed($uuid), $expectedResult); + } + + /** + * @return array + */ + public static function summaryDataProvider() + { + return [ + [2, false], + [1, true] + ]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkDescription/OptionsTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkDescription/OptionsTest.php new file mode 100644 index 0000000000000..d5835f7856dff --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkDescription/OptionsTest.php @@ -0,0 +1,73 @@ +bulkCollectionFactoryMock = $this->createPartialMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Bulk\CollectionFactory::class, + ['create'] + ); + $this->userContextMock = $this->createMock(\Magento\Authorization\Model\UserContextInterface::class); + $this->model = new \Magento\AsynchronousOperations\Model\BulkDescription\Options( + $this->bulkCollectionFactoryMock, + $this->userContextMock + ); + } + + public function testToOptionsArray() + { + $userId = 100; + $collectionMock = $this->createMock(\Magento\AsynchronousOperations\Model\ResourceModel\Bulk\Collection::class); + $selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + $this->bulkCollectionFactoryMock->expects($this->once())->method('create')->willReturn($collectionMock); + + $this->userContextMock->expects($this->once())->method('getUserId')->willReturn($userId); + + $collectionMock->expects($this->once())->method('getMainTable')->willReturn('table'); + + $selectMock->expects($this->once())->method('reset')->willReturnSelf(); + $selectMock->expects($this->once())->method('distinct')->with(true)->willReturnSelf(); + $selectMock->expects($this->once())->method('from')->with('table', ['description'])->willReturnSelf(); + $selectMock->expects($this->once())->method('where')->with('user_id = ?', $userId)->willReturnSelf(); + + $itemMock = $this->createPartialMock( + \Magento\AsynchronousOperations\Model\BulkSummary::class, + ['getDescription'] + ); + $itemMock->expects($this->exactly(2))->method('getDescription')->willReturn('description'); + + $collectionMock->expects($this->once())->method('getSelect')->willReturn($selectMock); + $collectionMock->expects($this->once())->method('getItems')->willReturn([$itemMock]); + + $expectedResult = [ + [ + 'value' => 'description', + 'label' => 'description' + ] + ]; + + $this->assertEquals($expectedResult, $this->model->toOptionArray()); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkManagementTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkManagementTest.php new file mode 100644 index 0000000000000..3a45c34df17f8 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkManagementTest.php @@ -0,0 +1,296 @@ +entityManager = $this->getMockBuilder(\Magento\Framework\EntityManager\EntityManager::class) + ->disableOriginalConstructor()->getMock(); + $this->bulkSummaryFactory = $this + ->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterfaceFactory::class) + ->setMethods(['create']) + ->disableOriginalConstructor() + ->getMock(); + $this->operationCollectionFactory = $this + ->getMockBuilder(\Magento\AsynchronousOperations\Model\ResourceModel\Operation\CollectionFactory::class) + ->setMethods(['create']) + ->disableOriginalConstructor() + ->getMock(); + $this->publisher = $this->getMockBuilder(\Magento\Framework\MessageQueue\BulkPublisherInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->metadataPool = $this->getMockBuilder(\Magento\Framework\EntityManager\MetadataPool::class) + ->disableOriginalConstructor()->getMock(); + $this->resourceConnection = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) + ->disableOriginalConstructor()->getMock(); + $this->logger = $this->getMockBuilder(\Psr\Log\LoggerInterface::class) + ->disableOriginalConstructor()->getMock(); + + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->bulkManagement = $objectManager->getObject( + \Magento\AsynchronousOperations\Model\BulkManagement::class, + [ + 'entityManager' => $this->entityManager, + 'bulkSummaryFactory' => $this->bulkSummaryFactory, + 'operationCollectionFactory' => $this->operationCollectionFactory, + 'publisher' => $this->publisher, + 'metadataPool' => $this->metadataPool, + 'resourceConnection' => $this->resourceConnection, + 'logger' => $this->logger, + ] + ); + } + + /** + * Test for scheduleBulk method. + * + * @return void + */ + public function testScheduleBulk() + { + $bulkUuid = 'bulk-001'; + $description = 'Bulk summary description...'; + $userId = 1; + $connectionName = 'default'; + $topicNames = ['topic.name.0', 'topic.name.1']; + $operation = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\OperationInterface::class) + ->disableOriginalConstructor()->getMock(); + $metadata = $this->getMockBuilder(\Magento\Framework\EntityManager\EntityMetadataInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->metadataPool->expects($this->once())->method('getMetadata') + ->with(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class) + ->willReturn($metadata); + $metadata->expects($this->once())->method('getEntityConnectionName')->willReturn($connectionName); + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resourceConnection->expects($this->once()) + ->method('getConnectionByName')->with($connectionName)->willReturn($connection); + $connection->expects($this->once())->method('beginTransaction')->willReturnSelf(); + $bulkSummary = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->bulkSummaryFactory->expects($this->once())->method('create')->willReturn($bulkSummary); + $this->entityManager->expects($this->once()) + ->method('load')->with($bulkSummary, $bulkUuid)->willReturn($bulkSummary); + $bulkSummary->expects($this->once())->method('setBulkId')->with($bulkUuid)->willReturnSelf(); + $bulkSummary->expects($this->once())->method('setDescription')->with($description)->willReturnSelf(); + $bulkSummary->expects($this->once())->method('setUserId')->with($userId)->willReturnSelf(); + $bulkSummary->expects($this->once())->method('getOperationCount')->willReturn(1); + $bulkSummary->expects($this->once())->method('setOperationCount')->with(3)->willReturnSelf(); + $this->entityManager->expects($this->once())->method('save')->with($bulkSummary)->willReturn($bulkSummary); + $connection->expects($this->once())->method('commit')->willReturnSelf(); + $operation->expects($this->exactly(2))->method('getTopicName') + ->willReturnOnConsecutiveCalls($topicNames[0], $topicNames[1]); + $this->publisher->expects($this->exactly(2))->method('publish') + ->withConsecutive([$topicNames[0], [$operation]], [$topicNames[1], [$operation]])->willReturn(null); + $this->assertTrue( + $this->bulkManagement->scheduleBulk($bulkUuid, [$operation, $operation], $description, $userId) + ); + } + + /** + * Test for scheduleBulk method with exception. + * + * @return void + */ + public function testScheduleBulkWithException() + { + $bulkUuid = 'bulk-001'; + $description = 'Bulk summary description...'; + $userId = 1; + $connectionName = 'default'; + $exceptionMessage = 'Exception message'; + $operation = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\OperationInterface::class) + ->disableOriginalConstructor()->getMock(); + $metadata = $this->getMockBuilder(\Magento\Framework\EntityManager\EntityMetadataInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->metadataPool->expects($this->once())->method('getMetadata') + ->with(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class) + ->willReturn($metadata); + $metadata->expects($this->once())->method('getEntityConnectionName')->willReturn($connectionName); + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resourceConnection->expects($this->once()) + ->method('getConnectionByName')->with($connectionName)->willReturn($connection); + $connection->expects($this->once())->method('beginTransaction')->willReturnSelf(); + $bulkSummary = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->bulkSummaryFactory->expects($this->once())->method('create')->willReturn($bulkSummary); + $this->entityManager->expects($this->once())->method('load') + ->with($bulkSummary, $bulkUuid)->willThrowException(new \LogicException($exceptionMessage)); + $connection->expects($this->once())->method('rollBack')->willReturnSelf(); + $this->logger->expects($this->once())->method('critical')->with($exceptionMessage); + $this->publisher->expects($this->never())->method('publish'); + $this->assertFalse($this->bulkManagement->scheduleBulk($bulkUuid, [$operation], $description, $userId)); + } + + /** + * Test for retryBulk method. + * + * @return void + */ + public function testRetryBulk() + { + $bulkUuid = 'bulk-001'; + $errorCodes = ['errorCode']; + $connectionName = 'default'; + $operationId = 1; + $operationTable = 'magento_operation'; + $topicName = 'topic.name'; + $metadata = $this->getMockBuilder(\Magento\Framework\EntityManager\EntityMetadataInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->metadataPool->expects($this->once())->method('getMetadata') + ->with(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class) + ->willReturn($metadata); + $metadata->expects($this->once())->method('getEntityConnectionName')->willReturn($connectionName); + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resourceConnection->expects($this->once()) + ->method('getConnectionByName')->with($connectionName)->willReturn($connection); + $operationCollection = $this + ->getMockBuilder(\Magento\AsynchronousOperations\Model\ResourceModel\Operation\Collection::class) + ->disableOriginalConstructor()->getMock(); + $this->operationCollectionFactory->expects($this->once())->method('create')->willReturn($operationCollection); + $operationCollection->expects($this->exactly(2))->method('addFieldToFilter') + ->withConsecutive(['error_code', ['in' => $errorCodes]], ['bulk_uuid', ['eq' => $bulkUuid]]) + ->willReturnSelf(); + $operation = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\OperationInterface::class) + ->disableOriginalConstructor()->getMock(); + $operationCollection->expects($this->once())->method('getItems')->willReturn([$operation]); + $connection->expects($this->once())->method('beginTransaction')->willReturnSelf(); + $operation->expects($this->once())->method('getId')->willReturn($operationId); + $operation->expects($this->once())->method('setId')->with(null)->willReturnSelf(); + $this->resourceConnection->expects($this->once()) + ->method('getTableName')->with($operationTable)->willReturn($operationTable); + $connection->expects($this->once()) + ->method('quoteInto')->with('id IN (?)', [$operationId])->willReturn('id IN (' . $operationId .')'); + $connection->expects($this->once()) + ->method('delete')->with($operationTable, 'id IN (' . $operationId .')')->willReturn(1); + $connection->expects($this->once())->method('commit')->willReturnSelf(); + $operation->expects($this->once())->method('getTopicName')->willReturn($topicName); + $this->publisher->expects($this->once())->method('publish')->with($topicName, [$operation])->willReturn(null); + $this->assertEquals(1, $this->bulkManagement->retryBulk($bulkUuid, $errorCodes)); + } + + /** + * Test for retryBulk method with exception. + * + * @return void + */ + public function testRetryBulkWithException() + { + $bulkUuid = 'bulk-001'; + $errorCodes = ['errorCode']; + $connectionName = 'default'; + $operationId = 1; + $operationTable = 'magento_operation'; + $exceptionMessage = 'Exception message'; + $metadata = $this->getMockBuilder(\Magento\Framework\EntityManager\EntityMetadataInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->metadataPool->expects($this->once())->method('getMetadata') + ->with(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class) + ->willReturn($metadata); + $metadata->expects($this->once())->method('getEntityConnectionName')->willReturn($connectionName); + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resourceConnection->expects($this->once()) + ->method('getConnectionByName')->with($connectionName)->willReturn($connection); + $operationCollection = $this + ->getMockBuilder(\Magento\AsynchronousOperations\Model\ResourceModel\Operation\Collection::class) + ->disableOriginalConstructor()->getMock(); + $this->operationCollectionFactory->expects($this->once())->method('create')->willReturn($operationCollection); + $operationCollection->expects($this->exactly(2))->method('addFieldToFilter') + ->withConsecutive(['error_code', ['in' => $errorCodes]], ['bulk_uuid', ['eq' => $bulkUuid]]) + ->willReturnSelf(); + $operation = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\OperationInterface::class) + ->disableOriginalConstructor()->getMock(); + $operationCollection->expects($this->once())->method('getItems')->willReturn([$operation]); + $connection->expects($this->once())->method('beginTransaction')->willReturnSelf(); + $operation->expects($this->once())->method('getId')->willReturn($operationId); + $operation->expects($this->once())->method('setId')->with(null)->willReturnSelf(); + $this->resourceConnection->expects($this->once()) + ->method('getTableName')->with($operationTable)->willReturn($operationTable); + $connection->expects($this->once()) + ->method('quoteInto')->with('id IN (?)', [$operationId])->willReturn('id IN (' . $operationId .')'); + $connection->expects($this->once()) + ->method('delete')->with($operationTable, 'id IN (' . $operationId .')') + ->willThrowException(new \Exception($exceptionMessage)); + $connection->expects($this->once())->method('rollBack')->willReturnSelf(); + $this->logger->expects($this->once())->method('critical')->with($exceptionMessage); + $this->publisher->expects($this->never())->method('publish'); + $this->assertEquals(0, $this->bulkManagement->retryBulk($bulkUuid, $errorCodes)); + } + + /** + * Test for deleteBulk method. + * + * @return void + */ + public function testDeleteBulk() + { + $bulkUuid = 'bulk-001'; + $bulkSummary = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->bulkSummaryFactory->expects($this->once())->method('create')->willReturn($bulkSummary); + $this->entityManager->expects($this->once()) + ->method('load')->with($bulkSummary, $bulkUuid)->willReturn($bulkSummary); + $this->entityManager->expects($this->once())->method('delete')->with($bulkSummary)->willReturn(true); + $this->assertTrue($this->bulkManagement->deleteBulk($bulkUuid)); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkStatusTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkStatusTest.php new file mode 100644 index 0000000000000..7a2f7941f9c04 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/BulkStatusTest.php @@ -0,0 +1,267 @@ +bulkCollectionFactory = $this->createPartialMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Bulk\CollectionFactory::class, + ['create'] + ); + $this->operationCollectionFactory = $this->createPartialMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Operation\CollectionFactory::class, + ['create'] + ); + $this->operationMock = $this->createMock(\Magento\AsynchronousOperations\Api\Data\OperationInterface::class); + $this->bulkMock = $this->createMock(\Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class); + $this->resourceConnectionMock = $this->createMock(\Magento\Framework\App\ResourceConnection::class); + $this->calculatedStatusSqlMock = $this->createMock( + \Magento\AsynchronousOperations\Model\BulkStatus\CalculatedStatusSql::class + ); + $this->metadataPoolMock = $this->createMock(\Magento\Framework\EntityManager\MetadataPool::class); + $this->bulkDetailedFactory = $this->createPartialMock( + \Magento\AsynchronousOperations\Api\Data\DetailedBulkOperationsStatusInterfaceFactory ::class, + ['create'] + ); + $this->bulkShortFactory = $this->createPartialMock( + \Magento\AsynchronousOperations\Api\Data\BulkOperationsStatusInterfaceFactory::class, + ['create'] + ); + $this->entityManager = $this->createMock(\Magento\Framework\EntityManager\EntityManager::class); + + $this->entityMetadataMock = $this->createMock(\Magento\Framework\EntityManager\EntityMetadataInterface::class); + $this->connectionMock = $this->createMock(\Magento\Framework\DB\Adapter\AdapterInterface::class); + + $this->model = new \Magento\AsynchronousOperations\Model\BulkStatus( + $this->bulkCollectionFactory, + $this->operationCollectionFactory, + $this->resourceConnectionMock, + $this->calculatedStatusSqlMock, + $this->metadataPoolMock, + $this->bulkDetailedFactory, + $this->bulkShortFactory, + $this->entityManager + ); + } + + /** + * @param int|null $failureType + * @param array $failureCodes + * @dataProvider getFailedOperationsByBulkIdDataProvider + */ + public function testGetFailedOperationsByBulkId($failureType, $failureCodes) + { + $bulkUuid = 'bulk-1'; + $operationCollection = $this->createMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Operation\Collection::class + ); + $this->operationCollectionFactory->expects($this->once())->method('create')->willReturn($operationCollection); + $operationCollection + ->expects($this->at(0)) + ->method('addFieldToFilter') + ->with('bulk_uuid', $bulkUuid) + ->willReturnSelf(); + $operationCollection + ->expects($this->at(1)) + ->method('addFieldToFilter') + ->with('status', $failureCodes) + ->willReturnSelf(); + $operationCollection->expects($this->once())->method('getItems')->willReturn([$this->operationMock]); + $this->assertEquals([$this->operationMock], $this->model->getFailedOperationsByBulkId($bulkUuid, $failureType)); + } + + public function testGetOperationsCountByBulkIdAndStatus() + { + $bulkUuid = 'bulk-1'; + $status = 1354; + $size = 32; + + $operationCollection = $this->createMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Operation\Collection::class + ); + $this->operationCollectionFactory->expects($this->once())->method('create')->willReturn($operationCollection); + $operationCollection + ->expects($this->at(0)) + ->method('addFieldToFilter') + ->with('bulk_uuid', $bulkUuid) + ->willReturnSelf(); + $operationCollection + ->expects($this->at(1)) + ->method('addFieldToFilter') + ->with('status', $status) + ->willReturnSelf(); + $operationCollection + ->expects($this->once()) + ->method('getSize') + ->willReturn($size); + $this->assertEquals($size, $this->model->getOperationsCountByBulkIdAndStatus($bulkUuid, $status)); + } + + public function getFailedOperationsByBulkIdDataProvider() + { + return [ + [1, [1]], + [ + null, + [ + OperationInterface::STATUS_TYPE_RETRIABLY_FAILED, + OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED, + ], + ], + ]; + } + + public function testGetBulksByUser() + { + $userId = 1; + $selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + $bulkCollection = $this->createMock(\Magento\AsynchronousOperations\Model\ResourceModel\Bulk\Collection::class); + $bulkCollection->expects($this->once())->method('getSelect')->willReturn($selectMock); + $selectMock->expects($this->once())->method('columns')->willReturnSelf(); + $selectMock->expects($this->once())->method('order')->willReturnSelf(); + $this->bulkCollectionFactory->expects($this->once())->method('create')->willReturn($bulkCollection); + $bulkCollection->expects($this->once())->method('addFieldToFilter')->with('user_id', $userId)->willReturnSelf(); + $bulkCollection->expects($this->once())->method('getItems')->willReturn([$this->bulkMock]); + $this->assertEquals([$this->bulkMock], $this->model->getBulksByUser($userId)); + } + + public function testGetBulksStatus() + { + $bulkUuid = 'bulk-1'; + $allProcessedOperationCollection = $this->createMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Operation\Collection::class + ); + + $completeOperationCollection = $this->createMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Operation\Collection::class + ); + + $connectionName = 'connection_name'; + $entityType = \Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::class; + $this->metadataPoolMock + ->expects($this->once()) + ->method('getMetadata') + ->with($entityType) + ->willReturn($this->entityMetadataMock); + $this->entityMetadataMock + ->expects($this->once()) + ->method('getEntityConnectionName') + ->willReturn($connectionName); + $this->resourceConnectionMock + ->expects($this->once()) + ->method('getConnectionByName') + ->with($connectionName) + ->willReturn($this->connectionMock); + + $selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + $selectMock->expects($this->once())->method('from')->willReturnSelf(); + $selectMock->expects($this->once())->method('where')->with('uuid = ?', $bulkUuid)->willReturnSelf(); + $this->connectionMock->expects($this->once())->method('select')->willReturn($selectMock); + $this->connectionMock->expects($this->once())->method('fetchOne')->with($selectMock)->willReturn(10); + + $this->operationCollectionFactory + ->expects($this->at(0)) + ->method('create') + ->willReturn($allProcessedOperationCollection); + $this->operationCollectionFactory + ->expects($this->at(1)) + ->method('create') + ->willReturn($completeOperationCollection); + $allProcessedOperationCollection + ->expects($this->once()) + ->method('addFieldToFilter') + ->with('bulk_uuid', $bulkUuid) + ->willReturnSelf(); + $allProcessedOperationCollection->expects($this->once())->method('getSize')->willReturn(5); + + $completeOperationCollection + ->expects($this->at(0)) + ->method('addFieldToFilter') + ->with('bulk_uuid', $bulkUuid) + ->willReturnSelf(); + $completeOperationCollection + ->expects($this->at(1)) + ->method('addFieldToFilter') + ->with('status', OperationInterface::STATUS_TYPE_COMPLETE) + ->willReturnSelf(); + $completeOperationCollection->expects($this->any())->method('getSize')->willReturn(5); + $this->assertEquals(BulkSummaryInterface::IN_PROGRESS, $this->model->getBulkStatus($bulkUuid)); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/Entity/BulkSummaryMapperTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/Entity/BulkSummaryMapperTest.php new file mode 100644 index 0000000000000..725eae3c01ea3 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/Entity/BulkSummaryMapperTest.php @@ -0,0 +1,105 @@ +metadataPoolMock = $this->createMock(\Magento\Framework\EntityManager\MetadataPool::class); + $this->resourceConnectionMock = $this->createMock(\Magento\Framework\App\ResourceConnection::class); + $this->entityMetadataMock = $this->createMock(\Magento\Framework\EntityManager\EntityMetadataInterface::class); + $this->connectionMock = $this->createMock(\Magento\Framework\DB\Adapter\AdapterInterface::class); + $this->selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + $this->model = new BulkSummaryMapper( + $this->metadataPoolMock, + $this->resourceConnectionMock + ); + } + + /** + * @param int $identifier + * @param array|false $result + * @dataProvider entityToDatabaseDataProvider + */ + public function testEntityToDatabase($identifier, $result) + { + $entityType = 'entityType'; + $data = ['uuid' => 'bulk-1']; + $connectionName = 'connection_name'; + $entityTable = 'table_name'; + $this->metadataPoolMock + ->expects($this->once()) + ->method('getMetadata') + ->with($entityType) + ->willReturn($this->entityMetadataMock); + $this->entityMetadataMock + ->expects($this->once()) + ->method('getEntityConnectionName') + ->willReturn($connectionName); + + $this->resourceConnectionMock + ->expects($this->once()) + ->method('getConnectionByName') + ->with($connectionName) + ->willReturn($this->connectionMock); + $this->connectionMock->expects($this->once())->method('select')->willReturn($this->selectMock); + $this->entityMetadataMock->expects($this->once())->method('getEntityTable')->willReturn($entityTable); + $this->selectMock->expects($this->once())->method('from')->with($entityTable, 'id')->willReturnSelf(); + $this->selectMock->expects($this->once())->method('where')->with("uuid = ?", 'bulk-1')->willReturnSelf(); + $this->connectionMock + ->expects($this->once()) + ->method('fetchOne') + ->with($this->selectMock) + ->willReturn($identifier); + + $this->assertEquals($result, $this->model->entityToDatabase($entityType, $data)); + } + + public function entityToDatabaseDataProvider() + { + return [ + [1, ['uuid' => 'bulk-1', 'id' => 1]], + [false, ['uuid' => 'bulk-1']] + ]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/Operation/DetailsTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/Operation/DetailsTest.php new file mode 100644 index 0000000000000..f62e2b7f9d5ea --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/Operation/DetailsTest.php @@ -0,0 +1,61 @@ +bulkStatusMock = $this->getMockBuilder(\Magento\Framework\Bulk\BulkStatusInterface::class) + ->getMock(); + $this->model = new \Magento\AsynchronousOperations\Model\Operation\Details($this->bulkStatusMock); + } + + public function testGetDetails() + { + $uuid = 'some_uuid_string'; + $completed = 100; + $failedRetriable = 23; + $failedNotRetriable = 45; + $open = 303; + $rejected = 0; + + $expectedResult = [ + 'operations_total' => $completed + $failedRetriable + $failedNotRetriable + $open, + 'operations_successful' => $completed, + 'operations_failed' => $failedRetriable + $failedNotRetriable, + 'failed_retriable' => $failedRetriable, + 'failed_not_retriable' => $failedNotRetriable, + 'rejected' => $rejected, + 'open' => $open, + ]; + + $this->bulkStatusMock->method('getOperationsCountByBulkIdAndStatus') + ->willReturnMap([ + [$uuid, OperationInterface::STATUS_TYPE_COMPLETE, $completed], + [$uuid, OperationInterface::STATUS_TYPE_RETRIABLY_FAILED, $failedRetriable], + [$uuid, OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED, $failedNotRetriable], + [$uuid, OperationInterface::STATUS_TYPE_OPEN, $open], + [$uuid, OperationInterface::STATUS_TYPE_REJECTED, $rejected], + ]); + + $result = $this->model->getDetails($uuid); + $this->assertEquals($expectedResult, $result); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/OperationManagementTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/OperationManagementTest.php new file mode 100644 index 0000000000000..0a4e5f2f3ecc3 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/OperationManagementTest.php @@ -0,0 +1,90 @@ +entityManagerMock = $this->createMock(\Magento\Framework\EntityManager\EntityManager::class); + $this->metadataPoolMock = $this->createMock(\Magento\Framework\EntityManager\MetadataPool::class); + $this->operationFactoryMock = $this->createPartialMock( + \Magento\AsynchronousOperations\Api\Data\DetailedOperationStatusInterfaceFactory::class, + ['create'] + ); + $this->operationMock = + $this->createMock(\Magento\AsynchronousOperations\Api\Data\DetailedOperationStatusInterface::class); + $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + $this->model = new \Magento\AsynchronousOperations\Model\OperationManagement( + $this->entityManagerMock, + $this->operationFactoryMock, + $this->loggerMock + ); + } + + public function testChangeOperationStatus() + { + $operationId = 1; + $status = 1; + $message = 'Message'; + $data = 'data'; + $errorCode = 101; + $this->operationFactoryMock->expects($this->once())->method('create')->willReturn($this->operationMock); + $this->entityManagerMock->expects($this->once())->method('load')->with($this->operationMock, $operationId); + $this->operationMock->expects($this->once())->method('setStatus')->with($status)->willReturnSelf(); + $this->operationMock->expects($this->once())->method('setResultMessage')->with($message)->willReturnSelf(); + $this->operationMock->expects($this->once())->method('setSerializedData')->with($data)->willReturnSelf(); + $this->operationMock->expects($this->once())->method('setErrorCode')->with($errorCode)->willReturnSelf(); + $this->entityManagerMock->expects($this->once())->method('save')->with($this->operationMock); + $this->assertTrue($this->model->changeOperationStatus($operationId, $status, $errorCode, $message, $data)); + } + + public function testChangeOperationStatusIfExceptionWasThrown() + { + $operationId = 1; + $status = 1; + $message = 'Message'; + $data = 'data'; + $errorCode = 101; + $this->operationFactoryMock->expects($this->once())->method('create')->willReturn($this->operationMock); + $this->entityManagerMock->expects($this->once())->method('load')->with($this->operationMock, $operationId); + $this->operationMock->expects($this->once())->method('setStatus')->with($status)->willReturnSelf(); + $this->operationMock->expects($this->once())->method('setResultMessage')->with($message)->willReturnSelf(); + $this->operationMock->expects($this->once())->method('setSerializedData')->with($data)->willReturnSelf(); + $this->operationMock->expects($this->once())->method('setErrorCode')->with($errorCode)->willReturnSelf(); + $this->entityManagerMock->expects($this->once())->method('save')->willThrowException(new \Exception()); + $this->loggerMock->expects($this->once())->method('critical'); + $this->assertFalse($this->model->changeOperationStatus($operationId, $status, $errorCode, $message, $data)); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/ResourceModel/Operation/CreateTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/ResourceModel/Operation/CreateTest.php new file mode 100644 index 0000000000000..2f0fc8ceba46f --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/ResourceModel/Operation/CreateTest.php @@ -0,0 +1,134 @@ +metadataPool = $this->getMockBuilder(\Magento\Framework\EntityManager\MetadataPool::class) + ->disableOriginalConstructor()->getMock(); + $this->typeResolver = $this->getMockBuilder(\Magento\Framework\EntityManager\TypeResolver::class) + ->disableOriginalConstructor()->getMock(); + $this->resourceConnection = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) + ->disableOriginalConstructor()->getMock(); + + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->create = $objectManager->getObject( + \Magento\AsynchronousOperations\Model\ResourceModel\Operation\Create::class, + [ + 'metadataPool' => $this->metadataPool, + 'typeResolver' => $this->typeResolver, + 'resourceConnection' => $this->resourceConnection, + ] + ); + } + + /** + * Test for execute method. + * + * @return void + */ + public function testExecute() + { + $connectionName = 'default'; + $operationData = ['key1' => 'value1']; + $operationTable = 'magento_operation'; + $operationList = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\OperationListInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->typeResolver->expects($this->once())->method('resolve')->with($operationList) + ->willReturn(\Magento\AsynchronousOperations\Api\Data\OperationListInterface::class); + $metadata = $this->getMockBuilder(\Magento\Framework\EntityManager\EntityMetadataInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->metadataPool->expects($this->once())->method('getMetadata') + ->with(\Magento\AsynchronousOperations\Api\Data\OperationListInterface::class)->willReturn($metadata); + $metadata->expects($this->once())->method('getEntityConnectionName')->willReturn($connectionName); + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resourceConnection->expects($this->once()) + ->method('getConnection')->with($connectionName)->willReturn($connection); + $connection->expects($this->once())->method('beginTransaction')->willReturnSelf(); + $operation = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\OperationInterface::class) + ->setMethods(['getData']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $operationList->expects($this->once())->method('getItems')->willReturn([$operation]); + $operation->expects($this->once())->method('getData')->willReturn($operationData); + $metadata->expects($this->once())->method('getEntityTable')->willReturn($operationTable); + $connection->expects($this->once())->method('insertOnDuplicate') + ->with($operationTable, [$operationData], ['status', 'error_code', 'result_message'])->willReturn(1); + $connection->expects($this->once())->method('commit')->willReturnSelf(); + $this->assertEquals($operationList, $this->create->execute($operationList)); + } + + /** + * Test for execute method with exception. + * + * @return void + * @expectedException \Exception + */ + public function testExecuteWithException() + { + $connectionName = 'default'; + $operationData = ['key1' => 'value1']; + $operationTable = 'magento_operation'; + $operationList = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\OperationListInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->typeResolver->expects($this->once())->method('resolve')->with($operationList) + ->willReturn(\Magento\AsynchronousOperations\Api\Data\OperationListInterface::class); + $metadata = $this->getMockBuilder(\Magento\Framework\EntityManager\EntityMetadataInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->metadataPool->expects($this->once())->method('getMetadata') + ->with(\Magento\AsynchronousOperations\Api\Data\OperationListInterface::class)->willReturn($metadata); + $metadata->expects($this->once())->method('getEntityConnectionName')->willReturn($connectionName); + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resourceConnection->expects($this->once()) + ->method('getConnection')->with($connectionName)->willReturn($connection); + $connection->expects($this->once())->method('beginTransaction')->willReturnSelf(); + $operation = $this->getMockBuilder(\Magento\AsynchronousOperations\Api\Data\OperationInterface::class) + ->setMethods(['getData']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $operationList->expects($this->once())->method('getItems')->willReturn([$operation]); + $operation->expects($this->once())->method('getData')->willReturn($operationData); + $metadata->expects($this->once())->method('getEntityTable')->willReturn($operationTable); + $connection->expects($this->once())->method('insertOnDuplicate') + ->with($operationTable, [$operationData], ['status', 'error_code', 'result_message']) + ->willThrowException(new \Exception()); + $connection->expects($this->once())->method('rollBack')->willReturnSelf(); + $this->create->execute($operationList); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/ResourceModel/System/Message/Collection/Synchronized/PluginTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/ResourceModel/System/Message/Collection/Synchronized/PluginTest.php new file mode 100644 index 0000000000000..68864d12e7672 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/ResourceModel/System/Message/Collection/Synchronized/PluginTest.php @@ -0,0 +1,169 @@ +messagefactoryMock = $this->createPartialMock( + \Magento\AdminNotification\Model\System\MessageFactory::class, + ['create'] + ); + $this->bulkStatusMock = $this->createMock(BulkStatusInterface::class); + + $this->userContextMock = $this->createMock(UserContextInterface::class); + $this->operationsDetailsMock = $this->createMock(Details::class); + $this->authorizationMock = $this->createMock(AuthorizationInterface::class); + $this->messageMock = $this->createMock(\Magento\AdminNotification\Model\System\Message::class); + $this->collectionMock = $this->createMock(Synchronized::class); + $this->bulkNotificationMock = $this->createMock(BulkNotificationManagement::class); + $this->statusMapper = $this->createMock(\Magento\AsynchronousOperations\Model\StatusMapper::class); + $this->plugin = new Plugin( + $this->messagefactoryMock, + $this->bulkStatusMock, + $this->bulkNotificationMock, + $this->userContextMock, + $this->operationsDetailsMock, + $this->authorizationMock, + $this->statusMapper + ); + } + + public function testAfterToArrayIfNotAllowed() + { + $result = []; + $this->authorizationMock + ->expects($this->once()) + ->method('isAllowed') + ->with($this->resourceName) + ->willReturn(false); + $this->assertEquals($result, $this->plugin->afterToArray($this->collectionMock, $result)); + } + + /** + * @param array $operationDetails + * @dataProvider afterToDataProvider + */ + public function testAfterTo($operationDetails) + { + $methods = ['getBulkId', 'getDescription', 'getStatus', 'getStartTime']; + $bulkMock = $this->createPartialMock(\Magento\AsynchronousOperations\Model\BulkSummary::class, $methods); + $result = ['items' =>[], 'totalRecords' => 1]; + $userBulks = [$bulkMock]; + $userId = 1; + $bulkUuid = 2; + $bulkArray = [ + 'status' => \Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface::NOT_STARTED + ]; + $bulkMock->expects($this->once())->method('getBulkId')->willReturn($bulkUuid); + $this->operationsDetailsMock + ->expects($this->once()) + ->method('getDetails') + ->with($bulkUuid) + ->willReturn($operationDetails); + $bulkMock->expects($this->once())->method('getDescription')->willReturn('Bulk Description'); + $this->messagefactoryMock->expects($this->once())->method('create')->willReturn($this->messageMock); + $this->messageMock->expects($this->once())->method('toArray')->willReturn($bulkArray); + $this->authorizationMock + ->expects($this->once()) + ->method('isAllowed') + ->with($this->resourceName) + ->willReturn(true); + $this->userContextMock->expects($this->once())->method('getUserId')->willReturn($userId); + $this->bulkNotificationMock + ->expects($this->once()) + ->method('getAcknowledgedBulksByUser') + ->with($userId) + ->willReturn([]); + $this->statusMapper->expects($this->once())->method('operationStatusToBulkSummaryStatus'); + $this->bulkStatusMock->expects($this->once())->method('getBulksByUser')->willReturn($userBulks); + $result2 = $this->plugin->afterToArray($this->collectionMock, $result); + $this->assertEquals(2, $result2['totalRecords']); + } + + public function afterToDataProvider() + { + return [ + ['operations_successful' => 0, + 'operations_failed' => 0, + 'operations_total' => 10 + ], + ['operations_successful' => 1, + 'operations_failed' => 2, + 'operations_total' => 10 + ], + ]; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Model/StatusMapperTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/StatusMapperTest.php new file mode 100644 index 0000000000000..89fa80de36378 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Model/StatusMapperTest.php @@ -0,0 +1,91 @@ +model = new \Magento\AsynchronousOperations\Model\StatusMapper(); + } + + public function testOperationStatusToBulkSummaryStatus() + { + $this->assertEquals( + $this->model->operationStatusToBulkSummaryStatus(OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED), + BulkSummaryInterface::FINISHED_WITH_FAILURE + ); + + $this->assertEquals( + $this->model->operationStatusToBulkSummaryStatus(OperationInterface::STATUS_TYPE_RETRIABLY_FAILED), + BulkSummaryInterface::FINISHED_WITH_FAILURE + ); + + $this->assertEquals( + $this->model->operationStatusToBulkSummaryStatus(OperationInterface::STATUS_TYPE_COMPLETE), + BulkSummaryInterface::FINISHED_SUCCESSFULLY + ); + + $this->assertEquals( + $this->model->operationStatusToBulkSummaryStatus(OperationInterface::STATUS_TYPE_OPEN), + BulkSummaryInterface::IN_PROGRESS + ); + + $this->assertEquals( + $this->model->operationStatusToBulkSummaryStatus(0), + BulkSummaryInterface::NOT_STARTED + ); + } + + public function testOperationStatusToBulkSummaryStatusWithUnknownStatus() + { + $this->assertNull($this->model->operationStatusToBulkSummaryStatus('unknown_status')); + } + + public function testBulkSummaryStatusToOperationStatus() + { + $this->assertEquals( + $this->model->bulkSummaryStatusToOperationStatus(BulkSummaryInterface::FINISHED_SUCCESSFULLY), + OperationInterface::STATUS_TYPE_COMPLETE + ); + + $this->assertEquals( + $this->model->bulkSummaryStatusToOperationStatus(BulkSummaryInterface::IN_PROGRESS), + OperationInterface::STATUS_TYPE_OPEN + ); + + $this->assertEquals( + $this->model->bulkSummaryStatusToOperationStatus(BulkSummaryInterface::FINISHED_WITH_FAILURE), + [ + OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED, + OperationInterface::STATUS_TYPE_RETRIABLY_FAILED, + OperationInterface::STATUS_TYPE_REJECTED + ] + ); + + $this->assertEquals( + $this->model->bulkSummaryStatusToOperationStatus(BulkSummaryInterface::NOT_STARTED), + 0 + ); + } + + public function testBulkSummaryStatusToOperationStatusWithUnknownStatus() + { + $this->assertNull($this->model->bulkSummaryStatusToOperationStatus('unknown_status')); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/AdminNotification/PluginTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/AdminNotification/PluginTest.php new file mode 100644 index 0000000000000..cc0b3a3da38a7 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/AdminNotification/PluginTest.php @@ -0,0 +1,48 @@ +authorizationMock = $this->createMock(AuthorizationInterface::class); + $this->plugin = new \Magento\AsynchronousOperations\Ui\Component\AdminNotification\Plugin( + $this->authorizationMock + ); + } + + public function testAfterGetMeta() + { + $result = []; + $expectedResult = [ + 'columns' => [ + 'arguments' => [ + 'data' => [ + 'config' => [ + 'isAllowed' => true + ] + ] + ] + ] + ]; + $dataProviderMock = $this->createMock(\Magento\AdminNotification\Ui\Component\DataProvider\DataProvider::class); + $this->authorizationMock->expects($this->once())->method('isAllowed')->willReturn(true); + $this->assertEquals($expectedResult, $this->plugin->afterGetMeta($dataProviderMock, $result)); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/ActionsTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/ActionsTest.php new file mode 100644 index 0000000000000..f5cce7af943a1 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/ActionsTest.php @@ -0,0 +1,76 @@ +context = $this->createMock(\Magento\Framework\View\Element\UiComponent\ContextInterface::class); + $this->uiComponentFactory = $this->createMock(\Magento\Framework\View\Element\UiComponentFactory::class); + $processor = $this->createPartialMock( + \Magento\Framework\View\Element\UiComponent\Processor::class, + ['getProcessor'] + ); + $this->context->expects($this->never())->method('getProcessor')->will($this->returnValue($processor)); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->actionColumn = $objectManager->getObject( + \Magento\AsynchronousOperations\Ui\Component\Listing\Column\Actions::class, + [ + 'context' => $this->context, + 'uiComponentFactory' => $this->uiComponentFactory, + 'components' => [], + 'data' => ['name' => 'Edit'], + 'editUrl' => '' + ] + ); + } + + /** + * Test for method prepareDataSource + */ + public function testPrepareDataSource() + { + $href = 'bulk/bulk/details/id/bulk-1'; + $this->context->expects($this->once())->method('getUrl')->with( + 'bulk/bulk/details', + ['uuid' => 'bulk-1'] + )->willReturn($href); + $dataSource['data']['items']['item'] = [BulkSummary::BULK_ID => 'bulk-1']; + $actionColumn['data']['items']['item'] = [ + 'Edit' => [ + 'edit' => [ + 'href' => $href, + 'label' => __('Details'), + 'hidden' => false + ] + ] + ]; + $expectedResult = array_merge_recursive($dataSource, $actionColumn); + $this->assertEquals($expectedResult, $this->actionColumn->prepareDataSource($dataSource)); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/NotificationActionsTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/NotificationActionsTest.php new file mode 100644 index 0000000000000..a35fd82774148 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/NotificationActionsTest.php @@ -0,0 +1,132 @@ +context = $this->createMock(\Magento\Framework\View\Element\UiComponent\ContextInterface::class); + $this->uiComponentFactory = $this->createMock(\Magento\Framework\View\Element\UiComponentFactory::class); + $processor = $this->createPartialMock( + \Magento\Framework\View\Element\UiComponent\Processor::class, + ['getProcessor'] + ); + $this->context->expects($this->never())->method('getProcessor')->will($this->returnValue($processor)); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->actionColumn = $objectManager->getObject( + \Magento\AsynchronousOperations\Ui\Component\Listing\Column\NotificationActions::class, + [ + 'context' => $this->context, + 'uiComponentFactory' => $this->uiComponentFactory, + 'components' => [], + 'data' => ['name' => 'actions'] + ] + ); + } + + public function testPrepareDataSource() + { + $testData['data']['items'] = [ + [ + 'key' => 'value', + ], + [ + BulkSummary::BULK_ID => 'uuid-1', + 'status' => BulkSummaryInterface::FINISHED_SUCCESSFULLY, + ], + [ + BulkSummary::BULK_ID => 'uuid-2', + ], + ]; + $expectedResult['data']['items'] = [ + [ + 'key' => 'value', + ], + [ + BulkSummary::BULK_ID => 'uuid-1', + 'status' => BulkSummaryInterface::FINISHED_SUCCESSFULLY, + 'actions' => [ + 'details' => [ + 'href' => '#', + 'label' => __('View Details'), + 'callback' => [ + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal.insertBulk', + 'target' => 'destroyInserted', + ], + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal.insertBulk', + 'target' => 'updateData', + 'params' => [ + BulkSummary::BULK_ID => 'uuid-1', + ], + ], + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal', + 'target' => 'openModal', + ], + [ + 'provider' => 'ns = notification_area, index = columns', + 'target' => 'dismiss', + 'params' => ['uuid-1'], + ], + ], + ], + ], + ], + [ + BulkSummary::BULK_ID => 'uuid-2', + 'actions' => [ + 'details' => [ + 'href' => '#', + 'label' => __('View Details'), + 'callback' => [ + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal.insertBulk', + 'target' => 'destroyInserted', + ], + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal.insertBulk', + 'target' => 'updateData', + 'params' => [ + BulkSummary::BULK_ID => 'uuid-2', + ], + ], + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal', + 'target' => 'openModal', + ], + ], + ], + ], + ], + ]; + $this->assertEquals($expectedResult, $this->actionColumn->prepareDataSource($testData)); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/NotificationDismissActionsTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/NotificationDismissActionsTest.php new file mode 100644 index 0000000000000..cf1f0db58dfdf --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Listing/Column/NotificationDismissActionsTest.php @@ -0,0 +1,96 @@ +context = $this->createMock(\Magento\Framework\View\Element\UiComponent\ContextInterface::class); + $this->uiComponentFactory = $this->createMock(\Magento\Framework\View\Element\UiComponentFactory::class); + $processor = $this->createPartialMock( + \Magento\Framework\View\Element\UiComponent\Processor::class, + ['getProcessor'] + ); + $this->context->expects($this->never())->method('getProcessor')->will($this->returnValue($processor)); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->actionColumn = $objectManager->getObject( + \Magento\AsynchronousOperations\Ui\Component\Listing\Column\NotificationDismissActions::class, + [ + 'context' => $this->context, + 'uiComponentFactory' => $this->uiComponentFactory, + 'components' => [], + 'data' => ['name' => 'actions'] + ] + ); + } + + public function testPrepareDataSource() + { + $testData['data']['items'] = [ + [ + 'key' => 'value', + ], + [ + BulkSummary::BULK_ID => 'uuid-1', + 'status' => BulkSummaryInterface::FINISHED_SUCCESSFULLY, + ], + [ + 'status' => BulkSummaryInterface::IN_PROGRESS, + ], + ]; + $expectedResult['data']['items'] = [ + [ + 'key' => 'value', + ], + [ + BulkSummary::BULK_ID => 'uuid-1', + 'status' => BulkSummaryInterface::FINISHED_SUCCESSFULLY, + 'actions' => [ + 'dismiss' => [ + 'href' => '#', + 'label' => __('Dismiss'), + 'callback' => [ + [ + 'provider' => 'ns = notification_area, index = columns', + 'target' => 'dismiss', + 'params' => [ + 0 => 'uuid-1', + ], + ], + ], + ], + ], + ], + [ + 'status' => BulkSummaryInterface::IN_PROGRESS, + ], + ]; + $this->assertEquals($expectedResult, $this->actionColumn->prepareDataSource($testData)); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Operation/DataProviderTest.php b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Operation/DataProviderTest.php new file mode 100644 index 0000000000000..bc1e4bcd7e3e2 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Test/Unit/Ui/Component/Operation/DataProviderTest.php @@ -0,0 +1,143 @@ +bulkCollectionFactoryMock = $this->createPartialMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Bulk\CollectionFactory::class, + ['create'] + ); + $this->bulkCollectionMock = $this->createMock( + \Magento\AsynchronousOperations\Model\ResourceModel\Bulk\Collection::class + ); + $this->operationDetailsMock = $this->createMock(\Magento\AsynchronousOperations\Model\Operation\Details::class); + $this->bulkMock = $this->createMock(\Magento\AsynchronousOperations\Model\BulkSummary::class); + $this->requestMock = $this->createMock(\Magento\Framework\App\RequestInterface::class); + + $this->bulkCollectionFactoryMock + ->expects($this->once()) + ->method('create') + ->willReturn($this->bulkCollectionMock); + + $this->dataProvider = $helper->getObject( + \Magento\AsynchronousOperations\Ui\Component\Operation\DataProvider::class, + [ + 'name' => 'test-name', + 'bulkCollectionFactory' => $this->bulkCollectionFactoryMock, + 'operationDetails' => $this->operationDetailsMock, + 'request' => $this->requestMock + ] + ); + } + + public function testGetData() + { + $testData = [ + 'id' => '1', + 'uuid' => 'bulk-uuid1', + 'user_id' => '2', + 'description' => 'Description' + ]; + $testOperationData = [ + 'operations_total' => 2, + 'operations_successful' => 1, + 'operations_failed' => 2 + ]; + $testSummaryData = [ + 'summary' => '2 items selected for mass update, 1 successfully updated, 2 failed to update' + ]; + $resultData[$testData['id']] = array_merge($testData, $testOperationData, $testSummaryData); + + $this->bulkCollectionMock + ->expects($this->once()) + ->method('getItems') + ->willReturn([$this->bulkMock]); + $this->bulkMock + ->expects($this->once()) + ->method('getData') + ->willReturn($testData); + $this->operationDetailsMock + ->expects($this->once()) + ->method('getDetails') + ->with($testData['uuid']) + ->willReturn($testOperationData); + $this->bulkMock + ->expects($this->once()) + ->method('getBulkId') + ->willReturn($testData['id']); + + $expectedResult = $this->dataProvider->getData(); + $this->assertEquals($resultData, $expectedResult); + } + + public function testPrepareMeta() + { + $resultData['retriable_operations']['arguments']['data']['disabled'] = true; + $resultData['failed_operations']['arguments']['data']['disabled'] = true; + $testData = [ + 'uuid' => 'bulk-uuid1', + 'failed_retriable' => 0, + 'failed_not_retriable' => 0 + ]; + + $this->requestMock + ->expects($this->once()) + ->method('getParam') + ->willReturn($testData['uuid']); + $this->operationDetailsMock + ->expects($this->once()) + ->method('getDetails') + ->with($testData['uuid']) + ->willReturn($testData); + + $expectedResult = $this->dataProvider->prepareMeta([]); + $this->assertEquals($resultData, $expectedResult); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/AdminNotification/Plugin.php b/app/code/Magento/AsynchronousOperations/Ui/Component/AdminNotification/Plugin.php new file mode 100644 index 0000000000000..b5670639dce09 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/AdminNotification/Plugin.php @@ -0,0 +1,54 @@ +authorization = $authorization; + } + + /** + * Prepares Meta + * + * @param \Magento\AdminNotification\Ui\Component\DataProvider\DataProvider $dataProvider + * @param array $result + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetMeta( + \Magento\AdminNotification\Ui\Component\DataProvider\DataProvider $dataProvider, + $result + ) { + if (!isset($this->isAllowed)) { + $this->isAllowed = $this->authorization->isAllowed( + 'Magento_Logging::system_magento_logging_bulk_operations' + ); + } + $result['columns']['arguments']['data']['config']['isAllowed'] = $this->isAllowed; + return $result; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Bulk/IdentifierResolver.php b/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Bulk/IdentifierResolver.php new file mode 100644 index 0000000000000..b5b7da1318001 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Bulk/IdentifierResolver.php @@ -0,0 +1,36 @@ +request = $request; + } + + /** + * @return null|string + */ + public function execute() + { + return $this->request->getParam('uuid'); + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Operation/Failed/SearchResult.php b/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Operation/Failed/SearchResult.php new file mode 100644 index 0000000000000..aba7554c26d1d --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Operation/Failed/SearchResult.php @@ -0,0 +1,142 @@ +jsonHelper = $jsonHelper; + $this->identifierResolver = $identifierResolver; + parent::__construct( + $entityFactory, + $logger, + $fetchStrategy, + $eventManager, + $mainTable, + $resourceModel, + $identifierName + ); + } + + /** + * {@inheritdoc} + */ + protected function _initSelect() + { + $bulkUuid = $this->identifierResolver->execute(); + $this->getSelect()->from(['main_table' => $this->getMainTable()], ['id', 'result_message', 'serialized_data']) + ->where('bulk_uuid=?', $bulkUuid) + ->where('status=?', OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED); + return $this; + } + + /** + * {@inheritdoc} + */ + protected function _afterLoad() + { + parent::_afterLoad(); + foreach ($this->_items as $key => $item) { + try { + $unserializedData = $this->jsonHelper->jsonDecode($item['serialized_data']); + } catch (\Exception $e) { + $this->_logger->error($e->getMessage()); + $unserializedData = []; + } + $this->_items[$key]->setData('meta_information', $this->provideMetaInfo($unserializedData)); + $this->_items[$key]->setData('link', $this->getLink($unserializedData)); + $this->_items[$key]->setData('entity_id', $this->getEntityId($unserializedData)); + } + return $this; + } + + /** + * Provide meta info by serialized data + * + * @param array $item + * @return string + */ + private function provideMetaInfo($item) + { + $metaInfo = ''; + if (isset($item['meta_information'])) { + $metaInfo = $item['meta_information']; + } + return $metaInfo; + } + + /** + * Get link from serialized data + * + * @param array $item + * @return string + */ + private function getLink($item) + { + $entityLink = ''; + if (isset($item['entity_link'])) { + $entityLink = $item['entity_link']; + } + return $entityLink; + } + + /** + * Get entity id from serialized data + * + * @param array $item + * @return string + */ + private function getEntityId($item) + { + $entityLink = ''; + if (isset($item['entity_id'])) { + $entityLink = $item['entity_id']; + } + return $entityLink; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Operation/Retriable/SearchResult.php b/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Operation/Retriable/SearchResult.php new file mode 100644 index 0000000000000..9641bd1333f9f --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/Operation/Retriable/SearchResult.php @@ -0,0 +1,71 @@ +identifierResolver = $identifierResolver; + parent::__construct( + $entityFactory, + $logger, + $fetchStrategy, + $eventManager, + $mainTable, + $resourceModel, + $identifierName + ); + } + + /** + * {@inheritdoc} + */ + protected function _initSelect() + { + $bulkUuid = $this->identifierResolver->execute(); + $this->getSelect()->from(['main_table' => $this->getMainTable()], ['id', 'result_message', 'error_code']) + ->where('bulk_uuid=?', $bulkUuid) + ->where('status=?', OperationInterface::STATUS_TYPE_RETRIABLY_FAILED) + ->group('error_code') + ->columns(['records_qty' => new \Zend_Db_Expr('COUNT(id)')]); + return $this; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/SearchResult.php b/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/SearchResult.php new file mode 100644 index 0000000000000..5f2fbd9ea8b11 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/DataProvider/SearchResult.php @@ -0,0 +1,148 @@ +userContext = $userContextInterface; + $this->statusMapper = $statusMapper; + $this->calculatedStatusSql = $calculatedStatusSql; + parent::__construct( + $entityFactory, + $logger, + $fetchStrategy, + $eventManager, + $mainTable, + $resourceModel, + $identifierName + ); + } + + /** + * {@inheritdoc} + */ + protected function _initSelect() + { + $this->getSelect()->from( + ['main_table' => $this->getMainTable()], + [ + '*', + 'status' => $this->calculatedStatusSql->get($this->getTable('magento_operation')) + ] + )->where( + 'user_id=?', + $this->userContext->getUserId() + ); + return $this; + } + + /** + * {@inheritdoc} + */ + protected function _afterLoad() + { + /** @var BulkSummaryInterface $item */ + foreach ($this->getItems() as $item) { + $item->setStatus($this->statusMapper->operationStatusToBulkSummaryStatus($item->getStatus())); + } + return parent::_afterLoad(); + } + + /** + * {@inheritdoc} + */ + public function addFieldToFilter($field, $condition = null) + { + if ($field == 'status') { + if (is_array($condition)) { + foreach ($condition as $value) { + $this->operationStatus = $this->statusMapper->bulkSummaryStatusToOperationStatus($value); + if (is_array($this->operationStatus)) { + foreach ($this->operationStatus as $statusValue) { + $this->getSelect()->orHaving('status = ?', $statusValue); + } + continue; + } + $this->getSelect()->having('status = ?', $this->operationStatus); + } + } + return $this; + } + return parent::addFieldToFilter($field, $condition); + } + + /** + * {@inheritdoc} + */ + public function getSelectCountSql() + { + $select = parent::getSelectCountSql(); + $select->columns(['status' => $this->calculatedStatusSql->get($this->getTable('magento_operation'))]); + //add grouping by status if filtering by status was executed + if (isset($this->operationStatus)) { + $select->group('status'); + } + return $select; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/Actions.php b/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/Actions.php new file mode 100644 index 0000000000000..232f8ca1356be --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/Actions.php @@ -0,0 +1,43 @@ +getData('name')]['edit'] = [ + 'href' => $this->context->getUrl( + 'bulk/bulk/details', + ['uuid' => $item['uuid']] + ), + 'label' => __('Details'), + 'hidden' => false, + ]; + } + + return $dataSource; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/NotificationActions.php b/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/NotificationActions.php new file mode 100644 index 0000000000000..1886bbf430bc7 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/NotificationActions.php @@ -0,0 +1,68 @@ +getData('name')]['details'] = [ + 'callback' => [ + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal.insertBulk', + 'target' => 'destroyInserted', + ], + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal.insertBulk', + 'target' => 'updateData', + 'params' => [ + 'uuid' => $item['uuid'], + ], + ], + [ + 'provider' => 'notification_area.notification_area.modalContainer.modal', + 'target' => 'openModal', + ], + ], + 'href' => '#', + 'label' => __('View Details'), + ]; + + if (isset($item['status']) + && ($item['status'] === BulkSummaryInterface::FINISHED_SUCCESSFULLY + || $item['status'] === BulkSummaryInterface::FINISHED_WITH_FAILURE) + ) { + $item[$this->getData('name')]['details']['callback'][] = [ + 'provider' => 'ns = notification_area, index = columns', + 'target' => 'dismiss', + 'params' => [ + 0 => $item['uuid'], + ], + ]; + } + } + } + + return $dataSource; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/NotificationDismissActions.php b/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/NotificationDismissActions.php new file mode 100644 index 0000000000000..cae2524f92600 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/Listing/Column/NotificationDismissActions.php @@ -0,0 +1,50 @@ +getData('name')]['dismiss'] = [ + 'callback' => [ + [ + 'provider' => 'ns = notification_area, index = columns', + 'target' => 'dismiss', + 'params' => [ + 0 => $item['uuid'], + ], + ], + ], + 'href' => '#', + 'label' => __('Dismiss'), + ]; + } + } + + return $dataSource; + } +} diff --git a/app/code/Magento/AsynchronousOperations/Ui/Component/Operation/DataProvider.php b/app/code/Magento/AsynchronousOperations/Ui/Component/Operation/DataProvider.php new file mode 100644 index 0000000000000..89aae531fec4e --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/Ui/Component/Operation/DataProvider.php @@ -0,0 +1,124 @@ +collection = $bulkCollectionFactory->create(); + $this->operationDetails = $operationDetails; + $this->request = $request; + parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data); + $this->meta = $this->prepareMeta($this->meta); + } + + /** + * Human readable summary for bulk + * + * @param array $operationDetails structure is implied as getOperationDetails() result + * @return string + */ + private function getSummaryReport($operationDetails) + { + if (0 == $operationDetails['operations_successful'] && 0 == $operationDetails['operations_failed']) { + return __('Pending, in queue...'); + } + + $summaryReport = __('%1 items selected for mass update', $operationDetails['operations_total'])->__toString(); + if ($operationDetails['operations_successful'] > 0) { + $summaryReport .= __(', %1 successfully updated', $operationDetails['operations_successful']); + } + + if ($operationDetails['operations_failed'] > 0) { + $summaryReport .= __(', %1 failed to update', $operationDetails['operations_failed']); + } + + return $summaryReport; + } + + /** + * Bulk summary with operation statistics + * + * @return array + */ + public function getData() + { + $data = []; + $items = $this->collection->getItems(); + if (count($items) == 0) { + return $data; + } + $bulk = array_shift($items); + /** @var \Magento\AsynchronousOperations\Api\Data\BulkSummaryInterface $bulk */ + $data = $bulk->getData(); + $operationDetails = $this->operationDetails->getDetails($data['uuid']); + $data['summary'] = $this->getSummaryReport($operationDetails); + $data = array_merge($data, $operationDetails); + + return [$bulk->getBulkId() => $data]; + } + + /** + * Prepares Meta + * + * @param array $meta + * @return array + */ + public function prepareMeta($meta) + { + $requestId = $this->request->getParam($this->requestFieldName); + $operationDetails = $this->operationDetails->getDetails($requestId); + + if (isset($operationDetails['failed_retriable']) && !$operationDetails['failed_retriable']) { + $meta['retriable_operations']['arguments']['data']['disabled'] = true; + } + + if (isset($operationDetails['failed_not_retriable']) && !$operationDetails['failed_not_retriable']) { + $meta['failed_operations']['arguments']['data']['disabled'] = true; + } + + return $meta; + } +} diff --git a/app/code/Magento/AsynchronousOperations/composer.json b/app/code/Magento/AsynchronousOperations/composer.json new file mode 100644 index 0000000000000..3acb92710e62b --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/composer.json @@ -0,0 +1,32 @@ +{ + "name": "magento/module-asynchronous-operations", + "description": "N/A", + "config": { + "sort-packages": true + }, + "require": { + "magento/framework": "*", + "magento/framework-bulk": "*", + "magento/module-authorization": "*", + "magento/module-backend": "*", + "magento/module-ui": "*", + "magento/module-user": "*", + "php": "~7.1.3||~7.2.0" + }, + "suggest": { + "magento/module-admin-notification": "*", + "magento/module-logging": "*" + }, + "type": "magento2-module", + "license": [ + "proprietary" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\AsynchronousOperations\\": "" + } + } +} diff --git a/app/code/Magento/AsynchronousOperations/etc/acl.xml b/app/code/Magento/AsynchronousOperations/etc/acl.xml new file mode 100644 index 0000000000000..42521ad40ff63 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/acl.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/adminhtml/di.xml b/app/code/Magento/AsynchronousOperations/etc/adminhtml/di.xml new file mode 100644 index 0000000000000..26dd6a39473a6 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/adminhtml/di.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/adminhtml/menu.xml b/app/code/Magento/AsynchronousOperations/etc/adminhtml/menu.xml new file mode 100644 index 0000000000000..2e9fe34c45cec --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/adminhtml/menu.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/adminhtml/routes.xml b/app/code/Magento/AsynchronousOperations/etc/adminhtml/routes.xml new file mode 100644 index 0000000000000..a255af90eac8a --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/adminhtml/routes.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/adminhtml/system.xml b/app/code/Magento/AsynchronousOperations/etc/adminhtml/system.xml new file mode 100644 index 0000000000000..7190b80750357 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/adminhtml/system.xml @@ -0,0 +1,20 @@ + + + + +
+ advanced + + + + + + +
+
+
diff --git a/app/code/Magento/AsynchronousOperations/etc/config.xml b/app/code/Magento/AsynchronousOperations/etc/config.xml new file mode 100644 index 0000000000000..e30c1005d0dd0 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/config.xml @@ -0,0 +1,16 @@ + + + + + + + 60 + + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/crontab.xml b/app/code/Magento/AsynchronousOperations/etc/crontab.xml new file mode 100644 index 0000000000000..c55b0a886ac79 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/crontab.xml @@ -0,0 +1,14 @@ + + + + + + * * * * * + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/db_schema.xml b/app/code/Magento/AsynchronousOperations/etc/db_schema.xml new file mode 100644 index 0000000000000..1b99ce9a2805f --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/db_schema.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
diff --git a/app/code/Magento/AsynchronousOperations/etc/db_schema_whitelist.json b/app/code/Magento/AsynchronousOperations/etc/db_schema_whitelist.json new file mode 100644 index 0000000000000..396e443355d8f --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/db_schema_whitelist.json @@ -0,0 +1,47 @@ +{ + "magento_bulk": { + "column": { + "id": true, + "uuid": true, + "user_id": true, + "description": true, + "operation_count": true, + "start_time": true + }, + "constraint": { + "PRIMARY": true, + "MAGENTO_BULK_USER_ID_ADMIN_USER_USER_ID": true, + "MAGENTO_BULK_UUID": true + } + }, + "magento_operation": { + "column": { + "id": true, + "bulk_uuid": true, + "topic_name": true, + "serialized_data": true, + "result_serialized_data": true, + "status": true, + "error_code": true, + "result_message": true + }, + "index": { + "MAGENTO_OPERATION_BULK_UUID_ERROR_CODE": true + }, + "constraint": { + "PRIMARY": true, + "MAGENTO_OPERATION_BULK_UUID_MAGENTO_BULK_UUID": true + } + }, + "magento_acknowledged_bulk": { + "column": { + "id": true, + "bulk_uuid": true + }, + "constraint": { + "PRIMARY": true, + "MAGENTO_ACKNOWLEDGED_BULK_BULK_UUID_MAGENTO_BULK_UUID": true, + "MAGENTO_ACKNOWLEDGED_BULK_BULK_UUID": true + } + } +} \ No newline at end of file diff --git a/app/code/Magento/AsynchronousOperations/etc/di.xml b/app/code/Magento/AsynchronousOperations/etc/di.xml new file mode 100644 index 0000000000000..c8fee29cd6838 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/di.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + magento_operation + id + + + magento_bulk + uuid + + + magento_operation + id + + + + + + + + + bulk_id + + + + + + + + Magento\AsynchronousOperations\Model\Entity\BulkSummaryMapper + + + + + + + bulkSummaryMapper + + + + + + + Magento\AsynchronousOperations\Ui\Component\DataProvider\SearchResult + Magento\AsynchronousOperations\Ui\Component\DataProvider\Operation\Failed\SearchResult + Magento\AsynchronousOperations\Ui\Component\DataProvider\Operation\Retriable\SearchResult + Magento\AsynchronousOperations\Ui\Component\DataProvider\Operation\Failed\SearchResult + Magento\AsynchronousOperations\Ui\Component\DataProvider\Operation\Retriable\SearchResult + + + + + + + + Magento\AsynchronousOperations\Model\ResourceModel\Operation\CheckIfExists + Magento\AsynchronousOperations\Model\ResourceModel\Operation\Create + + + + + + + + + + Magento\AsynchronousOperations\Model\MassPublisher + Magento\AsynchronousOperations\Model\MassPublisher + + + + + + + Magento\AsynchronousOperations\Model\VirtualType\PublisherPool + + + + + Magento\AsynchronousOperations\Model\VirtualType\BulkManagement + + + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/module.xml b/app/code/Magento/AsynchronousOperations/etc/module.xml new file mode 100644 index 0000000000000..8f7a9e144462b --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/module.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/etc/webapi.xml b/app/code/Magento/AsynchronousOperations/etc/webapi.xml new file mode 100644 index 0000000000000..253dedd1c7a0c --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/etc/webapi.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/i18n/en_US.csv b/app/code/Magento/AsynchronousOperations/i18n/en_US.csv new file mode 100644 index 0000000000000..44cc0a0ab7754 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/i18n/en_US.csv @@ -0,0 +1,35 @@ +Back,Back +Done,Done +Retry,Retry +"'Action Details - #' .","'Action Details - #' ." +"%1 item(s) have been scheduled for update.""","%1 item(s) have been scheduled for update.""" +"Bulk Actions Log","Bulk Actions Log" +"%1 item(s) are currently being updated.","%1 item(s) are currently being updated." +"Task ""%1"": ","Task ""%1"": " +"%1 item(s) have been scheduled for update.","%1 item(s) have been scheduled for update." +"%1 item(s) have been successfully updated.","%1 item(s) have been successfully updated." +"%1 item(s) failed to update","%1 item(s) failed to update" +Details,Details +"View Details","View Details" +Dismiss,Dismiss +"Pending, in queue...","Pending, in queue..." +"%1 items selected for mass update","%1 items selected for mass update" +", %1 successfully updated",", %1 successfully updated" +", %1 failed to update",", %1 failed to update" +"Something went wrong.","Something went wrong." +"Action Log","Action Log" +"Bulk Actions","Bulk Actions" +"Days Saved in Log","Days Saved in Log" +"Description of Operation","Description of Operation" +Summary,Summary +"Start Time","Start Time" +"Items to Retry","Items to Retry" +"To retry, select the items and click “Retry”.","To retry, select the items and click “Retry”." +"Items That Can’t Be Updated.","Items That Can’t Be Updated." +ID,ID +Status,Status +"Meta Information","Meta Information" +Error,Error +"Dismiss All Completed Tasks","Dismiss All Completed Tasks" +"Action Details - #","Action Details - #" +"Number of Records Affected","Number of Records Affected" diff --git a/app/code/Magento/AsynchronousOperations/registration.php b/app/code/Magento/AsynchronousOperations/registration.php new file mode 100644 index 0000000000000..d384df583fb5a --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/registration.php @@ -0,0 +1,9 @@ + + + + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/view/adminhtml/layout/bulk_bulk_details_modal.xml b/app/code/Magento/AsynchronousOperations/view/adminhtml/layout/bulk_bulk_details_modal.xml new file mode 100644 index 0000000000000..946cf0a898585 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/view/adminhtml/layout/bulk_bulk_details_modal.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/view/adminhtml/layout/bulk_index_index.xml b/app/code/Magento/AsynchronousOperations/view/adminhtml/layout/bulk_index_index.xml new file mode 100644 index 0000000000000..d8686887bbc59 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/view/adminhtml/layout/bulk_index_index.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/app/code/Magento/AsynchronousOperations/view/adminhtml/ui_component/bulk_details_form.xml b/app/code/Magento/AsynchronousOperations/view/adminhtml/ui_component/bulk_details_form.xml new file mode 100644 index 0000000000000..19793ac82ba39 --- /dev/null +++ b/app/code/Magento/AsynchronousOperations/view/adminhtml/ui_component/bulk_details_form.xml @@ -0,0 +1,150 @@ + + +
+ + + bulk_details_form.bulk_details_form_data_source + + templates/form/collapsible + + + + +
+ + + +
+ +
+
    +
  • + + + +
  • +
+ +
+ + diff --git a/app/code/Magento/Authorization/Model/ResourceModel/Role.php b/app/code/Magento/Authorization/Model/ResourceModel/Role.php index 633ae741b44a1..48fe65e7f8b92 100644 --- a/app/code/Magento/Authorization/Model/ResourceModel/Role.php +++ b/app/code/Magento/Authorization/Model/ResourceModel/Role.php @@ -68,6 +68,7 @@ protected function _beforeSave(\Magento\Framework\Model\AbstractModel $role) } if (!$role->getTreeLevel()) { + $treeLevel = 0; if ($role->getPid() > 0) { $select = $this->getConnection()->select()->from( $this->getMainTable(), @@ -79,8 +80,6 @@ protected function _beforeSave(\Magento\Framework\Model\AbstractModel $role) $binds = ['pid' => (int)$role->getPid()]; $treeLevel = $this->getConnection()->fetchOne($select, $binds); - } else { - $treeLevel = 0; } $role->setTreeLevel($treeLevel + 1); diff --git a/app/code/Magento/Authorization/Setup/Patch/Data/InitializeAuthRoles.php b/app/code/Magento/Authorization/Setup/Patch/Data/InitializeAuthRoles.php index a63ab272d633b..84992badf65db 100644 --- a/app/code/Magento/Authorization/Setup/Patch/Data/InitializeAuthRoles.php +++ b/app/code/Magento/Authorization/Setup/Patch/Data/InitializeAuthRoles.php @@ -8,8 +8,8 @@ use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Authorization\Model\Acl\Role\Group as RoleGroup; use Magento\Authorization\Model\UserContextInterface; diff --git a/app/code/Magento/Authorization/composer.json b/app/code/Magento/Authorization/composer.json index 65e0d2a57e36d..5f5e7c62ef83b 100644 --- a/app/code/Magento/Authorization/composer.json +++ b/app/code/Magento/Authorization/composer.json @@ -5,12 +5,11 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Authorization/etc/db_schema.xml b/app/code/Magento/Authorization/etc/db_schema.xml index ef615b4508a89..45c02128bfc99 100644 --- a/app/code/Magento/Authorization/etc/db_schema.xml +++ b/app/code/Magento/Authorization/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> diff --git a/app/code/Magento/Authorizenet/composer.json b/app/code/Magento/Authorizenet/composer.json index 89d3ba8045a40..31f2295da4307 100644 --- a/app/code/Magento/Authorizenet/composer.json +++ b/app/code/Magento/Authorizenet/composer.json @@ -5,21 +5,20 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-checkout": "100.3.*", - "magento/module-payment": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-checkout": "*", + "magento/module-payment": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-config": "100.3.*" + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "proprietary" ], diff --git a/app/code/Magento/Backend/Block/Menu.php b/app/code/Magento/Backend/Block/Menu.php index d6bdeb4ea8968..7d86497288a69 100644 --- a/app/code/Magento/Backend/Block/Menu.php +++ b/app/code/Magento/Backend/Block/Menu.php @@ -352,7 +352,7 @@ protected function _addSubMenu($menuItem, $level, $limit, $id = null) return $output; } $output .= '
- - - - diff --git a/app/code/Magento/Bundle/etc/di.xml b/app/code/Magento/Bundle/etc/di.xml index 287a6c8bfdbc0..b7fba3937ded4 100644 --- a/app/code/Magento/Bundle/etc/di.xml +++ b/app/code/Magento/Bundle/etc/di.xml @@ -50,7 +50,9 @@ Magento\Catalog\Pricing\Price\CustomOptionPrice Magento\Catalog\Pricing\Price\BasePrice Magento\Bundle\Pricing\Price\ConfiguredPrice + Magento\Bundle\Pricing\Price\ConfiguredRegularPrice Magento\Bundle\Pricing\Price\BundleOptionPrice + Magento\Bundle\Pricing\Price\BundleOptionRegularPrice Magento\CatalogRule\Pricing\Price\CatalogRulePrice @@ -75,6 +77,11 @@ Magento\Bundle\Pricing\Adjustment\BundleCalculatorInterface + + + Magento\Bundle\Pricing\Adjustment\BundleCalculatorInterface + + diff --git a/app/code/Magento/Bundle/view/adminhtml/templates/product/edit/bundle/option.phtml b/app/code/Magento/Bundle/view/adminhtml/templates/product/edit/bundle/option.phtml index 62f61a725097e..783d71beb1646 100644 --- a/app/code/Magento/Bundle/view/adminhtml/templates/product/edit/bundle/option.phtml +++ b/app/code/Magento/Bundle/view/adminhtml/templates/product/edit/bundle/option.phtml @@ -299,7 +299,7 @@ function togglePriceType() { jQuery('#bundle_product_container').bundleProduct(); jQuery('#product_bundle_container .collapse').collapse('hide'); -jQuery(window).load(function() { +jQuery(window).on('load', function() { togglePriceType(); Event.observe('price_type', 'change', togglePriceType); }); diff --git a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-checkbox.js b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-checkbox.js index b608cff85b067..09331d37bb3b6 100644 --- a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-checkbox.js +++ b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-checkbox.js @@ -14,7 +14,10 @@ define([ clearing: false, parentContainer: '', parentSelections: '', - changer: '' + changer: '', + exports: { + value: '${$.parentName}:isDefaultValue' + } }, /** @@ -58,10 +61,6 @@ define([ this.prefer = typeMap[type]; this.elementTmpl(this.templates[typeMap[type]]); - - if (this.prefer === 'radio' && this.checked()) { - this.clearValues(); - } }, /** diff --git a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-dynamic-rows-grid.js b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-dynamic-rows-grid.js index 428361f459544..a6fc84765cc65 100644 --- a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-dynamic-rows-grid.js +++ b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-dynamic-rows-grid.js @@ -14,7 +14,57 @@ define([ label: '', columnsHeader: false, columnsHeaderAfterRender: true, - addButton: false + addButton: false, + isDefaultFieldScope: 'is_default', + defaultRecords: { + use: [], + moreThanOne: false, + state: {} + }, + listens: { + inputType: 'onInputTypeChange', + isDefaultValue: 'onIsDefaultValue' + } + }, + + /** + * Handler for type select. + * + * @param {String} inputType - changed. + */ + onInputTypeChange: function (inputType) { + if (this.defaultRecords.moreThanOne && (inputType === 'radio' || inputType === 'select')) { + _.each(this.defaultRecords.use, function (index, counter) { + this.source.set( + this.dataScope + '.bundle_selections.' + index + '.' + this.isDefaultFieldScope, + counter ? '0' : '1' + ); + }.bind(this)); + } + }, + + /** + * Handler for is_default field. + * + * @param {Object} data - changed data. + */ + onIsDefaultValue: function (data) { + var cb, + use = 0; + + this.defaultRecords.use = []; + + cb = function (elem, key) { + + if (~~elem) { + this.defaultRecords.use.push(key); + use++; + } + + this.defaultRecords.moreThanOne = use > 1; + }.bind(this); + + _.each(data, cb); }, /** @@ -29,7 +79,6 @@ define([ recordIndex; this.parsePagesData(data); - this.templates.record.bundleOptionsDataScope = this.dataScope; if (newData.length) { if (this.insertData().length) { diff --git a/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-user-defined-checkbox.js b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-user-defined-checkbox.js new file mode 100644 index 0000000000000..a7ceded02d0c3 --- /dev/null +++ b/app/code/Magento/Bundle/view/adminhtml/web/js/components/bundle-user-defined-checkbox.js @@ -0,0 +1,30 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'Magento_Ui/js/form/element/single-checkbox' +], function (Checkbox) { + 'use strict'; + + return Checkbox.extend({ + defaults: { + listens: { + inputType: 'onInputTypeChange' + } + }, + + /** + * Handler for "inputType" property + * + * @param {String} data + */ + onInputTypeChange: function (data) { + data === 'checkbox' || data === 'multi' ? + this.clear() + .visible(false) : + this.visible(true); + } + }); +}); diff --git a/app/code/Magento/BundleGraphQl/Model/BundleProductTypeResolver.php b/app/code/Magento/BundleGraphQl/Model/BundleProductTypeResolver.php index 527bcf8975310..b904d3f62a748 100644 --- a/app/code/Magento/BundleGraphQl/Model/BundleProductTypeResolver.php +++ b/app/code/Magento/BundleGraphQl/Model/BundleProductTypeResolver.php @@ -3,10 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\BundleGraphQl\Model; -use Magento\Framework\GraphQl\Config\Data\TypeResolverInterface; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; /** * {@inheritdoc} @@ -16,10 +17,11 @@ class BundleProductTypeResolver implements TypeResolverInterface /** * {@inheritdoc} */ - public function resolveType(array $data) + public function resolveType(array $data) : string { if (isset($data['type_id']) && $data['type_id'] == 'bundle') { return 'BundleProduct'; } + return ''; } } diff --git a/app/code/Magento/BundleGraphQl/Model/Plugin/Model/Resolver/Products/DataProvider/ProductPlugin.php b/app/code/Magento/BundleGraphQl/Model/Plugin/Model/Resolver/Products/DataProvider/ProductPlugin.php deleted file mode 100644 index fe022aab62dda..0000000000000 --- a/app/code/Magento/BundleGraphQl/Model/Plugin/Model/Resolver/Products/DataProvider/ProductPlugin.php +++ /dev/null @@ -1,52 +0,0 @@ -productOptionList = $productOptionList; - } - - /** - * Intercept GraphQLCatalog getList, and add any necessary bundle fields - * - * @param Product $subject - * @param SearchResultsInterface $result - * @return SearchResultsInterface - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function afterGetList(Product $subject, SearchResultsInterface $result) - { - foreach ($result->getItems() as $product) { - if ($product->getTypeId() === Bundle::TYPE_CODE) { - $extensionAttributes = $product->getExtensionAttributes(); - $options = $this->productOptionList->getItems($product); - $extensionAttributes->setBundleProductOptions($options); - $product->setExtensionAttributes($extensionAttributes); - } - } - return $result; - } -} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/BundleItemLinks.php b/app/code/Magento/BundleGraphQl/Model/Resolver/BundleItemLinks.php new file mode 100644 index 0000000000000..f90945d19f948 --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/BundleItemLinks.php @@ -0,0 +1,62 @@ +linkCollection = $linkCollection; + $this->valueFactory = $valueFactory; + } + + /** + * {@inheritDoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + if (!isset($value['option_id']) || !isset($value['parent_id'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + $this->linkCollection->addIdFilters((int)$value['option_id'], (int)$value['parent_id']); + $result = function () use ($value) { + return $this->linkCollection->getLinksForOptionId((int)$value['option_id']); + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/BundleItems.php b/app/code/Magento/BundleGraphQl/Model/Resolver/BundleItems.php new file mode 100644 index 0000000000000..9474f825fe5e8 --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/BundleItems.php @@ -0,0 +1,85 @@ +bundleOptionCollection = $bundleOptionCollection; + $this->valueFactory = $valueFactory; + $this->metdataPool = $metdataPool; + } + + /** + * Fetch and format bundle option items. + * + * {@inheritDoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + $linkField = $this->metdataPool->getMetadata(ProductInterface::class)->getLinkField(); + if ($value['type_id'] !== Type::TYPE_CODE + || !isset($value[$linkField]) + || !isset($value[ProductInterface::SKU]) + ) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + $this->bundleOptionCollection->addParentFilterData( + (int)$value[$linkField], + (int)$value['entity_id'], + $value[ProductInterface::SKU] + ); + + $result = function () use ($value, $linkField) { + return $this->bundleOptionCollection->getOptionsByParentId((int)$value[$linkField]); + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Links/Collection.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Links/Collection.php new file mode 100644 index 0000000000000..ee695c319501d --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Links/Collection.php @@ -0,0 +1,137 @@ +linkCollectionFactory = $linkCollectionFactory; + $this->enumLookup = $enumLookup; + } + + /** + * Add option and id filter pair to filter for fetch. + * + * @param int $optionId + * @param int $parentId + * @return void + */ + public function addIdFilters(int $optionId, int $parentId) : void + { + if (!in_array($optionId, $this->optionIds)) { + $this->optionIds[] = $optionId; + } + if (!in_array($parentId, $this->parentIds)) { + $this->parentIds[] = $parentId; + } + } + + /** + * Retrieve links for passed in option id. + * + * @param int $optionId + * @return array + */ + public function getLinksForOptionId(int $optionId) : array + { + $linksList = $this->fetch(); + + if (!isset($linksList[$optionId])) { + return []; + } + + return $linksList[$optionId]; + } + + /** + * Fetch link data and return in array format. Keys for links will be their option Ids. + * + * @return array + */ + private function fetch() : array + { + if (empty($this->optionIds) || empty($this->parentIds) || !empty($this->links)) { + return $this->links; + } + + /** @var LinkCollection $linkCollection */ + $linkCollection = $this->linkCollectionFactory->create(); + $linkCollection->setOptionIdsFilter($this->optionIds); + $field = 'parent_product_id'; + foreach ($linkCollection->getSelect()->getPart('from') as $tableAlias => $data) { + if ($data['tableName'] == $linkCollection->getTable('catalog_product_bundle_selection')) { + $field = $tableAlias . '.' . $field; + } + } + + $linkCollection->getSelect() + ->where($field . ' IN (?)', $this->parentIds); + + /** @var Selection $link */ + foreach ($linkCollection as $link) { + $data = $link->getData(); + $formattedLink = [ + 'price' => $link->getSelectionPriceValue(), + 'position' => $link->getPosition(), + 'id' => $link->getSelectionId(), + 'qty' => (int)$link->getSelectionQty(), + 'is_default' => (bool)$link->getIsDefault(), + 'price_type' => $this->enumLookup->getEnumValueFromField( + 'PriceTypeEnum', + (string)$link->getSelectionPriceType() + ) ?: 'DYNAMIC', + 'can_change_quantity' => $link->getSelectionCanChangeQty(), + ]; + $data = array_replace($data, $formattedLink); + if (!isset($this->links[$link->getOptionId()])) { + $this->links[$link->getOptionId()] = []; + } + $this->links[$link->getOptionId()][] = $data; + } + + return $this->links; + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Options/Collection.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Options/Collection.php new file mode 100644 index 0000000000000..149155c86275a --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Options/Collection.php @@ -0,0 +1,134 @@ +bundleOptionFactory = $bundleOptionFactory; + $this->extensionAttributesJoinProcessor = $extensionAttributesJoinProcessor; + $this->storeManager = $storeManager; + } + + /** + * Add parent id/sku pair to use for option filter at fetch time. + * + * @param int $parentId + * @param string $sku + */ + public function addParentFilterData(int $parentId, int $parentEntityId, string $sku) : void + { + $this->skuMap[$parentId] = ['sku' => $sku, 'entity_id' => $parentEntityId]; + } + + /** + * Fetch data for bundle options and return the options for the given parent id. + * + * @param int $parentId + * @return array + */ + public function getOptionsByParentId(int $parentId) : array + { + $options = $this->fetch(); + if (!isset($options[$parentId])) { + return []; + } + + return $options[$parentId]; + } + + /** + * Fetch bundle option data and return in array format. Keys for bundle options will be their parent product ids. + * + * @return array + */ + private function fetch() : array + { + if (empty($this->skuMap) || !empty($this->optionMap)) { + return $this->optionMap; + } + + /** @var \Magento\Bundle\Model\ResourceModel\Option\Collection $optionsCollection */ + $optionsCollection = $this->bundleOptionFactory->create()->getResourceCollection(); + // All products in collection will have same store id. + $optionsCollection->joinValues($this->storeManager->getStore()->getId()); + + $productTable = $optionsCollection->getTable('catalog_product_entity'); + $linkField = $optionsCollection->getConnection()->getAutoIncrementField($productTable); + $optionsCollection->getSelect()->join( + ['cpe' => $productTable], + 'cpe.'.$linkField.' = main_table.parent_id', + [] + )->where( + "cpe.entity_id IN (?)", + $this->skuMap + ); + $optionsCollection->setPositionOrder(); + + $this->extensionAttributesJoinProcessor->process($optionsCollection); + if (empty($optionsCollection->getData())) { + return null; + } + + /** @var \Magento\Bundle\Model\Option $option */ + foreach ($optionsCollection as $option) { + if (!isset($this->optionMap[$option->getParentId()])) { + $this->optionMap[$option->getParentId()] = []; + } + $this->optionMap[$option->getParentId()][$option->getId()] = $option->getData(); + $this->optionMap[$option->getParentId()][$option->getId()]['title'] + = $option->getTitle() === null ? $option->getDefaultTitle() : $option->getTitle(); + $this->optionMap[$option->getParentId()][$option->getId()]['sku'] + = $this->skuMap[$option->getParentId()]['sku']; + } + + return $this->optionMap; + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Options/Label.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Options/Label.php new file mode 100644 index 0000000000000..a4757108ee5a4 --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Options/Label.php @@ -0,0 +1,72 @@ +valueFactory = $valueFactory; + $this->product = $product; + } + + /** + * @inheritDoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['sku'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + $this->product->addProductSku($value['sku']); + $this->product->addEavAttributes(['name']); + + $result = function () use ($value) { + $productData = $this->product->getProductBySku($value['sku']); + /** @var \Magento\Catalog\Model\Product $productModel */ + $productModel = isset($productData['model']) ? $productData['model'] : null; + return $productModel ? $productModel->getName() : null; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicPrice.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicPrice.php new file mode 100644 index 0000000000000..e8dc3decc2adf --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicPrice.php @@ -0,0 +1,57 @@ +valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + $result = null; + if ($value['type_id'] === Bundle::TYPE_CODE) { + $result = isset($value['price_type']) ? !$value['price_type'] : null; + } + + return $this->valueFactory->create( + function () use ($result) { + return $result; + } + ); + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicSku.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicSku.php new file mode 100644 index 0000000000000..37e1557d36df1 --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicSku.php @@ -0,0 +1,59 @@ +valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + $result = function () { + return null; + }; + if ($value['type_id'] === Bundle::TYPE_CODE) { + $result = isset($value['sku_type']) ? !$value['sku_type'] : null; + } + + return $this->valueFactory->create( + function () use ($result) { + return $result; + } + ); + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicWeight.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicWeight.php new file mode 100644 index 0000000000000..5f79bba449e54 --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/DynamicWeight.php @@ -0,0 +1,59 @@ +valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + $result = function () { + return null; + }; + if ($value['type_id'] === Bundle::TYPE_CODE) { + $result = isset($value['weight_type']) ? !$value['weight_type'] : null; + } + + return $this->valueFactory->create( + function () use ($result) { + return $result; + } + ); + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/PriceView.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/PriceView.php new file mode 100644 index 0000000000000..ef8e93748c73f --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/PriceView.php @@ -0,0 +1,68 @@ +enumLookup = $enumLookup; + $this->valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + $result = function () { + return null; + }; + if ($value['type_id'] === Bundle::TYPE_CODE) { + $result = isset($value['price_view']) + ? $this->enumLookup->getEnumValueFromField('PriceViewEnum', $value['price_view']) : null; + } + + return $this->valueFactory->create( + function () use ($result) { + return $result; + } + ); + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/ShipBundleItems.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/ShipBundleItems.php new file mode 100644 index 0000000000000..e2bd12a84e2b4 --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Product/Fields/ShipBundleItems.php @@ -0,0 +1,68 @@ +enumLookup = $enumLookup; + $this->valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + $result = function () { + return null; + }; + if ($value['type_id'] === Bundle::TYPE_CODE) { + $result = isset($value['shipment_type']) + ? $this->enumLookup->getEnumValueFromField('ShipBundleItemsEnum', $value['shipment_type']) : null; + } + + return $this->valueFactory->create( + function () use ($result) { + return $result; + } + ); + } +} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/BundleOptions.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/BundleOptions.php deleted file mode 100644 index 92b92c51ab0ad..0000000000000 --- a/app/code/Magento/BundleGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/BundleOptions.php +++ /dev/null @@ -1,78 +0,0 @@ -enumLookup = $enumLookup; - } - - /** - * Add bundle options and options to configurable types - * - * {@inheritdoc} - */ - public function format(Product $product, array $productData = []) - { - if ($product->getTypeId() === Bundle::TYPE_CODE) { - $productData = $this->formatBundleAttributes($productData); - $extensionAttributes = $product->getExtensionAttributes(); - $productData['bundle_product_options'] = $extensionAttributes->getBundleProductOptions(); - } - - return $productData; - } - - /** - * Format bundle specific top level attributes from product - * - * @param array $product - * @return array - * @throws RuntimeException - */ - private function formatBundleAttributes(array $product) - { - if (isset($product['price_view'])) { - $product['price_view'] - = $this->enumLookup->getEnumValueFromField('PriceViewEnum', $product['price_view']); - } - if (isset($product['shipment_type'])) { - $product['ship_bundle_items'] - = $this->enumLookup->getEnumValueFromField('ShipBundleItemsEnum', $product['shipment_type']); - } - if (isset($product['price_view'])) { - $product['dynamic_price'] = !(bool)$product['price_type']; - } - if (isset($product['sku_type'])) { - $product['dynamic_sku'] = !(bool)$product['sku_type']; - } - if (isset($product['weight_type'])) { - $product['dynamic_weight'] = !(bool)$product['weight_type']; - } - return $product; - } -} diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Products/Query/BundleProductPostProcessor.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Products/Query/BundleProductPostProcessor.php deleted file mode 100644 index 153427923b068..0000000000000 --- a/app/code/Magento/BundleGraphQl/Model/Resolver/Products/Query/BundleProductPostProcessor.php +++ /dev/null @@ -1,158 +0,0 @@ -searchCriteriaBuilder = $searchCriteriaBuilder; - $this->productDataProvider = $productDataProvider; - $this->productResource = $productResource; - $this->formatter = $formatter; - $this->enumLookup = $enumLookup; - } - - /** - * Process all bundle product data, including adding simple product data and formatting relevant attributes. - * - * @param array $resultData - * @return array - */ - public function process(array $resultData) - { - $childrenSkus = []; - $bundleMap = []; - foreach ($resultData as $productKey => $product) { - if (isset($product['type_id']) && $product['type_id'] === Bundle::TYPE_CODE) { - if (isset($product['bundle_product_options'])) { - $bundleMap[$product['sku']] = []; - /** @var Option $option */ - foreach ($product['bundle_product_options'] as $optionKey => $option) { - $resultData[$productKey]['items'][$optionKey] - = $option->getData(); - /** @var LinkInterface $link */ - foreach ($option['product_links'] as $link) { - $bundleMap[$product['sku']][] = $link->getSku(); - $childrenSkus[] = $link->getSku(); - $formattedLink = [ - 'product' => new GraphQlNoSuchEntityException( - __('Bundled product not found') - ), - 'price' => $link->getPrice(), - 'position' => $link->getPosition(), - 'id' => $link->getId(), - 'qty' => (int)$link->getQty(), - 'is_default' => (bool)$link->getIsDefault(), - 'price_type' => $this->enumLookup->getEnumValueFromField( - 'PriceTypeEnum', - $link->getPriceType() - ) ?: 'DYNAMIC', - 'can_change_quantity' => $link->getCanChangeQuantity() - ]; - $resultData[$productKey]['items'][$optionKey]['options'][$link['sku']] = $formattedLink; - } - } - } - } - } - - $this->searchCriteriaBuilder->addFilter(ProductInterface::SKU, $childrenSkus, 'in'); - $childProducts = $this->productDataProvider->getList($this->searchCriteriaBuilder->create()); - $resultData = $this->addChildData($childProducts->getItems(), $resultData, $bundleMap); - - return $resultData; - } - - /** - * Format and add children product data to bundle product response items. - * - * @param \Magento\Catalog\Model\Product[] $childrenProducts - * @param array $resultData - * @param array $bundleMap Map of parent skus and their children they contain [$parentSku => [$child1, $child2...]] - * @return array - */ - private function addChildData(array $childrenProducts, array $resultData, array $bundleMap) - { - foreach ($childrenProducts as $childProduct) { - $childData = $this->formatter->format($childProduct); - foreach ($resultData as $productKey => $item) { - if ($item['type_id'] === Bundle::TYPE_CODE - && in_array($childData['sku'], $bundleMap[$item['sku']]) - ) { - $categoryLinks = $this->productResource->getCategoryIds($childProduct); - foreach ($categoryLinks as $position => $categoryLink) { - $childData['category_links'][] = ['position' => $position, 'category_id' => $categoryLink]; - } - foreach ($item['items'] as $itemKey => $bundleItem) { - foreach (array_keys($bundleItem['options']) as $optionKey) { - if ($childData['sku'] === $optionKey) { - $resultData[$productKey]['items'][$itemKey]['options'][$optionKey]['product'] - = $childData; - $resultData[$productKey]['items'][$itemKey]['options'][$optionKey]['label'] - = $childData['name']; - } - } - } - } - } - } - - return $resultData; - } -} diff --git a/app/code/Magento/BundleGraphQl/composer.json b/app/code/Magento/BundleGraphQl/composer.json index c813c73ba1f3d..aea55cb5c644c 100644 --- a/app/code/Magento/BundleGraphQl/composer.json +++ b/app/code/Magento/BundleGraphQl/composer.json @@ -2,13 +2,13 @@ "name": "magento/module-bundle-graph-ql", "description": "N/A", "type": "magento2-module", - "version": "100.0.0-dev", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/module-catalog": "101.2.*", - "magento/module-bundle": "100.3.*", - "magento/module-catalog-graph-ql": "100.0.*", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/module-catalog": "*", + "magento/module-bundle": "*", + "magento/module-catalog-graph-ql": "*", + "magento/module-store": "*", + "magento/framework": "*" }, "license": [ "OSL-3.0", diff --git a/app/code/Magento/BundleGraphQl/etc/di.xml b/app/code/Magento/BundleGraphQl/etc/di.xml index 8629ed28c3ce2..4f41f3cb8dc80 100644 --- a/app/code/Magento/BundleGraphQl/etc/di.xml +++ b/app/code/Magento/BundleGraphQl/etc/di.xml @@ -6,13 +6,13 @@ */ --> - - - - + - - Magento\BundleGraphQl\Model\Resolver\Products\Query\BundleProductPostProcessor + + shipment_type + price_type + sku_type + weight_type diff --git a/app/code/Magento/BundleGraphQl/etc/graphql.xml b/app/code/Magento/BundleGraphQl/etc/graphql.xml deleted file mode 100644 index 531bec37ba8e7..0000000000000 --- a/app/code/Magento/BundleGraphQl/etc/graphql.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TOGETHER - SEPARATELY - - - PRICE_RANGE - AS_LOW_AS - - diff --git a/app/code/Magento/BundleGraphQl/etc/graphql/di.xml b/app/code/Magento/BundleGraphQl/etc/graphql/di.xml index 78c0e505bfcf7..50a2e32b8c9d5 100644 --- a/app/code/Magento/BundleGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/BundleGraphQl/etc/graphql/di.xml @@ -6,13 +6,6 @@ */ --> - - - - Magento\BundleGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\BundleOptions - - - @@ -20,7 +13,7 @@ - + @@ -38,7 +31,7 @@ - + diff --git a/app/code/Magento/BundleGraphQl/etc/module.xml b/app/code/Magento/BundleGraphQl/etc/module.xml index d6c45dd617a1a..352a46d7c171e 100644 --- a/app/code/Magento/BundleGraphQl/etc/module.xml +++ b/app/code/Magento/BundleGraphQl/etc/module.xml @@ -10,6 +10,7 @@ + diff --git a/app/code/Magento/BundleGraphQl/etc/schema.graphqls b/app/code/Magento/BundleGraphQl/etc/schema.graphqls new file mode 100644 index 0000000000000..edde6079dfb2f --- /dev/null +++ b/app/code/Magento/BundleGraphQl/etc/schema.graphqls @@ -0,0 +1,43 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +type BundleItem @doc(description: "BundleItem defines an individual item in a bundle product") { + option_id: Int @doc(description: "An ID assigned to each type of item in a bundle product") + title: String @doc(description: "The display name of the item") + required: Boolean @doc(description: "Indicates whether the item must be included in the bundle") + 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: "he relative position of this item compared to the other bundle items") + sku: String @doc(description: "The SKU of the bundle product") + options: [BundleItemOption] @doc(description: "An array of additional options for this bundle item") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\BundleItemLinks") +} + +type BundleItemOption @doc(description: "BundleItemOption defines characteristics and options for a specific bundle item") { + id: Int @doc(description: "The ID assigned to the bundled item option") + label: String @doc(description: "The text that identifies the bundled item option") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Options\\Label") + qty: Float @doc(description: "Indicates the quantity of this specific bundle item") + position: Int @doc(description: "When a bundle item contains multiple options, the relative position of this option compared to the other options") + is_default: Boolean @doc(description: "Indicates whether this option is the default option") + price: Float @doc(description: "The price of the selected option") + price_type: PriceTypeEnum @doc(description: "One of FIXED, PERCENT, or DYNAMIC") + can_change_quantity: Boolean @doc(description: "Indicates whether the customer can change the number of items for this option") + product: ProductInterface @doc(description: "Contains details about this product option") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product") +} + +type BundleProduct implements ProductInterface, PhysicalProductInterface, CustomizableProductInterface @doc(description: "BundleProduct defines basic features of a bundle product and contains multiple BundleItems") { + price_view: PriceViewEnum @doc(description: "One of PRICE_RANGE or AS_LOW_AS") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Product\\Fields\\PriceView") + dynamic_price: Boolean @doc(description: "Indicates whether the bundle product has a dynamic price") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Product\\Fields\\DynamicPrice") + dynamic_sku: Boolean @doc(description: "Indicates whether the bundle product has a dynamic SK") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Product\\Fields\\DynamicSku") + ship_bundle_items: ShipBundleItemsEnum @doc(description: "Indicates whether to ship bundle items together or individually") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Product\\Fields\\ShipBundleItems") + dynamic_weight: Boolean @doc(description: "Indicates whether the bundle product has a dynamically calculated weight") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Product\\Fields\\DynamicWeight") + items: [BundleItem] @doc(description: "An array containing information about individual bundle items") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\BundleItems") +} + +enum PriceViewEnum @doc(description: "This enumeration defines whether a bundle product's price is displayed as the lowest possible value or as a range.") { + PRICE_RANGE + AS_LOW_AS +} + +enum ShipBundleItemsEnum @doc(description: "This enumeration defines whether bundle items must be shipped together.") { + TOGETHER + SEPARATELY +} diff --git a/app/code/Magento/BundleImportExport/Model/Export/RowCustomizer.php b/app/code/Magento/BundleImportExport/Model/Export/RowCustomizer.php index 9b8518a41ffff..e0c94097e4d3f 100644 --- a/app/code/Magento/BundleImportExport/Model/Export/RowCustomizer.php +++ b/app/code/Magento/BundleImportExport/Model/Export/RowCustomizer.php @@ -9,9 +9,9 @@ use Magento\CatalogImportExport\Model\Export\RowCustomizerInterface; use Magento\CatalogImportExport\Model\Import\Product as ImportProductModel; use Magento\Bundle\Model\ResourceModel\Selection\Collection as SelectionCollection; -use Magento\ImportExport\Controller\Adminhtml\Import; use Magento\ImportExport\Model\Import as ImportModel; -use \Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Store\Model\StoreManagerInterface; /** * Class RowCustomizer @@ -105,6 +105,34 @@ class RowCustomizer implements RowCustomizerInterface AbstractType::SHIPMENT_SEPARATELY => 'separately', ]; + /** + * @var \Magento\Bundle\Model\ResourceModel\Option\Collection[] + */ + private $optionCollections = []; + + /** + * @var array + */ + private $storeIdToCode = []; + + /** + * @var string + */ + private $optionCollectionCacheKey = '_cache_instance_options_collection'; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @param StoreManagerInterface $storeManager + */ + public function __construct(StoreManagerInterface $storeManager) + { + $this->storeManager = $storeManager; + } + /** * Retrieve list of bundle specific columns * @return array @@ -205,17 +233,15 @@ protected function populateBundleData($collection) * @param \Magento\Catalog\Model\Product $product * @return string */ - protected function getFormattedBundleOptionValues($product) + protected function getFormattedBundleOptionValues(\Magento\Catalog\Model\Product $product): string { - /** @var \Magento\Bundle\Model\ResourceModel\Option\Collection $optionsCollection */ - $optionsCollection = $product->getTypeInstance() - ->getOptionsCollection($product) - ->setOrder('position', Collection::SORT_ORDER_ASC); - + $optionCollections = $this->getProductOptionCollection($product); $bundleData = ''; - foreach ($optionsCollection as $option) { + $optionTitles = $this->getBundleOptionTitles($product); + foreach ($optionCollections->getItems() as $option) { + $optionValues = $this->getFormattedOptionValues($option, $optionTitles); $bundleData .= $this->getFormattedBundleSelections( - $this->getFormattedOptionValues($option), + $optionValues, $product->getTypeInstance() ->getSelectionsCollection([$option->getId()], $product) ->setOrder('position', Collection::SORT_ORDER_ASC) @@ -242,7 +268,8 @@ protected function getFormattedBundleSelections($optionValues, SelectionCollecti 'price' => $selection->getSelectionPriceValue(), 'default' => $selection->getIsDefault(), 'default_qty' => $selection->getSelectionQty(), - 'price_type' => $this->getPriceTypeValue($selection->getSelectionPriceType()) + 'price_type' => $this->getPriceTypeValue($selection->getSelectionPriceType()), + 'can_change_qty' => $selection->getSelectionCanChangeQty(), ]; $bundleData .= $optionValues . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR @@ -266,16 +293,25 @@ function ($value, $key) { * Retrieve option value of bundle product * * @param \Magento\Bundle\Model\Option $option + * @param string[] $optionTitles * @return string */ - protected function getFormattedOptionValues($option) - { - return 'name' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR - . $option->getTitle() . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR - . 'type' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR - . $option->getType() . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR - . 'required' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR - . $option->getRequired(); + protected function getFormattedOptionValues( + \Magento\Bundle\Model\Option $option, + array $optionTitles = [] + ): string { + $names = implode(ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR, array_map( + function ($title, $storeName) { + return $storeName . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR . $title; + }, + $optionTitles[$option->getOptionId()], + array_keys($optionTitles[$option->getOptionId()]) + )); + return $names . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR + . 'type' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR + . $option->getType() . ImportModel::DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR + . 'required' . ImportProductModel::PAIR_NAME_VALUE_SEPARATOR + . $option->getRequired(); } /** @@ -380,4 +416,83 @@ private function parseAdditionalAttributes($additionalAttributes) } return $preparedAttributes; } + + /** + * Get product options titles. + * + * Values for all store views (default) should be specified with 'name' key. + * If user want to specify value or change existing for non default store views it should be specified with + * 'name_' prefix and needed store view suffix. + * + * For example: + * - 'name=All store views name' for all store views + * - 'name_specific_store=Specific store name' for store view with 'specific_store' store code + * + * @param \Magento\Catalog\Model\Product $product + * @return array + */ + private function getBundleOptionTitles(\Magento\Catalog\Model\Product $product): array + { + $optionCollections = $this->getProductOptionCollection($product); + $optionsTitles = []; + /** @var \Magento\Bundle\Model\Option $option */ + foreach ($optionCollections->getItems() as $option) { + $optionsTitles[$option->getId()]['name'] = $option->getTitle(); + } + $storeIds = $product->getStoreIds(); + if (count($storeIds) > 1) { + foreach ($storeIds as $storeId) { + $optionCollections = $this->getProductOptionCollection($product, (int)$storeId); + /** @var \Magento\Bundle\Model\Option $option */ + foreach ($optionCollections->getItems() as $option) { + $optionTitle = $option->getTitle(); + if ($optionsTitles[$option->getId()]['name'] != $optionTitle) { + $optionsTitles[$option->getId()]['name_' . $this->getStoreCodeById((int)$storeId)] = + $optionTitle; + } + } + } + } + return $optionsTitles; + } + + /** + * Get product options collection by provided product model. + * + * Set given store id to the product if it was defined (default store id will be set if was not). + * + * @param \Magento\Catalog\Model\Product $product $product + * @param int $storeId + * @return \Magento\Bundle\Model\ResourceModel\Option\Collection + */ + private function getProductOptionCollection( + \Magento\Catalog\Model\Product $product, + int $storeId = \Magento\Store\Model\Store::DEFAULT_STORE_ID + ): \Magento\Bundle\Model\ResourceModel\Option\Collection { + $productSku = $product->getSku(); + if (!isset($this->optionCollections[$productSku][$storeId])) { + $product->unsetData($this->optionCollectionCacheKey); + $product->setStoreId($storeId); + $this->optionCollections[$productSku][$storeId] = $product->getTypeInstance() + ->getOptionsCollection($product) + ->setOrder('position', Collection::SORT_ORDER_ASC); + } + return $this->optionCollections[$productSku][$storeId]; + } + + /** + * Retrieve store code by it's ID. + * + * Collect store id in $storeIdToCode[] private variable if it was not initialized earlier. + * + * @param int $storeId + * @return string + */ + private function getStoreCodeById(int $storeId): string + { + if (!isset($this->storeIdToCode[$storeId])) { + $this->storeIdToCode[$storeId] = $this->storeManager->getStore($storeId)->getCode(); + } + return $this->storeIdToCode[$storeId]; + } } diff --git a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php index bc32483b41680..3ed7e144ddd5a 100644 --- a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php +++ b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle.php @@ -8,10 +8,15 @@ */ namespace Magento\BundleImportExport\Model\Import\Product\Type; -use \Magento\Framework\App\ObjectManager; -use \Magento\Bundle\Model\Product\Price as BundlePrice; -use \Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory as AttributeCollectionFactory; +use Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory as AttributeSetCollectionFactory; +use Magento\Framework\App\ObjectManager; +use Magento\Bundle\Model\Product\Price as BundlePrice; +use Magento\Catalog\Model\Product\Type\AbstractType; use Magento\CatalogImportExport\Model\Import\Product; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Store\Model\StoreManagerInterface; /** * Class Bundle @@ -137,25 +142,39 @@ class Bundle extends \Magento\CatalogImportExport\Model\Import\Product\Type\Abst private $relationsDataSaver; /** - * @param \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory $attrSetColFac - * @param \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $prodAttrColFac - * @param \Magento\Framework\App\ResourceConnection $resource + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var array + */ + private $storeCodeToId = []; + + /** + * @param AttributeSetCollectionFactory $attrSetColFac + * @param AttributeCollectionFactory $prodAttrColFac + * @param ResourceConnection $resource * @param array $params - * @param \Magento\Framework\EntityManager\MetadataPool|null $metadataPool + * @param MetadataPool|null $metadataPool * @param Bundle\RelationsDataSaver|null $relationsDataSaver + * @param StoreManagerInterface|null $storeManager */ public function __construct( - \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory $attrSetColFac, - \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $prodAttrColFac, - \Magento\Framework\App\ResourceConnection $resource, + AttributeSetCollectionFactory $attrSetColFac, + AttributeCollectionFactory $prodAttrColFac, + ResourceConnection $resource, array $params, - \Magento\Framework\EntityManager\MetadataPool $metadataPool = null, - Bundle\RelationsDataSaver $relationsDataSaver = null + MetadataPool $metadataPool = null, + Bundle\RelationsDataSaver $relationsDataSaver = null, + StoreManagerInterface $storeManager = null ) { parent::__construct($attrSetColFac, $prodAttrColFac, $resource, $params, $metadataPool); $this->relationsDataSaver = $relationsDataSaver ?: ObjectManager::getInstance()->get(Bundle\RelationsDataSaver::class); + $this->storeManager = $storeManager + ?: ObjectManager::getInstance()->get(StoreManagerInterface::class); } /** @@ -262,20 +281,29 @@ protected function populateOptionTemplate($option, $entityId, $index = null) * @param array $option * @param int $optionId * @param int $storeId - * - * @return array|bool + * @return array */ - protected function populateOptionValueTemplate($option, $optionId, $storeId = 0) + protected function populateOptionValueTemplate(array $option, int $optionId, int $storeId = 0): array { - if (!isset($option['name']) || !isset($option['parent_id']) || !$optionId) { - return false; + $optionValues = []; + if (isset($option['name'], $option['parent_id']) && $optionId) { + $pattern = '/^name[_]?(.*)/'; + $keys = array_keys($option); + $optionNames = preg_grep($pattern, $keys); + foreach ($optionNames as $optionName) { + preg_match($pattern, $optionName, $storeCodes); + $storeCode = array_pop($storeCodes); + $storeId = $storeCode ? $this->getStoreIdByCode($storeCode) : $storeId; + $optionValues[] = [ + 'option_id' => $optionId, + 'parent_product_id' => $option['parent_id'], + 'store_id' => $storeId, + 'title' => $option[$optionName], + ]; + } } - return [ - 'option_id' => $optionId, - 'parent_product_id' => $option['parent_id'], - 'store_id' => $storeId, - 'title' => $option['name'], - ]; + + return $optionValues; } /** @@ -285,7 +313,7 @@ protected function populateOptionValueTemplate($option, $optionId, $storeId = 0) * @param int $optionId * @param int $parentId * @param int $index - * @return array + * @return array|bool * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ @@ -299,6 +327,7 @@ protected function populateSelectionTemplate($selection, $optionId, $parentId, $ } else { $productId = $selection['product_id']; } + $populatedSelection = [ 'selection_id' => null, 'option_id' => (int)$optionId, @@ -310,7 +339,8 @@ protected function populateSelectionTemplate($selection, $optionId, $parentId, $ ? self::SELECTION_PRICE_TYPE_FIXED : self::SELECTION_PRICE_TYPE_PERCENT, 'selection_price_value' => (isset($selection['price'])) ? (float)$selection['price'] : 0.0, 'selection_qty' => (isset($selection['default_qty'])) ? (float)$selection['default_qty'] : 1.0, - 'selection_can_change_qty' => 1, + 'selection_can_change_qty' => isset($selection['can_change_qty']) + ? ($selection['can_change_qty'] ? 1 : 0) : 1, ]; if (isset($selection['selection_id'])) { $populatedSelection['selection_id'] = $selection['selection_id']; @@ -381,6 +411,7 @@ public function saveData() $this->populateExistingOptions(); $this->insertOptions(); $this->insertSelections(); + $this->insertParentChildRelations(); $this->clear(); } } @@ -572,23 +603,27 @@ protected function insertOptions() * @param array $optionIds * @return array */ - protected function populateInsertOptionValues($optionIds) + protected function populateInsertOptionValues(array $optionIds): array { - $insertValues = []; + $optionValues = []; foreach ($this->_cachedOptions as $entityId => $options) { foreach ($options as $key => $option) { foreach ($optionIds as $optionId => $assoc) { if ($assoc['position'] == $this->_cachedOptions[$entityId][$key]['index'] && $assoc['parent_id'] == $entityId) { $option['parent_id'] = $entityId; - $insertValues[] = $this->populateOptionValueTemplate($option, $optionId); + $optionValues = array_merge( + $optionValues, + $this->populateOptionValueTemplate($option, $optionId) + ); $this->_cachedOptions[$entityId][$key]['option_id'] = $optionId; break; } } } } - return $insertValues; + + return $optionValues; } /** @@ -625,6 +660,32 @@ protected function insertSelections() return $this; } + /** + * Insert parent/child product relations + * + * @return \Magento\CatalogImportExport\Model\Import\Product\Type\AbstractType + */ + private function insertParentChildRelations() + { + foreach ($this->_cachedOptions as $productId => $options) { + $childIds = []; + foreach ($options as $option) { + foreach ($option['selections'] as $selection) { + if (!isset($selection['parent_product_id'])) { + if (!isset($this->_cachedSkuToProducts[$selection['sku']])) { + continue; + } + $childIds[] = $this->_cachedSkuToProducts[$selection['sku']]; + } + } + + $this->relationsDataSaver->saveProductRelations($productId, $childIds); + } + } + + return $this; + } + /** * Initialize attributes parameters for all attributes' sets. * @@ -709,4 +770,22 @@ protected function clear() $this->_cachedSkuToProducts = []; return $this; } + + /** + * Get store id by store code. + * + * @param string $storeCode + * @return int + */ + private function getStoreIdByCode(string $storeCode): int + { + if (!isset($this->storeIdToCode[$storeCode])) { + /** @var $store \Magento\Store\Model\Store */ + foreach ($this->storeManager->getStores() as $store) { + $this->storeCodeToId[$store->getCode()] = $store->getId(); + } + } + + return $this->storeCodeToId[$storeCode]; + } } diff --git a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle/RelationsDataSaver.php b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle/RelationsDataSaver.php index a58195f823bf1..5409d12ac56d3 100644 --- a/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle/RelationsDataSaver.php +++ b/app/code/Magento/BundleImportExport/Model/Import/Product/Type/Bundle/RelationsDataSaver.php @@ -5,6 +5,9 @@ */ namespace Magento\BundleImportExport\Model\Import\Product\Type\Bundle; +use Magento\Catalog\Model\ResourceModel\Product\Relation; +use Magento\Framework\App\ObjectManager; + /** * A bundle product relations (options, selections, etc.) data saver. * @@ -17,13 +20,22 @@ class RelationsDataSaver */ private $resource; + /** + * @var Relation + */ + private $productRelation; + /** * @param \Magento\Framework\App\ResourceConnection $resource + * @param Relation $productRelation */ public function __construct( - \Magento\Framework\App\ResourceConnection $resource + \Magento\Framework\App\ResourceConnection $resource, + Relation $productRelation = null ) { - $this->resource = $resource; + $this->resource = $resource; + $this->productRelation = $productRelation + ?: ObjectManager::getInstance()->get(Relation::class); } /** @@ -92,4 +104,17 @@ public function saveSelections(array $selections) ); } } + + /** + * Saves given parent/child relations. + * + * @param int $parentId + * @param array $childIds + * + * @return void + */ + public function saveProductRelations($parentId, $childIds) + { + $this->productRelation->processRelations($parentId, $childIds); + } } diff --git a/app/code/Magento/BundleImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php b/app/code/Magento/BundleImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php index e76e9e1ba565f..10e8a3443f5f9 100644 --- a/app/code/Magento/BundleImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php +++ b/app/code/Magento/BundleImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php @@ -52,14 +52,24 @@ class RowCustomizerTest extends \PHPUnit\Framework\TestCase */ protected $selection; + /** @var \Magento\Framework\App\ScopeResolverInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $scopeResolver; + /** * Set up */ protected function setUp() { $this->objectManagerHelper = new ObjectManagerHelper($this); + $this->scopeResolver = $this->getMockBuilder(\Magento\Framework\App\ScopeResolverInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getScope']) + ->getMockForAbstractClass(); $this->rowCustomizerMock = $this->objectManagerHelper->getObject( - \Magento\BundleImportExport\Model\Export\RowCustomizer::class + \Magento\BundleImportExport\Model\Export\RowCustomizer::class, + [ + 'scopeResolver' => $this->scopeResolver, + ] ); $this->productResourceCollection = $this->createPartialMock( \Magento\Catalog\Model\ResourceModel\Product\Collection::class, @@ -72,6 +82,8 @@ protected function setUp() 'getPriceType', 'getShipmentType', 'getSkuType', + 'getSku', + 'getStoreIds', 'getPriceView', 'getWeightType', 'getTypeInstance', @@ -79,6 +91,7 @@ protected function setUp() 'getSelectionsCollection' ] ); + $this->product->expects($this->any())->method('getStoreIds')->willReturn([1]); $this->product->expects($this->any())->method('getEntityId')->willReturn(1); $this->product->expects($this->any())->method('getPriceType')->willReturn(1); $this->product->expects($this->any())->method('getShipmentType')->willReturn(1); @@ -88,29 +101,38 @@ protected function setUp() $this->product->expects($this->any())->method('getTypeInstance')->willReturnSelf(); $this->optionsCollection = $this->createPartialMock( \Magento\Bundle\Model\ResourceModel\Option\Collection::class, - ['setOrder', 'getIterator'] + ['setOrder', 'getItems'] ); $this->product->expects($this->any())->method('getOptionsCollection')->willReturn($this->optionsCollection); $this->optionsCollection->expects($this->any())->method('setOrder')->willReturnSelf(); $this->option = $this->createPartialMock( \Magento\Bundle\Model\Option::class, - ['getId', 'getTitle', 'getType', 'getRequired'] + ['getId', 'getOptionId', 'getTitle', 'getType', 'getRequired'] ); $this->option->expects($this->any())->method('getId')->willReturn(1); + $this->option->expects($this->any())->method('getOptionId')->willReturn(1); $this->option->expects($this->any())->method('getTitle')->willReturn('title'); $this->option->expects($this->any())->method('getType')->willReturn(1); $this->option->expects($this->any())->method('getRequired')->willReturn(1); - $this->optionsCollection->expects($this->any())->method('getIterator')->will( + $this->optionsCollection->expects($this->any())->method('getItems')->will( $this->returnValue(new \ArrayIterator([$this->option])) ); $this->selection = $this->createPartialMock( \Magento\Catalog\Model\Product::class, - ['getSku', 'getSelectionPriceValue', 'getIsDefault', 'getSelectionQty', 'getSelectionPriceType'] + [ + 'getSku', + 'getSelectionPriceValue', + 'getIsDefault', + 'getSelectionQty', + 'getSelectionPriceType', + 'getSelectionCanChangeQty' + ] ); $this->selection->expects($this->any())->method('getSku')->willReturn(1); $this->selection->expects($this->any())->method('getSelectionPriceValue')->willReturn(1); $this->selection->expects($this->any())->method('getSelectionQty')->willReturn(1); $this->selection->expects($this->any())->method('getSelectionPriceType')->willReturn(1); + $this->selection->expects($this->any())->method('getSelectionCanChangeQty')->willReturn(1); $this->selectionsCollection = $this->createPartialMock( \Magento\Bundle\Model\ResourceModel\Selection\Collection::class, ['getIterator', 'addAttributeToSort'] @@ -122,6 +144,7 @@ protected function setUp() $this->product->expects($this->any())->method('getSelectionsCollection')->willReturn( $this->selectionsCollection ); + $this->product->expects($this->any())->method('getSku')->willReturn(1); $this->productResourceCollection->expects($this->any())->method('addAttributeToFilter')->willReturnSelf(); $this->productResourceCollection->expects($this->any())->method('getIterator')->will( $this->returnValue(new \ArrayIterator([$this->product])) @@ -133,6 +156,8 @@ protected function setUp() */ public function testPrepareData() { + $scope = $this->getMockBuilder(\Magento\Framework\App\ScopeInterface::class)->getMockForAbstractClass(); + $this->scopeResolver->expects($this->any())->method('getScope')->willReturn($scope); $result = $this->rowCustomizerMock->prepareData($this->productResourceCollection, [1]); $this->assertNotNull($result); } @@ -160,6 +185,8 @@ public function testAddHeaderColumns() */ public function testAddData() { + $scope = $this->getMockBuilder(\Magento\Framework\App\ScopeInterface::class)->getMockForAbstractClass(); + $this->scopeResolver->expects($this->any())->method('getScope')->willReturn($scope); $preparedData = $this->rowCustomizerMock->prepareData($this->productResourceCollection, [1]); $attributes = 'attribute=1,sku_type=1,attribute2="Text",price_type=1,price_view=1,weight_type=1,' . 'values=values,shipment_type=1,attribute3=One,Two,Three'; @@ -168,6 +195,19 @@ public function testAddData() 'additional_attributes' => $attributes ]; $preparedRow = $preparedData->addData($dataRow, 1); + + $bundleValues = [ + 'name=title', + 'type=1', + 'required=1', + 'sku=1', + 'price=1', + 'default=', + 'default_qty=1', + 'price_type=percent', + 'can_change_qty=1', + ]; + $expected = [ 'sku' => 'sku1', 'additional_attributes' => 'attribute=1,attribute2="Text",attribute3=One,Two,Three', @@ -176,7 +216,7 @@ public function testAddData() 'bundle_sku_type' => 'fixed', 'bundle_price_view' => 'As low as', 'bundle_weight_type' => 'fixed', - 'bundle_values' => 'name=title,type=1,required=1,sku=1,price=1,default=,default_qty=1,price_type=percent' + 'bundle_values' => implode(',', $bundleValues) ]; $this->assertEquals($expected, $preparedRow); } diff --git a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/Bundle/RelationsDataSaverTest.php b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/Bundle/RelationsDataSaverTest.php index 42d508cdfb195..d50243b3656f3 100644 --- a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/Bundle/RelationsDataSaverTest.php +++ b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/Bundle/RelationsDataSaverTest.php @@ -7,6 +7,7 @@ namespace Magento\BundleImportExport\Test\Unit\Model\Import\Product\Type\Bundle; use Magento\BundleImportExport\Model\Import\Product\Type\Bundle\RelationsDataSaver; +use Magento\Catalog\Model\ResourceModel\Product\Relation; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Adapter\AdapterInterface; @@ -30,6 +31,11 @@ class RelationsDataSaverTest extends \PHPUnit\Framework\TestCase */ private $connectionMock; + /** + * @var Relation|\PHPUnit_Framework_MockObject_MockObject + */ + private $productRelationMock; + protected function setUp() { $helper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -39,12 +45,16 @@ protected function setUp() $this->connectionMock = $this->getMockBuilder(AdapterInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->resourceMock->expects($this->once())->method('getConnection')->willReturn($this->connectionMock); + + $this->productRelationMock = $this->getMockBuilder(Relation::class) + ->disableOriginalConstructor() + ->getMock(); $this->relationsDataSaver = $helper->getObject( RelationsDataSaver::class, [ - 'resource' => $this->resourceMock + 'resource' => $this->resourceMock, + 'productRelation' => $this->productRelationMock ] ); } @@ -53,7 +63,7 @@ public function testSaveOptions() { $options = [1, 2]; $table_name= 'catalog_product_bundle_option'; - + $this->resourceMock->expects($this->once())->method('getConnection')->willReturn($this->connectionMock); $this->resourceMock->expects($this->once()) ->method('getTableName') ->with('catalog_product_bundle_option') @@ -78,6 +88,7 @@ public function testSaveOptionValues() $optionsValues = [1, 2]; $table_name= 'catalog_product_bundle_option_value'; + $this->resourceMock->expects($this->once())->method('getConnection')->willReturn($this->connectionMock); $this->resourceMock->expects($this->once()) ->method('getTableName') ->with('catalog_product_bundle_option_value') @@ -98,6 +109,7 @@ public function testSaveSelections() $selections = [1, 2]; $table_name= 'catalog_product_bundle_selection'; + $this->resourceMock->expects($this->once())->method('getConnection')->willReturn($this->connectionMock); $this->resourceMock->expects($this->once()) ->method('getTableName') ->with('catalog_product_bundle_selection') @@ -121,4 +133,16 @@ public function testSaveSelections() $this->relationsDataSaver->saveSelections($selections); } + + public function testSaveProductRelations() + { + $parentId = 1; + $children = [2, 3]; + + $this->productRelationMock->expects($this->once()) + ->method('processRelations') + ->with($parentId, $children); + + $this->relationsDataSaver->saveProductRelations($parentId, $children); + } } diff --git a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php index 8e1243b5eb3af..b0794f4564645 100644 --- a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php +++ b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php @@ -58,6 +58,9 @@ class BundleTest extends \Magento\ImportExport\Test\Unit\Model\Import\AbstractIm */ protected $setCollection; + /** @var \Magento\Framework\App\ScopeResolverInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $scopeResolver; + /** * * @return void @@ -170,14 +173,18 @@ protected function setUp() 0 => $this->entityModel, 1 => 'bundle' ]; - + $this->scopeResolver = $this->getMockBuilder(\Magento\Framework\App\ScopeResolverInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getScope']) + ->getMockForAbstractClass(); $this->bundle = $this->objectManagerHelper->getObject( \Magento\BundleImportExport\Model\Import\Product\Type\Bundle::class, [ 'attrSetColFac' => $this->attrSetColFac, 'prodAttrColFac' => $this->prodAttrColFac, 'resource' => $this->resource, - 'params' => $this->params + 'params' => $this->params, + 'scopeResolver' => $this->scopeResolver, ] ); @@ -214,7 +221,8 @@ public function testSaveData($skus, $bunch, $allowImport) $this->entityModel->expects($this->any())->method('isRowAllowedToImport')->will($this->returnValue( $allowImport )); - + $scope = $this->getMockBuilder(\Magento\Framework\App\ScopeInterface::class)->getMockForAbstractClass(); + $this->scopeResolver->expects($this->any())->method('getScope')->willReturn($scope); $this->connection->expects($this->any())->method('fetchAssoc')->with($this->select)->will($this->returnValue([ '1' => [ 'option_id' => '1', diff --git a/app/code/Magento/BundleImportExport/composer.json b/app/code/Magento/BundleImportExport/composer.json index 19aef21135d90..42d9dc558d31e 100644 --- a/app/code/Magento/BundleImportExport/composer.json +++ b/app/code/Magento/BundleImportExport/composer.json @@ -5,16 +5,16 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-bundle": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-import-export": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-import-export": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-bundle": "*", + "magento/module-store": "*", + "magento/module-catalog": "*", + "magento/module-catalog-import-export": "*", + "magento/module-eav": "*", + "magento/module-import-export": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CacheInvalidate/composer.json b/app/code/Magento/CacheInvalidate/composer.json index 61a4d8831f117..01ac4d30d844d 100644 --- a/app/code/Magento/CacheInvalidate/composer.json +++ b/app/code/Magento/CacheInvalidate/composer.json @@ -5,12 +5,11 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-page-cache": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-page-cache": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Captcha/Test/Unit/Model/CaptchaFactoryTest.php b/app/code/Magento/Captcha/Test/Unit/Model/CaptchaFactoryTest.php index bc2f8d02e2544..2890f10a2ae12 100644 --- a/app/code/Magento/Captcha/Test/Unit/Model/CaptchaFactoryTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Model/CaptchaFactoryTest.php @@ -54,12 +54,9 @@ public function testCreateNegative() $this->returnValue($defaultCaptchaMock) ); - $this->expectException( - 'InvalidArgumentException', - 'Magento\Captcha\Model\\' . ucfirst( - $captchaType - ) . ' does not implement \Magento\Captcha\Model\CaptchaInterface' - ); + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Magento\Captcha\Model\\' . ucfirst($captchaType) . + ' does not implement \Magento\Captcha\Model\CaptchaInterface'); $this->assertEquals($defaultCaptchaMock, $this->_model->create($captchaType, 'form_id')); } diff --git a/app/code/Magento/Captcha/composer.json b/app/code/Magento/Captcha/composer.json index 98a0d442c659d..62f586ba82ae0 100644 --- a/app/code/Magento/Captcha/composer.json +++ b/app/code/Magento/Captcha/composer.json @@ -5,18 +5,17 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-store": "100.3.*", + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-checkout": "*", + "magento/module-customer": "*", + "magento/module-store": "*", "zendframework/zend-captcha": "^2.7.1", "zendframework/zend-db": "^2.8.2", "zendframework/zend-session": "^2.7.3" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Captcha/etc/db_schema.xml b/app/code/Magento/Captcha/etc/db_schema.xml index 25aef55c606f1..fa9a14abb8963 100644 --- a/app/code/Magento/Captcha/etc/db_schema.xml +++ b/app/code/Magento/Captcha/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
diff --git a/app/code/Magento/Catalog/Api/Data/ProductAttributeInterface.php b/app/code/Magento/Catalog/Api/Data/ProductAttributeInterface.php index 12adf5b08938f..590c23a0aa0b1 100644 --- a/app/code/Magento/Catalog/Api/Data/ProductAttributeInterface.php +++ b/app/code/Magento/Catalog/Api/Data/ProductAttributeInterface.php @@ -32,4 +32,9 @@ interface ProductAttributeInterface extends \Magento\Catalog\Api\Data\EavAttribu const CODE_TIER_PRICE_FIELD_VALUE_TYPE = 'value_type'; const CODE_SEO_FIELD_META_DESCRIPTION = 'meta_description'; const CODE_WEIGHT = 'weight'; + + /** + * @return \Magento\Eav\Api\Data\AttributeExtensionInterface|null + */ + public function getExtensionAttributes(); } diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Category/Tab/Product.php b/app/code/Magento/Catalog/Block/Adminhtml/Category/Tab/Product.php index df5894bf4cbd1..20bd1b379beef 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Category/Tab/Product.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Category/Tab/Product.php @@ -14,6 +14,9 @@ use Magento\Backend\Block\Widget\Grid; use Magento\Backend\Block\Widget\Grid\Column; use Magento\Backend\Block\Widget\Grid\Extended; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Framework\App\ObjectManager; class Product extends \Magento\Backend\Block\Widget\Grid\Extended { @@ -29,22 +32,38 @@ class Product extends \Magento\Backend\Block\Widget\Grid\Extended */ protected $_productFactory; + /** + * @var Status + */ + private $status; + + /** + * @var Visibility + */ + private $visibility; + /** * @param \Magento\Backend\Block\Template\Context $context * @param \Magento\Backend\Helper\Data $backendHelper * @param \Magento\Catalog\Model\ProductFactory $productFactory * @param \Magento\Framework\Registry $coreRegistry * @param array $data + * @param Visibility|null $visibility + * @param Status|null $status */ public function __construct( \Magento\Backend\Block\Template\Context $context, \Magento\Backend\Helper\Data $backendHelper, \Magento\Catalog\Model\ProductFactory $productFactory, \Magento\Framework\Registry $coreRegistry, - array $data = [] + array $data = [], + Visibility $visibility = null, + Status $status = null ) { $this->_productFactory = $productFactory; $this->_coreRegistry = $coreRegistry; + $this->visibility = $visibility ?: ObjectManager::getInstance()->get(Visibility::class); + $this->status = $status ?: ObjectManager::getInstance()->get(Status::class); parent::__construct($context, $backendHelper, $data); } @@ -102,6 +121,10 @@ protected function _prepareCollection() 'name' )->addAttributeToSelect( 'sku' + )->addAttributeToSelect( + 'visibility' + )->addAttributeToSelect( + 'status' )->addAttributeToSelect( 'price' )->joinField( @@ -159,6 +182,28 @@ protected function _prepareColumns() ); $this->addColumn('name', ['header' => __('Name'), 'index' => 'name']); $this->addColumn('sku', ['header' => __('SKU'), 'index' => 'sku']); + $this->addColumn( + 'visibility', + [ + 'header' => __('Visibility'), + 'index' => 'visibility', + 'type' => 'options', + 'options' => $this->visibility->getOptionArray(), + 'header_css_class' => 'col-visibility', + 'column_css_class' => 'col-visibility' + ] + ); + + $this->addColumn( + 'status', + [ + 'header' => __('Status'), + 'index' => 'status', + 'type' => 'options', + 'options' => $this->status->getOptionArray() + ] + ); + $this->addColumn( 'price', [ diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit/Tab/Front.php b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit/Tab/Front.php index cc50dbde69ee3..a0ca53dce4f50 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit/Tab/Front.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Edit/Tab/Front.php @@ -184,33 +184,19 @@ protected function _prepareForm() 'form_after', $this->getLayout()->createBlock( \Magento\Backend\Block\Widget\Form\Element\Dependence::class - )->addFieldMap( - "is_wysiwyg_enabled", - 'wysiwyg_enabled' )->addFieldMap( "is_html_allowed_on_front", 'html_allowed_on_front' )->addFieldMap( "frontend_input", 'frontend_input_type' - )->addFieldDependence( - 'wysiwyg_enabled', - 'frontend_input_type', - 'textarea' - )->addFieldDependence( - 'html_allowed_on_front', - 'wysiwyg_enabled', - '0' - ) - ->addFieldMap( + )->addFieldMap( "is_searchable", 'searchable' - ) - ->addFieldMap( + )->addFieldMap( "is_visible_in_advanced_search", 'advanced_search' - ) - ->addFieldDependence( + )->addFieldDependence( 'advanced_search', 'searchable', '1' diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Set/Main.php b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Set/Main.php index c6e48c02805a6..6760b44c22ee1 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Set/Main.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Product/Attribute/Set/Main.php @@ -233,7 +233,7 @@ public function getGroupTreeJson() /* @var $node \Magento\Eav\Model\Entity\Attribute\Group */ foreach ($groups as $node) { $item = []; - $item['text'] = $node->getAttributeGroupName(); + $item['text'] = $this->escapeHtml($node->getAttributeGroupName()); $item['id'] = $node->getAttributeGroupId(); $item['cls'] = 'folder'; $item['allowDrop'] = true; @@ -280,7 +280,7 @@ public function getAttributeTreeJson() foreach ($attributes as $child) { $attr = [ - 'text' => $child->getAttributeCode(), + 'text' => $this->escapeHtml($child->getAttributeCode()), 'id' => $child->getAttributeId(), 'cls' => 'leaf', 'allowDrop' => false, diff --git a/app/code/Magento/Catalog/Block/Product/AbstractProduct.php b/app/code/Magento/Catalog/Block/Product/AbstractProduct.php index d4af775ad20da..f22edd91e7914 100644 --- a/app/code/Magento/Catalog/Block/Product/AbstractProduct.php +++ b/app/code/Magento/Catalog/Block/Product/AbstractProduct.php @@ -510,9 +510,6 @@ protected function getDetailsRendererList() */ public function getImage($product, $imageId, $attributes = []) { - return $this->imageBuilder->setProduct($product) - ->setImageId($imageId) - ->setAttributes($attributes) - ->create(); + return $this->imageBuilder->create($product, $imageId, $attributes); } } diff --git a/app/code/Magento/Catalog/Block/Product/Compare/ListCompare.php b/app/code/Magento/Catalog/Block/Product/Compare/ListCompare.php index c561913dee4d3..6c54aa4e171ef 100644 --- a/app/code/Magento/Catalog/Block/Product/Compare/ListCompare.php +++ b/app/code/Magento/Catalog/Block/Product/Compare/ListCompare.php @@ -213,6 +213,22 @@ public function getProductAttributeValue($product, $attribute) return (string)$value == '' ? __('No') : $value; } + /** + * Check if any of the products has a value set for the attribute + * + * @param \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute + * @return bool + */ + public function hasAttributeValueForProducts($attribute) + { + foreach ($this->getItems() as $item) { + if ($item->hasData($attribute->getAttributeCode())) { + return true; + } + } + return false; + } + /** * Retrieve Print URL * diff --git a/app/code/Magento/Catalog/Block/Product/Image.php b/app/code/Magento/Catalog/Block/Product/Image.php index a1fcdf43f6eb0..20a556ab41451 100644 --- a/app/code/Magento/Catalog/Block/Product/Image.php +++ b/app/code/Magento/Catalog/Block/Product/Image.php @@ -11,8 +11,6 @@ * @method string getWidth() * @method string getHeight() * @method string getLabel() - * @method mixed getResizedImageWidth() - * @method mixed getResizedImageHeight() * @method float getRatio() * @method string getCustomAttributes() * @since 100.0.2 @@ -20,16 +18,19 @@ class Image extends \Magento\Framework\View\Element\Template { /** + * @deprecated Property isn't used * @var \Magento\Catalog\Helper\Image */ protected $imageHelper; /** + * @deprecated Property isn't used * @var \Magento\Catalog\Model\Product */ protected $product; /** + * @deprecated Property isn't used * @var array */ protected $attributes = []; diff --git a/app/code/Magento/Catalog/Block/Product/ImageBuilder.php b/app/code/Magento/Catalog/Block/Product/ImageBuilder.php index b752000f5a19d..06d4fb39109d8 100644 --- a/app/code/Magento/Catalog/Block/Product/ImageBuilder.php +++ b/app/code/Magento/Catalog/Block/Product/ImageBuilder.php @@ -3,12 +3,17 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Block\Product; use Magento\Catalog\Helper\ImageFactory as HelperFactory; use Magento\Catalog\Model\Product; -use Magento\Catalog\Model\Product\Image\NotLoadInfoImageException; +/** + * @deprecated + * @see ImageFactory + */ class ImageBuilder { /** @@ -117,38 +122,16 @@ protected function getRatio(\Magento\Catalog\Helper\Image $helper) /** * Create image block * - * @return \Magento\Catalog\Block\Product\Image + * @param Product|null $product + * @param string|null $imageId + * @param array|null $attributes + * @return Image */ - public function create() + public function create(Product $product = null, string $imageId = null, array $attributes = null) { - /** @var \Magento\Catalog\Helper\Image $helper */ - $helper = $this->helperFactory->create() - ->init($this->product, $this->imageId); - - $template = $helper->getFrame() - ? 'Magento_Catalog::product/image.phtml' - : 'Magento_Catalog::product/image_with_borders.phtml'; - - try { - $imagesize = $helper->getResizedImageInfo(); - } catch (NotLoadInfoImageException $exception) { - $imagesize = [$helper->getWidth(), $helper->getHeight()]; - } - - $data = [ - 'data' => [ - 'template' => $template, - 'image_url' => $helper->getUrl(), - 'width' => $helper->getWidth(), - 'height' => $helper->getHeight(), - 'label' => $helper->getLabel(), - 'ratio' => $this->getRatio($helper), - 'custom_attributes' => $this->getCustomAttributes(), - 'resized_image_width' => $imagesize[0], - 'resized_image_height' => $imagesize[1], - ], - ]; - - return $this->imageFactory->create($data); + $product = $product ?? $this->product; + $imageId = $imageId ?? $this->imageId; + $attributes = $attributes ?? $this->attributes; + return $this->imageFactory->create($product, $imageId, $attributes); } } diff --git a/app/code/Magento/Catalog/Block/Product/ImageFactory.php b/app/code/Magento/Catalog/Block/Product/ImageFactory.php new file mode 100644 index 0000000000000..f9a576367ddeb --- /dev/null +++ b/app/code/Magento/Catalog/Block/Product/ImageFactory.php @@ -0,0 +1,163 @@ +objectManager = $objectManager; + $this->presentationConfig = $presentationConfig; + $this->viewAssetPlaceholderFactory = $viewAssetPlaceholderFactory; + $this->viewAssetImageFactory = $viewAssetImageFactory; + $this->imageParamsBuilder = $imageParamsBuilder; + } + + /** + * Retrieve image custom attributes for HTML element + * + * @param array $attributes + * @return string + */ + private function getStringCustomAttributes(array $attributes): string + { + $result = []; + foreach ($attributes as $name => $value) { + $result[] = $name . '="' . $value . '"'; + } + return !empty($result) ? implode(' ', $result) : ''; + } + + /** + * Calculate image ratio + * + * @param $width + * @param $height + * @return float + */ + private function getRatio(int $width, int $height): float + { + if ($width && $height) { + return $height / $width; + } + return 1.0; + } + + /** + * @param Product $product + * + * @param string $imageType + * @return string + */ + private function getLabel(Product $product, string $imageType): string + { + $label = $product->getData($imageType . '_' . 'label'); + if (empty($label)) { + $label = $product->getName(); + } + return (string) $label; + } + + /** + * Create image block from product + * @param Product $product + * @param string $imageId + * @param array|null $attributes + * @return ImageBlock + */ + public function create(Product $product, string $imageId, array $attributes = null): ImageBlock + { + $viewImageConfig = $this->presentationConfig->getViewConfig()->getMediaAttributes( + 'Magento_Catalog', + ImageHelper::MEDIA_TYPE_CONFIG_NODE, + $imageId + ); + + $imageMiscParams = $this->imageParamsBuilder->build($viewImageConfig); + $originalFilePath = $product->getData($imageMiscParams['image_type']); + + if ($originalFilePath === null || $originalFilePath === 'no_selection') { + $imageAsset = $this->viewAssetPlaceholderFactory->create( + [ + 'type' => $imageMiscParams['image_type'] + ] + ); + } else { + $imageAsset = $this->viewAssetImageFactory->create( + [ + 'miscParams' => $imageMiscParams, + 'filePath' => $originalFilePath, + ] + ); + } + + $data = [ + 'data' => [ + 'template' => 'Magento_Catalog::product/image_with_borders.phtml', + 'image_url' => $imageAsset->getUrl(), + 'width' => $imageMiscParams['image_width'], + 'height' => $imageMiscParams['image_height'], + 'label' => $this->getLabel($product, $imageMiscParams['image_type']), + 'ratio' => $this->getRatio($imageMiscParams['image_width'], $imageMiscParams['image_height']), + 'custom_attributes' => $this->getStringCustomAttributes($attributes), + 'product_id' => $product->getId() + ], + ]; + + return $this->objectManager->create(ImageBlock::class, $data); + } +} diff --git a/app/code/Magento/Catalog/Block/Product/ListProduct.php b/app/code/Magento/Catalog/Block/Product/ListProduct.php index 4a7d28c383d8b..c1b255c762dbb 100644 --- a/app/code/Magento/Catalog/Block/Product/ListProduct.php +++ b/app/code/Magento/Catalog/Block/Product/ListProduct.php @@ -9,11 +9,20 @@ use Magento\Catalog\Api\CategoryRepositoryInterface; use Magento\Catalog\Block\Product\ProductList\Toolbar; use Magento\Catalog\Model\Category; +use Magento\Catalog\Model\Config; +use Magento\Catalog\Model\Layer; +use Magento\Catalog\Model\Layer\Resolver; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\ResourceModel\Product\Collection; +use Magento\Catalog\Pricing\Price\FinalPrice; use Magento\Eav\Model\Entity\Collection\AbstractCollection; +use Magento\Framework\App\ActionInterface; +use Magento\Framework\App\Config\Element; +use Magento\Framework\Data\Helper\PostHelper; use Magento\Framework\DataObject\IdentityInterface; use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Pricing\Render; +use Magento\Framework\Url\Helper\Data; /** * Product list @@ -40,17 +49,17 @@ class ListProduct extends AbstractProduct implements IdentityInterface /** * Catalog layer * - * @var \Magento\Catalog\Model\Layer + * @var Layer */ protected $_catalogLayer; /** - * @var \Magento\Framework\Data\Helper\PostHelper + * @var PostHelper */ protected $_postDataHelper; /** - * @var \Magento\Framework\Url\Helper\Data + * @var Data */ protected $urlHelper; @@ -61,18 +70,18 @@ class ListProduct extends AbstractProduct implements IdentityInterface /** * @param Context $context - * @param \Magento\Framework\Data\Helper\PostHelper $postDataHelper - * @param \Magento\Catalog\Model\Layer\Resolver $layerResolver + * @param PostHelper $postDataHelper + * @param Resolver $layerResolver * @param CategoryRepositoryInterface $categoryRepository - * @param \Magento\Framework\Url\Helper\Data $urlHelper + * @param Data $urlHelper * @param array $data */ public function __construct( - \Magento\Catalog\Block\Product\Context $context, - \Magento\Framework\Data\Helper\PostHelper $postDataHelper, - \Magento\Catalog\Model\Layer\Resolver $layerResolver, + Context $context, + PostHelper $postDataHelper, + Resolver $layerResolver, CategoryRepositoryInterface $categoryRepository, - \Magento\Framework\Url\Helper\Data $urlHelper, + Data $urlHelper, array $data = [] ) { $this->_catalogLayer = $layerResolver->get(); @@ -113,7 +122,7 @@ protected function _getProductCollection() /** * Get catalog layer model * - * @return \Magento\Catalog\Model\Layer + * @return Layer */ public function getLayer() { @@ -137,7 +146,35 @@ public function getLoadedProductCollection() */ public function getMode() { - return $this->getChildBlock('toolbar')->getCurrentMode(); + if ($this->getChildBlock('toolbar')) { + return $this->getChildBlock('toolbar')->getCurrentMode(); + } + + return $this->getDefaultListingMode(); + } + + /** + * Get listing mode for products if toolbar is removed from layout. + * Use the general configuration for product list mode from config path catalog/frontend/list_mode as default value + * or mode data from block declaration from layout. + * + * @return string + */ + private function getDefaultListingMode() + { + // default Toolbar when the toolbar layout is not used + $defaultToolbar = $this->getToolbarBlock(); + $availableModes = $defaultToolbar->getModes(); + + // layout config mode + $mode = $this->getData('mode'); + + if (!$mode || !isset($availableModes[$mode])) { + // default config mode + $mode = $defaultToolbar->getCurrentMode(); + } + + return $mode; } /** @@ -148,28 +185,60 @@ public function getMode() protected function _beforeToHtml() { $collection = $this->_getProductCollection(); - $this->configureToolbar($this->getToolbarBlock(), $collection); + + $this->addToolbarBlock($collection); + $collection->load(); return parent::_beforeToHtml(); } /** - * Retrieve Toolbar block + * Add toolbar block from product listing layout + * + * @param Collection $collection + */ + private function addToolbarBlock(Collection $collection) + { + $toolbarLayout = $this->getToolbarFromLayout(); + + if ($toolbarLayout) { + $this->configureToolbar($toolbarLayout, $collection); + } + } + + /** + * Retrieve Toolbar block from layout or a default Toolbar * * @return Toolbar */ public function getToolbarBlock() + { + $block = $this->getToolbarFromLayout(); + + if (!$block) { + $block = $this->getLayout()->createBlock($this->_defaultToolbarBlock, uniqid(microtime())); + } + + return $block; + } + + /** + * Get toolbar block from layout + * + * @return bool|Toolbar + */ + private function getToolbarFromLayout() { $blockName = $this->getToolbarBlockName(); + + $toolbarLayout = false; + if ($blockName) { - $block = $this->getLayout()->getBlock($blockName); - if ($block) { - return $block; - } + $toolbarLayout = $this->getLayout()->getBlock($blockName); } - $block = $this->getLayout()->createBlock($this->_defaultToolbarBlock, uniqid(microtime())); - return $block; + + return $toolbarLayout; } /** @@ -203,7 +272,7 @@ public function setCollection($collection) } /** - * @param array|string|integer|\Magento\Framework\App\Config\Element $code + * @param array|string|integer| Element $code * @return $this */ public function addAttribute($code) @@ -223,7 +292,7 @@ public function getPriceBlockTemplate() /** * Retrieve Catalog Config object * - * @return \Magento\Catalog\Model\Config + * @return Config */ protected function _getConfig() { @@ -233,8 +302,8 @@ protected function _getConfig() /** * Prepare Sort By fields from Category Data * - * @param \Magento\Catalog\Model\Category $category - * @return \Magento\Catalog\Block\Product\ListProduct + * @param Category $category + * @return $this */ public function prepareSortableFieldsByCategory($category) { @@ -286,38 +355,38 @@ public function getIdentities() /** * Get post parameters * - * @param \Magento\Catalog\Model\Product $product - * @return string + * @param Product $product + * @return array */ - public function getAddToCartPostParams(\Magento\Catalog\Model\Product $product) + public function getAddToCartPostParams(Product $product) { $url = $this->getAddToCartUrl($product); return [ 'action' => $url, 'data' => [ 'product' => $product->getEntityId(), - \Magento\Framework\App\ActionInterface::PARAM_NAME_URL_ENCODED => $this->urlHelper->getEncodedUrl($url), + ActionInterface::PARAM_NAME_URL_ENCODED => $this->urlHelper->getEncodedUrl($url), ] ]; } /** - * @param \Magento\Catalog\Model\Product $product + * @param Product $product * @return string */ - public function getProductPrice(\Magento\Catalog\Model\Product $product) + public function getProductPrice(Product $product) { $priceRender = $this->getPriceRender(); $price = ''; if ($priceRender) { $price = $priceRender->render( - \Magento\Catalog\Pricing\Price\FinalPrice::PRICE_CODE, + FinalPrice::PRICE_CODE, $product, [ 'include_container' => true, 'display_minimal_price' => true, - 'zone' => \Magento\Framework\Pricing\Render::ZONE_ITEM_LIST, + 'zone' => Render::ZONE_ITEM_LIST, 'list_category_page' => true ] ); @@ -330,7 +399,7 @@ public function getProductPrice(\Magento\Catalog\Model\Product $product) * Specifies that price rendering should be done for the list of products * i.e. rendering happens in the scope of product list, but not single product * - * @return \Magento\Framework\Pricing\Render + * @return Render */ protected function getPriceRender() { @@ -356,7 +425,7 @@ protected function getPriceRender() private function initializeProductCollection() { $layer = $this->getLayer(); - /* @var $layer \Magento\Catalog\Model\Layer */ + /* @var $layer Layer */ if ($this->getShowRootCategory()) { $this->setCategoryId($this->_storeManager->getStore()->getRootCategoryId()); } @@ -395,8 +464,7 @@ private function initializeProductCollection() $layer->setCurrentCategory($origCategory); } - $toolbar = $this->getToolbarBlock(); - $this->configureToolbar($toolbar, $collection); + $this->addToolbarBlock($collection); $this->_eventManager->dispatch( 'catalog_block_product_list_collection', diff --git a/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php b/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php index 46080ab5c3330..c53691ecada24 100644 --- a/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php +++ b/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php @@ -689,7 +689,7 @@ public function getWidgetOptionsJson(array $customOptions = []) 'limit' => ToolbarModel::LIMIT_PARAM_NAME, 'modeDefault' => $defaultMode, 'directionDefault' => $this->_direction ?: ProductList::DEFAULT_SORT_DIRECTION, - 'orderDefault' => $this->_productListHelper->getDefaultSortField(), + 'orderDefault' => $this->getOrderField(), 'limitDefault' => $this->_productListHelper->getDefaultLimitPerPageValue($defaultMode), 'url' => $this->getPagerUrl(), ]; diff --git a/app/code/Magento/Catalog/Block/Product/View/Gallery.php b/app/code/Magento/Catalog/Block/Product/View/Gallery.php index 90e89acfba77a..ab01fc6d134e9 100644 --- a/app/code/Magento/Catalog/Block/Product/View/Gallery.php +++ b/app/code/Magento/Catalog/Block/Product/View/Gallery.php @@ -15,6 +15,7 @@ use Magento\Catalog\Helper\Image; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface; +use Magento\Catalog\Model\Product\Image\UrlBuilder; use Magento\Framework\Data\Collection; use Magento\Framework\DataObject; use Magento\Framework\App\ObjectManager; @@ -47,13 +48,19 @@ class Gallery extends AbstractView */ private $galleryImagesConfigFactory; + /** + * @var UrlBuilder + */ + private $imageUrlBuilder; + /** * @param Context $context * @param ArrayUtils $arrayUtils * @param EncoderInterface $jsonEncoder * @param array $data - * @param ImagesConfigFactoryInterface $imagesConfigFactory + * @param ImagesConfigFactoryInterface|null $imagesConfigFactory * @param array $galleryImagesConfig + * @param UrlBuilder|null $urlBuilder */ public function __construct( Context $context, @@ -61,13 +68,15 @@ public function __construct( EncoderInterface $jsonEncoder, array $data = [], ImagesConfigFactoryInterface $imagesConfigFactory = null, - array $galleryImagesConfig = [] + array $galleryImagesConfig = [], + UrlBuilder $urlBuilder = null ) { parent::__construct($context, $arrayUtils, $data); $this->jsonEncoder = $jsonEncoder; $this->galleryImagesConfigFactory = $imagesConfigFactory ?: ObjectManager::getInstance() ->get(ImagesConfigFactoryInterface::class); $this->galleryImagesConfig = $galleryImagesConfig; + $this->imageUrlBuilder = $urlBuilder ?? ObjectManager::getInstance()->get(UrlBuilder::class); } /** @@ -88,9 +97,7 @@ public function getGalleryImages() foreach ($galleryImagesConfig as $imageConfig) { $image->setData( $imageConfig->getData('data_object_key'), - $this->_imageHelper->init($product, $imageConfig['image_id']) - ->setImageFile($image->getData('file')) - ->getUrl() + $this->imageUrlBuilder->getUrl($image->getFile(), $imageConfig['image_id']) ); } } diff --git a/app/code/Magento/Catalog/Console/Command/ImagesResizeCommand.php b/app/code/Magento/Catalog/Console/Command/ImagesResizeCommand.php deleted file mode 100644 index 49f82562f33db..0000000000000 --- a/app/code/Magento/Catalog/Console/Command/ImagesResizeCommand.php +++ /dev/null @@ -1,100 +0,0 @@ -appState = $appState; - $this->productCollectionFactory = $productCollectionFactory; - $this->productRepository = $productRepository; - $this->imageCacheFactory = $imageCacheFactory; - parent::__construct(); - } - - /** - * {@inheritdoc} - */ - protected function configure() - { - $this->setName('catalog:images:resize') - ->setDescription('Creates resized product images'); - } - - /** - * {@inheritdoc} - */ - protected function execute( - \Symfony\Component\Console\Input\InputInterface $input, - \Symfony\Component\Console\Output\OutputInterface $output - ) { - $this->appState->setAreaCode(\Magento\Framework\App\Area::AREA_GLOBAL); - - /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $productCollection */ - $productCollection = $this->productCollectionFactory->create(); - $productIds = $productCollection->getAllIds(); - if (!count($productIds)) { - $output->writeln("No product images to resize"); - // we must have an exit code higher than zero to indicate something was wrong - return \Magento\Framework\Console\Cli::RETURN_SUCCESS; - } - - try { - foreach ($productIds as $productId) { - try { - /** @var \Magento\Catalog\Model\Product $product */ - $product = $this->productRepository->getById($productId); - } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { - continue; - } - - /** @var \Magento\Catalog\Model\Product\Image\Cache $imageCache */ - $imageCache = $this->imageCacheFactory->create(); - $imageCache->generate($product); - - $output->write("."); - } - } catch (\Exception $e) { - $output->writeln("{$e->getMessage()}"); - // we must have an exit code higher than zero to indicate something was wrong - return \Magento\Framework\Console\Cli::RETURN_FAILURE; - } - - $output->write("\n"); - $output->writeln("Product images resized successfully"); - } -} 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 f0ba9b518fa5e..e054a9d49b437 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php @@ -139,9 +139,7 @@ public function execute() ->setName($name) ->getAttributeSet(); } catch (AlreadyExistsException $alreadyExists) { - $this->messageManager->addErrorMessage( - __('A "%1" attribute set name already exists. Create a new name and try again.', $name) - ); + $this->messageManager->addErrorMessage(__('An attribute set named \'%1\' already exists.', $name)); $this->_session->setAttributeData($data); return $this->returnResult('catalog/*/edit', ['_current' => true], ['error' => true]); } catch (LocalizedException $e) { @@ -218,6 +216,7 @@ public function execute() $data['attribute_code'] = $model->getAttributeCode(); $data['is_user_defined'] = $model->getIsUserDefined(); + $data['frontend_input'] = $data['frontend_input'] ?? $model->getFrontendInput(); } else { /** * @todo add to helper and specify all relations for properties diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php index 8e11a57f96d71..b61be2b95b960 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php @@ -113,6 +113,11 @@ public function execute() $options ); $valueOptions = (isset($options['value']) && is_array($options['value'])) ? $options['value'] : []; + foreach (array_keys($valueOptions) as $key) { + if (!empty($options['delete'][$key])) { + unset($valueOptions[$key]); + } + } $this->checkEmptyOption($response, $valueOptions); } diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/GetSelected.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/GetSelected.php new file mode 100644 index 0000000000000..841715180a229 --- /dev/null +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/GetSelected.php @@ -0,0 +1,74 @@ +resultJsonFactory = $jsonFactory; + $this->productCollectionFactory = $productCollectionFactory; + parent::__construct($context); + } + + /** + * @return \Magento\Framework\Controller\ResultInterface + */ + public function execute() : \Magento\Framework\Controller\ResultInterface + { + $productId = $this->getRequest()->getParam('productId'); + /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $productCollection */ + $productCollection = $this->productCollectionFactory->create(); + $productCollection->addAttributeToSelect(ProductInterface::NAME); + $productCollection->addIdFilter($productId); + $option = []; + /** @var ProductInterface $product */ + if (!empty($productCollection->getFirstItem()->getData())) { + $product = $productCollection->getFirstItem(); + $option = [ + 'value' => $productId, + 'label' => $product->getName(), + 'is_active' => $product->getStatus(), + 'path' => $product->getSku(), + ]; + } + /** @var \Magento\Framework\Controller\Result\Json $resultJson */ + $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($option); + } +} diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php index 237168282afae..ee3a6d491e92f 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Initialization/Helper/AttributeFilter.php @@ -3,6 +3,9 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper; use \Magento\Catalog\Model\Product; @@ -28,14 +31,25 @@ class AttributeFilter public function prepareProductAttributes(Product $product, array $productData, array $useDefaults) { foreach ($productData as $attribute => $value) { - $considerUseDefaultsAttribute = !isset($useDefaults[$attribute]) || $useDefaults[$attribute] === "1"; - if ($value === '' && $considerUseDefaultsAttribute) { - /** @var $product Product */ - if ((bool)$product->getData($attribute) === (bool)$value) { - unset($productData[$attribute]); - } + if ($this->isAttributeShouldNotBeUpdated($product, $useDefaults, $attribute, $value)) { + unset($productData[$attribute]); } } + return $productData; } + + /** + * @param Product $product + * @param $useDefaults + * @param $attribute + * @param $value + * @return bool + */ + private function isAttributeShouldNotBeUpdated(Product $product, $useDefaults, $attribute, $value) : bool + { + $considerUseDefaultsAttribute = !isset($useDefaults[$attribute]) || $useDefaults[$attribute] === "1"; + + return ($value === '' && $considerUseDefaultsAttribute && !$product->getData($attribute)); + } } diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Search.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Search.php new file mode 100644 index 0000000000000..c7c71b2f56026 --- /dev/null +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Search.php @@ -0,0 +1,81 @@ +resultJsonFactory = $resultFactory; + $this->productSearch = $productSearch; + parent::__construct($context); + } + + /** + * @return \Magento\Framework\Controller\ResultInterface + */ + public function execute() : \Magento\Framework\Controller\ResultInterface + { + $searchKey = $this->getRequest()->getParam('searchKey'); + $pageNum = (int)$this->getRequest()->getParam('page'); + $limit = (int)$this->getRequest()->getParam('limit'); + + /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $productCollection */ + $productCollection = $this->productSearch->prepareCollection($searchKey, $pageNum, $limit); + $totalValues = $productCollection->getSize(); + $productById = []; + /** @var ProductInterface $product */ + foreach ($productCollection as $product) { + $productId = $product->getId(); + $productById[$productId] = [ + 'value' => $productId, + 'label' => $product->getName(), + 'is_active' => $product->getStatus(), + 'path' => $product->getSku(), + 'optgroup' => false + ]; + } + /** @var \Magento\Framework\Controller\Result\Json $resultJson */ + $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData([ + 'options' => $productById, + 'total' => empty($productById) ? 0 : $totalValues + ]); + } +} diff --git a/app/code/Magento/Catalog/Cron/DeleteOutdatedPriceValues.php b/app/code/Magento/Catalog/Cron/DeleteOutdatedPriceValues.php index 741463ee576cb..7cc3eb9e3d2da 100644 --- a/app/code/Magento/Catalog/Cron/DeleteOutdatedPriceValues.php +++ b/app/code/Magento/Catalog/Cron/DeleteOutdatedPriceValues.php @@ -48,27 +48,46 @@ public function __construct( } /** - * Delete all price values for non-admin stores if PRICE_SCOPE is global + * Delete all price values for non-admin stores if PRICE_SCOPE is set to global. * * @return void */ public function execute() { - $priceScope = $this->scopeConfig->getValue(Store::XML_PATH_PRICE_SCOPE); - if ($priceScope == Store::PRICE_SCOPE_GLOBAL) { - /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $priceAttribute */ - $priceAttribute = $this->attributeRepository - ->get(ProductAttributeInterface::ENTITY_TYPE_CODE, ProductAttributeInterface::CODE_PRICE); - $connection = $this->resource->getConnection(); - $conditions = [ - $connection->quoteInto('attribute_id = ?', $priceAttribute->getId()), - $connection->quoteInto('store_id != ?', Store::DEFAULT_STORE_ID), - ]; + if (!$this->isPriceScopeSetToGlobal()) { + return; + } + + /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $priceAttribute */ + $priceAttribute = $this->attributeRepository + ->get(ProductAttributeInterface::ENTITY_TYPE_CODE, ProductAttributeInterface::CODE_PRICE); + $connection = $this->resource->getConnection(); + $conditions = [ + $connection->quoteInto('attribute_id = ?', $priceAttribute->getId()), + $connection->quoteInto('store_id != ?', Store::DEFAULT_STORE_ID), + ]; - $connection->delete( - $priceAttribute->getBackend()->getTable(), - $conditions - ); + $connection->delete( + $priceAttribute->getBackend()->getTable(), + $conditions + ); + } + + /** + * Checks if price scope config option explicitly equal to global value. + * + * Such strict comparision is required to prevent price deleting when + * price scope config option is null for some reason. + * + * @return bool + */ + private function isPriceScopeSetToGlobal() + { + $priceScope = $this->scopeConfig->getValue(Store::XML_PATH_PRICE_SCOPE); + if ($priceScope === null) { + return false; } + + return (int)$priceScope === Store::PRICE_SCOPE_GLOBAL; } } diff --git a/app/code/Magento/Catalog/Helper/Image.php b/app/code/Magento/Catalog/Helper/Image.php index 380fd0298c2d7..4f128d639b2bb 100644 --- a/app/code/Magento/Catalog/Helper/Image.php +++ b/app/code/Magento/Catalog/Helper/Image.php @@ -845,10 +845,10 @@ public function getHeight() public function getFrame() { $frame = $this->getAttribute('frame'); - if (empty($frame)) { + if ($frame === null) { $frame = $this->getConfigView()->getVarValue('Magento_Catalog', 'product_image_white_borders'); } - return $frame; + return (bool)$frame; } /** diff --git a/app/code/Magento/Catalog/Helper/Product/Configuration.php b/app/code/Magento/Catalog/Helper/Product/Configuration.php index f732f9a4371cc..9b47e29900992 100644 --- a/app/code/Magento/Catalog/Helper/Product/Configuration.php +++ b/app/code/Magento/Catalog/Helper/Product/Configuration.php @@ -6,6 +6,7 @@ namespace Magento\Catalog\Helper\Product; use Magento\Framework\App\ObjectManager; +use Magento\Framework\Escaper; use Magento\Framework\Serialize\Serializer\Json; use Magento\Catalog\Helper\Product\Configuration\ConfigurationInterface; use Magento\Framework\App\Helper\AbstractHelper; @@ -43,6 +44,11 @@ class Configuration extends AbstractHelper implements ConfigurationInterface */ private $serializer; + /** + * @var Escaper + */ + private $escaper; + /** * @param \Magento\Framework\App\Helper\Context $context * @param \Magento\Catalog\Model\Product\OptionFactory $productOptionFactory @@ -55,12 +61,14 @@ public function __construct( \Magento\Catalog\Model\Product\OptionFactory $productOptionFactory, \Magento\Framework\Filter\FilterManager $filter, \Magento\Framework\Stdlib\StringUtils $string, - Json $serializer = null + Json $serializer = null, + Escaper $escaper = null ) { $this->_productOptionFactory = $productOptionFactory; $this->filter = $filter; $this->string = $string; $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class); + $this->escaper = $escaper ?: ObjectManager::getInstance()->get(Escaper::class); parent::__construct($context); } @@ -175,7 +183,7 @@ public function getFormattedOptionValue($optionValue, $params = null) if (isset($optionValue['option_id'])) { $optionInfo = $optionValue; if (isset($optionInfo['value'])) { - $optionValue = $optionInfo['value']; + $optionValue = $this->escaper->escapeHtml($optionInfo['value']); } } elseif (isset($optionValue['value'])) { $optionValue = $optionValue['value']; diff --git a/app/code/Magento/Catalog/Helper/Product/View.php b/app/code/Magento/Catalog/Helper/Product/View.php index 11c61df4af64b..5753910c125d2 100644 --- a/app/code/Magento/Catalog/Helper/Product/View.php +++ b/app/code/Magento/Catalog/Helper/Product/View.php @@ -110,13 +110,13 @@ public function __construct( private function preparePageMetadata(ResultPage $resultPage, $product) { $pageLayout = $resultPage->getLayout(); - $pageLayout->createBlock(\Magento\Catalog\Block\Breadcrumbs::class); - $pageConfig = $resultPage->getConfig(); $title = $product->getMetaTitle(); if ($title) { $pageConfig->getTitle()->set($title); + } else { + $pageConfig->getTitle()->set($product->getName()); } $keyword = $product->getMetaKeyword(); @@ -131,7 +131,7 @@ private function preparePageMetadata(ResultPage $resultPage, $product) if ($description) { $pageConfig->setDescription($description); } else { - $pageConfig->setDescription($this->string->substr($product->getDescription(), 0, 255)); + $pageConfig->setDescription($this->string->substr(strip_tags($product->getDescription()), 0, 255)); } if ($this->_catalogProduct->canUseCanonicalTag()) { diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php new file mode 100644 index 0000000000000..d3c84e69c9540 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php @@ -0,0 +1,131 @@ +eavConfig = $eavConfig; + $this->resourceConnection = $resourceConnection; + } + + /** + * Build condition to filter product collection by EAV attribute + * + * @param Filter $filter + * @return string + * @throws \DomainException + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function build(Filter $filter): string + { + $attribute = $this->getAttributeByCode($filter->getField()); + $tableAlias = 'ca_' . $attribute->getAttributeCode(); + + $conditionType = $this->mapConditionType($filter->getConditionType()); + $conditionValue = $this->mapConditionValue($conditionType, $filter->getValue()); + + // NOTE: store scope was ignored intentionally to perform search across all stores + $attributeSelect = $this->resourceConnection->getConnection() + ->select() + ->from( + [$tableAlias => $attribute->getBackendTable()], + $tableAlias . '.' . $attribute->getEntityIdField() + )->where( + $this->resourceConnection->getConnection()->prepareSqlCondition( + $tableAlias . '.' . $attribute->getIdFieldName(), + ['eq' => $attribute->getAttributeId()] + ) + )->where( + $this->resourceConnection->getConnection()->prepareSqlCondition( + $tableAlias . '.value', + [$conditionType => $conditionValue] + ) + ); + + return $this->resourceConnection + ->getConnection() + ->prepareSqlCondition( + Collection::MAIN_TABLE_ALIAS . '.' . $attribute->getEntityIdField(), + [ + 'in' => $attributeSelect + ] + ); + } + + /** + * @param string $field + * @return Attribute + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getAttributeByCode(string $field): Attribute + { + return $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $field); + } + + /** + * Map equal and not equal conditions to in and not in + * + * @param string $conditionType + * @return mixed + */ + private function mapConditionType(string $conditionType): string + { + $conditionsMap = [ + 'eq' => 'in', + 'neq' => 'nin' + ]; + + return isset($conditionsMap[$conditionType]) ? $conditionsMap[$conditionType] : $conditionType; + } + + /** + * Wraps value with '%' if condition type is 'like' or 'not like' + * + * @param string $conditionType + * @param string $conditionValue + * @return string + */ + private function mapConditionValue(string $conditionType, string $conditionValue): string + { + $conditionsMap = ['like', 'nlike']; + + if (in_array($conditionType, $conditionsMap)) { + $conditionValue = '%' . $conditionValue . '%'; + } + + return $conditionValue; + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/Factory.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/Factory.php new file mode 100644 index 0000000000000..66ddd0b8c0ad8 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/Factory.php @@ -0,0 +1,88 @@ +eavConfig = $eavConfig; + $this->productResource = $productResource; + $this->eavAttributeConditionBuilder = $eavAttributeConditionBuilder; + $this->nativeAttributeConditionBuilder = $nativeAttributeConditionBuilder; + } + + /** + * Decides which condition builder should be used for passed filter + * can be either EAV attribute builder or native attribute builder + * "native" attribute means attribute that is in catalog_product_entity table + * + * @param Filter $filter + * @return CustomConditionInterface + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function createByFilter(Filter $filter): CustomConditionInterface + { + $attribute = $this->getAttributeByCode($filter->getField()); + + if ($attribute->getBackendTable() === $this->productResource->getEntityTable()) { + return $this->nativeAttributeConditionBuilder; + } + + return $this->eavAttributeConditionBuilder; + } + + /** + * @param string $field + * @return \Magento\Catalog\Model\ResourceModel\Eav\Attribute + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getAttributeByCode(string $field): Attribute + { + return $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $field); + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/NativeAttributeCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/NativeAttributeCondition.php new file mode 100644 index 0000000000000..d072acf4c719c --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/NativeAttributeCondition.php @@ -0,0 +1,100 @@ +resourceConnection = $resourceConnection; + } + + /** + * Build condition to filter product collection by product native attribute + * "native" attribute means attribute that is in catalog_product_entity table + * + * @param Filter $filter + * @return string + * @throws \DomainException + */ + public function build(Filter $filter): string + { + $conditionType = $this->mapConditionType($filter->getConditionType(), $filter->getField()); + $conditionValue = $this->mapConditionValue($conditionType, $filter->getValue()); + + return $this->resourceConnection + ->getConnection() + ->prepareSqlCondition( + Collection::MAIN_TABLE_ALIAS . '.' . $filter->getField(), + [ + $conditionType => $conditionValue + ] + ); + } + + /** + * Map equal and not equal conditions to in and not in + * + * @param string $conditionType + * @param string $field + * @return mixed + */ + private function mapConditionType(string $conditionType, string $field): string + { + if (strtolower($field) === ProductInterface::SKU) { + $conditionsMap = [ + 'eq' => 'like', + 'neq' => 'nlike' + ]; + } else { + $conditionsMap = [ + 'eq' => 'in', + 'neq' => 'nin' + ]; + } + + return isset($conditionsMap[$conditionType]) ? $conditionsMap[$conditionType] : $conditionType; + } + + /** + * Wraps value with '%' if condition type is 'like' or 'not like' + * + * @param string $conditionType + * @param string $conditionValue + * @return string + */ + private function mapConditionValue(string $conditionType, string $conditionValue): string + { + $conditionsMap = ['like', 'nlike']; + + if (in_array($conditionType, $conditionsMap)) { + $conditionValue = '%' . $conditionValue . '%'; + } + + return $conditionValue; + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/DefaultCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/DefaultCondition.php new file mode 100644 index 0000000000000..14685d87762c2 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/DefaultCondition.php @@ -0,0 +1,45 @@ +conditionBuilderFactory = $conditionBuilderFactory; + } + + /** + * Builds condition to filter product collection either by EAV or by native attribute + * + * @param Filter $filter + * @return string + */ + public function build(Filter $filter): string + { + $filterBuilder = $this->conditionBuilderFactory->createByFilter($filter); + + return $filterBuilder->build($filter); + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php new file mode 100644 index 0000000000000..f70bab73d0830 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php @@ -0,0 +1,126 @@ +resourceConnection = $resourceConnection; + $this->categoryRepository = $categoryRepository; + } + + /** + * Builds condition to filter product collection by categories + * + * @param Filter $filter + * @return string + */ + public function build(Filter $filter): string + { + $categorySelect = $this->resourceConnection->getConnection()->select() + ->from( + ['cat' => $this->resourceConnection->getTableName('catalog_category_product')], + 'cat.product_id' + )->where( + $this->resourceConnection->getConnection()->prepareSqlCondition( + 'cat.category_id', + [$this->mapConditionType($filter->getConditionType()) => $this->getCategoryIds($filter)] + ) + ); + + $selectCondition = [ + 'in' => $categorySelect + ]; + + return $this->resourceConnection->getConnection() + ->prepareSqlCondition(Collection::MAIN_TABLE_ALIAS . '.entity_id', $selectCondition); + } + + /** + * Extracts required category ids from Filter + * If category is anchor all children categories will be included too + * If category is root all children categories will be included too + * + * @param Filter $filter + * @return array + */ + private function getCategoryIds(Filter $filter): array + { + $categoryIds = explode(',', $filter->getValue()); + $childCategoryIds = []; + + foreach ($categoryIds as $categoryId) { + try { + $category = $this->categoryRepository->get($categoryId); + } catch (CategoryDoesNotExistException $exception) { + continue; + } + + if ($category->getIsAnchor()) { + $childCategoryIds[] = $category->getAllChildren(true); + } + + // This is the simplest way to check if category is root + if ((int)$category->getLevel() === $this->rootCategoryLevel) { + $childCategoryIds[] = $category->getAllChildren(true); + } + } + + return array_unique(array_merge($categoryIds, ...$childCategoryIds)); + } + + /** + * Map equal and not equal conditions to in and not in + * + * @param string $conditionType + * @return string + */ + private function mapConditionType(string $conditionType): string + { + $conditionsMap = [ + 'eq' => 'in', + 'neq' => 'nin', + 'like' => 'in', + 'nlike' => 'nin', + ]; + return $conditionsMap[$conditionType] ?? $conditionType; + } +} diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductCategoryFilter.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductCategoryFilter.php index e0fbc16421f55..f8cf810ffb570 100644 --- a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductCategoryFilter.php +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductCategoryFilter.php @@ -21,7 +21,7 @@ class ProductCategoryFilter implements CustomFilterInterface */ public function apply(Filter $filter, AbstractDb $collection) { - $conditionType = $filter->getConditionType() ? $filter->getConditionType() : 'eq'; + $conditionType = $filter->getConditionType() ?: 'eq'; $categoryFilter = [$conditionType => [$filter->getValue()]]; /** @var Collection $collection */ diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductPriceFilter.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductPriceFilter.php new file mode 100644 index 0000000000000..859ad7cec16dc --- /dev/null +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductPriceFilter.php @@ -0,0 +1,44 @@ +addFinalPrice(); + $collection->addMinimalPrice(); + $collection->addPriceData(); + $collection->addTaxPercents(); + + $conditionType = $filter->getConditionType(); + $sqlCondition = $collection + ->getConnection() + ->prepareSqlCondition( + Collection::INDEX_TABLE_ALIAS . '.' . $filter->getField(), + [$conditionType => $filter->getValue()] + ); + $collection->getSelect()->where($sqlCondition); + return true; + } +} diff --git a/app/code/Magento/Catalog/Model/Category.php b/app/code/Magento/Catalog/Model/Category.php index 69340665b2ca1..4f605d0206264 100644 --- a/app/code/Magento/Catalog/Model/Category.php +++ b/app/code/Magento/Catalog/Model/Category.php @@ -32,6 +32,8 @@ * @method Category setUrlPath(string $urlPath) * @method Category getSkipDeleteChildren() * @method Category setSkipDeleteChildren(boolean $value) + * @method Category setChangedProductIds(array $categoryIds) Set products ids that inserted or deleted for category + * @method array getChangedProductIds() Get products ids that inserted or deleted for category * * @SuppressWarnings(PHPMD.LongVariable) * @SuppressWarnings(PHPMD.ExcessivePublicCount) @@ -97,6 +99,11 @@ class Category extends \Magento\Catalog\Model\AbstractModel implements */ protected $_url; + /** + * @var ResourceModel\Category + */ + protected $_resource; + /** * URL rewrite model * @@ -313,6 +320,16 @@ protected function getCustomAttributesCodes() return $this->getCustomAttributeCodes->execute($this->metadataService); } + /** + * @throws \Magento\Framework\Exception\LocalizedException + * @return \Magento\Catalog\Model\ResourceModel\Category + * @deprecated because resource models should be used directly + */ + protected function _getResource() + { + return parent::_getResource(); + } + /** * Get flat resource model flag * @@ -937,8 +954,11 @@ public function getAnchorsAbove() */ public function getProductCount() { - $count = $this->_getResource()->getProductCount($this); - $this->setData(self::KEY_PRODUCT_COUNT, $count); + if (!$this->hasData(self::KEY_PRODUCT_COUNT)) { + $count = $this->_getResource()->getProductCount($this); + $this->setData(self::KEY_PRODUCT_COUNT, $count); + } + return $this->getData(self::KEY_PRODUCT_COUNT); } diff --git a/app/code/Magento/Catalog/Model/Category/Attribute.php b/app/code/Magento/Catalog/Model/Category/Attribute.php index 968db224c01f5..b1803a0db947e 100644 --- a/app/code/Magento/Catalog/Model/Category/Attribute.php +++ b/app/code/Magento/Catalog/Model/Category/Attribute.php @@ -29,14 +29,15 @@ class Attribute extends \Magento\Catalog\Model\Entity\Attribute implements */ public function getApplyTo() { - if ($this->getData(self::APPLY_TO)) { - if (is_array($this->getData(self::APPLY_TO))) { - return $this->getData(self::APPLY_TO); + $applyTo = $this->_getData(self::APPLY_TO); + if ($applyTo) { + if (is_array($applyTo)) { + return $applyTo; } - return explode(',', $this->getData(self::APPLY_TO)); - } else { - return []; + return explode(',', $applyTo); } + + return []; } /** @@ -59,7 +60,7 @@ public function setApplyTo($applyTo) */ public function getIsWysiwygEnabled() { - return $this->getData(self::IS_WYSIWYG_ENABLED); + return $this->_getData(self::IS_WYSIWYG_ENABLED); } /** @@ -70,7 +71,7 @@ public function getIsWysiwygEnabled() */ public function setIsWysiwygEnabled($isWysiwygEnabled) { - return $this->getData(self::IS_WYSIWYG_ENABLED, $isWysiwygEnabled); + return $this->setData(self::IS_WYSIWYG_ENABLED, $isWysiwygEnabled); } /** @@ -78,7 +79,7 @@ public function setIsWysiwygEnabled($isWysiwygEnabled) */ public function getIsHtmlAllowedOnFront() { - return $this->getData(self::IS_HTML_ALLOWED_ON_FRONT); + return $this->_getData(self::IS_HTML_ALLOWED_ON_FRONT); } /** @@ -97,7 +98,7 @@ public function setIsHtmlAllowedOnFront($isHtmlAllowedOnFront) */ public function getUsedForSortBy() { - return $this->getData(self::USED_FOR_SORT_BY); + return $this->_getData(self::USED_FOR_SORT_BY); } /** @@ -116,7 +117,7 @@ public function setUsedForSortBy($usedForSortBy) */ public function getIsFilterable() { - return $this->getData(self::IS_FILTERABLE); + return $this->_getData(self::IS_FILTERABLE); } /** @@ -135,7 +136,7 @@ public function setIsFilterable($isFilterable) */ public function getIsFilterableInSearch() { - return $this->getData(self::IS_FILTERABLE_IN_SEARCH); + return $this->_getData(self::IS_FILTERABLE_IN_SEARCH); } /** @@ -143,7 +144,7 @@ public function getIsFilterableInSearch() */ public function getIsUsedInGrid() { - return (bool)$this->getData(self::IS_USED_IN_GRID); + return (bool)$this->_getData(self::IS_USED_IN_GRID); } /** @@ -151,7 +152,7 @@ public function getIsUsedInGrid() */ public function getIsVisibleInGrid() { - return (bool)$this->getData(self::IS_VISIBLE_IN_GRID); + return (bool)$this->_getData(self::IS_VISIBLE_IN_GRID); } /** @@ -159,7 +160,7 @@ public function getIsVisibleInGrid() */ public function getIsFilterableInGrid() { - return (bool)$this->getData(self::IS_FILTERABLE_IN_GRID); + return (bool)$this->_getData(self::IS_FILTERABLE_IN_GRID); } /** @@ -170,7 +171,7 @@ public function getIsFilterableInGrid() */ public function setIsFilterableInSearch($isFilterableInSearch) { - return $this->getData(self::IS_FILTERABLE_IN_SEARCH, $isFilterableInSearch); + return $this->setData(self::IS_FILTERABLE_IN_SEARCH, $isFilterableInSearch); } /** @@ -178,7 +179,7 @@ public function setIsFilterableInSearch($isFilterableInSearch) */ public function getPosition() { - return $this->getData(self::POSITION); + return $this->_getData(self::POSITION); } /** @@ -197,7 +198,7 @@ public function setPosition($position) */ public function getIsSearchable() { - return $this->getData(self::IS_SEARCHABLE); + return $this->_getData(self::IS_SEARCHABLE); } /** @@ -216,7 +217,7 @@ public function setIsSearchable($isSearchable) */ public function getIsVisibleInAdvancedSearch() { - return $this->getData(self::IS_VISIBLE_IN_ADVANCED_SEARCH); + return $this->_getData(self::IS_VISIBLE_IN_ADVANCED_SEARCH); } /** @@ -235,7 +236,7 @@ public function setIsVisibleInAdvancedSearch($isVisibleInAdvancedSearch) */ public function getIsComparable() { - return $this->getData(self::IS_COMPARABLE); + return $this->_getData(self::IS_COMPARABLE); } /** @@ -254,7 +255,7 @@ public function setIsComparable($isComparable) */ public function getIsUsedForPromoRules() { - return $this->getData(self::IS_USED_FOR_PROMO_RULES); + return $this->_getData(self::IS_USED_FOR_PROMO_RULES); } /** @@ -273,7 +274,7 @@ public function setIsUsedForPromoRules($isUsedForPromoRules) */ public function getIsVisibleOnFront() { - return $this->getData(self::IS_VISIBLE_ON_FRONT); + return $this->_getData(self::IS_VISIBLE_ON_FRONT); } /** @@ -292,7 +293,7 @@ public function setIsVisibleOnFront($isVisibleOnFront) */ public function getUsedInProductListing() { - return $this->getData(self::USED_IN_PRODUCT_LISTING); + return $this->_getData(self::USED_IN_PRODUCT_LISTING); } /** @@ -311,7 +312,7 @@ public function setUsedInProductListing($usedInProductListing) */ public function getIsVisible() { - return $this->getData(self::IS_VISIBLE); + return $this->_getData(self::IS_VISIBLE); } /** @@ -332,7 +333,7 @@ public function setIsVisible($isVisible) */ public function getScope() { - $scope = $this->getData(self::KEY_IS_GLOBAL); + $scope = $this->_getData(self::KEY_IS_GLOBAL); if ($scope == self::SCOPE_GLOBAL) { return self::SCOPE_GLOBAL_TEXT; } elseif ($scope == self::SCOPE_WEBSITE) { diff --git a/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php b/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php new file mode 100644 index 0000000000000..1e07c0cdd924e --- /dev/null +++ b/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php @@ -0,0 +1,50 @@ +_init('catalog_product_entity', 'entity_id'); + } + + /** + * Get category product positions + * + * @param int $categoryId + * @return array + */ + public function getPositions(int $categoryId): array + { + $connection = $this->getConnection(); + + $select = $connection->select()->from( + ['cpe' => $this->getTable('catalog_product_entity')], + 'entity_id' + )->joinLeft( + ['ccp' => $this->getTable('catalog_category_product')], + 'ccp.product_id=cpe.entity_id' + )->where( + 'ccp.category_id = ?', + $categoryId + )->order( + 'ccp.position ' . \Magento\Framework\DB\Select::SQL_ASC + ); + + return array_flip($connection->fetchCol($select)); + } +} diff --git a/app/code/Magento/Catalog/Model/CategoryRepository.php b/app/code/Magento/Catalog/Model/CategoryRepository.php index 8cb11c4306d52..7485d9f6cb247 100644 --- a/app/code/Magento/Catalog/Model/CategoryRepository.php +++ b/app/code/Magento/Catalog/Model/CategoryRepository.php @@ -129,7 +129,7 @@ public function save(\Magento\Catalog\Api\Data\CategoryInterface $category) */ public function get($categoryId, $storeId = null) { - $cacheKey = null !== $storeId ? $storeId : 'all'; + $cacheKey = $storeId ?? 'all'; if (!isset($this->instances[$categoryId][$cacheKey])) { /** @var Category $category */ $category = $this->categoryFactory->create(); diff --git a/app/code/Magento/Catalog/Model/Config/Source/LayoutList.php b/app/code/Magento/Catalog/Model/Config/Source/LayoutList.php new file mode 100644 index 0000000000000..f7b7d6b9e48b9 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Config/Source/LayoutList.php @@ -0,0 +1,49 @@ +Default Layouts>Default Product Layout/Default Category Layout + */ +class LayoutList implements \Magento\Framework\Option\ArrayInterface +{ + /** + * @var array + */ + private $options; + + /** + * @var \Magento\Catalog\Model\Product\Attribute\Source\Layout + */ + private $layoutSource; + + /** + * @param Layout $layoutSource + */ + public function __construct( + Layout $layoutSource + ) { + $this->layoutSource = $layoutSource; + } + + /** + * To option array + * + * @return array + */ + public function toOptionArray() + { + if (!$this->options) { + $this->options = $this->layoutSource->getAllOptions(); + } + return $this->options; + } +} diff --git a/app/code/Magento/Catalog/Model/ImageExtractor.php b/app/code/Magento/Catalog/Model/ImageExtractor.php index 7888d8de1c2ff..d2c11a3762961 100644 --- a/app/code/Magento/Catalog/Model/ImageExtractor.php +++ b/app/code/Magento/Catalog/Model/ImageExtractor.php @@ -37,7 +37,7 @@ public function process(\DOMElement $mediaNode, $mediaParentTag) } elseif ($attributeTagName === 'width' || $attributeTagName === 'height') { $nodeValue = intval($attribute->nodeValue); } else { - $nodeValue = $attribute->nodeValue; + $nodeValue = !in_array($attribute->nodeValue, ['false', '0']); } $result[$mediaParentTag][$moduleNameImage][Image::MEDIA_TYPE_CONFIG_NODE][$imageId][$attribute->tagName] = $nodeValue; diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/AbstractAction.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/AbstractAction.php index 9f074c013480c..6a499883ac723 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/AbstractAction.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/AbstractAction.php @@ -6,9 +6,14 @@ namespace Magento\Catalog\Model\Indexer\Category\Product; -use Magento\Framework\DB\Query\Generator as QueryGenerator; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product; +use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Query\Generator as QueryGenerator; +use Magento\Framework\DB\Select; use Magento\Framework\EntityManager\MetadataPool; +use Magento\Store\Model\Store; /** * Class AbstractAction @@ -37,27 +42,28 @@ abstract class AbstractAction /** * Suffix for table to show it is temporary + * @deprecated */ const TEMPORARY_TABLE_SUFFIX = '_tmp'; /** * Cached non anchor categories select by store id * - * @var \Magento\Framework\DB\Select[] + * @var Select[] */ protected $nonAnchorSelects = []; /** * Cached anchor categories select by store id * - * @var \Magento\Framework\DB\Select[] + * @var Select[] */ protected $anchorSelects = []; /** * Cached all product select by store id * - * @var \Magento\Framework\DB\Select[] + * @var Select[] */ protected $productsSelects = []; @@ -101,6 +107,11 @@ abstract class AbstractAction */ protected $metadataPool; + /** + * @var TableMaintainer + */ + protected $tableMaintainer; + /** * @var string * @since 101.0.0 @@ -117,19 +128,24 @@ abstract class AbstractAction * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Catalog\Model\Config $config * @param QueryGenerator $queryGenerator + * @param MetadataPool|null $metadataPool + * @param TableMaintainer|null $tableMaintainer */ public function __construct( \Magento\Framework\App\ResourceConnection $resource, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Catalog\Model\Config $config, - QueryGenerator $queryGenerator = null + QueryGenerator $queryGenerator = null, + MetadataPool $metadataPool = null, + TableMaintainer $tableMaintainer = null ) { $this->resource = $resource; $this->connection = $resource->getConnection(); $this->storeManager = $storeManager; $this->config = $config; - $this->queryGenerator = $queryGenerator ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(QueryGenerator::class); + $this->queryGenerator = $queryGenerator ?: ObjectManager::getInstance()->get(QueryGenerator::class); + $this->metadataPool = $metadataPool ?: ObjectManager::getInstance()->get(MetadataPool::class); + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); } /** @@ -173,6 +189,7 @@ protected function getTable($table) * The name is switched between 'catalog_category_product_index' and 'catalog_category_product_index_replica' * * @return string + * @deprecated */ protected function getMainTable() { @@ -183,12 +200,26 @@ protected function getMainTable() * Return temporary index table name * * @return string + * @deprecated */ protected function getMainTmpTable() { - return $this->useTempTable ? $this->getTable( - self::MAIN_INDEX_TABLE . self::TEMPORARY_TABLE_SUFFIX - ) : $this->getMainTable(); + return $this->useTempTable + ? $this->getTable(self::MAIN_INDEX_TABLE . self::TEMPORARY_TABLE_SUFFIX) + : $this->getMainTable(); + } + + /** + * Return index table name + * + * @param int $storeId + * @return string + */ + protected function getIndexTable($storeId) + { + return $this->useTempTable + ? $this->tableMaintainer->getMainReplicaTable($storeId) + : $this->tableMaintainer->getMainTable($storeId); } /** @@ -216,24 +247,25 @@ protected function getPathFromCategoryId($categoryId) /** * Retrieve select for reindex products of non anchor categories * - * @param \Magento\Store\Model\Store $store - * @return \Magento\Framework\DB\Select + * @param Store $store + * @return Select + * @throws \Exception when metadata not found for ProductInterface */ - protected function getNonAnchorCategoriesSelect(\Magento\Store\Model\Store $store) + protected function getNonAnchorCategoriesSelect(Store $store) { if (!isset($this->nonAnchorSelects[$store->getId()])) { $statusAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, + Product::ENTITY, 'status' )->getId(); $visibilityAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, + Product::ENTITY, 'visibility' )->getId(); $rootPath = $this->getPathFromCategoryId($store->getRootCategoryId()); - $metadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); $linkField = $metadata->getLinkField(); $select = $this->connection->select()->from( ['cc' => $this->getTable('catalog_category_entity')], @@ -302,12 +334,65 @@ protected function getNonAnchorCategoriesSelect(\Magento\Store\Model\Store $stor ] ); + $this->addFilteringByChildProductsToSelect($select, $store); + $this->nonAnchorSelects[$store->getId()] = $select; } return $this->nonAnchorSelects[$store->getId()]; } + /** + * Add filtering by child products to select + * + * It's used for correct handling of composite products. + * This method makes assumption that select already joins `catalog_product_entity` as `cpe`. + * + * @param Select $select + * @param Store $store + * @return void + * @throws \Exception when metadata not found for ProductInterface + */ + private function addFilteringByChildProductsToSelect(Select $select, Store $store) + { + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); + $linkField = $metadata->getLinkField(); + + $statusAttributeId = $this->config->getAttribute(Product::ENTITY, 'status')->getId(); + + $select->joinLeft( + ['relation' => $this->getTable('catalog_product_relation')], + 'cpe.' . $linkField . ' = relation.parent_id', + [] + )->joinLeft( + ['relation_product_entity' => $this->getTable('catalog_product_entity')], + 'relation.child_id = relation_product_entity.entity_id', + [] + )->joinLeft( + ['child_cpsd' => $this->getTable('catalog_product_entity_int')], + 'child_cpsd.' . $linkField . ' = '. 'relation_product_entity.' . $linkField + . ' AND child_cpsd.store_id = 0' + . ' AND child_cpsd.attribute_id = ' . $statusAttributeId, + [] + )->joinLeft( + ['child_cpss' => $this->getTable('catalog_product_entity_int')], + 'child_cpss.' . $linkField . ' = '. 'relation_product_entity.' . $linkField . '' + . ' AND child_cpss.attribute_id = child_cpsd.attribute_id' + . ' AND child_cpss.store_id = ' . $store->getId(), + [] + )->where( + 'relation.child_id IS NULL OR ' + . $this->connection->getIfNullSql('child_cpss.value', 'child_cpsd.value') . ' = ?', + \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED + )->group( + [ + 'cc.entity_id', + 'ccp.product_id', + 'visibility', + ] + ); + } + /** * Check whether select ranging is needed * @@ -321,15 +406,15 @@ protected function isRangingNeeded() /** * Return selects cut by min and max * - * @param \Magento\Framework\DB\Select $select + * @param Select $select * @param string $field * @param int $range - * @return \Magento\Framework\DB\Select[] + * @return Select[] */ protected function prepareSelectsByRange( - \Magento\Framework\DB\Select $select, - $field, - $range = self::RANGE_CATEGORY_STEP + Select $select, + string $field, + int $range = self::RANGE_CATEGORY_STEP ) { if ($this->isRangingNeeded()) { $iterator = $this->queryGenerator->generate( @@ -351,17 +436,17 @@ protected function prepareSelectsByRange( /** * Reindex products of non anchor categories * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return void */ - protected function reindexNonAnchorCategories(\Magento\Store\Model\Store $store) + protected function reindexNonAnchorCategories(Store $store) { $selects = $this->prepareSelectsByRange($this->getNonAnchorCategoriesSelect($store), 'entity_id'); foreach ($selects as $select) { $this->connection->query( $this->connection->insertFromSelect( $select, - $this->getMainTmpTable(), + $this->getIndexTable($store->getId()), ['category_id', 'product_id', 'position', 'is_parent', 'store_id', 'visibility'], \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE ) @@ -372,10 +457,10 @@ protected function reindexNonAnchorCategories(\Magento\Store\Model\Store $store) /** * Check if anchor select isset * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return bool */ - protected function hasAnchorSelect(\Magento\Store\Model\Store $store) + protected function hasAnchorSelect(Store $store) { return isset($this->anchorSelects[$store->getId()]); } @@ -383,32 +468,30 @@ protected function hasAnchorSelect(\Magento\Store\Model\Store $store) /** * Create anchor select * - * @param \Magento\Store\Model\Store $store - * @return \Magento\Framework\DB\Select + * @param Store $store + * @return Select + * @throws \Exception when metadata not found for ProductInterface or CategoryInterface * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function createAnchorSelect(\Magento\Store\Model\Store $store) + protected function createAnchorSelect(Store $store) { $isAnchorAttributeId = $this->config->getAttribute( \Magento\Catalog\Model\Category::ENTITY, 'is_anchor' )->getId(); - $statusAttributeId = $this->config->getAttribute(\Magento\Catalog\Model\Product::ENTITY, 'status')->getId(); - $visibilityAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, - 'visibility' - )->getId(); + $statusAttributeId = $this->config->getAttribute(Product::ENTITY, 'status')->getId(); + $visibilityAttributeId = $this->config->getAttribute(Product::ENTITY, 'visibility')->getId(); $rootCatIds = explode('/', $this->getPathFromCategoryId($store->getRootCategoryId())); array_pop($rootCatIds); $temporaryTreeTable = $this->makeTempCategoryTreeIndex(); - $productMetadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); - $categoryMetadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\CategoryInterface::class); + $productMetadata = $this->metadataPool->getMetadata(ProductInterface::class); + $categoryMetadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\CategoryInterface::class); $productLinkField = $productMetadata->getLinkField(); $categoryLinkField = $categoryMetadata->getLinkField(); - return $this->connection->select()->from( + $select = $this->connection->select()->from( ['cc' => $this->getTable('catalog_category_entity')], [] )->joinInner( @@ -496,6 +579,10 @@ protected function createAnchorSelect(\Magento\Store\Model\Store $store) 'visibility' => new \Zend_Db_Expr($this->connection->getIfNullSql('cpvs.value', 'cpvd.value')), ] ); + + $this->addFilteringByChildProductsToSelect($select, $store); + + return $select; } /** @@ -510,7 +597,7 @@ protected function getTemporaryTreeIndexTableName() if (empty($this->tempTreeIndexTableName)) { $this->tempTreeIndexTableName = $this->connection->getTableName('temp_catalog_category_tree_index') . '_' - . substr(md5(time() . rand(0, 999999999)), 0, 8); + . substr(md5(time() . random_int(0, 999999999)), 0, 8); } return $this->tempTreeIndexTableName; @@ -549,6 +636,12 @@ protected function makeTempCategoryTreeIndex() ['type' => \Magento\Framework\DB\Adapter\AdapterInterface::INDEX_TYPE_PRIMARY] ); + $temporaryTable->addIndex( + 'child_id', + ['child_id'], + ['type' => \Magento\Framework\DB\Adapter\AdapterInterface::INDEX_TYPE_INDEX] + ); + // Drop the temporary table in case it already exists on this (persistent?) connection. $this->connection->dropTemporaryTable($temporaryName); $this->connection->createTemporaryTable($temporaryTable); @@ -566,34 +659,39 @@ protected function makeTempCategoryTreeIndex() */ protected function fillTempCategoryTreeIndex($temporaryName) { - // This finds all children (cc2) that descend from a parent (cc) by path. - // For example, cc.path may be '1/2', and cc2.path may be '1/2/3/4/5'. - $temporarySelect = $this->connection->select()->from( - ['cc' => $this->getTable('catalog_category_entity')], - ['parent_id' => 'entity_id'] - )->joinInner( - ['cc2' => $this->getTable('catalog_category_entity')], - 'cc2.path LIKE ' . $this->connection->getConcatSql( - [$this->connection->quoteIdentifier('cc.path'), $this->connection->quote('/%')] - ), - ['child_id' => 'entity_id'] + $selects = $this->prepareSelectsByRange( + $this->connection->select() + ->from( + ['c' => $this->getTable('catalog_category_entity')], + ['entity_id', 'path'] + ), + 'entity_id' ); - $this->connection->query( - $temporarySelect->insertFromSelect( - $temporaryName, - ['parent_id', 'child_id'] - ) - ); + foreach ($selects as $select) { + $values = []; + + foreach ($this->connection->fetchAll($select) as $category) { + foreach (explode('/', $category['path']) as $parentId) { + if ($parentId !== $category['entity_id']) { + $values[] = [$parentId, $category['entity_id']]; + } + } + } + + if (count($values) > 0) { + $this->connection->insertArray($temporaryName, ['parent_id', 'child_id'], $values); + } + } } /** * Retrieve select for reindex products of non anchor categories * - * @param \Magento\Store\Model\Store $store - * @return \Magento\Framework\DB\Select + * @param Store $store + * @return Select */ - protected function getAnchorCategoriesSelect(\Magento\Store\Model\Store $store) + protected function getAnchorCategoriesSelect(Store $store) { if (!$this->hasAnchorSelect($store)) { $this->anchorSelects[$store->getId()] = $this->createAnchorSelect($store); @@ -604,10 +702,10 @@ protected function getAnchorCategoriesSelect(\Magento\Store\Model\Store $store) /** * Reindex products of anchor categories * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return void */ - protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) + protected function reindexAnchorCategories(Store $store) { $selects = $this->prepareSelectsByRange($this->getAnchorCategoriesSelect($store), 'entity_id'); @@ -615,7 +713,7 @@ protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) $this->connection->query( $this->connection->insertFromSelect( $select, - $this->getMainTmpTable(), + $this->getIndexTable($store->getId()), ['category_id', 'product_id', 'position', 'is_parent', 'store_id', 'visibility'], \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE ) @@ -626,22 +724,17 @@ protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) /** * Get select for all products * - * @param \Magento\Store\Model\Store $store - * @return \Magento\Framework\DB\Select + * @param Store $store + * @return Select + * @throws \Exception when metadata not found for ProductInterface */ - protected function getAllProducts(\Magento\Store\Model\Store $store) + protected function getAllProducts(Store $store) { if (!isset($this->productsSelects[$store->getId()])) { - $statusAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, - 'status' - )->getId(); - $visibilityAttributeId = $this->config->getAttribute( - \Magento\Catalog\Model\Product::ENTITY, - 'visibility' - )->getId(); + $statusAttributeId = $this->config->getAttribute(Product::ENTITY, 'status')->getId(); + $visibilityAttributeId = $this->config->getAttribute(Product::ENTITY, 'visibility')->getId(); - $metadata = $this->getMetadataPool()->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); $linkField = $metadata->getLinkField(); $select = $this->connection->select()->from( @@ -730,10 +823,10 @@ protected function isIndexRootCategoryNeeded() /** * Reindex all products to root category * - * @param \Magento\Store\Model\Store $store + * @param Store $store * @return void */ - protected function reindexRootCategory(\Magento\Store\Model\Store $store) + protected function reindexRootCategory(Store $store) { if ($this->isIndexRootCategoryNeeded()) { $selects = $this->prepareSelectsByRange( @@ -746,7 +839,7 @@ protected function reindexRootCategory(\Magento\Store\Model\Store $store) $this->connection->query( $this->connection->insertFromSelect( $select, - $this->getMainTmpTable(), + $this->getIndexTable($store->getId()), ['category_id', 'product_id', 'position', 'is_parent', 'store_id', 'visibility'], \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE ) @@ -754,16 +847,4 @@ protected function reindexRootCategory(\Magento\Store\Model\Store $store) } } } - - /** - * @return \Magento\Framework\EntityManager\MetadataPool - */ - private function getMetadataPool() - { - if (null === $this->metadataPool) { - $this->metadataPool = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Framework\EntityManager\MetadataPool::class); - } - return $this->metadataPool; - } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Full.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Full.php index ae0c3554c0d32..09dbed350c5e4 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Full.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Full.php @@ -88,63 +88,64 @@ public function __construct( } /** - * Refresh entities index - * - * @return $this + * @return void */ - public function execute() + private function createTables() { - $this->reindex(); - $this->activeTableSwitcher->switchTable($this->connection, [$this->getMainTable()]); - return $this; + foreach ($this->storeManager->getStores() as $store) { + $this->tableMaintainer->createTablesForStore($store->getId()); + } } /** - * Return select for remove unnecessary data - * - * @return \Magento\Framework\DB\Select + * @return void */ - protected function getSelectUnnecessaryData() + private function clearReplicaTables() { - return $this->connection->select()->from( - $this->getMainTable(), - [] - )->joinLeft( - ['t' => $this->getMainTable()], - $this->getMainTable() . - '.category_id = t.category_id AND ' . - $this->getMainTable() . - '.store_id = t.store_id AND ' . - $this->getMainTable() . - '.product_id = t.product_id', - [] - )->where( - 't.category_id IS NULL' - ); + foreach ($this->storeManager->getStores() as $store) { + $this->connection->truncateTable($this->tableMaintainer->getMainReplicaTable($store->getId())); + } } /** - * Remove unnecessary data - * * @return void */ - protected function removeUnnecessaryData() + private function switchTables() { - $this->connection->query( - $this->connection->deleteFromSelect($this->getSelectUnnecessaryData(), $this->getMainTable()) - ); + $tablesToSwitch = []; + foreach ($this->storeManager->getStores() as $store) { + $tablesToSwitch[] = $this->tableMaintainer->getMainTable($store->getId()); + } + $this->activeTableSwitcher->switchTable($this->connection, $tablesToSwitch); } /** - * Publish data from tmp to index + * Refresh entities index * + * @return $this + */ + public function execute() + { + $this->createTables(); + $this->clearReplicaTables(); + $this->reindex(); + $this->switchTables(); + return $this; + } + + /** + * Publish data from tmp to replica table + * + * @param \Magento\Store\Model\Store $store * @return void */ - protected function publishData() + private function publishData($store) { - $select = $this->connection->select()->from($this->getMainTmpTable()); - $columns = array_keys($this->connection->describeTable($this->getMainTable())); - $tableName = $this->activeTableSwitcher->getAdditionalTableName($this->getMainTable()); + $select = $this->connection->select()->from($this->tableMaintainer->getMainTmpTable($store->getId())); + $columns = array_keys( + $this->connection->describeTable($this->tableMaintainer->getMainReplicaTable($store->getId())) + ); + $tableName = $this->tableMaintainer->getMainReplicaTable($store->getId()); $this->connection->query( $this->connection->insertFromSelect( @@ -156,23 +157,13 @@ protected function publishData() ); } - /** - * Clear all index data - * - * @return void - */ - protected function clearTmpData() - { - $this->connection->delete($this->getMainTmpTable()); - } - /** * {@inheritdoc} */ protected function reindexRootCategory(\Magento\Store\Model\Store $store) { if ($this->isIndexRootCategoryNeeded()) { - $this->reindexCategoriesBySelect($this->getAllProducts($store), 'cp.entity_id IN (?)'); + $this->reindexCategoriesBySelect($this->getAllProducts($store), 'cp.entity_id IN (?)', $store); } } @@ -184,7 +175,7 @@ protected function reindexRootCategory(\Magento\Store\Model\Store $store) */ protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) { - $this->reindexCategoriesBySelect($this->getAnchorCategoriesSelect($store), 'ccp.product_id IN (?)'); + $this->reindexCategoriesBySelect($this->getAnchorCategoriesSelect($store), 'ccp.product_id IN (?)', $store); } /** @@ -195,7 +186,7 @@ protected function reindexAnchorCategories(\Magento\Store\Model\Store $store) */ protected function reindexNonAnchorCategories(\Magento\Store\Model\Store $store) { - $this->reindexCategoriesBySelect($this->getNonAnchorCategoriesSelect($store), 'ccp.product_id IN (?)'); + $this->reindexCategoriesBySelect($this->getNonAnchorCategoriesSelect($store), 'ccp.product_id IN (?)', $store); } /** @@ -203,12 +194,17 @@ protected function reindexNonAnchorCategories(\Magento\Store\Model\Store $store) * * @param \Magento\Framework\DB\Select $basicSelect * @param string $whereCondition + * @param \Magento\Store\Model\Store $store * @return void */ - private function reindexCategoriesBySelect(\Magento\Framework\DB\Select $basicSelect, $whereCondition) + private function reindexCategoriesBySelect(\Magento\Framework\DB\Select $basicSelect, $whereCondition, $store) { + $this->tableMaintainer->createMainTmpTable($store->getId()); + $entityMetadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); - $columns = array_keys($this->connection->describeTable($this->getMainTmpTable())); + $columns = array_keys( + $this->connection->describeTable($this->tableMaintainer->getMainTmpTable($store->getId())) + ); $this->batchSizeManagement->ensureBatchSize($this->connection, $this->batchRowsCount); $batches = $this->batchProvider->getBatches( $this->connection, @@ -217,7 +213,7 @@ private function reindexCategoriesBySelect(\Magento\Framework\DB\Select $basicSe $this->batchRowsCount ); foreach ($batches as $batch) { - $this->clearTmpData(); + $this->connection->delete($this->tableMaintainer->getMainTmpTable($store->getId())); $resultSelect = clone $basicSelect; $select = $this->connection->select(); $select->distinct(true); @@ -227,13 +223,12 @@ private function reindexCategoriesBySelect(\Magento\Framework\DB\Select $basicSe $this->connection->query( $this->connection->insertFromSelect( $resultSelect, - $this->getMainTmpTable(), + $this->tableMaintainer->getMainTmpTable($store->getId()), $columns, \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE ) ); - $this->publishData(); - $this->removeUnnecessaryData(); + $this->publishData($store); } } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php index 248ec970d2250..3bd4910767587 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php @@ -36,17 +36,16 @@ public function execute(array $entityIds = [], $useTempTable = false) /** * Return array of all category root IDs + tree root ID * - * @return int[] + * @param \Magento\Store\Model\Store $store + * @return int */ - protected function getRootCategoryIds() + private function getRootCategoryId($store) { - $rootIds = [\Magento\Catalog\Model\Category::TREE_ROOT_ID]; - foreach ($this->storeManager->getStores() as $store) { - if ($this->getPathFromCategoryId($store->getRootCategoryId())) { - $rootIds[] = $store->getRootCategoryId(); - } + $rootId = \Magento\Catalog\Model\Category::TREE_ROOT_ID; + if ($this->getPathFromCategoryId($store->getRootCategoryId())) { + $rootId = $store->getRootCategoryId(); } - return $rootIds; + return $rootId; } /** @@ -54,10 +53,15 @@ protected function getRootCategoryIds() * * @return void */ - protected function removeEntries() + private function removeEntries() { - $removalCategoryIds = array_diff($this->limitationByCategories, $this->getRootCategoryIds()); - $this->connection->delete($this->getMainTable(), ['category_id IN (?)' => $removalCategoryIds]); + foreach ($this->storeManager->getStores() as $store) { + $removalCategoryIds = array_diff($this->limitationByCategories, [$this->getRootCategoryId($store)]); + $this->connection->delete( + $this->getIndexTable($store->getId()), + ['category_id IN (?)' => $removalCategoryIds] + ); + } } /** diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreGroup.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreGroup.php index 2ee46b3a6096b..9f4e19bf95a8d 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreGroup.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreGroup.php @@ -9,6 +9,7 @@ use Magento\Framework\Model\ResourceModel\Db\AbstractDb; use Magento\Framework\Model\AbstractModel; use Magento\Catalog\Model\Indexer\Category\Product; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; class StoreGroup { @@ -22,12 +23,21 @@ class StoreGroup */ protected $indexerRegistry; + /** + * @var TableMaintainer + */ + protected $tableMaintainer; + /** * @param IndexerRegistry $indexerRegistry + * @param TableMaintainer $tableMaintainer */ - public function __construct(IndexerRegistry $indexerRegistry) - { + public function __construct( + IndexerRegistry $indexerRegistry, + TableMaintainer $tableMaintainer + ) { $this->indexerRegistry = $indexerRegistry; + $this->tableMaintainer = $tableMaintainer; } /** @@ -73,4 +83,22 @@ protected function validate(AbstractModel $group) return ($group->dataHasChangedFor('website_id') || $group->dataHasChangedFor('root_category_id')) && !$group->isObjectNew(); } + + /** + * Delete catalog_category_product indexer tables for deleted store group + * + * @param AbstractDb $subject + * @param AbstractDb $objectResource + * @param AbstractModel $storeGroup + * + * @return AbstractDb + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterDelete(AbstractDb $subject, AbstractDb $objectResource, AbstractModel $storeGroup) + { + foreach ($storeGroup->getStores() as $store) { + $this->tableMaintainer->dropTablesForStore($store->getId()); + } + return $objectResource; + } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreView.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreView.php index f49b685ba6f7f..114d2a94f5b35 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreView.php +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/StoreView.php @@ -5,6 +5,9 @@ */ namespace Magento\Catalog\Model\Indexer\Category\Product\Plugin; +use Magento\Framework\Model\ResourceModel\Db\AbstractDb; +use Magento\Framework\Model\AbstractModel; + class StoreView extends StoreGroup { /** @@ -17,4 +20,38 @@ protected function validate(\Magento\Framework\Model\AbstractModel $store) { return $store->isObjectNew() || $store->dataHasChangedFor('group_id'); } + + /** + * Invalidate catalog_category_product indexer + * + * @param AbstractDb $subject + * @param AbstractDb $objectResource + * @param AbstractModel $store + * + * @return AbstractDb + */ + public function afterSave(AbstractDb $subject, AbstractDb $objectResource, AbstractModel $store = null) + { + if ($store->isObjectNew()) { + $this->tableMaintainer->createTablesForStore($store->getId()); + } + + return parent::afterSave($subject, $objectResource); + } + + /** + * Delete catalog_category_product indexer table for deleted store + * + * @param AbstractDb $subject + * @param AbstractDb $objectResource + * @param AbstractModel $store + * + * @return AbstractDb + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterDelete(AbstractDb $subject, AbstractDb $objectResource, AbstractModel $store) + { + $this->tableMaintainer->dropTablesForStore($store->getId()); + return $objectResource; + } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php new file mode 100644 index 0000000000000..936e6163cbcc5 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/TableResolver.php @@ -0,0 +1,74 @@ +storeManager = $storeManager; + $this->tableResolver = $tableResolver; + } + + /** + * Replacing catalog_category_product_index table name on the table name segmented per store + * + * @param ResourceConnection $subject + * @param string $result + * @param string|string[] $modelEntity + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @return string + */ + public function afterGetTableName( + \Magento\Framework\App\ResourceConnection $subject, + string $result, + $modelEntity + ) { + if (!is_array($modelEntity) && $modelEntity === AbstractAction::MAIN_INDEX_TABLE) { + $catalogCategoryProductDimension = new Dimension( + \Magento\Store\Model\Store::ENTITY, + $this->storeManager->getStore()->getId() + ); + + $tableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); + return $tableName; + } + return $result; + } +} diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/Website.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/Website.php new file mode 100644 index 0000000000000..387a8085310e4 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Plugin/Website.php @@ -0,0 +1,46 @@ +tableMaintainer = $tableMaintainer; + } + + /** + * Delete catalog_category_product indexer tables for deleted website + * + * @param AbstractDb $subject + * @param AbstractDb $objectResource + * @param AbstractModel $website + * + * @return AbstractDb + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterDelete(AbstractDb $subject, AbstractDb $objectResource, AbstractModel $website) + { + foreach ($website->getStoreIds() as $storeId) { + $this->tableMaintainer->dropTablesForStore($storeId); + } + return $objectResource; + } +} diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/TableMaintainer.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/TableMaintainer.php new file mode 100644 index 0000000000000..1278434fcad43 --- /dev/null +++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/TableMaintainer.php @@ -0,0 +1,218 @@ +resource = $resource; + $this->tableResolver = $tableResolver; + } + + /** + * Get connection + * + * @return AdapterInterface + */ + private function getConnection() + { + if (!isset($this->connection)) { + $this->connection = $this->resource->getConnection(); + } + return $this->connection; + } + + /** + * Return validated table name + * + * @param string|string[] $table + * @return string + */ + private function getTable($table) + { + return $this->resource->getTableName($table); + } + + /** + * Create table based on main table + * + * @param string $mainTableName + * @param string $newTableName + * + * @return void + */ + private function createTable($mainTableName, $newTableName) + { + if (!$this->getConnection()->isTableExists($newTableName)) { + $this->getConnection()->createTable( + $this->getConnection()->createTableByDdl($mainTableName, $newTableName) + ); + } + } + + /** + * Drop table + * + * @param string $tableName + * + * @return void + */ + private function dropTable($tableName) + { + if ($this->getConnection()->isTableExists($tableName)) { + $this->getConnection()->dropTable($tableName); + } + } + + /** + * Return main index table name + * + * @param $storeId + * + * @return string + */ + public function getMainTable(int $storeId) + { + $catalogCategoryProductDimension = new Dimension(\Magento\Store\Model\Store::ENTITY, $storeId); + + return $this->tableResolver->resolve(AbstractAction::MAIN_INDEX_TABLE, [$catalogCategoryProductDimension]); + } + + /** + * Create main and replica index tables for store + * + * @param $storeId + * + * @return void + */ + public function createTablesForStore(int $storeId) + { + $mainTableName = $this->getMainTable($storeId); + //Create index table for store based on on main replica table + //Using main replica table is necessary for backward capability and TableResolver plugin work + $this->createTable( + $this->getTable(AbstractAction::MAIN_INDEX_TABLE . $this->additionalTableSuffix), + $mainTableName + ); + + $mainReplicaTableName = $this->getMainTable($storeId) . $this->additionalTableSuffix; + //Create replica table for store based on main replica table + $this->createTable( + $this->getTable(AbstractAction::MAIN_INDEX_TABLE . $this->additionalTableSuffix), + $mainReplicaTableName + ); + } + + /** + * Drop main and replica index tables for store + * + * @param $storeId + * + * @return void + */ + public function dropTablesForStore(int $storeId) + { + $mainTableName = $this->getMainTable($storeId); + $this->dropTable($mainTableName); + + $mainReplicaTableName = $this->getMainTable($storeId) . $this->additionalTableSuffix; + $this->dropTable($mainReplicaTableName); + } + + /** + * Return replica index table name + * + * @param $storeId + * + * @return string + */ + public function getMainReplicaTable(int $storeId) + { + return $this->getMainTable($storeId) . $this->additionalTableSuffix; + } + + /** + * Create temporary index table for store + * + * @param $storeId + * + * @return void + */ + public function createMainTmpTable(int $storeId) + { + if (!isset($this->mainTmpTable[$storeId])) { + $originTableName = $this->getMainTable($storeId); + $temporaryTableName = $this->getMainTable($storeId) . $this->tmpTableSuffix; + $this->getConnection()->createTemporaryTableLike($temporaryTableName, $originTableName, true); + $this->mainTmpTable[$storeId] = $temporaryTableName; + } + } + + /** + * Return temporary index table name + * + * @param $storeId + * + * @return string + * + * @throws \Exception + */ + public function getMainTmpTable(int $storeId) + { + if (!isset($this->mainTmpTable[$storeId])) { + throw new \Exception('Temporary table does not exist'); + } + return $this->mainTmpTable[$storeId]; + } +} diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php index 1b988534328e9..182f04de4ab0e 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php @@ -6,9 +6,19 @@ namespace Magento\Catalog\Model\Indexer\Product\Category\Action; use Magento\Catalog\Model\Category; +use Magento\Catalog\Model\Config; use Magento\Catalog\Model\Product; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Query\Generator as QueryGenerator; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Event\ManagerInterface as EventManagerInterface; use Magento\Framework\Indexer\CacheContext; +use Magento\Store\Model\StoreManagerInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractAction { /** @@ -19,32 +29,102 @@ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractActio protected $limitationByProducts; /** - * @var \Magento\Framework\Indexer\CacheContext + * @var CacheContext */ private $cacheContext; + /** + * @var EventManagerInterface|null + */ + private $eventManager; + + /** + * @param ResourceConnection $resource + * @param StoreManagerInterface $storeManager + * @param Config $config + * @param QueryGenerator|null $queryGenerator + * @param MetadataPool|null $metadataPool + * @param CacheContext|null $cacheContext + * @param EventManagerInterface|null $eventManager + */ + public function __construct( + ResourceConnection $resource, + StoreManagerInterface $storeManager, + Config $config, + QueryGenerator $queryGenerator = null, + MetadataPool $metadataPool = null, + CacheContext $cacheContext = null, + EventManagerInterface $eventManager = null + ) { + parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool); + $this->cacheContext = $cacheContext ?: ObjectManager::getInstance()->get(CacheContext::class); + $this->eventManager = $eventManager ?: ObjectManager::getInstance()->get(EventManagerInterface::class); + } + /** * Refresh entities index * * @param int[] $entityIds * @param bool $useTempTable * @return $this + * @throws \Exception if metadataPool doesn't contain metadata for ProductInterface + * @throws \DomainException */ public function execute(array $entityIds = [], $useTempTable = false) { - $this->limitationByProducts = $entityIds; + $idsToBeReIndexed = $this->getProductIdsWithParents($entityIds); + + $this->limitationByProducts = $idsToBeReIndexed; $this->useTempTable = $useTempTable; + $affectedCategories = $this->getCategoryIdsFromIndex($idsToBeReIndexed); + $this->removeEntries(); $this->reindex(); - $this->registerProducts($entityIds); - $this->registerCategories($entityIds); + $affectedCategories = array_merge($affectedCategories, $this->getCategoryIdsFromIndex($idsToBeReIndexed)); + + $this->registerProducts($idsToBeReIndexed); + $this->registerCategories($affectedCategories); + $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]); return $this; } + /** + * Get IDs of parent products by their child IDs. + * + * Returns identifiers of parent product from the catalog_product_relation. + * Please note that returned ids don't contain ids of passed child products. + * + * @param int[] $childProductIds + * @return int[] + * @throws \Exception if metadataPool doesn't contain metadata for ProductInterface + * @throws \DomainException + */ + private function getProductIdsWithParents(array $childProductIds) + { + /** @var \Magento\Framework\EntityManager\EntityMetadataInterface $metadata */ + $metadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + $fieldForParent = $metadata->getLinkField(); + + $select = $this->connection + ->select() + ->from(['relation' => $this->getTable('catalog_product_relation')], []) + ->distinct(true) + ->where('child_id IN (?)', $childProductIds) + ->join( + ['cpe' => $this->getTable('catalog_product_entity')], + 'relation.parent_id = cpe.' . $fieldForParent, + ['cpe.entity_id'] + ); + + $parentProductIds = $this->connection->fetchCol($select); + + return array_unique(array_merge($childProductIds, $parentProductIds)); + } + /** * Register affected products * @@ -53,26 +133,19 @@ public function execute(array $entityIds = [], $useTempTable = false) */ private function registerProducts($entityIds) { - $this->getCacheContext()->registerEntities(Product::CACHE_TAG, $entityIds); + $this->cacheContext->registerEntities(Product::CACHE_TAG, $entityIds); } /** * Register categories assigned to products * - * @param array $entityIds + * @param array $categoryIds * @return void */ - private function registerCategories($entityIds) + private function registerCategories(array $categoryIds) { - $categories = $this->connection->fetchCol( - $this->connection->select() - ->from($this->getMainTable(), ['category_id']) - ->where('product_id IN (?)', $entityIds) - ->distinct() - ); - - if ($categories) { - $this->getCacheContext()->registerEntities(Category::CACHE_TAG, $categories); + if ($categoryIds) { + $this->cacheContext->registerEntities(Category::CACHE_TAG, $categoryIds); } } @@ -83,10 +156,12 @@ private function registerCategories($entityIds) */ protected function removeEntries() { - $this->connection->delete( - $this->getMainTable(), - ['product_id IN (?)' => $this->limitationByProducts] - ); + foreach ($this->storeManager->getStores() as $store) { + $this->connection->delete( + $this->getIndexTable($store->getId()), + ['product_id IN (?)' => $this->limitationByProducts] + ); + }; } /** @@ -98,7 +173,7 @@ protected function removeEntries() protected function getNonAnchorCategoriesSelect(\Magento\Store\Model\Store $store) { $select = parent::getNonAnchorCategoriesSelect($store); - return $select->where('ccp.product_id IN (?)', $this->limitationByProducts); + return $select->where('ccp.product_id IN (?) OR relation.child_id IN (?)', $this->limitationByProducts); } /** @@ -136,16 +211,31 @@ protected function isRangingNeeded() } /** - * Get cache context + * Returns a list of category ids which are assigned to product ids in the index * * @return \Magento\Framework\Indexer\CacheContext - * @deprecated 101.0.0 */ - private function getCacheContext() + private function getCategoryIdsFromIndex(array $productIds) { - if ($this->cacheContext === null) { - $this->cacheContext = \Magento\Framework\App\ObjectManager::getInstance()->get(CacheContext::class); + $categoryIds = []; + foreach ($this->storeManager->getStores() as $store) { + $categoryIds = array_merge( + $categoryIds, + $this->connection->fetchCol( + $this->connection->select() + ->from($this->getIndexTable($store->getId()), ['category_id']) + ->where('product_id IN (?)', $productIds) + ->distinct() + ) + ); + }; + $parentCategories = $categoryIds; + foreach ($categoryIds as $categoryId) { + $parentIds = explode('/', $this->getPathFromCategoryId($categoryId)); + $parentCategories = array_merge($parentCategories, $parentIds); } - return $this->cacheContext; + $categoryIds = array_unique($parentCategories); + + return $categoryIds; } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Eav/AbstractAction.php b/app/code/Magento/Catalog/Model/Indexer/Product/Eav/AbstractAction.php index 6a2642a8568f4..b6206f96b91e0 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Eav/AbstractAction.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Eav/AbstractAction.php @@ -5,6 +5,8 @@ */ namespace Magento\Catalog\Model\Indexer\Product\Eav; +use Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav; + /** * Abstract action reindex class */ @@ -51,7 +53,7 @@ abstract public function execute($ids); /** * Retrieve array of EAV type indexers * - * @return \Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav[] + * @return AbstractEav[] */ public function getIndexers() { @@ -69,7 +71,7 @@ public function getIndexers() * Retrieve indexer instance by type * * @param string $type - * @return \Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav + * @return AbstractEav * @throws \Magento\Framework\Exception\LocalizedException */ public function getIndexer($type) @@ -108,7 +110,7 @@ public function reindex($ids = null) /** * Synchronize data between index storage and original storage * - * @param \Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav $indexer + * @param AbstractEav $indexer * @param string $destinationTable * @param array $ids * @throws \Exception @@ -134,16 +136,17 @@ protected function syncData($indexer, $destinationTable, $ids) /** * Retrieve product relations by children and parent * - * @param \Magento\Catalog\Model\ResourceModel\Product\Indexer\Eav\AbstractEav $indexer + * @param AbstractEav $indexer * @param array $ids - * * @param bool $onlyParents * @return array $ids */ - protected function processRelations($indexer, $ids, $onlyParents = false) + protected function processRelations(AbstractEav $indexer, array $ids, bool $onlyParents = false) { $parentIds = $indexer->getRelationsByChild($ids); + $parentIds = array_unique(array_merge($parentIds, $ids)); $childIds = $onlyParents ? [] : $indexer->getRelationsByParent($parentIds); + return array_unique(array_merge($ids, $childIds, $parentIds)); } } diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Price/AbstractAction.php b/app/code/Magento/Catalog/Model/Indexer/Product/Price/AbstractAction.php index 6db52f969d273..cfa5ec91a2e1b 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Price/AbstractAction.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Price/AbstractAction.php @@ -5,6 +5,8 @@ */ namespace Magento\Catalog\Model\Indexer\Product\Price; +use Magento\Framework\App\ObjectManager; + /** * Abstract action reindex class * @@ -71,9 +73,9 @@ abstract class AbstractAction protected $_indexers; /** - * @var \Magento\Catalog\Model\ResourceModel\Product + * @var \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\TierPrice */ - private $productResource; + private $tierPriceIndexResource; /** * @param \Magento\Framework\App\Config\ScopeConfigInterface $config @@ -84,6 +86,7 @@ abstract class AbstractAction * @param \Magento\Catalog\Model\Product\Type $catalogProductType * @param \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\Factory $indexerPriceFactory * @param \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\DefaultPrice $defaultIndexerResource + * @param \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\TierPrice $tierPriceIndexResource */ public function __construct( \Magento\Framework\App\Config\ScopeConfigInterface $config, @@ -93,7 +96,8 @@ public function __construct( \Magento\Framework\Stdlib\DateTime $dateTime, \Magento\Catalog\Model\Product\Type $catalogProductType, \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\Factory $indexerPriceFactory, - \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\DefaultPrice $defaultIndexerResource + \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\DefaultPrice $defaultIndexerResource, + \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\TierPrice $tierPriceIndexResource = null ) { $this->_config = $config; $this->_storeManager = $storeManager; @@ -104,6 +108,9 @@ public function __construct( $this->_indexerPriceFactory = $indexerPriceFactory; $this->_defaultIndexerResource = $defaultIndexerResource; $this->_connection = $this->_defaultIndexerResource->getConnection(); + $this->tierPriceIndexResource = $tierPriceIndexResource ?: ObjectManager::getInstance()->get( + \Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\TierPrice::class + ); } /** @@ -215,92 +222,8 @@ protected function _prepareWebsiteDateTable() */ protected function _prepareTierPriceIndex($entityIds = null) { - $table = $this->_defaultIndexerResource->getTable('catalog_product_index_tier_price'); - $this->_emptyTable($table); - if (empty($entityIds)) { - return $this; - } - $linkField = $this->getProductIdFieldName(); - $priceAttribute = $this->getProductResource()->getAttribute('price'); - $baseColumns = [ - 'cpe.entity_id', - 'tp.customer_group_id', - 'tp.website_id' - ]; - if ($linkField !== 'entity_id') { - $baseColumns[] = 'cpe.' . $linkField; - }; - $subSelect = $this->_connection->select()->from( - ['cpe' => $this->_defaultIndexerResource->getTable('catalog_product_entity')], - array_merge_recursive( - $baseColumns, - [ - 'min(tp.value) AS value', - 'min(tp.percentage_value) AS percentage_value' - ] - ) - )->joinInner( - ['tp' => $this->_defaultIndexerResource->getTable(['catalog_product_entity', 'tier_price'])], - 'tp.' . $linkField . ' = cpe.' . $linkField, - [] - )->where("cpe.entity_id IN(?)", $entityIds) - ->where("tp.website_id != 0") - ->group(['cpe.entity_id', 'tp.customer_group_id', 'tp.website_id']); - - $subSelect2 = $this->_connection->select() - ->from( - ['cpe' => $this->_defaultIndexerResource->getTable('catalog_product_entity')], - array_merge_recursive( - $baseColumns, - [ - 'MIN(ROUND(tp.value * cwd.rate, 4)) AS value', - 'MIN(ROUND(tp.percentage_value * cwd.rate, 4)) AS percentage_value' - - ] - ) - ) - ->joinInner( - ['tp' => $this->_defaultIndexerResource->getTable(['catalog_product_entity', 'tier_price'])], - 'tp.' . $linkField . ' = cpe.' . $linkField, - [] - )->join( - ['cw' => $this->_defaultIndexerResource->getTable('store_website')], - true, - [] - ) - ->joinInner( - ['cwd' => $this->_defaultIndexerResource->getTable('catalog_product_index_website')], - 'cw.website_id = cwd.website_id', - [] - ) - ->where("cpe.entity_id IN(?)", $entityIds) - ->where("tp.website_id = 0") - ->group( - ['cpe.entity_id', 'tp.customer_group_id', 'tp.website_id'] - ); - - $unionSelect = $this->_connection->select() - ->union([$subSelect, $subSelect2], \Magento\Framework\DB\Select::SQL_UNION_ALL); - $select = $this->_connection->select() - ->from( - ['b' => new \Zend_Db_Expr(sprintf('(%s)', $unionSelect->assemble()))], - [ - 'b.entity_id', - 'b.customer_group_id', - 'b.website_id', - 'MIN(IF(b.value = 0, product_price.value * (1 - b.percentage_value / 100), b.value))' - ] - ) - ->joinInner( - ['product_price' => $priceAttribute->getBackend()->getTable()], - 'b.' . $linkField . ' = product_price.' . $linkField, - [] - ) - ->group(['b.entity_id', 'b.customer_group_id', 'b.website_id']); - - $query = $select->insertFromSelect($table, [], false); + $this->tierPriceIndexResource->reindexEntity((array) $entityIds); - $this->_connection->query($query); return $this; } @@ -391,30 +314,17 @@ protected function _emptyTable($table) * * @param array $changedIds * @return array Affected ids - * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ protected function _reindexRows($changedIds = []) { $this->_emptyTable($this->_defaultIndexerResource->getIdxTable()); $this->_prepareWebsiteDateTable(); - $select = $this->_connection->select()->from( - $this->_defaultIndexerResource->getTable('catalog_product_entity'), - ['entity_id', 'type_id'] - )->where( - 'entity_id IN(?)', - $changedIds - ); - $pairs = $this->_connection->fetchPairs($select); - $byType = []; - foreach ($pairs as $productId => $productType) { - $byType[$productType][$productId] = $productId; - } - + $productsTypes = $this->getProductsTypes($changedIds); $compositeIds = []; $notCompositeIds = []; - foreach ($byType as $productType => $entityIds) { + foreach ($productsTypes as $productType => $entityIds) { $indexer = $this->_getIndexer($productType); if ($indexer->getIsComposite()) { $compositeIds += $entityIds; @@ -424,25 +334,11 @@ protected function _reindexRows($changedIds = []) } if (!empty($notCompositeIds)) { - $select = $this->_connection->select()->from( - ['l' => $this->_defaultIndexerResource->getTable('catalog_product_relation')], - '' - )->join( - ['e' => $this->_defaultIndexerResource->getTable('catalog_product_entity')], - 'e.' . $this->getProductIdFieldName() . ' = l.parent_id', - ['e.entity_id as parent_id', 'type_id'] - )->where( - 'l.child_id IN(?)', - $notCompositeIds - ); - $pairs = $this->_connection->fetchPairs($select); - foreach ($pairs as $productId => $productType) { - if (!in_array($productId, $changedIds)) { - $changedIds[] = (string) $productId; - $byType[$productType][$productId] = $productId; - $compositeIds[$productId] = $productId; - } - } + $parentProductsTypes = $this->getParentProductsTypes($notCompositeIds); + $productsTypes = array_merge_recursive($productsTypes, $parentProductsTypes); + $parentProductsIds = array_keys($parentProductsTypes); + $compositeIds = $compositeIds + array_combine($parentProductsIds, $parentProductsIds); + $changedIds = array_merge($changedIds, $parentProductsIds); } if (!empty($compositeIds)) { @@ -450,11 +346,9 @@ protected function _reindexRows($changedIds = []) } $this->_prepareTierPriceIndex($compositeIds + $notCompositeIds); - $indexers = $this->getTypeIndexers(); - foreach ($indexers as $indexer) { - if (!empty($byType[$indexer->getTypeId()])) { - $indexer->reindexEntity($byType[$indexer->getTypeId()]); - } + foreach ($productsTypes as $productType => $entityIds) { + $indexer = $this->_getIndexer($productType); + $indexer->reindexEntity($entityIds); } $this->_syncData($changedIds); @@ -524,15 +418,56 @@ protected function getProductIdFieldName() } /** - * @return \Magento\Catalog\Model\ResourceModel\Product - * @deprecated 101.1.0 + * Get products types. + * + * @param array $changedIds + * @return array + */ + private function getProductsTypes(array $changedIds = []) + { + $select = $this->_connection->select()->from( + $this->_defaultIndexerResource->getTable('catalog_product_entity'), + ['entity_id', 'type_id'] + ); + if ($changedIds) { + $select->where('entity_id IN (?)', $changedIds); + } + $pairs = $this->_connection->fetchPairs($select); + + $byType = []; + foreach ($pairs as $productId => $productType) { + $byType[$productType][$productId] = $productId; + } + + return $byType; + } + + /** + * Get parent products types. + * + * @param array $productsIds + * @return array */ - private function getProductResource() + private function getParentProductsTypes(array $productsIds) { - if (null === $this->productResource) { - $this->productResource = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Catalog\Model\ResourceModel\Product::class); + $select = $this->_connection->select()->from( + ['l' => $this->_defaultIndexerResource->getTable('catalog_product_relation')], + '' + )->join( + ['e' => $this->_defaultIndexerResource->getTable('catalog_product_entity')], + 'e.' . $this->getProductIdFieldName() . ' = l.parent_id', + ['e.entity_id as parent_id', 'type_id'] + )->where( + 'l.child_id IN(?)', + $productsIds + ); + $pairs = $this->_connection->fetchPairs($select); + + $byType = []; + foreach ($pairs as $productId => $productType) { + $byType[$productType][$productId] = $productId; } - return $this->productResource; + + return $byType; } } diff --git a/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Decimal.php b/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Decimal.php index 36caa148b2e4e..bb1fce309f4f4 100644 --- a/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Decimal.php +++ b/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Decimal.php @@ -56,7 +56,7 @@ public function getRange(FilterInterface $filter) $index = 1; do { $range = pow(10, strlen(floor($maxValue)) - $index); - $items = $this->getRangeItemCounts($range, $filter); + $items = $this->getRangeItemCounts($range, $filter) ?: []; $index++; } while ($range > self::MIN_RANGE_POWER && count($items) < 2); $this->range = $range; @@ -109,7 +109,7 @@ public function getMinValue(FilterInterface $filter) * * @param int $range * @param FilterInterface $filter - * @return mixed + * @return array */ public function getRangeItemCounts($range, FilterInterface $filter) { diff --git a/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Price.php b/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Price.php index 8a17a3b6c8cfa..d1aee8c4c5ba6 100644 --- a/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Price.php +++ b/app/code/Magento/Catalog/Model/Layer/Filter/DataProvider/Price.php @@ -282,7 +282,8 @@ public function getMaxPrice() public function getPriorFilters($filterParams) { $priorFilters = []; - for ($i = 1; $i < count($filterParams); ++$i) { + $count = count($filterParams); + for ($i = 1; $i < $count; ++$i) { $priorFilter = $this->validateFilter($filterParams[$i]); if ($priorFilter) { $priorFilters[] = $priorFilter; diff --git a/app/code/Magento/Catalog/Model/Product.php b/app/code/Magento/Catalog/Model/Product.php index db16c34f123f2..39f6dceed5460 100644 --- a/app/code/Magento/Catalog/Model/Product.php +++ b/app/code/Magento/Catalog/Model/Product.php @@ -120,6 +120,11 @@ class Product extends \Magento\Catalog\Model\AbstractModel implements */ protected $_urlModel = null; + /** + * @var ResourceModel\Product + */ + protected $_resource; + /** * @var string */ @@ -475,6 +480,18 @@ protected function _construct() $this->_init(\Magento\Catalog\Model\ResourceModel\Product::class); } + /** + * Get resource instance + * + * @throws \Magento\Framework\Exception\LocalizedException + * @return \Magento\Catalog\Model\ResourceModel\Product + * @deprecated because resource models should be used directly + */ + protected function _getResource() + { + return parent::_getResource(); + } + /** * {@inheritdoc} */ @@ -608,6 +625,7 @@ public function getUpdatedAt() * * @param bool $calculate * @return void + * @deprecated */ public function setPriceCalculation($calculate = true) { @@ -928,13 +946,6 @@ public function afterSave() $this->_getResource()->addCommitCallback([$this, 'reindex']); $this->reloadPriceInfo(); - // Resize images for catalog product and save results to image cache - /** @var Product\Image\Cache $imageCache */ - if (!$this->_appState->isAreaCodeEmulated()) { - $imageCache = $this->imageCacheFactory->create(); - $imageCache->generate($this); - } - return $result; } @@ -1157,10 +1168,11 @@ public function setFinalPrice($price) */ public function getFinalPrice($qty = null) { - if ($this->_getData('final_price') === null) { - $this->setFinalPrice($this->getPriceModel()->getFinalPrice($qty, $this)); + if ($this->_calculatePrice || $this->_getData('final_price') === null) { + return $this->getPriceModel()->getFinalPrice($qty, $this); + } else { + return $this->_getData('final_price'); } - return $this->_getData('final_price'); } /** @@ -1469,10 +1481,14 @@ public function getMediaAttributeValues() public function getMediaGalleryImages() { $directory = $this->_filesystem->getDirectoryRead(DirectoryList::MEDIA); - if (!$this->hasData('media_gallery_images') && is_array($this->getMediaGallery('images'))) { - $images = $this->_collectionFactory->create(); + if (!$this->hasData('media_gallery_images')) { + $this->setData('media_gallery_images', $this->_collectionFactory->create()); + } + if (!$this->getData('media_gallery_images')->count() && is_array($this->getMediaGallery('images'))) { + $images = $this->getData('media_gallery_images'); foreach ($this->getMediaGallery('images') as $image) { - if ((isset($image['disabled']) && $image['disabled']) + if (!empty($image['disabled']) + || !empty($image['removed']) || empty($image['value_id']) || $images->getItemById($image['value_id']) != null ) { @@ -1989,7 +2005,7 @@ public function getIsVirtual() */ public function addCustomOption($code, $value, $product = null) { - $product = $product ? $product : $this; + $product = $product ?: $this; $option = $this->_itemOptionFactory->create()->addData( ['product_id' => $product->getId(), 'product' => $product, 'code' => $code, 'value' => $value] ); @@ -2092,6 +2108,8 @@ public function reset() /** * Get cache tags associated with object id * + * @deprecated + * @see \Magento\Catalog\Model\Product::getIdentities * @return string[] */ public function getCacheIdTags() @@ -2517,13 +2535,7 @@ public function setTypeId($typeId) */ public function getExtensionAttributes() { - $extensionAttributes = $this->_getExtensionAttributes(); - if (null === $extensionAttributes) { - /** @var \Magento\Catalog\Api\Data\ProductExtensionInterface $extensionAttributes */ - $extensionAttributes = $this->extensionAttributesFactory->create(ProductInterface::class); - $this->setExtensionAttributes($extensionAttributes); - } - return $extensionAttributes; + return $this->_getExtensionAttributes(); } /** diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/Backend/GroupPrice/AbstractGroupPrice.php b/app/code/Magento/Catalog/Model/Product/Attribute/Backend/GroupPrice/AbstractGroupPrice.php index 1b418bd5882c7..3779cab431cb7 100644 --- a/app/code/Magento/Catalog/Model/Product/Attribute/Backend/GroupPrice/AbstractGroupPrice.php +++ b/app/code/Magento/Catalog/Model/Product/Attribute/Backend/GroupPrice/AbstractGroupPrice.php @@ -358,7 +358,7 @@ protected function modifyPriceData($object, $data) { /** @var array $priceItem */ foreach ($data as $key => $priceItem) { - if (isset($priceItem['price']) && $priceItem['price'] > 0) { + if (array_key_exists('price', $priceItem)) { $data[$key]['website_price'] = $priceItem['price']; } if ($priceItem['all_groups']) { diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/Frontend/Inputtype/Presentation.php b/app/code/Magento/Catalog/Model/Product/Attribute/Frontend/Inputtype/Presentation.php index 28e0f22fc6ec9..03b8c7aa1cadc 100644 --- a/app/code/Magento/Catalog/Model/Product/Attribute/Frontend/Inputtype/Presentation.php +++ b/app/code/Magento/Catalog/Model/Product/Attribute/Frontend/Inputtype/Presentation.php @@ -4,6 +4,8 @@ * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Model\Product\Attribute\Frontend\Inputtype; use Magento\Catalog\Model\ResourceModel\Eav\Attribute; @@ -19,9 +21,9 @@ class Presentation * Get input type for presentation layer from stored input type. * * @param Attribute $attribute - * @return string + * @return string|null */ - public function getPresentationInputType(Attribute $attribute) + public function getPresentationInputType(Attribute $attribute) :?string { $inputType = $attribute->getFrontendInput(); if ($inputType == 'textarea' && $attribute->getIsWysiwygEnabled()) { @@ -37,12 +39,12 @@ public function getPresentationInputType(Attribute $attribute) * * @return array */ - public function convertPresentationDataToInputType(array $data) + public function convertPresentationDataToInputType(array $data) : array { - if ($data['frontend_input'] === 'texteditor') { + if (isset($data['frontend_input']) && $data['frontend_input'] === 'texteditor') { $data['is_wysiwyg_enabled'] = 1; $data['frontend_input'] = 'textarea'; - } elseif ($data['frontend_input'] === 'textarea') { + } elseif (isset($data['frontend_input']) && $data['frontend_input'] === 'textarea') { $data['is_wysiwyg_enabled'] = 0; } return $data; diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/Source/Inputtype.php b/app/code/Magento/Catalog/Model/Product/Attribute/Source/Inputtype.php index adbd6579e6828..07a8c4cc4d1a7 100644 --- a/app/code/Magento/Catalog/Model/Product/Attribute/Source/Inputtype.php +++ b/app/code/Magento/Catalog/Model/Product/Attribute/Source/Inputtype.php @@ -28,13 +28,16 @@ class Inputtype extends \Magento\Eav\Model\Adminhtml\System\Config\Source\Inputt /** * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Framework\Registry $coreRegistry + * @param array $optionsArray */ public function __construct( \Magento\Framework\Event\ManagerInterface $eventManager, - \Magento\Framework\Registry $coreRegistry + \Magento\Framework\Registry $coreRegistry, + array $optionsArray = [] ) { $this->_eventManager = $eventManager; $this->_coreRegistry = $coreRegistry; + parent::__construct($optionsArray); } /** diff --git a/app/code/Magento/Catalog/Model/Product/Image.php b/app/code/Magento/Catalog/Model/Product/Image.php index 971f34e02f9e5..09ba68ddbe2b2 100644 --- a/app/code/Magento/Catalog/Model/Product/Image.php +++ b/app/code/Magento/Catalog/Model/Product/Image.php @@ -6,10 +6,13 @@ namespace Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Image\NotLoadInfoImageException; +use Magento\Catalog\Model\View\Asset\ImageFactory; +use Magento\Catalog\Model\View\Asset\PlaceholderFactory; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\ObjectManager; use Magento\Framework\Image as MagentoImage; use Magento\Framework\Serialize\SerializerInterface; +use Magento\Catalog\Model\Product\Image\ParamsBuilder; /** * @method string getFile() @@ -159,12 +162,12 @@ class Image extends \Magento\Framework\Model\AbstractModel protected $_storeManager; /** - * @var \Magento\Catalog\Model\View\Asset\ImageFactory + * @var ImageFactory */ private $viewAssetImageFactory; /** - * @var \Magento\Catalog\Model\View\Asset\PlaceholderFactory + * @var PlaceholderFactory */ private $viewAssetPlaceholderFactory; @@ -173,6 +176,11 @@ class Image extends \Magento\Framework\Model\AbstractModel */ private $imageAsset; + /** + * @var ParamsBuilder + */ + private $paramsBuilder; + /** * @var string */ @@ -199,9 +207,10 @@ class Image extends \Magento\Framework\Model\AbstractModel * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data - * @param \Magento\Catalog\Model\View\Asset\ImageFactory|null $viewAssetImageFactory - * @param \Magento\Catalog\Model\View\Asset\PlaceholderFactory|null $viewAssetPlaceholderFactory + * @param ImageFactory|null $viewAssetImageFactory + * @param PlaceholderFactory|null $viewAssetPlaceholderFactory * @param SerializerInterface|null $serializer + * @param ParamsBuilder $paramsBuilder * @SuppressWarnings(PHPMD.ExcessiveParameterList) * @SuppressWarnings(PHPMD.UnusedLocalVariable) */ @@ -215,13 +224,14 @@ public function __construct( \Magento\Framework\Image\Factory $imageFactory, \Magento\Framework\View\Asset\Repository $assetRepo, \Magento\Framework\View\FileSystem $viewFileSystem, + ImageFactory $viewAssetImageFactory, + PlaceholderFactory $viewAssetPlaceholderFactory, \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, array $data = [], - \Magento\Catalog\Model\View\Asset\ImageFactory $viewAssetImageFactory = null, - \Magento\Catalog\Model\View\Asset\PlaceholderFactory $viewAssetPlaceholderFactory = null, - SerializerInterface $serializer = null + SerializerInterface $serializer = null, + ParamsBuilder $paramsBuilder = null ) { $this->_storeManager = $storeManager; $this->_catalogProductMediaConfig = $catalogProductMediaConfig; @@ -232,11 +242,10 @@ public function __construct( $this->_assetRepo = $assetRepo; $this->_viewFileSystem = $viewFileSystem; $this->_scopeConfig = $scopeConfig; - $this->viewAssetImageFactory = $viewAssetImageFactory ?: ObjectManager::getInstance() - ->get(\Magento\Catalog\Model\View\Asset\ImageFactory::class); - $this->viewAssetPlaceholderFactory = $viewAssetPlaceholderFactory ?: ObjectManager::getInstance() - ->get(\Magento\Catalog\Model\View\Asset\PlaceholderFactory::class); + $this->viewAssetImageFactory = $viewAssetImageFactory; + $this->viewAssetPlaceholderFactory = $viewAssetPlaceholderFactory; $this->serializer = $serializer ?: ObjectManager::getInstance()->get(SerializerInterface::class); + $this->paramsBuilder = $paramsBuilder ?: ObjectManager::getInstance()->get(ParamsBuilder::class); } /** @@ -370,25 +379,6 @@ public function setSize($size) return $this; } - /** - * Convert array of 3 items (decimal r, g, b) to string of their hex values - * - * @param int[] $rgbArray - * @return string - */ - protected function _rgbToString($rgbArray) - { - $result = []; - foreach ($rgbArray as $value) { - if (null === $value) { - $result[] = 'null'; - } else { - $result[] = sprintf('%02s', dechex($value)); - } - } - return implode($result); - } - /** * Set filenames for base file and new file * @@ -618,10 +608,8 @@ public function getDestinationSubdir() */ public function isCached() { - return ( - is_array($this->loadImageInfoFromCache($this->imageAsset->getPath())) || - file_exists($this->imageAsset->getPath()) - ); + $path = $this->imageAsset->getPath(); + return is_array($this->loadImageInfoFromCache($path)) || file_exists($path); } /** @@ -826,7 +814,7 @@ public function getResizedImageInfo() $image = $this->imageAsset->getPath(); } - $imageProperties = $this->getimagesize($image); + $imageProperties = $this->getImageSize($image); return $imageProperties; } finally { @@ -844,29 +832,20 @@ public function getResizedImageInfo() */ private function getMiscParams() { - $miscParams = [ - 'image_type' => $this->getDestinationSubdir(), - 'image_height' => $this->getHeight(), - 'image_width' => $this->getWidth(), - 'keep_aspect_ratio' => ($this->_keepAspectRatio ? '' : 'non') . 'proportional', - 'keep_frame' => ($this->_keepFrame ? '' : 'no') . 'frame', - 'keep_transparency' => ($this->_keepTransparency ? '' : 'no') . 'transparency', - 'constrain_only' => ($this->_constrainOnly ? 'do' : 'not') . 'constrainonly', - 'background' => $this->_rgbToString($this->_backgroundColor), - 'angle' => $this->_angle, - 'quality' => $this->_quality, - ]; - - // if has watermark add watermark params to hash - if ($this->getWatermarkFile()) { - $miscParams['watermark_file'] = $this->getWatermarkFile(); - $miscParams['watermark_image_opacity'] = $this->getWatermarkImageOpacity(); - $miscParams['watermark_position'] = $this->getWatermarkPosition(); - $miscParams['watermark_width'] = $this->getWatermarkWidth(); - $miscParams['watermark_height'] = $this->getWatermarkHeight(); - } - - return $miscParams; + return $this->paramsBuilder->build( + [ + 'type' => $this->getDestinationSubdir(), + 'width' => $this->getWidth(), + 'height' => $this->getHeight(), + 'frame' => $this->_keepFrame, + 'constrain' => $this->_constrainOnly, + 'aspect_ratio' => $this->_keepAspectRatio, + 'transparency' => $this->_keepTransparency, + 'background' => $this->_backgroundColor, + 'angle' => $this->_angle, + 'quality' => $this->_quality + ] + ); } /** diff --git a/app/code/Magento/Catalog/Model/Product/Image/ParamsBuilder.php b/app/code/Magento/Catalog/Model/Product/Image/ParamsBuilder.php new file mode 100644 index 0000000000000..dd8d352fecebc --- /dev/null +++ b/app/code/Magento/Catalog/Model/Product/Image/ParamsBuilder.php @@ -0,0 +1,165 @@ +scopeConfig = $scopeConfig; + $this->viewConfig = $viewConfig; + } + + /** + * @param array $imageArguments + * @return array + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function build(array $imageArguments): array + { + $miscParams = [ + 'image_type' => $imageArguments['type'] ?? null, + 'image_height' => $imageArguments['height'] ?? null, + 'image_width' => $imageArguments['width'] ?? null, + ]; + + $overwritten = $this->overwriteDefaultValues($imageArguments); + $watermark = isset($miscParams['image_type']) ? $this->getWatermark($miscParams['image_type']) : []; + + return array_merge($miscParams, $overwritten, $watermark); + } + + /** + * @param array $imageArguments + * @return array + */ + private function overwriteDefaultValues(array $imageArguments): array + { + $frame = $imageArguments['frame'] ?? $this->hasDefaultFrame(); + $constrain = $imageArguments['constrain'] ?? $this->defaultConstrainOnly; + $aspectRatio = $imageArguments['aspect_ratio'] ?? $this->defaultKeepAspectRatio; + $transparency = $imageArguments['transparency'] ?? $this->defaultKeepTransparency; + $background = $imageArguments['background'] ?? $this->defaultBackground; + $angle = $imageArguments['angle'] ?? $this->defaultAngle; + + return [ + 'background' => (array) $background, + 'angle' => $angle, + 'quality' => $this->defaultQuality, + 'keep_aspect_ratio' => (bool) $aspectRatio, + 'keep_frame' => (bool) $frame, + 'keep_transparency' => (bool) $transparency, + 'constrain_only' => (bool) $constrain, + ]; + } + + /** + * @param string $type + * @return array + */ + private function getWatermark(string $type): array + { + $file = $this->scopeConfig->getValue( + "design/watermark/{$type}_image", + ScopeInterface::SCOPE_STORE + ); + + if ($file) { + $size = $this->scopeConfig->getValue( + "design/watermark/{$type}_size", + ScopeInterface::SCOPE_STORE + ); + $opacity = $this->scopeConfig->getValue( + "design/watermark/{$type}_imageOpacity", + ScopeInterface::SCOPE_STORE + ); + $position = $this->scopeConfig->getValue( + "design/watermark/{$type}_position", + ScopeInterface::SCOPE_STORE + ); + $width = !empty($size['width']) ? $size['width'] : null; + $height = !empty($size['height']) ? $size['height'] : null; + + return [ + 'watermark_file' => $file, + 'watermark_image_opacity' => $opacity, + 'watermark_position' => $position, + 'watermark_width' => $width, + 'watermark_height' => $height + ]; + } + + return []; + } + + /** + * Get frame from product_image_white_borders + * @return bool + */ + private function hasDefaultFrame(): bool + { + return (bool) $this->viewConfig->getViewConfig()->getVarValue( + 'Magento_Catalog', + 'product_image_white_borders' + ); + } +} diff --git a/app/code/Magento/Catalog/Model/Product/Image/UrlBuilder.php b/app/code/Magento/Catalog/Model/Product/Image/UrlBuilder.php new file mode 100644 index 0000000000000..a5fdc3b0a6eaf --- /dev/null +++ b/app/code/Magento/Catalog/Model/Product/Image/UrlBuilder.php @@ -0,0 +1,92 @@ +presentationConfig = $presentationConfig; + $this->imageParamsBuilder = $imageParamsBuilder; + $this->viewAssetImageFactory = $viewAssetImageFactory; + $this->placeholderFactory = $placeholderFactory; + } + + /** + * Build image url using base path and params + * + * @param string $baseFilePath + * @param string $imageDisplayArea + * @return string + */ + public function getUrl(string $baseFilePath, string $imageDisplayArea): string + { + $imageArguments = $this->presentationConfig->getViewConfig()->getMediaAttributes( + 'Magento_Catalog', + Image::MEDIA_TYPE_CONFIG_NODE, + $imageDisplayArea + ); + + $imageMiscParams = $this->imageParamsBuilder->build($imageArguments); + + if ($baseFilePath === null || $baseFilePath === 'no_selection') { + $asset = $this->placeholderFactory->create( + [ + 'type' => $imageMiscParams['image_type'] + ] + ); + } else { + $asset = $this->viewAssetImageFactory->create( + [ + 'miscParams' => $imageMiscParams, + 'filePath' => $baseFilePath, + ] + ); + } + + return $asset->getUrl(); + } +} diff --git a/app/code/Magento/Catalog/Model/Product/Option.php b/app/code/Magento/Catalog/Model/Product/Option.php index 67d9074d91382..39595cdaa60ad 100644 --- a/app/code/Magento/Catalog/Model/Product/Option.php +++ b/app/code/Magento/Catalog/Model/Product/Option.php @@ -377,6 +377,10 @@ public function beforeSave() } } } + if ($this->getGroupByType($this->getData('type')) === self::OPTION_GROUP_FILE) { + $this->cleanFileExtensions(); + } + return $this; } @@ -902,4 +906,20 @@ private function getMetadataPool() } //@codeCoverageIgnoreEnd + + /** + * Clears all non-accepted characters from file_extension field. + * + * @return void + */ + private function cleanFileExtensions() + { + $rawExtensions = $this->getFileExtension(); + $matches = []; + preg_match_all('/(?[a-z0-9]+)/i', strtolower($rawExtensions), $matches); + if (!empty($matches)) { + $extensions = implode(', ', array_unique($matches['extensions'])); + } + $this->setFileExtension($extensions); + } } diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/File.php b/app/code/Magento/Catalog/Model/Product/Option/Type/File.php index aecb8525915d7..9f1eae207e116 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/File.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/File.php @@ -127,7 +127,7 @@ public function __construct( $this->mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); $this->validatorInfo = $validatorInfo; $this->validatorFile = $validatorFile; - $this->serializer = $serializer ? $serializer : ObjectManager::getInstance()->get(Json::class); + $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class); parent::__construct($checkoutSession, $scopeConfig, $data); } diff --git a/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php b/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php index 08e1a3e327d2f..0ace0372c43bb 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Type/Select.php @@ -71,7 +71,8 @@ public function validateUserValue($values) } if (!$this->_isSingleSelection()) { $valuesCollection = $option->getOptionValuesByOptionId($value, $this->getProduct()->getStoreId())->load(); - if ($valuesCollection->count() != count($value)) { + $valueCount = is_array($value) ? count($value) : 1; + if ($valuesCollection->count() != $valueCount) { $this->setIsValid(false); throw new LocalizedException( __( diff --git a/app/code/Magento/Catalog/Model/Product/Option/Value.php b/app/code/Magento/Catalog/Model/Product/Option/Value.php index ff21cd296ec90..fb7759b210bd9 100644 --- a/app/code/Magento/Catalog/Model/Product/Option/Value.php +++ b/app/code/Magento/Catalog/Model/Product/Option/Value.php @@ -8,8 +8,10 @@ use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Option; -use Magento\Catalog\Pricing\Price\BasePrice; use Magento\Framework\Model\AbstractModel; +use Magento\Catalog\Pricing\Price\BasePrice; +use Magento\Catalog\Pricing\Price\CustomOptionPriceCalculator; +use Magento\Catalog\Pricing\Price\RegularPrice; /** * Catalog product option select type model @@ -19,6 +21,9 @@ * @method \Magento\Catalog\Model\Product\Option\Value setOptionId(int $value) * * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - added use of constants instead of string literals: + * BasePrice::PRICE_CODE - instead of 'base_price' + * RegularPrice::PRICE_CODE - instead of 'regular_price' * @since 100.0.2 */ class Value extends AbstractModel implements \Magento\Catalog\Api\Data\ProductCustomOptionValuesInterface @@ -59,6 +64,11 @@ class Value extends AbstractModel implements \Magento\Catalog\Api\Data\ProductCu */ protected $_valueCollectionFactory; + /** + * @var CustomOptionPriceCalculator + */ + private $customOptionPriceCalculator; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -73,9 +83,12 @@ public function __construct( \Magento\Catalog\Model\ResourceModel\Product\Option\Value\CollectionFactory $valueCollectionFactory, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + CustomOptionPriceCalculator $customOptionPriceCalculator = null ) { $this->_valueCollectionFactory = $valueCollectionFactory; + $this->customOptionPriceCalculator = $customOptionPriceCalculator + ?? \Magento\Framework\App\ObjectManager::getInstance()->get(CustomOptionPriceCalculator::class); parent::__construct( $context, $registry, @@ -222,10 +235,8 @@ public function saveValues() */ public function getPrice($flag = false) { - if ($flag && $this->getPriceType() == self::TYPE_PERCENT) { - $basePrice = $this->getOption()->getProduct()->getPriceInfo()->getPrice(BasePrice::PRICE_CODE)->getValue(); - $price = $basePrice * ($this->_getData(self::KEY_PRICE) / 100); - return $price; + if ($flag) { + return $this->customOptionPriceCalculator->getOptionPriceByPriceCode($this, BasePrice::PRICE_CODE); } return $this->_getData(self::KEY_PRICE); } @@ -237,13 +248,7 @@ public function getPrice($flag = false) */ public function getRegularPrice() { - if ($this->getPriceType() == self::TYPE_PERCENT) { - $basePrice = $this->getOption()->getProduct()->getPriceInfo() - ->getPrice('regular_price')->getAmount()->getValue(); - $price = $basePrice * ($this->_getData(self::KEY_PRICE) / 100); - return $price; - } - return $this->_getData(self::KEY_PRICE); + return $this->customOptionPriceCalculator->getOptionPriceByPriceCode($this, RegularPrice::PRICE_CODE); } /** diff --git a/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php b/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php index 3bb6bba69bfb4..1bddd2d07cd81 100644 --- a/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php +++ b/app/code/Magento/Catalog/Model/Product/Price/TierPriceStorage.php @@ -172,8 +172,10 @@ private function getExistingPrices(array $skus, $groupBySku = false) $rawPrices = $this->tierPricePersistence->get($ids); $prices = []; + $linkField = $this->tierPricePersistence->getEntityLinkField(); + $skuByIdLookup = $this->buildSkuByIdLookup($skus); foreach ($rawPrices as $rawPrice) { - $sku = $this->retrieveSkuById($rawPrice[$this->tierPricePersistence->getEntityLinkField()], $skus); + $sku = $skuByIdLookup[$rawPrice[$linkField]]; $price = $this->tierPriceFactory->create($rawPrice, $sku); if ($groupBySku) { $prices[$sku][] = $price; @@ -300,21 +302,21 @@ private function isCorrectPriceValue(array $existingPrice, array $price) } /** - * Retrieve SKU by product ID. + * Generate lookup to retrieve SKU by product ID. * - * @param int $id * @param array $skus - * @return string|null + * @return array */ - private function retrieveSkuById($id, $skus) + private function buildSkuByIdLookup($skus) { + $lookup = []; foreach ($this->productIdLocator->retrieveProductIdsBySkus($skus) as $sku => $ids) { - if (isset($ids[$id])) { - return $sku; + foreach (array_keys($ids) as $id) { + $lookup[$id] = $sku; } } - return null; + return $lookup; } /** diff --git a/app/code/Magento/Catalog/Model/Product/ProductList/Toolbar.php b/app/code/Magento/Catalog/Model/Product/ProductList/Toolbar.php index c2046bea550e6..af0772251e235 100644 --- a/app/code/Magento/Catalog/Model/Product/ProductList/Toolbar.php +++ b/app/code/Magento/Catalog/Model/Product/ProductList/Toolbar.php @@ -102,6 +102,6 @@ public function getLimit() public function getCurrentPage() { $page = (int) $this->request->getParam(self::PAGE_PARM_NAME); - return $page ? $page : 1; + return $page ?: 1; } } diff --git a/app/code/Magento/Catalog/Model/Product/Type/Price.php b/app/code/Magento/Catalog/Model/Product/Type/Price.php index aa28a3478ebf7..7eaedf77eb859 100644 --- a/app/code/Magento/Catalog/Model/Product/Type/Price.php +++ b/app/code/Magento/Catalog/Model/Product/Type/Price.php @@ -341,7 +341,7 @@ public function getTierPrice($qty, $product) } } - return $prices ? $prices : []; + return $prices ?: []; } /** diff --git a/app/code/Magento/Catalog/Model/ProductCategoryList.php b/app/code/Magento/Catalog/Model/ProductCategoryList.php index ae875453be938..5bbae772d5c2b 100644 --- a/app/code/Magento/Catalog/Model/ProductCategoryList.php +++ b/app/code/Magento/Catalog/Model/ProductCategoryList.php @@ -5,9 +5,11 @@ */ namespace Magento\Catalog\Model; -use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; use Magento\Framework\DB\Select; use Magento\Framework\DB\Sql\UnionExpression; +use Magento\Framework\App\ObjectManager; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; /** * Provides info about product categories. @@ -29,16 +31,32 @@ class ProductCategoryList */ private $category; + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var TableMaintainer + */ + private $tableMaintainer; + /** * @param ResourceModel\Product $productResource * @param ResourceModel\Category $category + * @param StoreManagerInterface $storeManager + * @param TableMaintainer|null $tableMaintainer */ public function __construct( ResourceModel\Product $productResource, - ResourceModel\Category $category + ResourceModel\Category $category, + StoreManagerInterface $storeManager = null, + TableMaintainer $tableMaintainer = null ) { $this->productResource = $productResource; $this->category = $category; + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); + $this->storeManager = $storeManager ?: ObjectManager::getInstance()->get(StoreManagerInterface::class); } /** @@ -50,14 +68,15 @@ public function __construct( public function getCategoryIds($productId) { if (!isset($this->categoryIdList[$productId])) { + $unionTables[] = $this->getCategorySelect($productId, $this->category->getCategoryProductTable()); + foreach ($this->storeManager->getStores() as $store) { + $unionTables[] = $this->getCategorySelect( + $productId, + $this->tableMaintainer->getMainTable($store->getId()) + ); + } $unionSelect = new UnionExpression( - [ - $this->getCategorySelect($productId, $this->category->getCategoryProductTable()), - $this->getCategorySelect( - $productId, - $this->productResource->getTable(AbstractAction::MAIN_INDEX_TABLE) - ) - ], + $unionTables, Select::SQL_UNION_ALL ); diff --git a/app/code/Magento/Catalog/Model/ProductLink/Search.php b/app/code/Magento/Catalog/Model/ProductLink/Search.php new file mode 100644 index 0000000000000..8750345aa222b --- /dev/null +++ b/app/code/Magento/Catalog/Model/ProductLink/Search.php @@ -0,0 +1,67 @@ +productCollectionFactory = $productCollectionFactory; + $this->filter = $filter; + $this->catalogVisibility = $catalogVisibility; + } + + /** + * Add required filters and limitations for product collection + * + * @param string $searchKey + * @param int $pageNum + * @param int $limit + * @return \Magento\Catalog\Model\ResourceModel\Product\Collection + */ + public function prepareCollection( + string $searchKey, + int $pageNum, + int $limit + ): \Magento\Catalog\Model\ResourceModel\Product\Collection { + $productCollection = $this->productCollectionFactory->create(); + $productCollection->addAttributeToSelect(ProductInterface::NAME); + $productCollection->setVisibility($this->catalogVisibility->getVisibleInCatalogIds()); + $productCollection->setPage($pageNum, $limit); + $this->filter->addFilter($productCollection, 'fulltext', ['fulltext' => $searchKey]); + $productCollection->setPage($pageNum, $limit); + return $productCollection; + } +} diff --git a/app/code/Magento/Catalog/Model/ProductRepository.php b/app/code/Magento/Catalog/Model/ProductRepository.php index e3329832e134a..6a82658342824 100644 --- a/app/code/Magento/Catalog/Model/ProductRepository.php +++ b/app/code/Magento/Catalog/Model/ProductRepository.php @@ -740,7 +740,7 @@ protected function addFilterGroupToCollection( $fields = []; $categoryFilter = []; foreach ($filterGroup->getFilters() as $filter) { - $conditionType = $filter->getConditionType() ? $filter->getConditionType() : 'eq'; + $conditionType = $filter->getConditionType() ?: 'eq'; if ($filter->getField() == 'category_id') { $categoryFilter[$conditionType][] = $filter->getValue(); diff --git a/app/code/Magento/Catalog/Model/ResourceModel/AbstractResource.php b/app/code/Magento/Catalog/Model/ResourceModel/AbstractResource.php index 2e5b4ca538ffe..b9e629912a5b3 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/AbstractResource.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/AbstractResource.php @@ -78,7 +78,7 @@ public function getDefaultStoreId() */ protected function _isApplicableAttribute($object, $attribute) { - $applyTo = $attribute->getApplyTo(); + $applyTo = $attribute->getApplyTo() ?: []; return (count($applyTo) == 0 || in_array($object->getTypeId(), $applyTo)) && $attribute->isInSet($object->getAttributeSetId()); } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category.php b/app/code/Magento/Catalog/Model/ResourceModel/Category.php index a9c705697b268..6c9867359d40b 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category.php @@ -410,9 +410,18 @@ protected function _saveCategoryProducts($category) * Update product positions in category */ if (!empty($update)) { + $newPositions = []; foreach ($update as $productId => $position) { - $where = ['category_id = ?' => (int)$id, 'product_id = ?' => (int)$productId]; - $bind = ['position' => (int)$position]; + $delta = $position - $oldProducts[$productId]; + if (!isset($newPositions[$delta])) { + $newPositions[$delta] = []; + } + $newPositions[$delta][] = $productId; + } + + foreach ($newPositions as $delta => $productIds) { + $bind = ['position' => new \Zend_Db_Expr("position + ({$delta})")]; + $where = ['category_id = ?' => (int)$id, 'product_id IN (?)' => $productIds]; $connection->update($this->getCategoryProductTable(), $bind, $where); } } @@ -423,6 +432,8 @@ protected function _saveCategoryProducts($category) 'catalog_category_change_products', ['category' => $category, 'product_ids' => $productIds] ); + + $category->setChangedProductIds($productIds); } if (!empty($insert) || !empty($update) || !empty($delete)) { diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php index 573914a13f6e5..46bb74513b59c 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php @@ -371,7 +371,7 @@ public function joinUrlRewrite() ['request_path'], sprintf( '{{table}}.is_autogenerated = 1 AND {{table}}.store_id = %d AND {{table}}.entity_type = \'%s\'', - $this->_storeManager->getStore()->getId(), + $this->getStoreId(), CategoryUrlRewriteGenerator::ENTITY_TYPE ), 'left' diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Collection/AbstractCollection.php b/app/code/Magento/Catalog/Model/ResourceModel/Collection/AbstractCollection.php index 0d8c3992ddbb8..9ab863cde2704 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Collection/AbstractCollection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Collection/AbstractCollection.php @@ -140,7 +140,7 @@ public function getDefaultStoreId() * * @param string $table * @param array|int $attributeIds - * @return \Magento\Eav\Model\Entity\Collection\AbstractCollection + * @return \Magento\Framework\DB\Select */ protected function _getLoadAttributesSelect($table, $attributeIds = []) { diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php b/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php index 7879c629970ab..8f8e9f6bfedfa 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Eav/Attribute.php @@ -21,7 +21,6 @@ * @method int setSearchWeight(int $value) * @method bool getIsUsedForPriceRules() * @method int setIsUsedForPriceRules(int $value) - * @method \Magento\Eav\Api\Data\AttributeExtensionInterface getExtensionAttributes() * * @author Magento Core Team * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -80,6 +79,11 @@ class Attribute extends \Magento\Eav\Model\Entity\Attribute implements */ protected $_indexerEavProcessor; + /** + * @var \Magento\Eav\Api\Data\AttributeExtensionFactory + */ + private $eavAttributeFactory; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -101,9 +105,10 @@ class Attribute extends \Magento\Eav\Model\Entity\Attribute implements * @param \Magento\Catalog\Model\Indexer\Product\Eav\Processor $indexerEavProcessor * @param \Magento\Catalog\Helper\Product\Flat\Indexer $productFlatIndexerHelper * @param LockValidatorInterface $lockValidator - * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource - * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection + * @param \Magento\Framework\Model\ResourceModel\AbstractResource|null $resource + * @param \Magento\Framework\Data\Collection\AbstractDb|null $resourceCollection * @param array $data + * @param \Magento\Eav\Api\Data\AttributeExtensionFactory|null $eavAttributeFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -129,12 +134,15 @@ public function __construct( LockValidatorInterface $lockValidator, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + \Magento\Eav\Api\Data\AttributeExtensionFactory $eavAttributeFactory = null ) { $this->_indexerEavProcessor = $indexerEavProcessor; $this->_productFlatIndexerProcessor = $productFlatIndexerProcessor; $this->_productFlatIndexerHelper = $productFlatIndexerHelper; $this->attrLockValidator = $lockValidator; + $this->eavAttributeFactory = $eavAttributeFactory ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Eav\Api\Data\AttributeExtensionFactory::class); parent::__construct( $context, $registry, @@ -247,11 +255,11 @@ public function isEnabledInFlat() */ protected function _isEnabledInFlat() { - return $this->getData('backend_type') == 'static' + return $this->_getData('backend_type') == 'static' || $this->_productFlatIndexerHelper->isAddFilterableAttributes() - && $this->getData('is_filterable') > 0 - || $this->getData('used_in_product_listing') == 1 - || $this->getData('used_for_sort_by') == 1; + && $this->_getData('is_filterable') > 0 + || $this->_getData('used_in_product_listing') == 1 + || $this->_getData('used_for_sort_by') == 1; } /** @@ -349,7 +357,7 @@ public function getStoreId() if ($dataObject) { return $dataObject->getStoreId(); } - return $this->getData('store_id'); + return $this->_getData('store_id'); } /** @@ -374,7 +382,7 @@ public function getApplyTo() */ public function getSourceModel() { - $model = $this->getData('source_model'); + $model = $this->_getData('source_model'); if (empty($model)) { if ($this->getBackendType() == 'int' && $this->getFrontendInput() == 'select') { return $this->_getDefaultSourceModel(); @@ -504,7 +512,7 @@ public function getIndexType() */ public function getIsWysiwygEnabled() { - return $this->getData(self::IS_WYSIWYG_ENABLED); + return $this->_getData(self::IS_WYSIWYG_ENABLED); } /** @@ -512,7 +520,7 @@ public function getIsWysiwygEnabled() */ public function getIsHtmlAllowedOnFront() { - return $this->getData(self::IS_HTML_ALLOWED_ON_FRONT); + return $this->_getData(self::IS_HTML_ALLOWED_ON_FRONT); } /** @@ -520,7 +528,7 @@ public function getIsHtmlAllowedOnFront() */ public function getUsedForSortBy() { - return $this->getData(self::USED_FOR_SORT_BY); + return $this->_getData(self::USED_FOR_SORT_BY); } /** @@ -528,7 +536,7 @@ public function getUsedForSortBy() */ public function getIsFilterable() { - return $this->getData(self::IS_FILTERABLE); + return $this->_getData(self::IS_FILTERABLE); } /** @@ -536,7 +544,7 @@ public function getIsFilterable() */ public function getIsFilterableInSearch() { - return $this->getData(self::IS_FILTERABLE_IN_SEARCH); + return $this->_getData(self::IS_FILTERABLE_IN_SEARCH); } /** @@ -544,7 +552,7 @@ public function getIsFilterableInSearch() */ public function getIsUsedInGrid() { - return (bool)$this->getData(self::IS_USED_IN_GRID); + return (bool)$this->_getData(self::IS_USED_IN_GRID); } /** @@ -552,7 +560,7 @@ public function getIsUsedInGrid() */ public function getIsVisibleInGrid() { - return (bool)$this->getData(self::IS_VISIBLE_IN_GRID); + return (bool)$this->_getData(self::IS_VISIBLE_IN_GRID); } /** @@ -560,7 +568,7 @@ public function getIsVisibleInGrid() */ public function getIsFilterableInGrid() { - return (bool)$this->getData(self::IS_FILTERABLE_IN_GRID); + return (bool)$this->_getData(self::IS_FILTERABLE_IN_GRID); } /** @@ -568,7 +576,7 @@ public function getIsFilterableInGrid() */ public function getPosition() { - return $this->getData(self::POSITION); + return $this->_getData(self::POSITION); } /** @@ -576,7 +584,7 @@ public function getPosition() */ public function getIsSearchable() { - return $this->getData(self::IS_SEARCHABLE); + return $this->_getData(self::IS_SEARCHABLE); } /** @@ -584,7 +592,7 @@ public function getIsSearchable() */ public function getIsVisibleInAdvancedSearch() { - return $this->getData(self::IS_VISIBLE_IN_ADVANCED_SEARCH); + return $this->_getData(self::IS_VISIBLE_IN_ADVANCED_SEARCH); } /** @@ -592,7 +600,7 @@ public function getIsVisibleInAdvancedSearch() */ public function getIsComparable() { - return $this->getData(self::IS_COMPARABLE); + return $this->_getData(self::IS_COMPARABLE); } /** @@ -600,7 +608,7 @@ public function getIsComparable() */ public function getIsUsedForPromoRules() { - return $this->getData(self::IS_USED_FOR_PROMO_RULES); + return $this->_getData(self::IS_USED_FOR_PROMO_RULES); } /** @@ -608,7 +616,7 @@ public function getIsUsedForPromoRules() */ public function getIsVisibleOnFront() { - return $this->getData(self::IS_VISIBLE_ON_FRONT); + return $this->_getData(self::IS_VISIBLE_ON_FRONT); } /** @@ -616,7 +624,7 @@ public function getIsVisibleOnFront() */ public function getUsedInProductListing() { - return $this->getData(self::USED_IN_PRODUCT_LISTING); + return $this->_getData(self::USED_IN_PRODUCT_LISTING); } /** @@ -624,7 +632,7 @@ public function getUsedInProductListing() */ public function getIsVisible() { - return $this->getData(self::IS_VISIBLE); + return $this->_getData(self::IS_VISIBLE); } //@codeCoverageIgnoreEnd diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product.php b/app/code/Magento/Catalog/Model/ResourceModel/Product.php index a5fdc264aa19a..d71ec23881982 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product.php @@ -7,6 +7,7 @@ use Magento\Catalog\Model\ResourceModel\Product\Website\Link as ProductWebsiteLink; use Magento\Framework\App\ObjectManager; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; /** * Product entity resource model @@ -83,6 +84,11 @@ class Product extends AbstractResource */ private $productCategoryLink; + /** + * @var TableMaintainer + */ + private $tableMaintainer; + /** * @param \Magento\Eav\Model\Entity\Context $context * @param \Magento\Store\Model\StoreManagerInterface $storeManager @@ -94,6 +100,7 @@ class Product extends AbstractResource * @param \Magento\Eav\Model\Entity\TypeFactory $typeFactory * @param \Magento\Catalog\Model\Product\Attribute\DefaultAttributes $defaultAttributes * @param array $data + * @param TableMaintainer|null $tableMaintainer * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -107,7 +114,8 @@ public function __construct( \Magento\Eav\Model\Entity\Attribute\SetFactory $setFactory, \Magento\Eav\Model\Entity\TypeFactory $typeFactory, \Magento\Catalog\Model\Product\Attribute\DefaultAttributes $defaultAttributes, - $data = [] + $data = [], + TableMaintainer $tableMaintainer = null ) { $this->_categoryCollectionFactory = $categoryCollectionFactory; $this->_catalogCategory = $catalogCategory; @@ -122,6 +130,7 @@ public function __construct( $data ); $this->connectionName = 'catalog'; + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); } /** @@ -366,22 +375,42 @@ public function getAvailableInCategories($object) // fetching all parent IDs, including those are higher on the tree $entityId = (int)$object->getEntityId(); if (!isset($this->availableCategoryIdsCache[$entityId])) { - $this->availableCategoryIdsCache[$entityId] = $this->getConnection()->fetchCol( - $this->getConnection()->select()->distinct()->from( - $this->getTable('catalog_category_product_index'), - ['category_id'] - )->where( - 'product_id = ? AND is_parent = 1', - $entityId - )->where( - 'visibility != ?', - \Magento\Catalog\Model\Product\Visibility::VISIBILITY_NOT_VISIBLE - ) + foreach ($this->_storeManager->getStores() as $store) { + $unionTables[] = $this->getAvailableInCategoriesSelect( + $entityId, + $this->tableMaintainer->getMainTable($store->getId()) + ); + } + $unionSelect = new \Magento\Framework\DB\Sql\UnionExpression( + $unionTables, + \Magento\Framework\DB\Select::SQL_UNION_ALL ); + $this->availableCategoryIdsCache[$entityId] = array_unique($this->getConnection()->fetchCol($unionSelect)); } return $this->availableCategoryIdsCache[$entityId]; } + /** + * Returns DB select for available categories. + * + * @param int $entityId + * @param string $tableName + * @return \Magento\Framework\DB\Select + */ + private function getAvailableInCategoriesSelect($entityId, $tableName) + { + return $this->getConnection()->select()->distinct()->from( + $tableName, + ['category_id'] + )->where( + 'product_id = ? AND is_parent = 1', + $entityId + )->where( + 'visibility != ?', + \Magento\Catalog\Model\Product\Visibility::VISIBILITY_NOT_VISIBLE + ); + } + /** * Get default attribute source model * @@ -402,7 +431,7 @@ public function getDefaultAttributeSourceModel() public function canBeShowInCategory($product, $categoryId) { $select = $this->getConnection()->select()->from( - $this->getTable('catalog_category_product_index'), + $this->tableMaintainer->getMainTable($product->getStoreId()), 'product_id' )->where( 'product_id = ?', @@ -505,11 +534,29 @@ public function getProductsIdsBySkus(array $productSkuList) $result = []; foreach ($this->getConnection()->fetchAll($select) as $row) { - $result[$row['sku']] = $row['entity_id']; + $result[$this->getResultKey($row['sku'], $productSkuList)] = $row['entity_id']; } return $result; } + /** + * Return correct key for result array in getProductIdsBySku + * Allows for different case sku to be passed in search array + * with original cased sku to be passed back in result array + * + * @param string $sku + * @param array $productSkuList + * @return string + */ + private function getResultKey(string $sku, array $productSkuList): string + { + $key = array_search(strtolower($sku), array_map('strtolower', $productSkuList)); + if ($key !== false) { + $sku = $productSkuList[$key]; + } + return $sku; + } + /** * Retrieve product entities info * diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php index 0729f2bd1f8f5..9b87515450a12 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php @@ -16,6 +16,7 @@ use Magento\Framework\DB\Select; use Magento\Framework\EntityManager\MetadataPool; use Magento\Store\Model\Store; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; /** * Product collection @@ -270,6 +271,11 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Collection\Abstrac */ private $backend; + /** + * @var TableMaintainer + */ + private $tableMaintainer; + /** * Collection constructor * @@ -295,6 +301,7 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Collection\Abstrac * @param \Magento\Framework\DB\Adapter\AdapterInterface|null $connection * @param ProductLimitationFactory|null $productLimitationFactory * @param MetadataPool|null $metadataPool + * @param TableMaintainer|null $tableMaintainer * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -320,7 +327,8 @@ public function __construct( GroupManagementInterface $groupManagement, \Magento\Framework\DB\Adapter\AdapterInterface $connection = null, ProductLimitationFactory $productLimitationFactory = null, - MetadataPool $metadataPool = null + MetadataPool $metadataPool = null, + TableMaintainer $tableMaintainer = null ) { $this->moduleManager = $moduleManager; $this->_catalogProductFlatState = $catalogProductFlatState; @@ -350,6 +358,7 @@ public function __construct( $storeManager, $connection ); + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); } /** @@ -1193,7 +1202,7 @@ public function getProductCountSelect() )->distinct( false )->join( - ['count_table' => $this->getTable('catalog_category_product_index')], + ['count_table' => $this->tableMaintainer->getMainTable($this->getStoreId())], 'count_table.product_id = e.entity_id', [ 'count_table.category_id', @@ -1561,7 +1570,7 @@ protected function getEntityPkName(\Magento\Eav\Model\Entity\AbstractEntity $ent } /** - * Add requere tax percent flag for product collection + * Add require tax percent flag for product collection * * @return $this */ @@ -1967,7 +1976,7 @@ protected function _applyProductLimitations() $this->getSelect()->setPart(\Magento\Framework\DB\Select::FROM, $fromPart); } else { $this->getSelect()->join( - ['cat_index' => $this->getTable('catalog_category_product_index')], + ['cat_index' => $this->tableMaintainer->getMainTable($this->getStoreId())], $joinCond, ['cat_index_position' => 'position'] ); diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php new file mode 100644 index 0000000000000..123f358be40c8 --- /dev/null +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php @@ -0,0 +1,97 @@ +batchQueryGenerator = $generator; + $this->resourceConnection = $resourceConnection; + $this->connection = $this->resourceConnection->getConnection(); + $this->batchSize = $batchSize; + } + + /** + * Returns product images + * + * @return \Generator + */ + public function getAllProductImages(): \Generator + { + $batchSelectIterator = $this->batchQueryGenerator->generate( + 'value_id', + $this->getVisibleImagesSelect(), + $this->batchSize, + \Magento\Framework\DB\Query\BatchIteratorInterface::NON_UNIQUE_FIELD_ITERATOR + ); + + foreach ($batchSelectIterator as $select) { + foreach ($this->connection->fetchAll($select) as $key => $value) { + yield $key => $value; + } + } + } + + /** + * Get the number of unique pictures of products + * @return int + */ + public function getCountAllProductImages(): int + { + $select = $this->getVisibleImagesSelect()->reset('columns')->columns('count(*)'); + return (int) $this->connection->fetchOne($select); + } + + /** + * @return Select + */ + private function getVisibleImagesSelect(): Select + { + return $this->connection->select()->distinct() + ->from( + ['images' => $this->resourceConnection->getTableName(Gallery::GALLERY_TABLE)], + 'value as filepath' + )->where( + 'disabled = 0' + ); + } +} diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php index c4e3fb1bf1e70..c33ea7c781aa3 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Eav/AbstractEav.php @@ -82,6 +82,7 @@ public function reindexEntities($processIds) $this->_prepareIndex($processIds); $this->_prepareRelationIndex($processIds); $this->_removeNotVisibleEntityFromIndex(); + return $this; } @@ -159,11 +160,12 @@ protected function _removeNotVisibleEntityFromIndex() * @param array $parentIds the parent entity ids limitation * @return \Magento\Framework\DB\Select */ - protected function _prepareRelationIndexSelect($parentIds = null) + protected function _prepareRelationIndexSelect(array $parentIds = null) { $connection = $this->getConnection(); $idxTable = $this->getIdxTable(); $linkField = $this->getMetadataPool()->getMetadata(ProductInterface::class)->getLinkField(); + $select = $connection->select()->from( ['l' => $this->getTable('catalog_product_relation')], [] @@ -179,6 +181,14 @@ protected function _prepareRelationIndexSelect($parentIds = null) ['i' => $idxTable], 'l.child_id = i.entity_id AND cs.store_id = i.store_id', [] + )->join( + ['sw' => $this->getTable('store_website')], + "cs.website_id = sw.website_id", + [] + )->join( + ['cpw' => $this->getTable('catalog_product_website')], + 'i.entity_id = cpw.product_id AND sw.website_id = cpw.website_id', + [] )->group( ['parent_id', 'i.attribute_id', 'i.store_id', 'i.value', 'l.child_id'] )->columns( diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/CompositeProductRowSizeEstimator.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/CompositeProductRowSizeEstimator.php index a499777df871a..24cb4fedd57e5 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/CompositeProductRowSizeEstimator.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/CompositeProductRowSizeEstimator.php @@ -18,7 +18,7 @@ class CompositeProductRowSizeEstimator implements IndexTableRowSizeEstimatorInte /** * Calculated memory size for one record in catalog_product_index_price table */ - const MEMORY_SIZE_FOR_ONE_ROW = 200; + const MEMORY_SIZE_FOR_ONE_ROW = 250; /** * @var WebsiteManagementInterface diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/DefaultPrice.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/DefaultPrice.php index 4761b8b1ce896..591a26efbf615 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/DefaultPrice.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/DefaultPrice.php @@ -361,18 +361,26 @@ protected function getSelect($entityIds = null, $type = null) ); $currentDate = $connection->getDatePartSql('cwd.website_date'); + $maxUnsignedBigint = '~0'; $specialFromDate = $connection->getDatePartSql($specialFrom); $specialToDate = $connection->getDatePartSql($specialTo); - - $specialFromUse = $connection->getCheckSql("{$specialFromDate} <= {$currentDate}", '1', '0'); - $specialToUse = $connection->getCheckSql("{$specialToDate} >= {$currentDate}", '1', '0'); - $specialFromHas = $connection->getCheckSql("{$specialFrom} IS NULL", '1', "{$specialFromUse}"); - $specialToHas = $connection->getCheckSql("{$specialTo} IS NULL", '1', "{$specialToUse}"); - $finalPrice = $connection->getCheckSql( - "{$specialFromHas} > 0 AND {$specialToHas} > 0" . " AND {$specialPrice} < {$price}", + $specialFromExpr = "{$specialFrom} IS NULL OR {$specialFromDate} <= {$currentDate}"; + $specialToExpr = "{$specialTo} IS NULL OR {$specialToDate} >= {$currentDate}"; + $specialPriceExpr = $connection->getCheckSql( + "{$specialPrice} IS NOT NULL AND {$specialFromExpr} AND {$specialToExpr}", $specialPrice, - $price + $maxUnsignedBigint + ); + $tierPrice = new \Zend_Db_Expr('tp.min_price'); + $tierPriceExpr = $connection->getIfNullSql( + $tierPrice, + $maxUnsignedBigint ); + $finalPrice = $connection->getLeastSql([ + $price, + $specialPriceExpr, + $tierPriceExpr, + ]); $select->columns( [ @@ -380,8 +388,8 @@ protected function getSelect($entityIds = null, $type = null) 'price' => $connection->getIfNullSql($finalPrice, 0), 'min_price' => $connection->getIfNullSql($finalPrice, 0), 'max_price' => $connection->getIfNullSql($finalPrice, 0), - 'tier_price' => new \Zend_Db_Expr('tp.min_price'), - 'base_tier' => new \Zend_Db_Expr('tp.min_price'), + 'tier_price' => $tierPrice, + 'base_tier' => $tierPrice, ] ); diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/IndexTableRowSizeEstimator.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/IndexTableRowSizeEstimator.php index 89df6677f2490..7707fadffe98c 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/IndexTableRowSizeEstimator.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/IndexTableRowSizeEstimator.php @@ -15,7 +15,7 @@ class IndexTableRowSizeEstimator implements \Magento\Framework\Indexer\IndexTabl /** * Calculated memory size for one record in catalog_product_index_price table */ - const MEMORY_SIZE_FOR_ONE_ROW = 120; + const MEMORY_SIZE_FOR_ONE_ROW = 200; /** * @var \Magento\Store\Api\WebsiteManagementInterface diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/TierPrice.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/TierPrice.php new file mode 100644 index 0000000000000..a866c1eaa413f --- /dev/null +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Indexer/Price/TierPrice.php @@ -0,0 +1,220 @@ +tierPriceResourceModel = $tierPriceResourceModel; + $this->metadataPool = $metadataPool; + $this->attributeRepository = $attributeRepository; + } + + /** + * @inheritdoc + */ + protected function _construct() + { + $this->_init('catalog_product_index_tier_price', 'entity_id'); + } + + /** + * Reindex tier price for entities. + * + * @param array $entityIds + * @return void + */ + public function reindexEntity(array $entityIds = []) + { + $this->getConnection()->delete($this->getMainTable(), ['entity_id IN (?)' => $entityIds]); + + //separate by variations for increase performance + $tierPriceVariations = [ + [true, true], //all websites; all customer groups + [true, false], //all websites; specific customer group + [false, true], //specific website; all customer groups + [false, false], //specific website; specific customer group + ]; + foreach ($tierPriceVariations as $variation) { + list ($isAllWebsites, $isAllCustomerGroups) = $variation; + $select = $this->getTierPriceSelect($isAllWebsites, $isAllCustomerGroups, $entityIds); + $query = $select->insertFromSelect($this->getMainTable()); + $this->getConnection()->query($query); + } + } + + /** + * Join websites table. + * If $isAllWebsites is true, for each website will be used default value for all websites, + * otherwise per each website will be used their own values. + * + * @param Select $select + * @param bool $isAllWebsites + */ + private function joinWebsites(Select $select, bool $isAllWebsites) + { + $websiteTable = ['website' => $this->getTable('store_website')]; + if ($isAllWebsites) { + $select->joinCross($websiteTable, []) + ->where('website.website_id > ?', 0) + ->where('tier_price.website_id = ?', 0); + } else { + $select->join($websiteTable, 'website.website_id = tier_price.website_id', []) + ->where('tier_price.website_id > 0'); + } + } + + /** + * Join customer groups table. + * If $isAllCustomerGroups is true, for each customer group will be used default value for all customer groups, + * otherwise per each customer group will be used their own values. + * + * @param Select $select + * @param bool $isAllCustomerGroups + */ + private function joinCustomerGroups(Select $select, bool $isAllCustomerGroups) + { + $customerGroupTable = ['customer_group' => $this->getTable('customer_group')]; + if ($isAllCustomerGroups) { + $select->joinCross($customerGroupTable, []) + ->where('tier_price.all_groups = ?', 1) + ->where('tier_price.customer_group_id = ?', 0); + } else { + $select->join($customerGroupTable, 'customer_group.customer_group_id = tier_price.customer_group_id', []) + ->where('tier_price.all_groups = ?', 0); + } + } + + /** + * Join price table and return price value. + * + * @param Select $select + * @param string $linkField + * @return string + */ + private function joinPrice(Select $select, string $linkField): string + { + /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $priceAttribute */ + $priceAttribute = $this->attributeRepository->get('price'); + $select->joinLeft( + ['entity_price_default' => $priceAttribute->getBackend()->getTable()], + 'entity_price_default.' . $linkField . ' = entity.' . $linkField + . ' AND entity_price_default.attribute_id = ' . $priceAttribute->getAttributeId() + . ' AND entity_price_default.store_id = 0', + [] + ); + $priceValue = 'entity_price_default.value'; + + if (!$priceAttribute->isScopeGlobal()) { + $select->joinLeft( + ['store_group' => $this->getTable('store_group')], + 'store_group.group_id = website.default_group_id', + [] + )->joinLeft( + ['entity_price_store' => $priceAttribute->getBackend()->getTable()], + 'entity_price_store.' . $linkField . ' = entity.' . $linkField + . ' AND entity_price_store.attribute_id = ' . $priceAttribute->getAttributeId() + . ' AND entity_price_store.store_id = store_group.default_store_id', + [] + ); + $priceValue = $this->getConnection() + ->getIfNullSql('entity_price_store.value', 'entity_price_default.value'); + } + + return (string) $priceValue; + } + + /** + * Build select for getting tier price data. + * + * @param bool $isAllWebsites + * @param bool $isAllCustomerGroups + * @param array $entityIds + * @return Select + */ + private function getTierPriceSelect(bool $isAllWebsites, bool $isAllCustomerGroups, array $entityIds = []): Select + { + $entityMetadata = $this->metadataPool->getMetadata(ProductInterface::class); + $linkField = $entityMetadata->getLinkField(); + + $select = $this->getConnection()->select(); + $select->from(['tier_price' => $this->tierPriceResourceModel->getMainTable()], []) + ->where('tier_price.qty = ?', 1); + + $select->join( + ['entity' => $this->getTable('catalog_product_entity')], + "entity.{$linkField} = tier_price.{$linkField}", + [] + ); + if (!empty($entityIds)) { + $select->where('entity.entity_id IN (?)', $entityIds); + } + $this->joinWebsites($select, $isAllWebsites); + $this->joinCustomerGroups($select, $isAllCustomerGroups); + + $priceValue = $this->joinPrice($select, $linkField); + $tierPriceValue = 'tier_price.value'; + $tierPricePercentageValue = 'tier_price.percentage_value'; + $tierPriceValueExpr = $this->getConnection()->getCheckSql( + $tierPriceValue, + $tierPriceValue, + sprintf('(1 - %s / 100) * %s', $tierPricePercentageValue, $priceValue) + ); + $select->columns( + [ + 'entity.entity_id', + 'customer_group.customer_group_id', + 'website.website_id', + 'tier_price' => $tierPriceValueExpr, + ] + ); + + return $select; + } +} diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Url.php b/app/code/Magento/Catalog/Model/ResourceModel/Url.php index 682f5c1f45e31..be95f088a2477 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Url.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Url.php @@ -14,6 +14,7 @@ use Magento\Catalog\Api\Data\CategoryInterface; use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\App\ObjectManager; +use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer; /** * Class Url @@ -101,6 +102,11 @@ class Url extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb */ protected $metadataPool; + /** + * @var TableMaintainer + */ + private $tableMaintainer; + /** * Url constructor. * @param \Magento\Framework\Model\ResourceModel\Db\Context $context @@ -110,6 +116,7 @@ class Url extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb * @param \Magento\Catalog\Model\Category $catalogCategory * @param \Psr\Log\LoggerInterface $logger * @param null $connectionName + * @param TableMaintainer|null $tableMaintainer */ public function __construct( \Magento\Framework\Model\ResourceModel\Db\Context $context, @@ -118,7 +125,8 @@ public function __construct( Product $productResource, \Magento\Catalog\Model\Category $catalogCategory, \Psr\Log\LoggerInterface $logger, - $connectionName = null + $connectionName = null, + TableMaintainer $tableMaintainer = null ) { $this->_storeManager = $storeManager; $this->_eavConfig = $eavConfig; @@ -126,6 +134,7 @@ public function __construct( $this->_catalogCategory = $catalogCategory; $this->_logger = $logger; parent::__construct($context, $connectionName); + $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class); } /** @@ -655,43 +664,52 @@ public function getRewriteByProductStore(array $products) } $connection = $this->getConnection(); - $select = $connection->select()->from( - ['i' => $this->getTable('catalog_category_product_index')], - ['product_id', 'store_id', 'visibility'] - )->joinLeft( - ['u' => $this->getMainTable()], - 'i.product_id = u.entity_id AND i.store_id = u.store_id' - . ' AND u.entity_type = "' . ProductUrlRewriteGenerator::ENTITY_TYPE . '"', - ['request_path'] - )->joinLeft( - ['r' => $this->getTable('catalog_url_rewrite_product_category')], - 'u.url_rewrite_id = r.url_rewrite_id AND r.category_id is NULL', - [] - ); - - $bind = []; + $storesProducts = []; foreach ($products as $productId => $storeId) { - $catId = $this->_storeManager->getStore($storeId)->getRootCategoryId(); - $productBind = 'product_id' . $productId; - $storeBind = 'store_id' . $storeId; - $catBind = 'category_id' . $catId; - $cond = '(' . implode( - ' AND ', - ['i.product_id = :' . $productBind, 'i.store_id = :' . $storeBind, 'i.category_id = :' . $catBind] - ) . ')'; - $bind[$productBind] = $productId; - $bind[$storeBind] = $storeId; - $bind[$catBind] = $catId; - $select->orWhere($cond); + $storesProducts[$storeId][] = $productId; } - $rowSet = $connection->fetchAll($select, $bind); - foreach ($rowSet as $row) { - $result[$row['product_id']] = [ - 'store_id' => $row['store_id'], - 'visibility' => $row['visibility'], - 'url_rewrite' => $row['request_path'], - ]; + foreach ($storesProducts as $storeId => $productIds) { + $select = $connection->select()->from( + ['i' => $this->tableMaintainer->getMainTable($storeId)], + ['product_id', 'store_id', 'visibility'] + )->joinLeft( + ['u' => $this->getMainTable()], + 'i.product_id = u.entity_id AND i.store_id = u.store_id' + . ' AND u.entity_type = "' . ProductUrlRewriteGenerator::ENTITY_TYPE . '"', + ['request_path'] + )->joinLeft( + ['r' => $this->getTable('catalog_url_rewrite_product_category')], + 'u.url_rewrite_id = r.url_rewrite_id AND r.category_id is NULL', + [] + ); + + $bind = []; + foreach ($productIds as $productId) { + $catId = $this->_storeManager->getStore($storeId)->getRootCategoryId(); + $productBind = 'product_id' . $productId; + $storeBind = 'store_id' . $storeId; + $catBind = 'category_id' . $catId; + $bindArray = [ + 'i.product_id = :' . $productBind, + 'i.store_id = :' . $storeBind, + 'i.category_id = :' . $catBind + ]; + $cond = '(' . implode(' AND ', $bindArray) . ')'; + $bind[$productBind] = $productId; + $bind[$storeBind] = $storeId; + $bind[$catBind] = $catId; + $select->orWhere($cond); + } + + $rowSet = $connection->fetchAll($select, $bind); + foreach ($rowSet as $row) { + $result[$row['product_id']] = [ + 'store_id' => $row['store_id'], + 'visibility' => $row['visibility'], + 'url_rewrite' => $row['request_path'], + ]; + } } return $result; diff --git a/app/code/Magento/Catalog/Model/View/Asset/Image.php b/app/code/Magento/Catalog/Model/View/Asset/Image.php index ce85ba21d211f..dfae9f4b0da9c 100644 --- a/app/code/Magento/Catalog/Model/View/Asset/Image.php +++ b/app/code/Magento/Catalog/Model/View/Asset/Image.php @@ -19,6 +19,13 @@ */ class Image implements LocalInterface { + /** + * Image type of image (thumbnail,small_image,image,swatch_image,swatch_thumb) + * + * @var string + */ + private $sourceContentType; + /** * @var string */ @@ -65,8 +72,14 @@ public function __construct( ContextInterface $context, EncryptorInterface $encryptor, $filePath, - array $miscParams = [] + array $miscParams ) { + if (isset($miscParams['image_type'])) { + $this->sourceContentType = $miscParams['image_type']; + unset($miscParams['image_type']); + } else { + $this->sourceContentType = $this->contentType; + } $this->mediaConfig = $mediaConfig; $this->context = $context; $this->filePath = $filePath; @@ -79,7 +92,7 @@ public function __construct( */ public function getUrl() { - return $this->context->getBaseUrl() . $this->getRelativePath(DIRECTORY_SEPARATOR); + return $this->context->getBaseUrl() . DIRECTORY_SEPARATOR . $this->getImageInfo(); } /** @@ -95,22 +108,7 @@ public function getContentType() */ public function getPath() { - return $this->getAbsolutePath($this->context->getPath()); - } - - /** - * Subroutine for building path - * - * @param string $path - * @param string $item - * @return string - */ - private function join($path, $item) - { - return trim( - $path . ($item ? DIRECTORY_SEPARATOR . ltrim($item, DIRECTORY_SEPARATOR) : ''), - DIRECTORY_SEPARATOR - ); + return $this->context->getPath() . DIRECTORY_SEPARATOR . $this->getImageInfo(); } /** @@ -119,7 +117,7 @@ private function join($path, $item) public function getSourceFile() { return $this->mediaConfig->getBaseMediaPath() - . DIRECTORY_SEPARATOR . ltrim($this->filePath, DIRECTORY_SEPARATOR); + . DIRECTORY_SEPARATOR . ltrim($this->getFilePath(), DIRECTORY_SEPARATOR); } /** @@ -129,7 +127,7 @@ public function getSourceFile() */ public function getSourceContentType() { - return $this->contentType; + return $this->sourceContentType; } /** @@ -172,35 +170,43 @@ public function getModule() */ private function getMiscPath() { - return $this->encryptor->hash(implode('_', $this->miscParams), Encryptor::HASH_VERSION_MD5); + return $this->encryptor->hash( + implode('_', $this->convertToReadableFormat($this->miscParams)), + Encryptor::HASH_VERSION_MD5 + ); } /** - * Generate absolute path + * Generate path from image info * - * @param string $result * @return string */ - private function getAbsolutePath($result) + private function getImageInfo() { - $prefix = (substr($result, 0, 1) == DIRECTORY_SEPARATOR) ? DIRECTORY_SEPARATOR : ''; - $result = $this->join($result, $this->getModule()); - $result = $this->join($result, $this->getMiscPath()); - $result = $this->join($result, $this->getFilePath()); - return $prefix . $result; + $path = $this->getModule() + . DIRECTORY_SEPARATOR . $this->getMiscPath() + . DIRECTORY_SEPARATOR . $this->getFilePath(); + return preg_replace('|\Q'. DIRECTORY_SEPARATOR . '\E+|', DIRECTORY_SEPARATOR, $path); } /** - * Generate relative path - * - * @param string $result - * @return string + * Converting bool into a string representation + * @param $miscParams + * @return array */ - private function getRelativePath($result) + private function convertToReadableFormat($miscParams) { - $result = $this->join($result, $this->getModule()); - $result = $this->join($result, $this->getMiscPath()); - $result = $this->join($result, $this->getFilePath()); - return DIRECTORY_SEPARATOR . $result; + $miscParams['image_height'] = 'h:' . ($miscParams['image_height'] ?? 'empty'); + $miscParams['image_width'] = 'w:' . ($miscParams['image_width'] ?? 'empty'); + $miscParams['quality'] = 'q:' . ($miscParams['quality'] ?? 'empty'); + $miscParams['angle'] = 'r:' . ($miscParams['angle'] ?? 'empty'); + $miscParams['keep_aspect_ratio'] = (isset($miscParams['keep_aspect_ratio']) ? '' : 'non') . 'proportional'; + $miscParams['keep_frame'] = (isset($miscParams['keep_frame']) ? '' : 'no') . 'frame'; + $miscParams['keep_transparency'] = (isset($miscParams['keep_transparency']) ? '' : 'no') . 'transparency'; + $miscParams['constrain_only'] = (isset($miscParams['constrain_only']) ? 'do' : 'not') . 'constrainonly'; + $miscParams['background'] = isset($miscParams['background']) + ? 'rgb' . implode(',', $miscParams['background']) + : 'nobackground'; + return $miscParams; } } diff --git a/app/code/Magento/Catalog/Model/View/Asset/Image/Context.php b/app/code/Magento/Catalog/Model/View/Asset/Image/Context.php index 5b9f6d33d3aa8..49d150a31750c 100644 --- a/app/code/Magento/Catalog/Model/View/Asset/Image/Context.php +++ b/app/code/Magento/Catalog/Model/View/Asset/Image/Context.php @@ -37,6 +37,8 @@ class Context implements ContextInterface private $filesystem; /** + * @param \Magento\Catalog\Model\Product\Media\ConfigInterface $mediaConfig + * @param \Magento\Framework\Filesystem $filesystem */ public function __construct( \Magento\Catalog\Model\Product\Media\ConfigInterface $mediaConfig, diff --git a/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php b/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php new file mode 100644 index 0000000000000..91d2868afab8c --- /dev/null +++ b/app/code/Magento/Catalog/Observer/ImageResizeAfterProductSave.php @@ -0,0 +1,69 @@ +imageResize = $imageResize; + $this->state = $state; + } + + /** + * Process event on 'save_commit_after' event + * + * @param \Magento\Framework\Event\Observer $observer + * @return void + */ + public function execute(\Magento\Framework\Event\Observer $observer) + { + /** @var $product \Magento\Catalog\Model\Product */ + $product = $observer->getEvent()->getProduct(); + + if ($this->state->isAreaCodeEmulated()) { + return; + } + + if (!(bool) $product->getId()) { + foreach ($product->getMediaGalleryImages() as $image) { + $this->imageResize->resizeFromImageName($image->getFile()); + } + } else { + $new = $product->getData('media_gallery'); + $original = $product->getOrigData('media_gallery'); + $mediaGallery = !empty($new['images']) ? array_column($new['images'], 'file') : []; + $mediaOriginalGallery = !empty($original['images']) ? array_column($original['images'], 'file') : []; + + foreach (array_diff($mediaGallery, $mediaOriginalGallery) as $image) { + $this->imageResize->resizeFromImageName($image); + } + } + } +} diff --git a/app/code/Magento/Catalog/Plugin/Block/Topmenu.php b/app/code/Magento/Catalog/Plugin/Block/Topmenu.php index ceaae832fc36f..8cbe235e05f26 100644 --- a/app/code/Magento/Catalog/Plugin/Block/Topmenu.php +++ b/app/code/Magento/Catalog/Plugin/Block/Topmenu.php @@ -162,6 +162,7 @@ private function getCategoryAsArray($category, $currentCategory, $isParentActive 'url' => $this->catalogCategory->getCategoryUrl($category), 'has_active' => in_array((string)$category->getId(), explode('/', $currentCategory->getPath()), true), 'is_active' => $category->getId() == $currentCategory->getId(), + 'is_category' => true, 'is_parent_active' => $isParentActive ]; } diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredOptions.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredOptions.php new file mode 100644 index 0000000000000..f96d56cb8818c --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredOptions.php @@ -0,0 +1,47 @@ +getProduct(); + $value = 0.; + $optionIds = $item->getOptionByCode('option_ids'); + if ($optionIds) { + foreach (explode(',', $optionIds->getValue()) as $optionId) { + $option = $product->getOptionById($optionId); + if ($option !== null) { + $itemOption = $item->getOptionByCode('option_' . $option->getId()); + /** @var $group \Magento\Catalog\Model\Product\Option\Type\DefaultType */ + $group = $option->groupFactory($option->getType()) + ->setOption($option) + ->setConfigurationItem($item) + ->setConfigurationItemOption($itemOption); + $value += $group->getOptionPrice($itemOption->getValue(), $basePrice); + } + } + } + + return $value; + } +} diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPrice.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPrice.php index 87d031d8d5b35..6ec282e45a1a0 100644 --- a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPrice.php +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPrice.php @@ -9,6 +9,7 @@ use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Configuration\Item\ItemInterface; use Magento\Framework\Pricing\Adjustment\CalculatorInterface; +use Magento\Framework\App\ObjectManager; /** * Configured price model @@ -25,21 +26,29 @@ class ConfiguredPrice extends FinalPrice implements ConfiguredPriceInterface */ protected $item; + /** + * @var ConfiguredOptions + */ + private $configuredOptions; + /** * @param Product $saleableItem * @param float $quantity * @param CalculatorInterface $calculator * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency - * @param ItemInterface $item + * @param ItemInterface|null $item + * @param ConfiguredOptions|null $configuredOptions */ public function __construct( Product $saleableItem, $quantity, CalculatorInterface $calculator, \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, - ItemInterface $item = null + ItemInterface $item = null, + ConfiguredOptions $configuredOptions = null ) { $this->item = $item; + $this->configuredOptions = $configuredOptions ?: ObjectManager::getInstance()->get(ConfiguredOptions::class); parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); } @@ -54,11 +63,12 @@ public function setItem(ItemInterface $item) } /** - * Get value of configured options + * Get value of configured options. * - * @return array + * @deprecated ConfiguredOptions::getItemOptionsValue is used instead + * @return float */ - protected function getOptionsValue() + protected function getOptionsValue(): float { $product = $this->item->getProduct(); $value = 0.; @@ -78,16 +88,21 @@ protected function getOptionsValue() } } } + return $value; } /** - * Price value of product with configured options + * Price value of product with configured options. * * @return bool|float */ public function getValue() { - return $this->item ? parent::getValue() + $this->getOptionsValue() : parent::getValue(); + $basePrice = parent::getValue(); + + return $this->item + ? $basePrice + $this->configuredOptions->getItemOptionsValue($basePrice, $this->item) + : $basePrice; } } diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceInterface.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceInterface.php index e6d35a0f5239a..e41df30ea1dec 100644 --- a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceInterface.php +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceInterface.php @@ -9,15 +9,20 @@ use Magento\Catalog\Model\Product\Configuration\Item\ItemInterface; /** - * Configured price interface + * Configured price interface. */ interface ConfiguredPriceInterface { /** - * Price type configured + * Price type configured. */ const CONFIGURED_PRICE_CODE = 'configured_price'; + /** + * Regular price type configured. + */ + const CONFIGURED_REGULAR_PRICE_CODE = 'configured_regular_price'; + /** * @param ItemInterface $item * @return $this diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceSelection.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceSelection.php new file mode 100644 index 0000000000000..1dedbfa354466 --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredPriceSelection.php @@ -0,0 +1,64 @@ +calculator = $calculator; + } + + /** + * Get Selection pricing list. + * + * @param ConfiguredPriceInterface $price + * @return array + */ + public function getSelectionPriceList(ConfiguredPriceInterface $price): array + { + $selectionPriceList = []; + foreach ($price->getOptions() as $option) { + $selectionPriceList = array_merge( + $selectionPriceList, + $this->createSelectionPriceList($option, $price->getProduct()) + ); + } + + return $selectionPriceList; + } + + /** + * Create Selection Price List. + * + * @param ExtensibleDataInterface $option + * @param Product $product + * @return array + */ + private function createSelectionPriceList(ExtensibleDataInterface $option, Product $product): array + { + return $this->calculator->createSelectionPriceList($option, $product); + } +} diff --git a/app/code/Magento/Catalog/Pricing/Price/ConfiguredRegularPrice.php b/app/code/Magento/Catalog/Pricing/Price/ConfiguredRegularPrice.php new file mode 100644 index 0000000000000..bcb6638b9cd25 --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/ConfiguredRegularPrice.php @@ -0,0 +1,80 @@ +item = $item; + $this->configuredOptions = $configuredOptions; + parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); + } + + /** + * @param ItemInterface $item + * @return $this + */ + public function setItem(ItemInterface $item) : ConfiguredRegularPrice + { + $this->item = $item; + + return $this; + } + + /** + * Price value of product with configured options. + * + * @return bool|float + */ + public function getValue() + { + $basePrice = parent::getValue(); + + return $this->item + ? $basePrice + $this->configuredOptions->getItemOptionsValue($basePrice, $this->item) + : $basePrice; + } +} diff --git a/app/code/Magento/Catalog/Pricing/Price/CustomOptionPrice.php b/app/code/Magento/Catalog/Pricing/Price/CustomOptionPrice.php index 5026286610118..93bf7920355db 100644 --- a/app/code/Magento/Catalog/Pricing/Price/CustomOptionPrice.php +++ b/app/code/Magento/Catalog/Pricing/Price/CustomOptionPrice.php @@ -35,32 +35,42 @@ class CustomOptionPrice extends AbstractPrice implements CustomOptionPriceInterf */ protected $excludeAdjustment = null; + /** + * @var CustomOptionPriceCalculator + */ + private $customOptionPriceCalculator; + /** * @param SaleableInterface $saleableItem * @param float $quantity * @param CalculatorInterface $calculator * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency - * @param array $excludeAdjustment + * @param array|null $excludeAdjustment + * @param CustomOptionPriceCalculator|null $customOptionPriceCalculator */ public function __construct( SaleableInterface $saleableItem, $quantity, CalculatorInterface $calculator, \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, - $excludeAdjustment = null + $excludeAdjustment = null, + CustomOptionPriceCalculator $customOptionPriceCalculator = null ) { parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); $this->excludeAdjustment = $excludeAdjustment; + $this->customOptionPriceCalculator = $customOptionPriceCalculator + ?? \Magento\Framework\App\ObjectManager::getInstance()->get(CustomOptionPriceCalculator::class); } /** - * Get minimal and maximal option values + * Get minimal and maximal option values. * + * @param string $priceCode * @return array * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ - public function getValue() + public function getValue($priceCode = \Magento\Catalog\Pricing\Price\BasePrice::PRICE_CODE) { $optionValues = []; $options = $this->product->getOptions(); @@ -85,7 +95,8 @@ public function getValue() } else { /** @var $optionValue \Magento\Catalog\Model\Product\Option\Value */ foreach ($optionItem->getValues() as $optionValue) { - $price = $optionValue->getPrice($optionValue->getPriceType() == Value::TYPE_PERCENT); + $price = + $this->customOptionPriceCalculator->getOptionPriceByPriceCode($optionValue, $priceCode); if ($min === null) { $min = $price; } elseif ($price < $min) { @@ -130,15 +141,16 @@ public function getCustomAmount($amount = null, $exclude = null, $context = []) } /** - * Return the minimal or maximal price for custom options + * Return the minimal or maximal price for custom options. * * @param bool $getMin + * @param string $priceCode * @return float */ - public function getCustomOptionRange($getMin) + public function getCustomOptionRange($getMin, $priceCode = \Magento\Catalog\Pricing\Price\BasePrice::PRICE_CODE) { $optionValue = 0.; - $options = $this->getValue(); + $options = $this->getValue($priceCode); foreach ($options as $option) { if ($getMin) { $optionValue += $option['min']; diff --git a/app/code/Magento/Catalog/Pricing/Price/CustomOptionPriceCalculator.php b/app/code/Magento/Catalog/Pricing/Price/CustomOptionPriceCalculator.php new file mode 100644 index 0000000000000..dd5c295ce0db7 --- /dev/null +++ b/app/code/Magento/Catalog/Pricing/Price/CustomOptionPriceCalculator.php @@ -0,0 +1,46 @@ +getPriceType() === ProductOptionValue::TYPE_PERCENT) { + $basePrice = $optionValue->getOption()->getProduct()->getPriceInfo()->getPrice($priceCode)->getValue(); + $price = $basePrice * ($optionValue->getData(ProductOptionValue::KEY_PRICE) / 100); + + return $price; + } + + return $optionValue->getData(ProductOptionValue::KEY_PRICE); + } +} diff --git a/app/code/Magento/Catalog/Pricing/Render/ConfiguredPriceBox.php b/app/code/Magento/Catalog/Pricing/Render/ConfiguredPriceBox.php index 0722f018ae4eb..91b2b6d2a0d7f 100644 --- a/app/code/Magento/Catalog/Pricing/Render/ConfiguredPriceBox.php +++ b/app/code/Magento/Catalog/Pricing/Render/ConfiguredPriceBox.php @@ -7,12 +7,62 @@ namespace Magento\Catalog\Pricing\Render; use Magento\Catalog\Model\Product\Configuration\Item\ItemInterface; +use Magento\Catalog\Model\Product\Pricing\Renderer\SalableResolverInterface; +use Magento\Catalog\Pricing\Price\MinimalPriceCalculatorInterface; +use Magento\Framework\Pricing\Price\PriceInterface; +use Magento\Catalog\Pricing\Price\ConfiguredPriceInterface; +use Magento\Catalog\Pricing\Price\FinalPrice; +use Magento\Catalog\Pricing\Price\RegularPrice; +use Magento\Framework\Pricing\Render\RendererPool; +use Magento\Framework\Pricing\SaleableInterface; +use Magento\Framework\View\Element\Template\Context; +use Magento\Catalog\Pricing\Price\ConfiguredPriceSelection; +use Magento\Framework\App\ObjectManager; /** - * Class for configured_price rendering + * Class for configured_price rendering. */ class ConfiguredPriceBox extends FinalPriceBox { + /** + * @var ConfiguredPriceSelection + */ + private $configuredPriceSelection; + + /** + * @param Context $context + * @param SaleableInterface $saleableItem + * @param PriceInterface $price + * @param RendererPool $rendererPool + * @param array $data + * @param SalableResolverInterface|null $salableResolver + * @param MinimalPriceCalculatorInterface|null $minimalPriceCalculator + * @param ConfiguredPriceSelection|null $configuredPriceSelection + */ + public function __construct( + Context $context, + SaleableInterface $saleableItem, + PriceInterface $price, + RendererPool $rendererPool, + array $data = [], + SalableResolverInterface $salableResolver = null, + MinimalPriceCalculatorInterface $minimalPriceCalculator = null, + ConfiguredPriceSelection $configuredPriceSelection = null + ) { + $this->configuredPriceSelection = $configuredPriceSelection + ?: ObjectManager::getInstance() + ->get(ConfiguredPriceSelection::class); + parent::__construct( + $context, + $saleableItem, + $price, + $rendererPool, + $data, + $salableResolver, + $minimalPriceCalculator + ); + } + /** * Retrieve an item instance to the configured price model * @@ -34,4 +84,67 @@ protected function _prepareLayout() } return parent::_prepareLayout(); } + + /** + * {@inheritdoc} + */ + public function getPriceType($priceCode) + { + $price = $this->saleableItem->getPriceInfo()->getPrice($priceCode); + $item = $this->getData('item'); + if ($price instanceof ConfiguredPriceInterface + && $item instanceof ItemInterface + ) { + $price->setItem($item); + } + + return $price; + } + + /** + * @return PriceInterface + */ + public function getConfiguredPrice(): PriceInterface + { + /** @var \Magento\Bundle\Pricing\Price\ConfiguredPrice $configuredPrice */ + $configuredPrice = $this->getPrice(); + if (empty($this->configuredPriceSelection->getSelectionPriceList($configuredPrice))) { + // If there was no selection we must show minimal regular price + return $this->getSaleableItem()->getPriceInfo()->getPrice(FinalPrice::PRICE_CODE); + } + + return $configuredPrice; + } + + /** + * @return PriceInterface + */ + public function getConfiguredRegularPrice(): PriceInterface + { + /** @var \Magento\Bundle\Pricing\Price\ConfiguredPrice $configuredPrice */ + $configuredPrice = $this->getPriceType(ConfiguredPriceInterface::CONFIGURED_REGULAR_PRICE_CODE); + if (empty($this->configuredPriceSelection->getSelectionPriceList($configuredPrice))) { + // If there was no selection we must show minimal regular price + return $this->getSaleableItem()->getPriceInfo()->getPrice(RegularPrice::PRICE_CODE); + } + + return $configuredPrice; + } + + /** + * Define if the special price should be shown. + * + * @return bool + */ + public function hasSpecialPrice(): bool + { + if ($this->price->getPriceCode() == ConfiguredPriceInterface::CONFIGURED_PRICE_CODE) { + $displayRegularPrice = $this->getConfiguredRegularPrice()->getAmount()->getValue(); + $displayFinalPrice = $this->getConfiguredPrice()->getAmount()->getValue(); + + return $displayFinalPrice < $displayRegularPrice; + } + + return parent::hasSpecialPrice(); + } } diff --git a/app/code/Magento/Catalog/Pricing/Render/FinalPriceBox.php b/app/code/Magento/Catalog/Pricing/Render/FinalPriceBox.php index f370c49cdfa20..e0a92ea0e0bea 100644 --- a/app/code/Magento/Catalog/Pricing/Render/FinalPriceBox.php +++ b/app/code/Magento/Catalog/Pricing/Render/FinalPriceBox.php @@ -115,7 +115,8 @@ protected function wrapResult($html) { return '
' . $html . '
'; } diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/ChangePriceAttributeDefaultScope.php b/app/code/Magento/Catalog/Setup/Patch/Data/ChangePriceAttributeDefaultScope.php index 023ffd316c9ee..9698e2e049f26 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/ChangePriceAttributeDefaultScope.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/ChangePriceAttributeDefaultScope.php @@ -10,8 +10,8 @@ use Magento\Catalog\Setup\CategorySetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ChangePriceAttributeDefaultScope diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/DisallowUsingHtmlForProductName.php b/app/code/Magento/Catalog/Setup/Patch/Data/DisallowUsingHtmlForProductName.php index 6bd0c69def2da..ea8f6bbf39b31 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/DisallowUsingHtmlForProductName.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/DisallowUsingHtmlForProductName.php @@ -9,8 +9,8 @@ use Magento\Catalog\Setup\CategorySetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class DisallowUsingHtmlForProductName. diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/EnableSegmentation.php b/app/code/Magento/Catalog/Setup/Patch/Data/EnableSegmentation.php new file mode 100644 index 0000000000000..d7b683a439ff1 --- /dev/null +++ b/app/code/Magento/Catalog/Setup/Patch/Data/EnableSegmentation.php @@ -0,0 +1,90 @@ +moduleDataSetup = $moduleDataSetup; + } + + /** + * {@inheritdoc} + */ + public function apply() + { + $this->moduleDataSetup->startSetup(); + $setup = $this->moduleDataSetup; + + $catalogCategoryProductIndexColumns = array_keys( + $setup->getConnection()->describeTable($setup->getTable('catalog_category_product_index')) + ); + $storeSelect = $setup->getConnection()->select()->from($setup->getTable('store'))->where('store_id > 0'); + foreach ($setup->getConnection()->fetchAll($storeSelect) as $store) { + $catalogCategoryProductIndexSelect = $setup->getConnection()->select() + ->from( + $setup->getTable('catalog_category_product_index') + )->where( + 'store_id = ?', + $store['store_id'] + ); + $indexTable = $setup->getTable('catalog_category_product_index') . + '_' . + \Magento\Store\Model\Store::ENTITY . + $store['store_id']; + $setup->getConnection()->query( + $setup->getConnection()->insertFromSelect( + $catalogCategoryProductIndexSelect, + $indexTable, + $catalogCategoryProductIndexColumns, + \Magento\Framework\DB\Adapter\AdapterInterface::INSERT_ON_DUPLICATE + ) + ); + } + $setup->getConnection()->delete($setup->getTable('catalog_category_product_index')); + $setup->getConnection()->delete($setup->getTable('catalog_category_product_index_replica')); + $setup->getConnection()->delete($setup->getTable('catalog_category_product_index_tmp')); + + $this->moduleDataSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/InstallDefaultCategories.php b/app/code/Magento/Catalog/Setup/Patch/Data/InstallDefaultCategories.php index 23f5e88979337..f1d836a5862f6 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/InstallDefaultCategories.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/InstallDefaultCategories.php @@ -11,8 +11,8 @@ use Magento\Catalog\Setup\CategorySetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class InstallDefaultCategories data patch. diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/SetNewResourceModelsPaths.php b/app/code/Magento/Catalog/Setup/Patch/Data/SetNewResourceModelsPaths.php index bc480443019f1..d59347f501de1 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/SetNewResourceModelsPaths.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/SetNewResourceModelsPaths.php @@ -10,8 +10,8 @@ use Magento\Catalog\Setup\CategorySetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class SetNewResourceModelsPaths diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/UpdateDefaultAttributeValue.php b/app/code/Magento/Catalog/Setup/Patch/Data/UpdateDefaultAttributeValue.php index 293506530dc6a..1d58de1994a11 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/UpdateDefaultAttributeValue.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/UpdateDefaultAttributeValue.php @@ -10,8 +10,8 @@ use Magento\Catalog\Setup\CategorySetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateDefaultAttributeValue diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/UpdateMediaAttributesBackendTypes.php b/app/code/Magento/Catalog/Setup/Patch/Data/UpdateMediaAttributesBackendTypes.php index 4dfd795273a37..43665c569c0c9 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/UpdateMediaAttributesBackendTypes.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/UpdateMediaAttributesBackendTypes.php @@ -10,8 +10,8 @@ use Magento\Catalog\Setup\CategorySetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateMediaAttributesBackendTypes diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/UpdateProductAttributes.php b/app/code/Magento/Catalog/Setup/Patch/Data/UpdateProductAttributes.php index e6a69ba680be1..d02753d44adee 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/UpdateProductAttributes.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/UpdateProductAttributes.php @@ -10,8 +10,8 @@ use Magento\Catalog\Setup\CategorySetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateProductAttributes diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/UpdateProductMetaDescription.php b/app/code/Magento/Catalog/Setup/Patch/Data/UpdateProductMetaDescription.php index e7936560d862c..0c8f248d1d5c5 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/UpdateProductMetaDescription.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/UpdateProductMetaDescription.php @@ -10,8 +10,8 @@ use Magento\Eav\Setup\EavSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateProductMetaDescription diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/UpgradeWebsiteAttributes.php b/app/code/Magento/Catalog/Setup/Patch/Data/UpgradeWebsiteAttributes.php index 85a4ec789b508..f9d6abbc37493 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/UpgradeWebsiteAttributes.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/UpgradeWebsiteAttributes.php @@ -12,8 +12,8 @@ use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpgradeWebsiteAttributes diff --git a/app/code/Magento/Catalog/Setup/Patch/Data/UpgradeWidgetData.php b/app/code/Magento/Catalog/Setup/Patch/Data/UpgradeWidgetData.php index 46c579bde20d5..8f72f94319971 100644 --- a/app/code/Magento/Catalog/Setup/Patch/Data/UpgradeWidgetData.php +++ b/app/code/Magento/Catalog/Setup/Patch/Data/UpgradeWidgetData.php @@ -12,8 +12,8 @@ use Magento\Framework\DB\FieldToConvert; use Magento\Framework\DB\Select\QueryModifierFactory; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Widget\Setup\LayoutUpdateConverter; /** diff --git a/app/code/Magento/Catalog/Setup/Patch/Schema/EnableSegmentation.php b/app/code/Magento/Catalog/Setup/Patch/Schema/EnableSegmentation.php new file mode 100644 index 0000000000000..8ae84f9f8e321 --- /dev/null +++ b/app/code/Magento/Catalog/Setup/Patch/Schema/EnableSegmentation.php @@ -0,0 +1,85 @@ +schemaSetup = $schemaSetup; + } + + /** + * {@inheritdoc} + */ + public function apply() + { + $this->schemaSetup->startSetup(); + $setup = $this->schemaSetup; + + $storeSelect = $setup->getConnection()->select()->from($setup->getTable('store'))->where('store_id > 0'); + foreach ($setup->getConnection()->fetchAll($storeSelect) as $store) { + $indexTable = $setup->getTable('catalog_category_product_index') . + '_' . + \Magento\Store\Model\Store::ENTITY . + $store['store_id']; + if (!$setup->getConnection()->isTableExists($indexTable)) { + $setup->getConnection()->createTable( + $setup->getConnection()->createTableByDdl( + $setup->getTable('catalog_category_product_index'), + $indexTable + ) + ); + } + if (!$setup->getConnection()->isTableExists($indexTable . '_replica')) { + $setup->getConnection()->createTable( + $setup->getConnection()->createTableByDdl( + $setup->getTable('catalog_category_product_index'), + $indexTable . '_replica' + ) + ); + } + } + + $this->schemaSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/AbstractProductTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/AbstractProductTest.php index 36bd6abea761b..d003c6d01373f 100644 --- a/app/code/Magento/Catalog/Test/Unit/Block/Product/AbstractProductTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Block/Product/AbstractProductTest.php @@ -6,6 +6,10 @@ namespace Magento\Catalog\Test\Unit\Block\Product; +use Magento\Catalog\Block\Product\Image; +use Magento\Catalog\Block\Product\ImageBuilder; +use Magento\Catalog\Model\Product; + /** * Class for testing methods of AbstractProduct */ @@ -32,7 +36,7 @@ class AbstractProductTest extends \PHPUnit\Framework\TestCase protected $stockRegistryMock; /** - * @var \Magento\Catalog\Block\Product\ImageBuilder|\PHPUnit_Framework_MockObject_MockObject + * @var ImageBuilder|\PHPUnit_Framework_MockObject_MockObject */ protected $imageBuilder; @@ -58,9 +62,7 @@ protected function setUp() ['getStockItem'] ); - $this->imageBuilder = $this->getMockBuilder(\Magento\Catalog\Block\Product\ImageBuilder::class) - ->disableOriginalConstructor() - ->getMock(); + $this->imageBuilder = $this->createPartialMock(ImageBuilder::class, ['create']); $this->productContextMock->expects($this->once()) ->method('getStockRegistry') @@ -88,7 +90,7 @@ public function testGetProductPrice() { $expectedPriceHtml = 'Expected Price html with price $30'; $priceRenderBlock = $this->createPartialMock(\Magento\Framework\Pricing\Render::class, ['render']); - $product = $this->createMock(\Magento\Catalog\Model\Product::class); + $product = $this->createMock(Product::class); $this->layoutMock->expects($this->once()) ->method('getBlock') @@ -108,7 +110,7 @@ public function testGetProductPriceHtml() { $expectedPriceHtml = 'Expected Price html with price $30'; $priceRenderBlock = $this->createPartialMock(\Magento\Framework\Pricing\Render::class, ['render']); - $product = $this->createMock(\Magento\Catalog\Model\Product::class); + $product = $this->createMock(Product::class); $this->layoutMock->expects($this->once()) ->method('getBlock') @@ -139,7 +141,7 @@ public function testGetMinimalQty($minSale, $result) $id = 10; $websiteId = 99; - $productMock = $this->createPartialMock(\Magento\Catalog\Model\Product::class, ['getId', 'getStore']); + $productMock = $this->createPartialMock(Product::class, ['getId', 'getStore']); $storeMock = $this->createPartialMock(\Magento\Store\Model\Store::class, ['getWebsiteId']); $stockItemMock = $this->getMockForAbstractClass( \Magento\CatalogInventory\Api\Data\StockItemInterface::class, @@ -168,7 +170,7 @@ public function testGetMinimalQty($minSale, $result) ->method('getMinSaleQty') ->will($this->returnValue($minSale)); - /** @var \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject $productMock */ + /** @var Product|\PHPUnit_Framework_MockObject_MockObject $productMock */ $this->assertEquals($result, $this->block->getMinimalQty($productMock)); } @@ -195,34 +197,14 @@ public function testGetImage() { $imageId = 'test_image_id'; $attributes = []; - - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $imageMock = $this->getMockBuilder(\Magento\Catalog\Block\Product\Image::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imageBuilder->expects($this->once()) - ->method('setProduct') - ->with($productMock) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) - ->method('setImageId') - ->with($imageId) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) - ->method('setAttributes') - ->with($attributes) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) + $productMock = $this->createMock(Product::class); + $imageMock = $this->createMock(Image::class); + $this->imageBuilder->expects(static::once()) ->method('create') ->willReturn($imageMock); - $this->assertInstanceOf( - \Magento\Catalog\Block\Product\Image::class, - $this->block->getImage($productMock, $imageId, $attributes) - ); + $image = $this->block->getImage($productMock, $imageId, $attributes); + + static::assertInstanceOf(Image::class, $image); } } diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageBuilderTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageBuilderTest.php deleted file mode 100644 index dc152aaf05867..0000000000000 --- a/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageBuilderTest.php +++ /dev/null @@ -1,293 +0,0 @@ -helperFactory = $this->createPartialMock(\Magento\Catalog\Helper\ImageFactory::class, ['create']); - - $this->imageFactory = $this->createPartialMock(ImageFactory::class, ['create']); - - $this->model = new ImageBuilder($this->helperFactory, $this->imageFactory); - } - - public function testSetProduct() - { - $productMock = $this->createMock(Product::class); - - $this->assertInstanceOf( - ImageBuilder::class, - $this->model->setProduct($productMock) - ); - } - - public function testSetImageId() - { - $imageId = 'test_image_id'; - - $this->assertInstanceOf( - ImageBuilder::class, - $this->model->setImageId($imageId) - ); - } - - public function testSetAttributes() - { - $attributes = [ - 'name' => 'value', - ]; - $this->assertInstanceOf( - ImageBuilder::class, - $this->model->setAttributes($attributes) - ); - } - - /** - * @param array $data - * @dataProvider createDataProvider - */ - public function testCreate($data, $expected) - { - $imageId = 'test_image_id'; - - $productMock = $this->createMock(Product::class); - - $helperMock = $this->createMock(Image::class); - $helperMock->expects($this->once()) - ->method('init') - ->with($productMock, $imageId) - ->willReturnSelf(); - - $helperMock->expects($this->once()) - ->method('getFrame') - ->willReturn($data['frame']); - $helperMock->expects($this->once()) - ->method('getUrl') - ->willReturn($data['url']); - $helperMock->expects($this->exactly(2)) - ->method('getWidth') - ->willReturn($data['width']); - $helperMock->expects($this->exactly(2)) - ->method('getHeight') - ->willReturn($data['height']); - $helperMock->expects($this->once()) - ->method('getLabel') - ->willReturn($data['label']); - $helperMock->expects($this->once()) - ->method('getResizedImageInfo') - ->willReturn($data['imagesize']); - - $this->helperFactory->expects($this->once()) - ->method('create') - ->willReturn($helperMock); - - $imageMock = $this->createMock(\Magento\Catalog\Block\Product\Image::class); - - $this->imageFactory->expects($this->once()) - ->method('create') - ->with($expected) - ->willReturn($imageMock); - - $this->model->setProduct($productMock); - $this->model->setImageId($imageId); - $this->model->setAttributes($data['custom_attributes']); - $this->assertInstanceOf(\Magento\Catalog\Block\Product\Image::class, $this->model->create()); - } - - /** - * Check if custom attributes will be overridden when builder used few times - * @param array $data - * @dataProvider createMultipleCallsDataProvider - */ - public function testCreateMultipleCalls($data) - { - list ($firstCall, $secondCall) = array_values($data); - - $imageId = 'test_image_id'; - - $productMock = $this->createMock(Product::class); - - $helperMock = $this->createMock(Image::class); - $helperMock->expects($this->exactly(2)) - ->method('init') - ->with($productMock, $imageId) - ->willReturnSelf(); - - $helperMock->expects($this->exactly(2)) - ->method('getFrame') - ->willReturnOnConsecutiveCalls($firstCall['data']['frame'], $secondCall['data']['frame']); - $helperMock->expects($this->exactly(2)) - ->method('getUrl') - ->willReturnOnConsecutiveCalls($firstCall['data']['url'], $secondCall['data']['url']); - $helperMock->expects($this->exactly(4)) - ->method('getWidth') - ->willReturnOnConsecutiveCalls( - $firstCall['data']['width'], - $firstCall['data']['width'], - $secondCall['data']['width'], - $secondCall['data']['width'] - ); - $helperMock->expects($this->exactly(4)) - ->method('getHeight') - ->willReturnOnConsecutiveCalls( - $firstCall['data']['height'], - $firstCall['data']['height'], - $secondCall['data']['height'], - $secondCall['data']['height'] - ); - $helperMock->expects($this->exactly(2)) - ->method('getLabel') - ->willReturnOnConsecutiveCalls($firstCall['data']['label'], $secondCall['data']['label']); - $helperMock->expects($this->exactly(2)) - ->method('getResizedImageInfo') - ->willReturnOnConsecutiveCalls($firstCall['data']['imagesize'], $secondCall['data']['imagesize']); - $this->helperFactory->expects($this->exactly(2)) - ->method('create') - ->willReturn($helperMock); - - $imageMock = $this->createMock(\Magento\Catalog\Block\Product\Image::class); - - $this->imageFactory->expects($this->at(0)) - ->method('create') - ->with($firstCall['expected']) - ->willReturn($imageMock); - - $this->imageFactory->expects($this->at(1)) - ->method('create') - ->with($secondCall['expected']) - ->willReturn($imageMock); - - $this->model->setProduct($productMock); - $this->model->setImageId($imageId); - $this->model->setAttributes($firstCall['data']['custom_attributes']); - - $this->assertInstanceOf(\Magento\Catalog\Block\Product\Image::class, $this->model->create()); - - $this->model->setProduct($productMock); - $this->model->setImageId($imageId); - $this->model->setAttributes($secondCall['data']['custom_attributes']); - $this->assertInstanceOf(\Magento\Catalog\Block\Product\Image::class, $this->model->create()); - } - - /** - * @return array - */ - public function createDataProvider(): array - { - return [ - $this->getTestDataWithoutAttributes(), - $this->getTestDataWithAttributes(), - ]; - } - - /** - * @return array - */ - public function createMultipleCallsDataProvider(): array - { - return [ - [ - [ - 'without_attributes' => $this->getTestDataWithoutAttributes(), - 'with_attributes' => $this->getTestDataWithAttributes(), - ], - ], - [ - [ - 'with_attributes' => $this->getTestDataWithAttributes(), - 'without_attributes' => $this->getTestDataWithoutAttributes(), - ], - ], - ]; - } - - /** - * @return array - */ - private function getTestDataWithoutAttributes(): array - { - return [ - 'data' => [ - 'frame' => 0, - 'url' => 'test_url_1', - 'width' => 100, - 'height' => 100, - 'label' => 'test_label', - 'custom_attributes' => [], - 'imagesize' => [100, 100], - ], - 'expected' => [ - 'data' => [ - 'template' => 'Magento_Catalog::product/image_with_borders.phtml', - 'image_url' => 'test_url_1', - 'width' => 100, - 'height' => 100, - 'label' => 'test_label', - 'ratio' => 1, - 'custom_attributes' => '', - 'resized_image_width' => 100, - 'resized_image_height' => 100, - ], - ], - ]; - } - - /** - * @return array - */ - private function getTestDataWithAttributes(): array - { - return [ - 'data' => [ - 'frame' => 1, - 'url' => 'test_url_2', - 'width' => 100, - 'height' => 50, - 'label' => 'test_label_2', - 'custom_attributes' => [ - 'name_1' => 'value_1', - 'name_2' => 'value_2', - ], - 'imagesize' => [120, 70], - ], - 'expected' => [ - 'data' => [ - 'template' => 'Magento_Catalog::product/image.phtml', - 'image_url' => 'test_url_2', - 'width' => 100, - 'height' => 50, - 'label' => 'test_label_2', - 'ratio' => 0.5, - 'custom_attributes' => 'name_1="value_1" name_2="value_2"', - 'resized_image_width' => 120, - 'resized_image_height' => 70, - ], - ], - ]; - } -} diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageFactoryTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageFactoryTest.php new file mode 100644 index 0000000000000..8a42865a3fe4d --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Block/Product/ImageFactoryTest.php @@ -0,0 +1,209 @@ +viewConfig = $this->createMock(View::class); + $configInterface = $this->createMock(ConfigInterface::class); + $configInterface->method('getViewConfig')->willReturn($this->viewConfig); + $this->viewAssetImageFactory = $this->createMock(ViewAssetImageFactory::class); + $this->paramsBuilder = $this->createMock(ParamsBuilder::class); + $this->objectManager = $this->createMock(ObjectManager::class); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->model = $objectManager->getObject( + ImageFactory::class, + [ + 'objectManager' => $this->objectManager, + 'presentationConfig' => $configInterface, + 'viewAssetImageFactory' => $this->viewAssetImageFactory, + 'imageParamsBuilder' => $this->paramsBuilder + ] + ); + } + + /** + * @param array $data + * @dataProvider createDataProvider + */ + public function testCreate($data, $expected) + { + $product = $this->createMock(Product::class); + $product->method('getName')->willReturn($data['product']['name']); + $product->method('getData')->willReturnOnConsecutiveCalls( + $data['product']['image_type'], + $data['product']['image_type_label'] + ); + $imageBlock = $this->createMock(Image::class); + $this->viewConfig->method('getMediaAttributes')->willReturn($data['viewImageConfig']); + $this->viewConfig->method('getVarValue')->willReturn($data['frame']); + $this->viewAssetImageFactory->method('create')->willReturn( + $viewAssetImage = $this->createMock(ViewAssetImage::class) + ); + $this->paramsBuilder->method('build')->willReturn($data['imageParamsBuilder']); + $viewAssetImage->method('getUrl')->willReturn($data['url']); + + $this->objectManager->expects(self::once()) + ->method('create') + ->with(Image::class, $expected) + ->willReturn($imageBlock); + $actual = $this->model->create($product, 'image_id', $data['custom_attributes']); + self::assertInstanceOf(Image::class, $actual); + } + + /** + * @return array + */ + public function createDataProvider(): array + { + return [ + $this->getTestDataWithoutAttributes(), + $this->getTestDataWithAttributes(), + ]; + } + + /** + * @return array + */ + private function getTestDataWithoutAttributes(): array + { + return [ + 'data' => [ + 'viewImageConfig' => [ + 'width' => 100, + 'height' => 100, + 'constrain_only' => false, + 'aspect_ratio' => false, + 'frame' => false, + 'transparency' => false, + 'background' => '255,255,255', + 'type' => 'image_type' //thumbnail,small_image,image,swatch_image,swatch_thumb + ], + 'imageParamsBuilder' => [ + 'image_width' => 100, + 'image_height' => 100, + 'constrain_only' => false, + 'keep_aspect_ratio' => false, + 'keep_frame' => false, + 'keep_transparency' => false, + 'background' => '255,255,255', + 'image_type' => 'image_type', //thumbnail,small_image,image,swatch_image,swatch_thumb + 'quality' => 80, // <=== + 'angle' => null // <=== + ], + 'product' => [ + 'image_type_label' => 'test_image_label', + 'name' => 'test_product_name', + 'image_type' => 'test_image_path' + ], + 'url' => 'test_url_1', + 'frame' => 'test_frame', + 'custom_attributes' => [], + ], + 'expected' => [ + 'data' => [ + 'template' => 'Magento_Catalog::product/image_with_borders.phtml', + 'image_url' => 'test_url_1', + 'width' => 100, + 'height' => 100, + 'label' => 'test_image_label', + 'ratio' => 1, + 'custom_attributes' => '', + 'product_id' => null + ], + ], + ]; + } + + /** + * @return array + */ + private function getTestDataWithAttributes(): array + { + return [ + 'data' => [ + 'viewImageConfig' => [ + 'width' => 100, + 'height' => 50, // <=== + 'constrain_only' => false, + 'aspect_ratio' => false, + 'frame' => true, // <=== + 'transparency' => false, + 'background' => '255,255,255', + 'type' => 'image_type' //thumbnail,small_image,image,swatch_image,swatch_thumb + ], + 'imageParamsBuilder' => [ + 'image_width' => 100, + 'image_height' => 50, + 'constrain_only' => false, + 'keep_aspect_ratio' => false, + 'keep_frame' => true, + 'keep_transparency' => false, + 'background' => '255,255,255', + 'image_type' => 'image_type', //thumbnail,small_image,image,swatch_image,swatch_thumb + 'quality' => 80, + 'angle' => null + ], + 'product' => [ + 'image_type_label' => null, // <== + 'name' => 'test_product_name', + 'image_type' => 'test_image_path' + ], + 'url' => 'test_url_2', + 'frame' => 'test_frame', + 'custom_attributes' => [ + 'name_1' => 'value_1', + 'name_2' => 'value_2', + ], + ], + 'expected' => [ + 'data' => [ + 'template' => 'Magento_Catalog::product/image_with_borders.phtml', + 'image_url' => 'test_url_2', + 'width' => 100, + 'height' => 50, + 'label' => 'test_product_name', + 'ratio' => 0.5, // <== + 'custom_attributes' => 'name_1="value_1" name_2="value_2"', + 'product_id' => null + ], + ], + ]; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/View/GalleryTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/View/GalleryTest.php index b10b9061a9b4c..c9b7dc50beb9e 100644 --- a/app/code/Magento/Catalog/Test/Unit/Block/Product/View/GalleryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Block/Product/View/GalleryTest.php @@ -5,23 +5,38 @@ */ namespace Magento\Catalog\Test\Unit\Block\Product\View; +use Magento\Catalog\Block\Product\Context; +use Magento\Catalog\Block\Product\ImageBuilder; +use Magento\Catalog\Block\Product\View\Gallery; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface; +use Magento\Catalog\Model\Product\Image\UrlBuilder; +use Magento\Catalog\Model\Product\Type\AbstractType; +use Magento\Framework\Data\Collection; +use Magento\Framework\DataObject; +use Magento\Framework\Json\EncoderInterface; +use Magento\Framework\Registry; +use Magento\Framework\Stdlib\ArrayUtils; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Store\Model\Store; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class GalleryTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Catalog\Block\Product\View\Gallery + * @var Gallery */ protected $model; /** - * @var \Magento\Catalog\Block\Product\Context|\PHPUnit_Framework_MockObject_MockObject + * @var Context|\PHPUnit_Framework_MockObject_MockObject */ protected $context; /** - * @var \Magento\Framework\Stdlib\ArrayUtils|\PHPUnit_Framework_MockObject_MockObject + * @var ArrayUtils|\PHPUnit_Framework_MockObject_MockObject */ protected $arrayUtils; @@ -31,174 +46,94 @@ class GalleryTest extends \PHPUnit\Framework\TestCase protected $imageHelper; /** - * @var \Magento\Framework\Registry|\PHPUnit_Framework_MockObject_MockObject + * @var Registry|\PHPUnit_Framework_MockObject_MockObject */ protected $registry; /** - * @var \Magento\Framework\Json\EncoderInterface|\PHPUnit_Framework_MockObject_MockObject + * @var EncoderInterface|\PHPUnit_Framework_MockObject_MockObject */ protected $jsonEncoderMock; /** - * @var \Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ImagesConfigFactoryInterface|\PHPUnit_Framework_MockObject_MockObject */ protected $imagesConfigFactoryMock; /** - * @var \Magento\Framework\Data\Collection|\PHPUnit_Framework_MockObject_MockObject + * @var Collection|\PHPUnit_Framework_MockObject_MockObject */ protected $galleryImagesConfigMock; + /** @var UrlBuilder|\PHPUnit_Framework_MockObject_MockObject */ + private $urlBuilder; + protected function setUp() { - $this->mockContext(); - - $this->arrayUtils = $this->getMockBuilder(\Magento\Framework\Stdlib\ArrayUtils::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->jsonEncoderMock = $this->getMockBuilder(\Magento\Framework\Json\EncoderInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imagesConfigFactoryMock = $this->getImagesConfigFactory(); - - $this->model = new \Magento\Catalog\Block\Product\View\Gallery( - $this->context, - $this->arrayUtils, - $this->jsonEncoderMock, - [], - $this->imagesConfigFactoryMock + $this->registry = $this->createMock(Registry::class); + $this->context = $this->createConfiguredMock( + Context::class, + ['getRegistry' => $this->registry] ); - } - protected function mockContext() - { - $this->context = $this->getMockBuilder(\Magento\Catalog\Block\Product\Context::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imageHelper = $this->getMockBuilder(\Magento\Catalog\Helper\Image::class) - ->disableOriginalConstructor() - ->getMock(); - $this->context->expects($this->any()) - ->method('getImageHelper') - ->willReturn($this->imageHelper); - - $this->registry = $this->getMockBuilder(\Magento\Framework\Registry::class) - ->disableOriginalConstructor() - ->getMock(); - $this->context->expects($this->any()) - ->method('getRegistry') - ->willReturn($this->registry); + $this->arrayUtils = $this->createMock(ArrayUtils::class); + $this->jsonEncoderMock = $this->createMock(EncoderInterface::class); + $this->imagesConfigFactoryMock = $this->getImagesConfigFactory(); + $this->urlBuilder = $this->createMock(UrlBuilder::class); + + $objectManager = new ObjectManager($this); + $this->model = $objectManager->getObject(Gallery::class, [ + 'context' => $this->context, + 'arrayUtils' => $this->arrayUtils, + 'jsonEncoder' => $this->jsonEncoderMock, + 'urlBuilder' => $this->urlBuilder, + 'imagesConfigFactory' => $this->imagesConfigFactoryMock + ]); } public function testGetGalleryImages() { - $storeMock = $this->getMockBuilder(\Magento\Store\Model\Store::class) - ->disableOriginalConstructor() - ->getMock(); - - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $productTypeMock = $this->getMockBuilder(\Magento\Catalog\Model\Product\Type\AbstractType::class) - ->disableOriginalConstructor() - ->getMock(); - $productTypeMock->expects($this->once()) + $productMock = $this->createMock(Product::class); + $productTypeMock = $this->createMock(AbstractType::class); + $productTypeMock->expects(static::once()) ->method('getStoreFilter') ->with($productMock) - ->willReturn($storeMock); + ->willReturn($this->createMock(Store::class)); - $productMock->expects($this->once()) - ->method('getTypeInstance') - ->willReturn($productTypeMock); - $productMock->expects($this->once()) - ->method('getMediaGalleryImages') - ->willReturn($this->getImagesCollection()); + $imagesCollection = $this->createConfiguredMock( + Collection::class, + ['getIterator' => new \ArrayIterator([new DataObject(['file' => 'test_file'])])] + ); - $this->registry->expects($this->once()) + $productMock->method('getTypeInstance')->willReturn($productTypeMock); + $productMock->method('getMediaGalleryImages')->willReturn($imagesCollection); + $this->registry->expects(static::once()) ->method('registry') ->with('product') ->willReturn($productMock); - - $this->galleryImagesConfigMock->expects($this->exactly(1)) + $this->galleryImagesConfigMock->expects(static::exactly(1)) ->method('getItems') ->willReturn($this->getGalleryImagesConfigItems()); - $this->imageHelper->expects($this->exactly(3)) - ->method('init') - ->willReturnMap([ - [$productMock, 'product_page_image_small', [], $this->imageHelper], - [$productMock, 'product_page_image_medium_no_frame', [], $this->imageHelper], - [$productMock, 'product_page_image_large_no_frame', [], $this->imageHelper], - ]) - ->willReturnSelf(); - $this->imageHelper->expects($this->exactly(3)) - ->method('setImageFile') - ->with('test_file') - ->willReturnSelf(); - $this->imageHelper->expects($this->at(0)) - ->method('getUrl') - ->willReturn('product_page_image_small_url'); - $this->imageHelper->expects($this->at(1)) - ->method('getUrl') - ->willReturn('product_page_image_medium_url'); - $this->imageHelper->expects($this->at(2)) - ->method('getUrl') - ->willReturn('product_page_image_large_url'); - $images = $this->model->getGalleryImages(); - $this->assertInstanceOf(\Magento\Framework\Data\Collection::class, $images); - } - - /** - * @return \Magento\Framework\Data\Collection - */ - private function getImagesCollection() - { - $collectionMock = $this->getMockBuilder(\Magento\Framework\Data\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - - $items = [ - new \Magento\Framework\DataObject([ - 'file' => 'test_file' - ]), - ]; - - $collectionMock->expects($this->any()) - ->method('getIterator') - ->willReturn(new \ArrayIterator($items)); - - return $collectionMock; + static::assertInstanceOf(Collection::class, $images); } /** * getImagesConfigFactory * - * @return \Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface + * @return ImagesConfigFactoryInterface */ private function getImagesConfigFactory() { - $this->galleryImagesConfigMock = $this->getMockBuilder(\Magento\Framework\Data\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->galleryImagesConfigMock->expects($this->any()) - ->method('getIterator') - ->willReturn(new \ArrayIterator($this->getGalleryImagesConfigItems())); - - $galleryImagesConfigFactoryMock = $this - ->getMockBuilder(\Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $galleryImagesConfigFactoryMock->expects($this->any()) - ->method('create') - ->willReturn($this->galleryImagesConfigMock); + $this->galleryImagesConfigMock = $this->createConfiguredMock( + Collection::class, + ['getIterator' => new \ArrayIterator($this->getGalleryImagesConfigItems())] + ); + $galleryImagesConfigFactoryMock = $this->createConfiguredMock( + ImagesConfigFactoryInterface::class, + ['create' => $this->galleryImagesConfigMock] + ); return $galleryImagesConfigFactoryMock; } @@ -211,17 +146,17 @@ private function getImagesConfigFactory() private function getGalleryImagesConfigItems() { return [ - new \Magento\Framework\DataObject([ + new DataObject([ 'image_id' => 'product_page_image_small', 'data_object_key' => 'small_image_url', 'json_object_key' => 'thumb' ]), - new \Magento\Framework\DataObject([ + new DataObject([ 'image_id' => 'product_page_image_medium', 'data_object_key' => 'medium_image_url', 'json_object_key' => 'img' ]), - new \Magento\Framework\DataObject([ + new DataObject([ 'image_id' => 'product_page_image_large', 'data_object_key' => 'large_image_url', 'json_object_key' => 'full' diff --git a/app/code/Magento/Catalog/Test/Unit/Console/Command/ImagesResizeCommandTest.php b/app/code/Magento/Catalog/Test/Unit/Console/Command/ImagesResizeCommandTest.php deleted file mode 100644 index 457ba7b94529b..0000000000000 --- a/app/code/Magento/Catalog/Test/Unit/Console/Command/ImagesResizeCommandTest.php +++ /dev/null @@ -1,211 +0,0 @@ -appState = $this->getMockBuilder(\Magento\Framework\App\State::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->productRepository = $this->getMockBuilder(\Magento\Catalog\Api\ProductRepositoryInterface::class) - ->getMockForAbstractClass(); - - $this->prepareProductCollection(); - $this->prepareImageCache(); - - $this->command = new ImagesResizeCommand( - $this->appState, - $this->productCollectionFactory, - $this->productRepository, - $this->imageCacheFactory - ); - } - - public function testExecuteNoProducts() - { - $this->appState->expects($this->once()) - ->method('setAreaCode') - ->with(Area::AREA_GLOBAL) - ->willReturnSelf(); - - $this->productCollection->expects($this->once()) - ->method('getAllIds') - ->willReturn([]); - - $commandTester = new CommandTester($this->command); - $commandTester->execute([]); - - $this->assertContains( - 'No product images to resize', - $commandTester->getDisplay() - ); - } - - public function testExecute() - { - $productsIds = [1, 2]; - - $this->appState->expects($this->once()) - ->method('setAreaCode') - ->with(Area::AREA_GLOBAL) - ->willReturnSelf(); - - $this->productCollection->expects($this->once()) - ->method('getAllIds') - ->willReturn($productsIds); - - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->productRepository->expects($this->at(0)) - ->method('getById') - ->with($productsIds[0]) - ->willReturn($productMock); - $this->productRepository->expects($this->at(1)) - ->method('getById') - ->with($productsIds[1]) - ->willThrowException(new NoSuchEntityException()); - - $this->imageCache->expects($this->exactly(count($productsIds) - 1)) - ->method('generate') - ->with($productMock) - ->willReturnSelf(); - - $commandTester = new CommandTester($this->command); - $commandTester->execute([]); - - $this->assertContains( - 'Product images resized successfully', - $commandTester->getDisplay() - ); - } - - public function testExecuteWithException() - { - $productsIds = [1]; - $exceptionMessage = 'Test exception text'; - - $this->appState->expects($this->once()) - ->method('setAreaCode') - ->with(Area::AREA_GLOBAL) - ->willReturnSelf(); - - $this->productCollection->expects($this->once()) - ->method('getAllIds') - ->willReturn($productsIds); - - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->productRepository->expects($this->exactly(count($productsIds))) - ->method('getById') - ->with($productsIds[0]) - ->willReturn($productMock); - - $this->imageCache->expects($this->once()) - ->method('generate') - ->with($productMock) - ->willThrowException(new \Exception($exceptionMessage)); - - $commandTester = new CommandTester($this->command); - $commandTester->execute([]); - - $this->assertContains( - $exceptionMessage, - $commandTester->getDisplay() - ); - } - - protected function prepareProductCollection() - { - $this->productCollectionFactory = $this->getMockBuilder( - \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory::class - ) - ->disableOriginalConstructor() - ->setMethods(['create']) - ->getMock(); - - $this->productCollection = $this->getMockBuilder( - \Magento\Catalog\Model\ResourceModel\Product\Collection::class - ) - ->disableOriginalConstructor() - ->getMock(); - - $this->productCollectionFactory->expects($this->any()) - ->method('create') - ->willReturn($this->productCollection); - } - - protected function prepareImageCache() - { - $this->imageCacheFactory = $this->getMockBuilder(\Magento\Catalog\Model\Product\Image\CacheFactory::class) - ->disableOriginalConstructor() - ->setMethods(['create']) - ->getMock(); - - $this->imageCache = $this->getMockBuilder(\Magento\Catalog\Model\Product\Image\Cache::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imageCacheFactory->expects($this->any()) - ->method('create') - ->willReturn($this->imageCache); - } -} diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php index 6373712066695..450cf4663c99c 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php @@ -249,6 +249,20 @@ public function provideUniqueData() ] ], false ], + 'empty and deleted' => [ + [ + 'value' => [ + "option_0" => [1, 0], + "option_1" => [2, 0], + "option_2" => ["", ""], + ], + 'delete' => [ + "option_0" => "", + "option_1" => "", + "option_2" => "1", + ] + ], false + ], ]; } @@ -321,7 +335,34 @@ public function provideEmptyOption() (object) [ 'error' => false, ] - ] + ], + 'empty admin scope options and deleted' => [ + [ + 'value' => [ + "option_0" => [''], + ], + 'delete' => [ + 'option_0' => '1', + ], + ], + (object) [ + 'error' => false, + ], + ], + 'empty admin scope options and not deleted' => [ + [ + 'value' => [ + "option_0" => [''], + ], + 'delete' => [ + 'option_0' => '0', + ], + ], + (object) [ + 'error' => true, + 'message' => 'The value of Admin scope can\'t be empty.', + ], + ], ]; } } diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/HandlerFactoryTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/HandlerFactoryTest.php index 9d0ab628a6450..53d2770f19cc8 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/HandlerFactoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Initialization/Helper/HandlerFactoryTest.php @@ -27,11 +27,9 @@ protected function setUp() public function testCreateWithInvalidType() { - $this->expectException( - '\InvalidArgumentException', - \Magento\Framework\DataObject::class . ' does not implement ' . - \Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper\HandlerInterface::class - ); + $this->expectException('\InvalidArgumentException'); + $this->expectExceptionMessage(\Magento\Framework\DataObject::class . ' does not implement ' . + \Magento\Catalog\Controller\Adminhtml\Product\Initialization\Helper\HandlerInterface::class); $this->_objectManagerMock->expects($this->never())->method('create'); $this->_model->create(\Magento\Framework\DataObject::class); } diff --git a/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php index 6f4d30de1468a..2759371dc96e7 100644 --- a/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Helper/ImageTest.php @@ -5,10 +5,12 @@ */ namespace Magento\Catalog\Test\Unit\Helper; +use Magento\Catalog\Helper\Image; + class ImageTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Catalog\Helper\Image + * @var Image */ protected $helper; @@ -63,7 +65,7 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); - $this->helper = new \Magento\Catalog\Helper\Image( + $this->helper = new Image( $this->context, $this->imageFactory, $this->assetRepository, @@ -118,7 +120,7 @@ public function testInit($data) $this->prepareWatermarkProperties($data); $this->assertInstanceOf( - \Magento\Catalog\Helper\Image::class, + Image::class, $this->helper->init($productMock, $imageId, $attributes) ); } @@ -160,7 +162,7 @@ protected function prepareAttributes($data, $imageId) ->getMock(); $configViewMock->expects($this->once()) ->method('getMediaAttributes') - ->with('Magento_Catalog', 'images', $imageId) + ->with('Magento_Catalog', Image::MEDIA_TYPE_CONFIG_NODE, $imageId) ->willReturn($data); $this->viewConfig->expects($this->once()) diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/FactoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/FactoryTest.php new file mode 100644 index 0000000000000..e29eef0bed076 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/FactoryTest.php @@ -0,0 +1,141 @@ +productResourceMock = $this->getMockBuilder(ProductResource::class) + ->disableOriginalConstructor() + ->setMethods(['getEntityTable']) + ->getMock(); + + $this->eavConfigMock = $this->getMockBuilder(EavConfig::class) + ->disableOriginalConstructor() + ->setMethods(['getAttribute']) + ->getMock(); + + $this->eavAttrConditionBuilderMock = $this->getMockBuilder(CustomConditionInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->nativeAttrConditionBuilderMock = $this->getMockBuilder(CustomConditionInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + + $this->conditionBuilderFactory = $objectManagerHelper->getObject( + Factory::class, + [ + 'eavConfig' => $this->eavConfigMock, + 'productResource' => $this->productResourceMock, + 'eavAttributeConditionBuilder' => $this->eavAttrConditionBuilderMock, + 'nativeAttributeConditionBuilder' => $this->nativeAttrConditionBuilderMock, + ] + ); + } + + public function testNativeAttrConditionBuilder() + { + $fieldName = 'super_field'; + $attributeTable = 'my-table'; + $productResourceTable = 'my-table'; + + $filterMock = $this->getMockBuilder(Filter::class) + ->disableOriginalConstructor() + ->setMethods(['getField']) + ->getMock(); + + $filterMock + ->method('getField') + ->willReturn($fieldName); + + $attributeMock = $this->getMockBuilder(Attribute::class) + ->disableOriginalConstructor() + ->setMethods(['getBackendTable']) + ->getMock(); + + $this->eavConfigMock + ->method('getAttribute') + ->with(Product::ENTITY, $fieldName) + ->willReturn($attributeMock); + + $attributeMock + ->method('getBackendTable') + ->willReturn($attributeTable); + + $this->productResourceMock + ->method('getEntityTable') + ->willReturn($productResourceTable); + + $this->assertEquals( + $this->nativeAttrConditionBuilderMock, + $this->conditionBuilderFactory->createByFilter($filterMock) + ); + } + + public function testEavAttrConditionBuilder() + { + $fieldName = 'super_field'; + $attributeTable = 'my-table'; + $productResourceTable = 'not-my-table'; + + $filterMock = $this->getMockBuilder(Filter::class) + ->disableOriginalConstructor() + ->setMethods(['getField']) + ->getMock(); + + $filterMock + ->method('getField') + ->willReturn($fieldName); + + $attributeMock = $this->getMockBuilder(Attribute::class) + ->disableOriginalConstructor() + ->setMethods(['getBackendTable']) + ->getMock(); + + $this->eavConfigMock + ->method('getAttribute') + ->with(Product::ENTITY, $fieldName) + ->willReturn($attributeMock); + + $attributeMock + ->method('getBackendTable') + ->willReturn($attributeTable); + + $this->productResourceMock + ->method('getEntityTable') + ->willReturn($productResourceTable); + + $this->assertEquals( + $this->eavAttrConditionBuilderMock, + $this->conditionBuilderFactory->createByFilter($filterMock) + ); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductCategoryFilterTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductCategoryFilterTest.php index f667638cc4da8..942a87ce3414f 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductCategoryFilterTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Api/SearchCriteria/CollectionProcessor/FilterProcessor/ProductCategoryFilterTest.php @@ -31,7 +31,7 @@ public function testApply() ->disableOriginalConstructor() ->getMock(); - $filterMock->expects($this->exactly(2)) + $filterMock->expects($this->exactly(1)) ->method('getConditionType') ->willReturn('condition'); $filterMock->expects($this->once()) diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Category/Product/PositionResolverTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Category/Product/PositionResolverTest.php new file mode 100644 index 0000000000000..1ff3a1bae5c28 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Category/Product/PositionResolverTest.php @@ -0,0 +1,122 @@ + 100, + '2' => 101, + '1' => 102 + ]; + + /** + * @var array + */ + private $flippedPositions = [ + '100' => 3, + '101' => 2, + '102' => 1 + ]; + + /** + * @var int + */ + private $categoryId = 1; + + protected function setUp() + { + $this->context = $this->getMockBuilder(Context::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->resources = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->connection = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->select = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->model = (new ObjectManager($this))->getObject( + PositionResolver::class, + [ + 'context' => $this->context, + null, + '_resources' => $this->resources + ] + ); + } + + public function testGetPositions() + { + $this->resources->expects($this->once()) + ->method('getConnection') + ->willReturn($this->connection); + + $this->connection->expects($this->once()) + ->method('select') + ->willReturn($this->select); + $this->select->expects($this->once()) + ->method('from') + ->willReturnSelf(); + $this->select->expects($this->once()) + ->method('where') + ->willReturnSelf(); + $this->select->expects($this->once()) + ->method('order') + ->willReturnSelf(); + $this->select->expects($this->once()) + ->method('joinLeft') + ->willReturnSelf(); + $this->connection->expects($this->once()) + ->method('fetchCol') + ->willReturn($this->positions); + + $this->assertEquals($this->flippedPositions, $this->model->getPositions($this->categoryId)); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php index 1bc5e450ae153..0f2166d1a2a6f 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CategoryRepositoryTest.php @@ -255,7 +255,8 @@ public function testSaveWithException() */ public function testSaveWithValidateCategoryException($error, $expectedException, $expectedExceptionMessage) { - $this->expectException($expectedException, $expectedExceptionMessage); + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); $categoryId = 5; $categoryMock = $this->createMock(\Magento\Catalog\Model\Category::class); $this->extensibleDataObjectConverterMock @@ -284,7 +285,7 @@ public function saveWithValidateCategoryExceptionDataProvider() return [ [ true, \Magento\Framework\Exception\CouldNotSaveException::class, - 'Could not save category: Attribute "ValidateCategoryTest" is required.', + 'Could not save category: The "ValidateCategoryTest" attribute is required. Enter and try again.' ], [ 'Something went wrong', \Magento\Framework\Exception\CouldNotSaveException::class, 'Could not save category: Something went wrong' diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CategoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CategoryTest.php index 9f5f3313c6859..60937dd3f83f0 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CategoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CategoryTest.php @@ -441,33 +441,39 @@ public function testReindexFlatDisabled( public function testGetCustomAttributes() { - $nameAttributeCode = 'name'; - $descriptionAttributeCode = 'description'; + $interfaceAttributeCode = 'name'; + $customAttributeCode = 'description'; + $initialCustomAttributeValue = 'initial description'; + $newCustomAttributeValue = 'new description'; + $this->getCustomAttributeCodes->expects($this->exactly(3)) ->method('execute') - ->willReturn([$descriptionAttributeCode]); - $this->category->setData($nameAttributeCode, "sub"); + ->willReturn([$customAttributeCode]); + $this->category->setData($interfaceAttributeCode, "sub"); //The description attribute is not set, expect empty custom attribute array $this->assertEquals([], $this->category->getCustomAttributes()); //Set the description attribute; - $this->category->setData($descriptionAttributeCode, "description"); + $this->category->setData($customAttributeCode, $initialCustomAttributeValue); $attributeValue = new \Magento\Framework\Api\AttributeValue(); $attributeValue2 = new \Magento\Framework\Api\AttributeValue(); $this->attributeValueFactory->expects($this->exactly(2))->method('create') ->willReturnOnConsecutiveCalls($attributeValue, $attributeValue2); $this->assertEquals(1, count($this->category->getCustomAttributes())); - $this->assertNotNull($this->category->getCustomAttribute($descriptionAttributeCode)); - $this->assertEquals("description", $this->category->getCustomAttribute($descriptionAttributeCode)->getValue()); + $this->assertNotNull($this->category->getCustomAttribute($customAttributeCode)); + $this->assertEquals( + $initialCustomAttributeValue, + $this->category->getCustomAttribute($customAttributeCode)->getValue() + ); //Change the attribute value, should reflect in getCustomAttribute - $this->category->setData($descriptionAttributeCode, "new description"); + $this->category->setCustomAttribute($customAttributeCode, $newCustomAttributeValue); $this->assertEquals(1, count($this->category->getCustomAttributes())); - $this->assertNotNull($this->category->getCustomAttribute($descriptionAttributeCode)); + $this->assertNotNull($this->category->getCustomAttribute($customAttributeCode)); $this->assertEquals( - "new description", - $this->category->getCustomAttribute($descriptionAttributeCode)->getValue() + $newCustomAttributeValue, + $this->category->getCustomAttribute($customAttributeCode)->getValue() ); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CustomOptions/CustomOptionTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CustomOptions/CustomOptionTest.php index 33b7892462f4c..fb90eaaaf1ec5 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CustomOptions/CustomOptionTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CustomOptions/CustomOptionTest.php @@ -7,6 +7,8 @@ use Magento\Catalog\Model\CustomOptions\CustomOption; use Magento\Catalog\Model\Webapi\Product\Option\Type\File\Processor as FileProcessor; +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Catalog\Api\Data\CustomOptionExtensionInterface; class CustomOptionTest extends \PHPUnit\Framework\TestCase { @@ -15,6 +17,12 @@ class CustomOptionTest extends \PHPUnit\Framework\TestCase */ protected $model; + /** @var \Magento\Framework\Api\ExtensionAttributesFactory | \PHPUnit_Framework_MockObject_MockObject */ + private $extensionAttributesFactoryMock; + + /** @var \Magento\Catalog\Api\Data\CustomOptionExtensionInterface | \PHPUnit_Framework_MockObject_MockObject */ + private $extensionMock; + /** * @var FileProcessor | \PHPUnit_Framework_MockObject_MockObject */ @@ -30,7 +38,7 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); - $extensionAttributesFactory = $this->getMockBuilder(\Magento\Framework\Api\ExtensionAttributesFactory::class) + $this->extensionAttributesFactoryMock = $this->getMockBuilder(ExtensionAttributesFactory::class) ->disableOriginalConstructor() ->getMock(); @@ -52,10 +60,17 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->extensionMock = $this->getMockBuilder(\Magento\Catalog\Api\Data\CustomOptionExtensionInterface::class) + ->setMethods(['getFileInfo']) + ->getMockForAbstractClass(); + + $this->extensionAttributesFactoryMock->expects(self::any()) + ->method('create')->willReturn($this->extensionMock); + $this->model = new CustomOption( $context, $registry, - $extensionAttributesFactory, + $this->extensionAttributesFactoryMock, $attributeValueFactory, $this->fileProcessor, $resource, @@ -84,14 +99,10 @@ public function testGetOptionValue() public function testGetOptionValueWithFileInfo() { - $customOption = $this->getMockBuilder(\Magento\Catalog\Api\Data\CustomOptionExtensionInterface::class) - ->setMethods(['getFileInfo']) - ->getMockForAbstractClass(); - $imageContent = $this->getMockBuilder(\Magento\Framework\Api\Data\ImageContentInterface::class) ->getMockForAbstractClass(); - $customOption->expects($this->once()) + $this->extensionMock->expects($this->once()) ->method('getFileInfo') ->willReturn($imageContent); @@ -112,7 +123,6 @@ public function testGetOptionValueWithFileInfo() ->with($imageContent) ->willReturn($imageResult); - $this->model->setExtensionAttributes($customOption); $this->model->setData(\Magento\Catalog\Api\Data\CustomOptionInterface::OPTION_VALUE, 'file'); $this->assertEquals($imageResult, $this->model->getOptionValue()); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/StoreViewTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/StoreViewTest.php index 6e3cd6ed30b52..4da831f5257d0 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/StoreViewTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Plugin/StoreViewTest.php @@ -33,6 +33,11 @@ class StoreViewTest extends \PHPUnit\Framework\TestCase */ protected $indexerRegistryMock; + /** + * @var \Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer|\PHPUnit_Framework_MockObject_MockObject + */ + protected $tableMaintainer; + /** * @var \PHPUnit_Framework_MockObject_MockObject */ @@ -51,15 +56,30 @@ protected function setUp() ); $this->subject = $this->createMock(Group::class); $this->indexerRegistryMock = $this->createPartialMock(IndexerRegistry::class, ['get']); - $this->storeMock = $this->createPartialMock(Store::class, ['isObjectNew', 'dataHasChangedFor', '__wakeup']); + $this->storeMock = $this->createPartialMock( + Store::class, + [ + 'isObjectNew', + 'getId', + 'dataHasChangedFor', + '__wakeup' + ] + ); + $this->tableMaintainer = $this->createPartialMock( + \Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer::class, + [ + 'createTablesForStore' + ] + ); - $this->model = new StoreView($this->indexerRegistryMock); + $this->model = new StoreView($this->indexerRegistryMock, $this->tableMaintainer); } public function testAroundSaveNewObject() { $this->mockIndexerMethods(); - $this->storeMock->expects($this->once())->method('isObjectNew')->willReturn(true); + $this->storeMock->expects($this->atLeastOnce())->method('isObjectNew')->willReturn(true); + $this->storeMock->expects($this->atLeastOnce())->method('getId')->willReturn(1); $this->model->beforeSave($this->subject, $this->storeMock); $this->assertSame($this->subject, $this->model->afterSave($this->subject, $this->subject, $this->storeMock)); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/AbstractActionTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/AbstractActionTest.php index 58654136ab5a8..9d58822fb6073 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/AbstractActionTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/AbstractActionTest.php @@ -113,11 +113,20 @@ public function testReindexWithoutArgumentsExecutesReindexAll() $this->_model->reindex(); } - public function testReindexWithNotNullArgumentExecutesReindexEntities() - { - $childIds = [1, 2, 3]; - $parentIds = [4]; - $reindexIds = array_merge($childIds, $parentIds); + /** + * @param array $ids + * @param array $parentIds + * @param array $childIds + * @return void + * @dataProvider reindexEntitiesDataProvider + */ + public function testReindexWithNotNullArgumentExecutesReindexEntities( + array $ids, + array $parentIds, + array $childIds + ) : void { + $reindexIds = array_unique(array_merge($ids, $parentIds, $childIds)); + $connectionMock = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) ->getMockForAbstractClass(); @@ -129,11 +138,23 @@ public function testReindexWithNotNullArgumentExecutesReindexEntities() ->disableOriginalConstructor() ->getMock(); - $eavSource->expects($this->once())->method('getRelationsByChild')->with($childIds)->willReturn($childIds); - $eavSource->expects($this->once())->method('getRelationsByParent')->with($childIds)->willReturn($parentIds); + $eavSource->expects($this->once()) + ->method('getRelationsByChild') + ->with($ids) + ->willReturn($parentIds); + $eavSource->expects($this->once()) + ->method('getRelationsByParent') + ->with(array_unique(array_merge($parentIds, $ids))) + ->willReturn($childIds); - $eavDecimal->expects($this->once())->method('getRelationsByChild')->with($reindexIds)->willReturn($reindexIds); - $eavDecimal->expects($this->once())->method('getRelationsByParent')->with($reindexIds)->willReturn([]); + $eavDecimal->expects($this->once()) + ->method('getRelationsByChild') + ->with($reindexIds) + ->willReturn($parentIds); + $eavDecimal->expects($this->once()) + ->method('getRelationsByParent') + ->with(array_unique(array_merge($parentIds, $reindexIds))) + ->willReturn($childIds); $eavSource->expects($this->once())->method('getConnection')->willReturn($connectionMock); $eavDecimal->expects($this->once())->method('getConnection')->willReturn($connectionMock); @@ -153,6 +174,18 @@ public function testReindexWithNotNullArgumentExecutesReindexEntities() ->method('create') ->will($this->returnValue($eavDecimal)); - $this->_model->reindex($childIds); + $this->_model->reindex($ids); + } + + /** + * @return array + */ + public function reindexEntitiesDataProvider() : array + { + return [ + [[4], [], [1, 2, 3]], + [[3], [4], []], + [[5], [], []], + ]; } } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/Action/FullTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/Action/FullTest.php index eb5fdabe53303..c254557904da1 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/Action/FullTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Eav/Action/FullTest.php @@ -48,7 +48,8 @@ public function testExecuteWithAdapterErrorThrowsException() $tableSwitcherMock ); - $this->expectException(\Magento\Framework\Exception\LocalizedException::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage($exceptionMessage); $model->execute(); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Backend/TierpriceTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Backend/TierpriceTest.php index ebcf76b739162..db103c3017e04 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Backend/TierpriceTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Backend/TierpriceTest.php @@ -141,6 +141,10 @@ public function testSetPriceData() { $attributeName = 'tier_price'; $tierPrices = [ + [ + 'price' => 0, + 'all_groups' => 1, + ], [ 'price' => 10, 'all_groups' => 1, @@ -153,6 +157,12 @@ public function testSetPriceData() $productPrice = 20; $allCustomersGroupId = 32000; $finalTierPrices = [ + [ + 'price' => 0, + 'all_groups' => 1, + 'website_price' => 0, + 'cust_group' => 32000, + ], [ 'price' => 10, 'all_groups' => 1, @@ -170,8 +180,11 @@ public function testSetPriceData() ->disableOriginalConstructor()->getMock(); $allCustomersGroup = $this->getMockBuilder(\Magento\Customer\Api\Data\GroupInterface::class) ->disableOriginalConstructor()->getMock(); - $this->groupManagement->expects($this->once())->method('getAllCustomersGroup')->willReturn($allCustomersGroup); - $allCustomersGroup->expects($this->once())->method('getId')->willReturn($allCustomersGroupId); + $this->groupManagement + ->expects($this->exactly(2)) + ->method('getAllCustomersGroup') + ->willReturn($allCustomersGroup); + $allCustomersGroup->expects($this->exactly(2))->method('getId')->willReturn($allCustomersGroupId); $object->expects($this->once())->method('getPrice')->willReturn($productPrice); $this->attribute->expects($this->atLeastOnce())->method('isScopeGlobal')->willReturn(true); $object->expects($this->once())->method('getStoreId')->willReturn(null); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Frontend/InputType/PresentationTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Frontend/InputType/PresentationTest.php new file mode 100644 index 0000000000000..16dff2d210f27 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Frontend/InputType/PresentationTest.php @@ -0,0 +1,80 @@ +presentation = new \Magento\Catalog\Model\Product\Attribute\Frontend\Inputtype\Presentation(); + $this->attributeMock = $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @param string $inputType + * @param boolean $isWysiwygEnabled + * @param string $expectedResult + * @dataProvider getPresentationInputTypeDataProvider + */ + public function testGetPresentationInputType(string $inputType, bool $isWysiwygEnabled, string $expectedResult) + { + $this->attributeMock->expects($this->once())->method('getFrontendInput')->willReturn($inputType); + $this->attributeMock->expects($this->any())->method('getIsWysiwygEnabled')->willReturn($isWysiwygEnabled); + $this->assertEquals($expectedResult, $this->presentation->getPresentationInputType($this->attributeMock)); + } + + public function getPresentationInputTypeDataProvider() + { + return [ + 'attribute_is_textarea_and_wysiwyg_enabled' => ['textarea', true, 'texteditor'], + 'attribute_is_input_and_wysiwyg_enabled' => ['input', true, 'input'], + 'attribute_is_textarea_and_wysiwyg_disabled' => ['textarea', false, 'textarea'], + ]; + } + + /** + * @param array $data + * @param array $expectedResult + * @dataProvider convertPresentationDataToInputTypeDataProvider + */ + public function testConvertPresentationDataToInputType(array $data, array $expectedResult) + { + $this->assertEquals($expectedResult, $this->presentation->convertPresentationDataToInputType($data)); + } + + public function convertPresentationDataToInputTypeDataProvider() + { + return [ + [['key' => 'value'], ['key' => 'value']], + [ + ['frontend_input' => 'texteditor'], + ['frontend_input' => 'textarea', 'is_wysiwyg_enabled' => 1] + ], + [ + ['frontend_input' => 'textarea'], + ['frontend_input' => 'textarea', 'is_wysiwyg_enabled' => 0] + ], + [ + ['frontend_input' => 'input'], + ['frontend_input' => 'input'] + ] + ]; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Source/InputtypeTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Source/InputtypeTest.php index f5c71e45a6647..0246ba337dbc9 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Source/InputtypeTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Attribute/Source/InputtypeTest.php @@ -27,27 +27,37 @@ protected function setUp() $this->inputtypeModel = $this->objectManagerHelper->getObject( \Magento\Catalog\Model\Product\Attribute\Source\Inputtype::class, [ - 'coreRegistry' => $this->registry + 'coreRegistry' => $this->registry, + 'optionsArray' => $this->getInputTypeSet() ] ); } public function testToOptionArray() { - $inputTypesSet = [ + + $extraValues = [ + ['value' => 'price', 'label' => 'Price'], + ['value' => 'media_image', 'label' => 'Media Image'] + ]; + $inputTypesSet = $this->getInputTypeSet(); + $inputTypesSet = array_merge($inputTypesSet, $extraValues); + + $this->registry->expects($this->once())->method('registry'); + $this->registry->expects($this->once())->method('register'); + $this->assertEquals($inputTypesSet, $this->inputtypeModel->toOptionArray()); + } + + private function getInputTypeSet() + { + return [ ['value' => 'text', 'label' => 'Text Field'], ['value' => 'textarea', 'label' => 'Text Area'], ['value' => 'texteditor', 'label' => 'Text Editor'], ['value' => 'date', 'label' => 'Date'], ['value' => 'boolean', 'label' => 'Yes/No'], ['value' => 'multiselect', 'label' => 'Multiple Select'], - ['value' => 'select', 'label' => 'Dropdown'], - ['value' => 'price', 'label' => 'Price'], - ['value' => 'media_image', 'label' => 'Media Image'], + ['value' => 'select', 'label' => 'Dropdown'] ]; - - $this->registry->expects($this->once())->method('registry'); - $this->registry->expects($this->once())->method('register'); - $this->assertEquals($inputTypesSet, $this->inputtypeModel->toOptionArray()); } } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/CopyConstructorFactoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/CopyConstructorFactoryTest.php index a01e9814b0579..1c0ea6ee1ed39 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/CopyConstructorFactoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/CopyConstructorFactoryTest.php @@ -27,8 +27,8 @@ protected function setUp() public function testCreateWithInvalidType() { - $this->expectException( - '\InvalidArgumentException', + $this->expectException('\InvalidArgumentException'); + $this->expectExceptionMessage( 'Magento\Framework\DataObject does not implement \Magento\Catalog\Model\Product\CopyConstructorInterface' ); $this->_objectManagerMock->expects($this->never())->method('create'); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php index 627aa1848506e..430db70701356 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php @@ -5,10 +5,12 @@ */ namespace Magento\Catalog\Test\Unit\Model\Product; +use Magento\Catalog\Model\Product\Image\ParamsBuilder; +use Magento\Catalog\Model\View\Asset\Image\ContextFactory; use Magento\Catalog\Model\View\Asset\ImageFactory; use Magento\Catalog\Model\View\Asset\PlaceholderFactory; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -81,6 +83,11 @@ class ImageTest extends \PHPUnit\Framework\TestCase */ private $cacheManager; + /** + * @var ParamsBuilder|\PHPUnit_Framework_MockObject_MockObject + */ + private $paramsBuilder; + protected function setUp() { $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -141,6 +148,9 @@ function ($value) { return json_decode($value, true); } ); + $this->paramsBuilder = $this->getMockBuilder(ParamsBuilder::class) + ->disableOriginalConstructor() + ->getMock(); $this->image = $objectManager->getObject( \Magento\Catalog\Model\Product\Image::class, @@ -153,15 +163,14 @@ function ($value) { 'imageFactory' => $this->factory, 'viewAssetImageFactory' => $this->viewAssetImageFactory, 'viewAssetPlaceholderFactory' => $this->viewAssetPlaceholderFactory, - 'serializer' => $this->serializer + 'serializer' => $this->serializer, + 'paramsBuilder' => $this->paramsBuilder ] ); - // Settings for backward compatible property - $objectManagerHelper = new ObjectManagerHelper($this); $this->imageAsset = $this->getMockBuilder(\Magento\Framework\View\Asset\LocalInterface::class) ->getMockForAbstractClass(); - $objectManagerHelper->setBackwardCompatibleProperty( + $objectManager->setBackwardCompatibleProperty( $this->image, 'imageAsset', $this->imageAsset @@ -213,6 +222,21 @@ public function testSetSize() public function testSetGetBaseFile() { + $miscParams = [ + 'image_type' => null, + 'image_height' => null, + 'image_width' => null, + 'keep_aspect_ratio' => 'proportional', + 'keep_frame' => 'frame', + 'keep_transparency' => 'transparency', + 'constrain_only' => 'doconstrainonly', + 'background' => 'ffffff', + 'angle' => null, + 'quality' => 80, + ]; + $this->paramsBuilder->expects(self::once()) + ->method('build') + ->willReturn($miscParams); $this->mediaDirectory->expects($this->any())->method('isFile')->will($this->returnValue(true)); $this->mediaDirectory->expects($this->any())->method('isExist')->will($this->returnValue(true)); $absolutePath = dirname(dirname(__DIR__)) . '/_files/catalog/product/somefile.png'; @@ -222,18 +246,7 @@ public function testSetGetBaseFile() ->method('create') ->with( [ - 'miscParams' => [ - 'image_type' => null, - 'image_height' => null, - 'image_width' => null, - 'keep_aspect_ratio' => 'proportional', - 'keep_frame' => 'frame', - 'keep_transparency' => 'transparency', - 'constrain_only' => 'doconstrainonly', - 'background' => 'ffffff', - 'angle' => null, - 'quality' => 80, - ], + 'miscParams' => $miscParams, 'filePath' => '/somefile.png', ] ) @@ -243,6 +256,10 @@ public function testSetGetBaseFile() $this->imageAsset->expects($this->any())->method('getSourceFile')->willReturn('catalog/product/somefile.png'); $this->image->setBaseFile('/somefile.png'); $this->assertEquals('catalog/product/somefile.png', $this->image->getBaseFile()); + $this->assertEquals( + null, + $this->image->getNewFile() + ); } public function testSetBaseNoSelectionFile() diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php index d3a0cb81b8876..1ff5bef78cd79 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/Option/ValueTest.php @@ -19,6 +19,32 @@ class ValueTest extends \PHPUnit\Framework\TestCase */ private $model; + /** + * @var \Magento\Catalog\Pricing\Price\CustomOptionPriceCalculator + */ + private $customOptionPriceCalculatorMock; + + protected function setUp() + { + $mockedResource = $this->getMockedResource(); + $mockedCollectionFactory = $this->getMockedValueCollectionFactory(); + + $this->customOptionPriceCalculatorMock = $this->createMock( + \Magento\Catalog\Pricing\Price\CustomOptionPriceCalculator::class + ); + + $helper = new ObjectManager($this); + $this->model = $helper->getObject( + \Magento\Catalog\Model\Product\Option\Value::class, + [ + 'resource' => $mockedResource, + 'valueCollectionFactory' => $mockedCollectionFactory, + 'customOptionPriceCalculator' => $this->customOptionPriceCalculatorMock, + ] + ); + $this->model->setOption($this->getMockedOption()); + } + public function testSaveProduct() { $this->model->setValues([100]) @@ -35,11 +61,16 @@ public function testSaveProduct() public function testGetPrice() { - $this->model->setPrice(1000); + $price = 1000; + $this->model->setPrice($price); $this->model->setPriceType(Value::TYPE_PERCENT); - $this->assertEquals(1000, $this->model->getPrice(false)); + $this->assertEquals($price, $this->model->getPrice(false)); - $this->assertEquals(100, $this->model->getPrice(true)); + $percentPice = 100; + $this->customOptionPriceCalculatorMock->expects($this->atLeastOnce()) + ->method('getOptionPriceByPriceCode') + ->willReturn($percentPice); + $this->assertEquals($percentPice, $this->model->getPrice(true)); } public function testGetValuesCollection() @@ -78,23 +109,6 @@ public function testDeleteValue() $this->assertInstanceOf(\Magento\Catalog\Model\Product\Option\Value::class, $this->model->deleteValue(1)); } - protected function setUp() - { - $mockedResource = $this->getMockedResource(); - $mockedCollectionFactory = $this->getMockedValueCollectionFactory(); - $mockedContext = $this->getMockedContext(); - $helper = new ObjectManager($this); - $this->model = $helper->getObject( - \Magento\Catalog\Model\Product\Option\Value::class, - [ - 'resource' => $mockedResource, - 'valueCollectionFactory' => $mockedCollectionFactory, - 'context' => $mockedContext - ] - ); - $this->model->setOption($this->getMockedOption()); - } - /** * @return \Magento\Catalog\Model\ResourceModel\Product\Option\Value\CollectionFactory */ @@ -243,61 +257,4 @@ private function getMockedResource() return $mock; } - - /** - * @return \Magento\Framework\Model\Context - */ - private function getMockedContext() - { - $mockedRemoveAction = $this->getMockedRemoveAction(); - $mockEventManager = $this->getMockedEventManager(); - - $mockBuilder = $this->getMockBuilder(\Magento\Framework\Model\Context::class) - ->setMethods(['getActionValidator', 'getEventDispatcher']) - ->disableOriginalConstructor(); - $mock = $mockBuilder->getMock(); - - $mock->expects($this->any()) - ->method('getActionValidator') - ->will($this->returnValue($mockedRemoveAction)); - - $mock->expects($this->any()) - ->method('getEventDispatcher') - ->will($this->returnValue($mockEventManager)); - - return $mock; - } - - /** - * @return RemoveAction - */ - private function getMockedRemoveAction() - { - $mockBuilder = $this->getMockBuilder(\Magento\Framework\Model\Context::class) - ->setMethods(['isAllowed']) - ->disableOriginalConstructor(); - $mock = $mockBuilder->getMock(); - - $mock->expects($this->any()) - ->method('isAllowed') - ->will($this->returnValue(true)); - - return $mock; - } - - /** - * @return \Magento\Framework\Event\ManagerInterface - */ - private function getMockedEventManager() - { - $mockBuilder = $this->getMockBuilder(\Magento\Framework\Event\ManagerInterface::class) - ->setMethods(['dispatch']) - ->disableOriginalConstructor(); - $mock = $mockBuilder->getMockForAbstractClass(); - - $mock->expects($this->any()) - ->method('dispatch'); - - return $mock; - } } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/OptionTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/OptionTest.php index cd0af47180974..1bd85c4053263 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/OptionTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/OptionTest.php @@ -68,4 +68,38 @@ public function testGetRegularPrice() $this->model->setPriceType(null); $this->assertEquals(50, $this->model->getRegularPrice()); } + + /** + * Tests removing ineligible characters from file_extension. + * + * @param string $rawExtensions + * @param string $expectedExtensions + * @dataProvider cleanFileExtensionsDataProvider + */ + public function testCleanFileExtensions(string $rawExtensions, string $expectedExtensions) + { + $this->model->setType(Option::OPTION_GROUP_FILE); + $this->model->setFileExtension($rawExtensions); + $this->model->beforeSave(); + $actualExtensions = $this->model->getFileExtension(); + $this->assertEquals($expectedExtensions, $actualExtensions); + } + + /** + * Data provider for testCleanFileExtensions. + * + * @return array + */ + public function cleanFileExtensionsDataProvider() + { + return [ + ['JPG, PNG, GIF', 'jpg, png, gif'], + ['jpg, jpg, jpg', 'jpg'], + ['jpg, png, gif', 'jpg, png, gif'], + ['jpg png gif', 'jpg, png, gif'], + ['!jpg@png#gif%', 'jpg, png, gif'], + ['jpg, png, 123', 'jpg, png, 123'], + ['', ''], + ]; + } } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php index 8d65153d7ba20..a370cbea13c2b 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ProductRepositoryTest.php @@ -1336,7 +1336,6 @@ public function testSaveExistingWithMediaGalleryEntries() $expectedResult = [ [ - 'value_id' => 5, 'value_id' => 5, "label" => "new_label_text", 'file' => 'filename1', diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php index 74a71a2828e1d..809ec8b9b7a2d 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php @@ -330,9 +330,9 @@ protected function setUp() $this->mediaGalleryEntryFactoryMock = $this->getMockBuilder(\Magento\Catalog\Api\Data\ProductAttributeMediaGalleryEntryInterfaceFactory::class) - ->setMethods(['create']) - ->disableOriginalConstructor() - ->getMock(); + ->setMethods(['create']) + ->disableOriginalConstructor() + ->getMock(); $this->metadataServiceMock = $this->createMock(\Magento\Catalog\Api\ProductAttributeRepositoryInterface::class); $this->attributeValueFactory = $this->getMockBuilder(\Magento\Framework\Api\AttributeValueFactory::class) @@ -870,13 +870,10 @@ public function testGetQty() */ public function testSave() { - $this->imageCache->expects($this->once()) - ->method('generate') - ->with($this->model); - $this->imageCacheFactory->expects($this->once()) - ->method('create') - ->willReturn($this->imageCache); - + $collection = $this->createMock(\Magento\Framework\Data\Collection::class); + $collection->method('count')->willReturn(1); + $collection->method('getIterator')->willReturn(new \ArrayObject([])); + $this->collectionFactoryMock->method('create')->willReturn($collection); $this->model->setIsDuplicate(false); $this->configureSaveTest(); $this->model->beforeSave(); @@ -902,13 +899,10 @@ public function testSaveIfAreaEmulated() */ public function testSaveAndDuplicate() { - $this->imageCache->expects($this->once()) - ->method('generate') - ->with($this->model); - $this->imageCacheFactory->expects($this->once()) - ->method('create') - ->willReturn($this->imageCache); - + $collection = $this->createMock(\Magento\Framework\Data\Collection::class); + $collection->method('count')->willReturn(1); + $collection->method('getIterator')->willReturn(new \ArrayObject([])); + $this->collectionFactoryMock->method('create')->willReturn($collection); $this->model->setIsDuplicate(true); $this->configureSaveTest(); $this->model->beforeSave(); @@ -1171,19 +1165,19 @@ public function testSetMediaGalleryEntries() ]; $entryMock = $this->getMockBuilder(\Magento\Catalog\Api\Data\ProductAttributeMediaGalleryEntryInterface::class) - ->setMethods( - [ - 'getId', - 'getFile', - 'getLabel', - 'getPosition', - 'isDisabled', - 'types', - 'getContent', - 'getMediaType' - ] - ) - ->getMockForAbstractClass(); + ->setMethods( + [ + 'getId', + 'getFile', + 'getLabel', + 'getPosition', + 'isDisabled', + 'types', + 'getContent', + 'getMediaType' + ] + ) + ->getMockForAbstractClass(); $result = [ 'value_id' => 1, @@ -1218,7 +1212,7 @@ public function testGetMediaGalleryImagesMerging() 'media_type' => 'image', ], [ - 'value_id' => 1, + 'value_id' => 3, 'file' => 'imageFile.jpg', ], [ @@ -1245,64 +1239,71 @@ public function testGetMediaGalleryImagesMerging() 'path' => '/var/www/html/pub/smallImageFile.jpg', ]); - $directoryMock = $this->getMockBuilder(\Magento\Framework\Filesystem\Directory\ReadInterface::class) - ->disableOriginalConstructor() - ->getMock(); - $this->filesystemMock->expects($this->once())->method('getDirectoryRead')->willReturn($directoryMock); + $directoryMock = $this->createMock(\Magento\Framework\Filesystem\Directory\ReadInterface::class); + $directoryMock->method('getAbsolutePath')->willReturnOnConsecutiveCalls( + '/var/www/html/pub/imageFile.jpg', + '/var/www/html/pub/smallImageFile.jpg' + ); + $this->mediaConfig->method('getMediaUrl')->willReturnOnConsecutiveCalls( + 'http://magento.dev/pub/imageFile.jpg', + 'http://magento.dev/pub/smallImageFile.jpg' + ); + $this->filesystemMock->method('getDirectoryRead')->willReturn($directoryMock); $this->model->setData('media_gallery', $mediaEntries); - $imagesCollectionMock = $this->getMockBuilder(\Magento\Framework\Data\Collection::class) - ->disableOriginalConstructor() - ->getMock(); - $this->collectionFactoryMock->expects($this->once())->method('create')->willReturn($imagesCollectionMock); - $imagesCollectionMock->expects($this->at(2)) - ->method('getItemById') - ->with(1) - ->willReturn($expectedImageDataObject); - $this->mediaConfig->expects($this->at(0)) - ->method('getMediaUrl') - ->willReturn('http://magento.dev/pub/imageFile.jpg'); - $directoryMock->expects($this->at(0)) - ->method('getAbsolutePath') - ->willReturn('/var/www/html/pub/imageFile.jpg'); - $this->mediaConfig->expects($this->at(2)) - ->method('getMediaUrl') - ->willReturn('http://magento.dev/pub/smallImageFile.jpg'); - $directoryMock->expects($this->at(1)) - ->method('getAbsolutePath') - ->willReturn('/var/www/html/pub/smallImageFile.jpg'); - $imagesCollectionMock->expects($this->at(1))->method('addItem')->with($expectedImageDataObject); - $imagesCollectionMock->expects($this->at(4))->method('addItem')->with($expectedSmallImageDataObject); + $imagesCollectionMock = $this->createMock(\Magento\Framework\Data\Collection::class); + $imagesCollectionMock->method('count')->willReturn(0); + $imagesCollectionMock->method('getItemById')->willReturnMap( + [ + [1, null], + [2, null], + [3, 'not_null_skeep_foreache'], + ] + ); + $imagesCollectionMock->expects(self::exactly(2))->method('addItem')->withConsecutive( + $expectedImageDataObject, + $expectedSmallImageDataObject + ); + $this->collectionFactoryMock->method('create')->willReturn($imagesCollectionMock); $this->model->getMediaGalleryImages(); } public function testGetCustomAttributes() { - $priceCode = 'price'; - $colorAttributeCode = 'color'; + $interfaceAttributeCode = 'price'; + $customAttributeCode = 'color'; + $initialCustomAttributeValue = 'red'; + $newCustomAttributeValue = 'blue'; + $this->getCustomAttributeCodes->expects($this->exactly(3)) ->method('execute') - ->willReturn([$colorAttributeCode]); - $this->model->setData($priceCode, 10); + ->willReturn([$customAttributeCode]); + $this->model->setData($interfaceAttributeCode, 10); //The color attribute is not set, expect empty custom attribute array $this->assertEquals([], $this->model->getCustomAttributes()); //Set the color attribute; - $this->model->setData($colorAttributeCode, "red"); + $this->model->setData($customAttributeCode, $initialCustomAttributeValue); $attributeValue = new \Magento\Framework\Api\AttributeValue(); $attributeValue2 = new \Magento\Framework\Api\AttributeValue(); $this->attributeValueFactory->expects($this->exactly(2))->method('create') ->willReturnOnConsecutiveCalls($attributeValue, $attributeValue2); $this->assertEquals(1, count($this->model->getCustomAttributes())); - $this->assertNotNull($this->model->getCustomAttribute($colorAttributeCode)); - $this->assertEquals("red", $this->model->getCustomAttribute($colorAttributeCode)->getValue()); + $this->assertNotNull($this->model->getCustomAttribute($customAttributeCode)); + $this->assertEquals( + $initialCustomAttributeValue, + $this->model->getCustomAttribute($customAttributeCode)->getValue() + ); //Change the attribute value, should reflect in getCustomAttribute - $this->model->setData($colorAttributeCode, "blue"); + $this->model->setCustomAttribute($customAttributeCode, $newCustomAttributeValue); $this->assertEquals(1, count($this->model->getCustomAttributes())); - $this->assertNotNull($this->model->getCustomAttribute($colorAttributeCode)); - $this->assertEquals("blue", $this->model->getCustomAttribute($colorAttributeCode)->getValue()); + $this->assertNotNull($this->model->getCustomAttribute($customAttributeCode)); + $this->assertEquals( + $newCustomAttributeValue, + $this->model->getCustomAttribute($customAttributeCode)->getValue() + ); } /** @@ -1398,7 +1399,20 @@ public function testGetFinalPricePreset() $qty = 1; $this->model->setQty($qty); $this->model->setFinalPrice($finalPrice); - $this->productTypeInstanceMock->expects($this->never())->method('priceFactory'); + $productTypePriceMock = $this->createPartialMock( + \Magento\Catalog\Model\Product\Type\Price::class, + ['getFinalPrice'] + ); + $productTypePriceMock->expects($this->any()) + ->method('getFinalPrice') + ->with($qty, $this->model) + ->willReturn($finalPrice); + + $this->productTypeInstanceMock->expects($this->any()) + ->method('priceFactory') + ->with($this->model->getTypeId()) + ->willReturn($productTypePriceMock); + $this->assertEquals($finalPrice, $this->model->getFinalPrice($qty)); } diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AbstractTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AbstractTest.php index 96336d2b0706a..3dcea33d5e00e 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AbstractTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/AbstractTest.php @@ -25,7 +25,7 @@ protected function _getAttributes() foreach ($codes as $code) { $mock = $this->createPartialMock( \Magento\Eav\Model\Entity\Attribute\AbstractAttribute::class, - ['isInSet', 'getBackend', '__wakeup'] + ['isInSet', 'getApplyTo', 'getBackend', '__wakeup'] ); $mock->setAttributeId($code); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/Indexer/Price/CompositeProductRowSizeEstimatorTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/Indexer/Price/CompositeProductRowSizeEstimatorTest.php index 728044b89cafe..1c47644338143 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/Indexer/Price/CompositeProductRowSizeEstimatorTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/Indexer/Price/CompositeProductRowSizeEstimatorTest.php @@ -49,6 +49,7 @@ protected function setUp() public function testEstimateRowSize() { + $this->markTestSkipped('Unskip after MAGETWO-89738'); $expectedResult = 40000000; $maxRelatedProductCount = 10; diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/Indexer/Price/IndexTableRowSizeEstimatorTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/Indexer/Price/IndexTableRowSizeEstimatorTest.php index e5720b4f0536c..c0ecc4370816b 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/Indexer/Price/IndexTableRowSizeEstimatorTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/Indexer/Price/IndexTableRowSizeEstimatorTest.php @@ -38,7 +38,7 @@ protected function setUp() public function testEstimateRowSize() { - $expectedValue = 2400000; + $expectedValue = 4000000; $this->websiteManagementMock->expects($this->once())->method('getCount')->willReturn(100); $collectionMock = $this->createMock(\Magento\Customer\Model\ResourceModel\Group\Collection::class); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php index 517b5949ee8ea..431d5736bb6dd 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/View/Asset/ImageTest.php @@ -8,7 +8,9 @@ use Magento\Catalog\Model\Product\Media\ConfigInterface; use Magento\Catalog\Model\View\Asset\Image; use Magento\Framework\Encryption\EncryptorInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\View\Asset\ContextInterface; +use Magento\Framework\View\Asset\Repository; /** * Class ImageTest @@ -33,18 +35,43 @@ class ImageTest extends \PHPUnit\Framework\TestCase /** * @var ContextInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $imageContext; + protected $context; + + /** + * @var Repository|\PHPUnit_Framework_MockObject_MockObject + */ + private $assetRepo; + + private $objectManager; protected function setUp() { - $this->mediaConfig = $this->getMockBuilder(ConfigInterface::class)->getMockForAbstractClass(); - $this->encryptor = $this->getMockBuilder(EncryptorInterface::class)->getMockForAbstractClass(); - $this->imageContext = $this->getMockBuilder(ContextInterface::class)->getMockForAbstractClass(); - $this->model = new Image( - $this->mediaConfig, - $this->imageContext, - $this->encryptor, - '/somefile.png' + $this->mediaConfig = $this->createMock(ConfigInterface::class); + $this->encryptor = $this->createMock(EncryptorInterface::class); + $this->context = $this->createMock(ContextInterface::class); + $this->assetRepo = $this->createMock(Repository::class); + $this->objectManager = new ObjectManager($this); + $this->model = $this->objectManager->getObject( + Image::class, + [ + 'mediaConfig' => $this->mediaConfig, + 'imageContext' => $this->context, + 'encryptor' => $this->encryptor, + 'filePath' => '/somefile.png', + 'assetRepo' => $this->assetRepo, + 'miscParams' => [ + 'image_width' => 100, + 'image_height' => 50, + 'constrain_only' => false, + 'keep_aspect_ratio' => false, + 'keep_frame' => true, + 'keep_transparency' => false, + 'background' => '255,255,255', + 'image_type' => 'image', //thumbnail,small_image,image,swatch_image,swatch_thumb + 'quality' => 80, + 'angle' => null + ] + ] ); } @@ -80,43 +107,24 @@ public function testGetContext() */ public function testGetPath($filePath, $miscParams) { - $imageModel = new Image( - $this->mediaConfig, - $this->imageContext, - $this->encryptor, - $filePath, - $miscParams + $imageModel = $this->objectManager->getObject( + Image::class, + [ + 'mediaConfig' => $this->mediaConfig, + 'context' => $this->context, + 'encryptor' => $this->encryptor, + 'filePath' => $filePath, + 'assetRepo' => $this->assetRepo, + 'miscParams' => $miscParams + ] ); + $miscParams['background'] = isset($miscParams['background']) ? implode(',', $miscParams['background']) : ''; $absolutePath = '/var/www/html/magento2ce/pub/media/catalog/product'; $hashPath = md5(implode('_', $miscParams)); - $this->imageContext->expects($this->once())->method('getPath')->willReturn($absolutePath); - $this->encryptor->expects($this->once())->method('hash')->willReturn($hashPath); - $this->assertEquals( - $absolutePath . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . $hashPath . $filePath, - $imageModel->getPath() - ); - } - - /** - * @param string $filePath - * @param array $miscParams - * @dataProvider getPathDataProvider - */ - public function testGetNotUnixPath($filePath, $miscParams) - { - $imageModel = new Image( - $this->mediaConfig, - $this->imageContext, - $this->encryptor, - $filePath, - $miscParams - ); - $absolutePath = 'C:\www\magento2ce\pub\media\catalog\product'; - $hashPath = md5(implode('_', $miscParams)); - $this->imageContext->expects($this->once())->method('getPath')->willReturn($absolutePath); - $this->encryptor->expects($this->once())->method('hash')->willReturn($hashPath); - $this->assertEquals( - $absolutePath . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . $hashPath . $filePath, + $this->context->method('getPath')->willReturn($absolutePath); + $this->encryptor->method('hash')->willReturn($hashPath); + static::assertEquals( + $absolutePath . '/cache/'. $hashPath . $filePath, $imageModel->getPath() ); } @@ -128,19 +136,24 @@ public function testGetNotUnixPath($filePath, $miscParams) */ public function testGetUrl($filePath, $miscParams) { - $imageModel = new Image( - $this->mediaConfig, - $this->imageContext, - $this->encryptor, - $filePath, - $miscParams + $imageModel = $this->objectManager->getObject( + Image::class, + [ + 'mediaConfig' => $this->mediaConfig, + 'context' => $this->context, + 'encryptor' => $this->encryptor, + 'filePath' => $filePath, + 'assetRepo' => $this->assetRepo, + 'miscParams' => $miscParams + ] ); + $miscParams['background'] = isset($miscParams['background']) ? implode(',', $miscParams['background']) : ''; $absolutePath = 'http://localhost/pub/media/catalog/product'; $hashPath = md5(implode('_', $miscParams)); - $this->imageContext->expects($this->once())->method('getBaseUrl')->willReturn($absolutePath); - $this->encryptor->expects($this->once())->method('hash')->willReturn($hashPath); - $this->assertEquals( - $absolutePath . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . $hashPath . $filePath, + $this->context->expects(static::once())->method('getBaseUrl')->willReturn($absolutePath); + $this->encryptor->expects(static::once())->method('hash')->willReturn($hashPath); + static::assertEquals( + $absolutePath . '/cache/' . $hashPath . $filePath, $imageModel->getUrl() ); } @@ -162,7 +175,7 @@ public function getPathDataProvider() 'keep_frame' => 'frame', 'keep_transparency' => 'transparency', 'constrain_only' => 'doconstrainonly', - 'background' => 'ffffff', + 'background' => [233,1,0], 'angle' => null, 'quality' => 80, ], diff --git a/app/code/Magento/Catalog/Test/Unit/Pricing/Price/CustomOptionPriceTest.php b/app/code/Magento/Catalog/Test/Unit/Pricing/Price/CustomOptionPriceTest.php index f3436b8f9f09f..9225a37c3e5b4 100644 --- a/app/code/Magento/Catalog/Test/Unit/Pricing/Price/CustomOptionPriceTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Pricing/Price/CustomOptionPriceTest.php @@ -279,7 +279,7 @@ protected function getOptionValueMock($price) { $optionValueMock = $this->getMockBuilder(\Magento\Catalog\Model\Product\Option\Value::class) ->disableOriginalConstructor() - ->setMethods(['getPriceType', 'getPrice', 'getId', '__wakeup']) + ->setMethods(['getPriceType', 'getPrice', 'getId', '__wakeup', 'getOption', 'getData']) ->getMock(); $optionValueMock->expects($this->any()) ->method('getPriceType') @@ -288,6 +288,29 @@ protected function getOptionValueMock($price) ->method('getPrice') ->with($this->equalTo(true)) ->will($this->returnValue($price)); + + $optionValueMock->expects($this->any()) + ->method('getData') + ->with(\Magento\Catalog\Model\Product\Option\Value::KEY_PRICE) + ->willReturn($price); + + $optionMock = $this->getMockBuilder(\Magento\Catalog\Model\Product\Option::class) + ->disableOriginalConstructor() + ->setMethods(['getProduct']) + ->getMock(); + + $optionValueMock->expects($this->any())->method('getOption')->willReturn($optionMock); + + $optionMock->expects($this->any())->method('getProduct')->willReturn($this->product); + + $priceMock = $this->getMockBuilder(\Magento\Framework\Pricing\Price\PriceInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getValue']) + ->getMockForAbstractClass(); + $priceMock->method('getValue')->willReturn($price); + + $this->priceInfo->method('getPrice')->willReturn($priceMock); + return $optionValueMock; } diff --git a/app/code/Magento/Catalog/Test/Unit/Pricing/Render/FinalPriceBoxTest.php b/app/code/Magento/Catalog/Test/Unit/Pricing/Render/FinalPriceBoxTest.php index e4f19e550a170..90384c122f095 100644 --- a/app/code/Magento/Catalog/Test/Unit/Pricing/Render/FinalPriceBoxTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Pricing/Render/FinalPriceBoxTest.php @@ -246,7 +246,8 @@ public function testRenderMsrpEnabled() //assert price wrapper $this->assertEquals( - '
test
', + '
test
', $result ); } diff --git a/app/code/Magento/Catalog/Test/Unit/ViewModel/Product/BreadcrumbsTest.php b/app/code/Magento/Catalog/Test/Unit/ViewModel/Product/BreadcrumbsTest.php new file mode 100644 index 0000000000000..316e08a56f99c --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/ViewModel/Product/BreadcrumbsTest.php @@ -0,0 +1,129 @@ +catalogHelper = $this->getMockBuilder(CatalogHelper::class) + ->setMethods(['getProduct']) + ->disableOriginalConstructor() + ->getMock(); + + $this->scopeConfig = $this->getMockBuilder(ScopeConfigInterface::class) + ->setMethods(['getValue', 'isSetFlag']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->viewModel = $this->getObjectManager()->getObject( + Breadcrumbs::class, + [ + 'catalogData' => $this->catalogHelper, + 'scopeConfig' => $this->scopeConfig, + ] + ); + } + + /** + * @return void + */ + public function testGetCategoryUrlSuffix() + { + $this->scopeConfig->expects($this->once()) + ->method('getValue') + ->with('catalog/seo/category_url_suffix', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn('.html'); + + $this->assertEquals('.html', $this->viewModel->getCategoryUrlSuffix()); + } + + /** + * @return void + */ + public function testIsCategoryUsedInProductUrl() + { + $this->scopeConfig->expects($this->once()) + ->method('isSetFlag') + ->with('catalog/seo/product_use_categories', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn(false); + + $this->assertFalse($this->viewModel->isCategoryUsedInProductUrl()); + } + + /** + * @dataProvider productDataProvider + * + * @param Product|null $product + * @param string $expectedName + * @return void + */ + public function testGetProductName($product, string $expectedName) + { + $this->catalogHelper->expects($this->atLeastOnce()) + ->method('getProduct') + ->willReturn($product); + + $this->assertEquals($expectedName, $this->viewModel->getProductName()); + } + + /** + * @return array + */ + public function productDataProvider() + { + return [ + [$this->getObjectManager()->getObject(Product::class, ['data' => ['name' => 'Test']]), 'Test'], + [null, ''], + ]; + } + + /** + * @return ObjectManager + */ + private function getObjectManager() + { + if (null === $this->objectManager) { + $this->objectManager = new ObjectManager($this); + } + + return $this->objectManager; + } +} diff --git a/app/code/Magento/Catalog/Ui/Component/UrlInput/Category.php b/app/code/Magento/Catalog/Ui/Component/UrlInput/Category.php new file mode 100644 index 0000000000000..01d93de577927 --- /dev/null +++ b/app/code/Magento/Catalog/Ui/Component/UrlInput/Category.php @@ -0,0 +1,52 @@ +options = $options; + } + + /** + * {@inheritdoc} + */ + public function getConfig(): array + { + return [ + 'label' => __('Category'), + 'component' => 'Magento_Ui/js/form/element/ui-select', + 'template' => 'ui/grid/filters/elements/ui-select', + 'formElement' => 'select', + 'disableLabel' => true, + 'multiple' => false, + 'chipsEnabled' => false, + 'filterOptions' => true, + 'levelsVisibility' => '1', + 'options' => $this->options->toOptionArray(), + 'sortOrder' => 30, + 'missingValuePlaceholder' => __('Category with ID: %s doesn\'t exist'), + 'isDisplayMissingValuePlaceholder' => true, + 'isRemoveSelectedIcon' => true, + ]; + } +} diff --git a/app/code/Magento/Catalog/Ui/Component/UrlInput/Product.php b/app/code/Magento/Catalog/Ui/Component/UrlInput/Product.php new file mode 100644 index 0000000000000..be73940237db4 --- /dev/null +++ b/app/code/Magento/Catalog/Ui/Component/UrlInput/Product.php @@ -0,0 +1,57 @@ +urlBuilder = $urlBuilder; + } + + /** + * {@inheritdoc} + */ + public function getConfig(): array + { + return [ + 'label' => __('Product'), + 'component' => 'Magento_Catalog/js/components/product-ui-select', + 'disableLabel' => true, + 'filterOptions' => true, + 'searchOptions' => true, + 'chipsEnabled' => true, + 'levelsVisibility' => '1', + 'options' => [], + 'sortOrder' => 25, + 'multiple' => false, + 'closeBtn' => true, + 'template' => 'ui/grid/filters/elements/ui-select', + 'searchUrl' => $this->urlBuilder->getUrl('catalog/product/search'), + 'filterPlaceholder' => __('Product Name or SKU'), + 'isDisplayEmptyPlaceholder' => true, + 'emptyOptionsHtml' => __('Start typing to find products'), + 'missingValuePlaceholder' => __('Product with ID: %s doesn\'t exist'), + 'isDisplayMissingValuePlaceholder' => true, + 'isRemoveSelectedIcon' => true, + 'validationUrl' => $this->urlBuilder->getUrl('catalog/product/getSelected'), + ]; + } +} diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/AddSearchKeyConditionToCollection.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/AddSearchKeyConditionToCollection.php new file mode 100644 index 0000000000000..2f90e18074fc6 --- /dev/null +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/AddSearchKeyConditionToCollection.php @@ -0,0 +1,37 @@ +addFieldToFilter( + ProductInterface::NAME, + $condition['fulltext'] + )->addFieldToFilter( + ProductInterface::SKU, + $condition['fulltext'] + ); + } + } +} diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Attributes.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Attributes.php index aec6549f400fc..683a96133ad30 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Attributes.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Attributes.php @@ -182,6 +182,11 @@ private function customizeAddAttributeModal(array $meta) . '.create_new_attribute_modal', 'actionName' => 'toggleModal', ], + [ + 'targetName' => 'product_form.product_form.add_attribute_modal' + . '.create_new_attribute_modal.product_attribute_add_form', + 'actionName' => 'destroyInserted' + ], [ 'targetName' => 'product_form.product_form.add_attribute_modal' diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CustomOptions.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CustomOptions.php index 7995926d27de5..7196a721f1d02 100755 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CustomOptions.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CustomOptions.php @@ -1046,6 +1046,7 @@ protected function getFileExtensionFieldConfig($sortOrder) 'data' => [ 'config' => [ 'label' => __('Compatible File Extensions'), + 'notice' => __('Enter separated extensions, like: png, jpg, gif.'), 'componentType' => Field::NAME, 'formElement' => Input::NAME, 'dataScope' => static::FIELD_FILE_EXTENSION_NAME, diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav.php index ee6d483c9d4fb..b216ee8c9c547 100755 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav.php @@ -31,6 +31,8 @@ use Magento\Ui\Component\Form\Fieldset; use Magento\Ui\DataProvider\Mapper\FormElement as FormElementMapper; use Magento\Ui\DataProvider\Mapper\MetaProperties as MetaPropertiesMapper; +use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Eav\CompositeConfigProcessor; +use Magento\Framework\App\Config\ScopeConfigInterface; /** * Class Eav @@ -188,6 +190,17 @@ class Eav extends AbstractModifier private $localeCurrency; /** + * @var CompositeConfigProcessor + */ + private $wysiwygConfigProcessor; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * Eav constructor. * @param LocatorInterface $locator * @param CatalogEavValidationRules $catalogEavValidationRules * @param Config $eavConfig @@ -207,6 +220,8 @@ class Eav extends AbstractModifier * @param DataPersistorInterface $dataPersistor * @param array $attributesToDisable * @param array $attributesToEliminate + * @param CompositeConfigProcessor|null $wysiwygConfigProcessor + * @param ScopeConfigInterface|null $scopeConfig * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -228,7 +243,9 @@ public function __construct( ScopeOverriddenValue $scopeOverriddenValue, DataPersistorInterface $dataPersistor, $attributesToDisable = [], - $attributesToEliminate = [] + $attributesToEliminate = [], + CompositeConfigProcessor $wysiwygConfigProcessor = null, + ScopeConfigInterface $scopeConfig = null ) { $this->locator = $locator; $this->catalogEavValidationRules = $catalogEavValidationRules; @@ -249,6 +266,10 @@ public function __construct( $this->dataPersistor = $dataPersistor; $this->attributesToDisable = $attributesToDisable; $this->attributesToEliminate = $attributesToEliminate; + $this->wysiwygConfigProcessor = $wysiwygConfigProcessor ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(CompositeConfigProcessor::class); + $this->scopeConfig = $scopeConfig ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(ScopeConfigInterface::class); } /** @@ -572,14 +593,13 @@ private function isProductExists() public function setupAttributeMeta(ProductAttributeInterface $attribute, $groupCode, $sortOrder) { $configPath = ltrim(static::META_CONFIG_PATH, ArrayManager::DEFAULT_PATH_DELIMITER); - $meta = $this->arrayManager->set($configPath, [], [ 'dataType' => $attribute->getFrontendInput(), 'formElement' => $this->getFormElementsMapValue($attribute->getFrontendInput()), 'visible' => $attribute->getIsVisible(), 'required' => $attribute->getIsRequired(), 'notice' => $attribute->getNote() === null ? null : __($attribute->getNote()), - 'default' => (!$this->isProductExists()) ? $attribute->getDefaultValue() : null, + 'default' => (!$this->isProductExists()) ? $this->getAttributeDefaultValue($attribute) : null, 'label' => __($attribute->getDefaultFrontendLabel()), 'code' => $attribute->getAttributeCode(), 'source' => $groupCode, @@ -645,6 +665,24 @@ public function setupAttributeMeta(ProductAttributeInterface $attribute, $groupC return $meta; } + /** + * Returns attribute default value, based on db setting or setting in the system configuration + * @param ProductAttributeInterface $attribute + * @return null|string + */ + private function getAttributeDefaultValue(ProductAttributeInterface $attribute) + { + if ($attribute->getAttributeCode() === 'page_layout') { + $defaultValue = $this->scopeConfig->getValue( + 'web/default_layouts/default_product_layout', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + $this->storeManager->getStore() + ); + $attribute->setDefaultValue($defaultValue); + } + return $attribute->getDefaultValue(); + } + /** * @param ProductAttributeInterface $attribute * @param array $meta @@ -779,13 +817,7 @@ private function customizeWysiwyg(ProductAttributeInterface $attribute, array $m $meta['arguments']['data']['config']['formElement'] = WysiwygElement::NAME; $meta['arguments']['data']['config']['wysiwyg'] = true; - $meta['arguments']['data']['config']['wysiwygConfigData'] = [ - 'add_variables' => false, - 'add_widgets' => false, - 'add_directives' => true, - 'use_container' => true, - 'container_class' => 'hor-scroll', - ]; + $meta['arguments']['data']['config']['wysiwygConfigData'] = $this->wysiwygConfigProcessor->process($attribute); return $meta; } diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/CompositeConfigProcessor.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/CompositeConfigProcessor.php new file mode 100644 index 0000000000000..5513af9d98e7d --- /dev/null +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/CompositeConfigProcessor.php @@ -0,0 +1,58 @@ +logger = $logger; + $this->eavWysiwygDataProcessors = $eavWysiwygDataProcessors; + } + + /** + * {@inheritdoc} + */ + public function process(\Magento\Catalog\Api\Data\ProductAttributeInterface $attribute) + { + $wysiwygConfigData = []; + + foreach ($this->eavWysiwygDataProcessors as $processor) { + if (!$processor instanceof WysiwygConfigDataProcessorInterface) { + $this->logger->critical( + __( + 'Processor %1 doesn\'t implement WysiwygConfigDataProcessorInterface. It will be skipped', + get_class($processor) + ) + ); + continue; + } + + $wysiwygConfigData = array_merge_recursive($wysiwygConfigData, $processor->process($attribute)); + } + + return $wysiwygConfigData; + } +} diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/WysiwygConfigDataProcessor.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/WysiwygConfigDataProcessor.php new file mode 100644 index 0000000000000..d301a45d14ff5 --- /dev/null +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/WysiwygConfigDataProcessor.php @@ -0,0 +1,29 @@ + false, + 'add_widgets' => false, + 'add_directives' => true, + 'use_container' => true, + 'container_class' => 'hor-scroll', + ]; + } +} diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/WysiwygConfigDataProcessorInterface.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/WysiwygConfigDataProcessorInterface.php new file mode 100644 index 0000000000000..64faef7ba2761 --- /dev/null +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Eav/WysiwygConfigDataProcessorInterface.php @@ -0,0 +1,23 @@ +collection = $collectionFactory->create(); $this->addFieldStrategies = $addFieldStrategies; $this->addFilterStrategies = $addFilterStrategies; + $this->modifiersPool = $modifiersPool ?: ObjectManager::getInstance()->get(PoolInterface::class); } /** @@ -72,10 +81,16 @@ public function getData() } $items = $this->getCollection()->toArray(); - return [ + $data = [ 'totalRecords' => $this->getCollection()->getSize(), 'items' => array_values($items), ]; + + /** @var ModifierInterface $modifier */ + foreach ($this->modifiersPool->getModifiersInstances() as $modifier) { + $data = $modifier->modifyData($data); + } + return $data; } /** @@ -110,4 +125,19 @@ public function addFilter(\Magento\Framework\Api\Filter $filter) parent::addFilter($filter); } } + + /** + * @inheritdoc + */ + public function getMeta() + { + $meta = parent::getMeta(); + + /** @var ModifierInterface $modifier */ + foreach ($this->modifiersPool->getModifiersInstances() as $modifier) { + $meta = $modifier->modifyMeta($meta); + } + + return $meta; + } } diff --git a/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php b/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php new file mode 100644 index 0000000000000..4c3945569db2a --- /dev/null +++ b/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php @@ -0,0 +1,83 @@ +catalogData = $catalogData; + $this->scopeConfig = $scopeConfig; + } + + /** + * Returns category URL suffix. + * + * @return mixed + */ + public function getCategoryUrlSuffix() + { + return $this->scopeConfig->getValue( + 'catalog/seo/category_url_suffix', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Checks if categories path is used for product URLs. + * + * @return bool + */ + public function isCategoryUsedInProductUrl(): bool + { + return $this->scopeConfig->isSetFlag( + 'catalog/seo/product_use_categories', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Returns product name. + * + * @return string + */ + public function getProductName(): string + { + return $this->catalogData->getProduct() !== null + ? $this->catalogData->getProduct()->getName() + : ''; + } +} diff --git a/app/code/Magento/Catalog/composer.json b/app/code/Magento/Catalog/composer.json index 94d4f2d492bba..44d051933909b 100644 --- a/app/code/Magento/Catalog/composer.json +++ b/app/code/Magento/Catalog/composer.json @@ -5,39 +5,38 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-catalog-rule": "100.3.*", - "magento/module-catalog-url-rewrite": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-cms": "101.2.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-indexer": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-msrp": "100.3.*", - "magento/module-page-cache": "100.3.*", - "magento/module-product-alert": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-url-rewrite": "100.3.*", - "magento/module-widget": "100.3.*", - "magento/module-wishlist": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog-inventory": "*", + "magento/module-catalog-rule": "*", + "magento/module-catalog-url-rewrite": "*", + "magento/module-checkout": "*", + "magento/module-cms": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-indexer": "*", + "magento/module-media-storage": "*", + "magento/module-msrp": "*", + "magento/module-page-cache": "*", + "magento/module-product-alert": "*", + "magento/module-quote": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-theme": "*", + "magento/module-ui": "*", + "magento/module-url-rewrite": "*", + "magento/module-widget": "*", + "magento/module-wishlist": "*" }, "suggest": { - "magento/module-cookie": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-catalog-sample-data": "Sample Data version:100.3.*" + "magento/module-cookie": "*", + "magento/module-sales": "*", + "magento/module-catalog-sample-data": "*" }, "type": "magento2-module", - "version": "101.2.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Catalog/etc/adminhtml/di.xml b/app/code/Magento/Catalog/etc/adminhtml/di.xml index 9739ee28a6dae..10251d35dffcd 100644 --- a/app/code/Magento/Catalog/etc/adminhtml/di.xml +++ b/app/code/Magento/Catalog/etc/adminhtml/di.xml @@ -92,6 +92,7 @@ Magento\Catalog\Ui\DataProvider\Product\AddStoreFieldToCollection \Magento\Catalog\Ui\DataProvider\Product\ProductCollectionFactory + Magento\Catalog\Ui\DataProvider\Product\Listing\Modifier\Pool @@ -165,6 +166,7 @@ Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Pool + product_form.product_form @@ -193,4 +195,29 @@ + + + + Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Eav\WysiwygConfigDataProcessor + + + + + + web/default_layouts/default_category_layout + + + + + + Magento\Catalog\Ui\Component\UrlInput\Product + Magento\Catalog\Ui\Component\UrlInput\Category + + + + + + Magento\Catalog\Ui\DataProvider\Product\AddSearchKeyConditionToCollection + + diff --git a/app/code/Magento/Catalog/etc/adminhtml/system.xml b/app/code/Magento/Catalog/etc/adminhtml/system.xml index 2ce40386f0553..e6dbb10e811b4 100644 --- a/app/code/Magento/Catalog/etc/adminhtml/system.xml +++ b/app/code/Magento/Catalog/etc/adminhtml/system.xml @@ -36,10 +36,10 @@ - + - + @@ -83,7 +83,7 @@ Magento\Catalog\Model\Indexer\Product\Flat\System\Config\Mode Magento\Config\Model\Config\Source\Yesno - + Magento\Catalog\Model\Config\Source\ListSort @@ -186,5 +186,18 @@ +
+ + + + + Magento\Catalog\Model\Config\Source\LayoutList + + + + Magento\Catalog\Model\Config\Source\LayoutList + + +
diff --git a/app/code/Magento/Catalog/etc/config.xml b/app/code/Magento/Catalog/etc/config.xml index 3569c0a27b83f..1d92197e390a1 100644 --- a/app/code/Magento/Catalog/etc/config.xml +++ b/app/code/Magento/Catalog/etc/config.xml @@ -56,6 +56,7 @@ tmp + media/catalog/product/cache/ catalog custom_options diff --git a/app/code/Magento/Catalog/etc/db_schema.xml b/app/code/Magento/Catalog/etc/db_schema.xml index 5160eeff9abff..6efd2d1c1eafe 100644 --- a/app/code/Magento/Catalog/etc/db_schema.xml +++ b/app/code/Magento/Catalog/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
@@ -1639,7 +1639,7 @@ - + diff --git a/app/code/Magento/Catalog/etc/di.xml b/app/code/Magento/Catalog/etc/di.xml index a7502d12e1f7b..875c3fecf37c6 100644 --- a/app/code/Magento/Catalog/etc/di.xml +++ b/app/code/Magento/Catalog/etc/di.xml @@ -363,6 +363,7 @@ Magento\Catalog\Pricing\Price\BasePrice Magento\Catalog\Pricing\Price\CustomOptionPrice Magento\Catalog\Pricing\Price\ConfiguredPrice + Magento\Catalog\Pricing\Price\ConfiguredRegularPrice @@ -552,16 +553,10 @@ - Magento\Catalog\Console\Command\ImagesResizeCommand Magento\Catalog\Console\Command\ProductAttributesCleanUp - - - Magento\Catalog\Api\ProductRepositoryInterface\Proxy - - @@ -692,7 +687,7 @@ string int[] Magento\CatalogInventory\Api\Data\StockItemInterface[] - Magento\Eav\Api\Data\AttributeOptionInterface + string[] @@ -1073,4 +1068,15 @@ Magento\Catalog\Api\ProductRepositoryInterface\Proxy + + + indexer + + + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\ConditionBuilder\EavAttributeCondition + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\ConditionBuilder\NativeAttributeCondition + + diff --git a/app/code/Magento/Catalog/etc/events.xml b/app/code/Magento/Catalog/etc/events.xml index 63bd574894339..5bcdc88369064 100644 --- a/app/code/Magento/Catalog/etc/events.xml +++ b/app/code/Magento/Catalog/etc/events.xml @@ -60,4 +60,7 @@ + + + diff --git a/app/code/Magento/Catalog/etc/frontend/di.xml b/app/code/Magento/Catalog/etc/frontend/di.xml index 3989a62a56cc4..659ba2b731366 100644 --- a/app/code/Magento/Catalog/etc/frontend/di.xml +++ b/app/code/Magento/Catalog/etc/frontend/di.xml @@ -100,4 +100,7 @@ + + + diff --git a/app/code/Magento/Catalog/etc/webapi_rest/di.xml b/app/code/Magento/Catalog/etc/webapi_rest/di.xml index 1d2b013f2035d..a0d3e850b3c64 100644 --- a/app/code/Magento/Catalog/etc/webapi_rest/di.xml +++ b/app/code/Magento/Catalog/etc/webapi_rest/di.xml @@ -8,7 +8,6 @@ - @@ -16,4 +15,7 @@ + + + diff --git a/app/code/Magento/Catalog/etc/webapi_soap/di.xml b/app/code/Magento/Catalog/etc/webapi_soap/di.xml index 98a8ef4de8408..a0d3e850b3c64 100644 --- a/app/code/Magento/Catalog/etc/webapi_soap/di.xml +++ b/app/code/Magento/Catalog/etc/webapi_soap/di.xml @@ -15,4 +15,7 @@ + + + diff --git a/app/code/Magento/Catalog/etc/widget.xml b/app/code/Magento/Catalog/etc/widget.xml index ef9009549da24..a11d206e2ce42 100644 --- a/app/code/Magento/Catalog/etc/widget.xml +++ b/app/code/Magento/Catalog/etc/widget.xml @@ -64,7 +64,11 @@ - 86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache. + + If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
Widget will not show products that begin to match the specified conditions until cache is refreshed.]]> +
diff --git a/app/code/Magento/Catalog/i18n/en_US.csv b/app/code/Magento/Catalog/i18n/en_US.csv index 9745ff0bdac0c..0727d03df1340 100644 --- a/app/code/Magento/Catalog/i18n/en_US.csv +++ b/app/code/Magento/Catalog/i18n/en_US.csv @@ -705,7 +705,13 @@ Template,Template "New Products Names Only Template","New Products Names Only Template" "New Products Images Only Template","New Products Images Only Template" "Cache Lifetime (Seconds)","Cache Lifetime (Seconds)" -"86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache.","86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache." +"Time in seconds between the widget updates. +
If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
Widget will not show products that begin to match the specified conditions until cache is refreshed." +, +"Time in seconds between the widget updates. +
If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
Widget will not show products that begin to match the specified conditions until cache is refreshed." "Catalog Product Link","Catalog Product Link" "Link to a Specified Product","Link to a Specified Product" "Select Product...","Select Product..." @@ -797,3 +803,7 @@ Details,Details "Recently Viewed","Recently Viewed" "The value of Admin must be unique.", "The value of Admin must be unique." "The value of Admin must be unique. (%1)", "The value of Admin must be unique. (%1)" +"Product Name or SKU", "Product Name or SKU" +"Start typing to find products", "Start typing to find products" +"Product with ID: (%1) doesn't exist", "Product with ID: (%1) doesn't exist" +"Category with ID: (%1) doesn't exist", "Category with ID: (%1) doesn't exist" \ No newline at end of file diff --git a/app/code/Magento/Catalog/view/adminhtml/requirejs-config.js b/app/code/Magento/Catalog/view/adminhtml/requirejs-config.js index 5ffc587f65bec..9053c700d1a3b 100644 --- a/app/code/Magento/Catalog/view/adminhtml/requirejs-config.js +++ b/app/code/Magento/Catalog/view/adminhtml/requirejs-config.js @@ -6,12 +6,13 @@ var config = { map: { '*': { - categoryForm: 'Magento_Catalog/catalog/category/form', - newCategoryDialog: 'Magento_Catalog/js/new-category-dialog', - categoryTree: 'Magento_Catalog/js/category-tree', - productGallery: 'Magento_Catalog/js/product-gallery', - baseImage: 'Magento_Catalog/catalog/base-image-uploader', - productAttributes: 'Magento_Catalog/catalog/product-attributes' + categoryForm: 'Magento_Catalog/catalog/category/form', + newCategoryDialog: 'Magento_Catalog/js/new-category-dialog', + categoryTree: 'Magento_Catalog/js/category-tree', + productGallery: 'Magento_Catalog/js/product-gallery', + baseImage: 'Magento_Catalog/catalog/base-image-uploader', + productAttributes: 'Magento_Catalog/catalog/product-attributes', + categoryCheckboxTree: 'Magento_Catalog/js/category-checkbox-tree' } }, deps: [ diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml index 740d389735974..00a1580923a7b 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml @@ -5,187 +5,33 @@ */ // @codingStandardsIgnoreFile - -?> - - -
- - - categoryLoader.buildHash = function(node) + diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/set/main.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/set/main.phtml index d5dfe845e54c2..54b945b48c104 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/set/main.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/set/main.phtml @@ -315,7 +315,7 @@ }, validateGroupName : function(name, exceptNodeId) { - name = name.strip(); + name = name.strip().escapeHTML(); var result = true; if (name === '') { result = false; diff --git a/app/code/Magento/Catalog/view/adminhtml/ui_component/category_form.xml b/app/code/Magento/Catalog/view/adminhtml/ui_component/category_form.xml index 130e7b169ccfe..ee4638670f60e 100644 --- a/app/code/Magento/Catalog/view/adminhtml/ui_component/category_form.xml +++ b/app/code/Magento/Catalog/view/adminhtml/ui_component/category_form.xml @@ -173,13 +173,14 @@ Magento_Catalog/image-preview Media Gallery + catalog/category jpg jpeg gif png 4194304 - + @@ -462,7 +463,7 @@ - + string diff --git a/app/code/Magento/Catalog/view/adminhtml/ui_component/product_attribute_add_form.xml b/app/code/Magento/Catalog/view/adminhtml/ui_component/product_attribute_add_form.xml index 44e13da69fc3b..6c5d37a92ea4a 100644 --- a/app/code/Magento/Catalog/view/adminhtml/ui_component/product_attribute_add_form.xml +++ b/app/code/Magento/Catalog/view/adminhtml/ui_component/product_attribute_add_form.xml @@ -152,7 +152,6 @@ true true container - attribute_options.position @@ -189,12 +188,8 @@ - - true - text false - position diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/category-checkbox-tree.js b/app/code/Magento/Catalog/view/adminhtml/web/js/category-checkbox-tree.js new file mode 100644 index 0000000000000..bc44128663cd0 --- /dev/null +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/category-checkbox-tree.js @@ -0,0 +1,271 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/* global Ext, varienWindowOnload, varienElementMethods */ + +define([ + 'jquery', + 'prototype', + 'extjs/ext-tree-checkbox', + 'mage/adminhtml/form' +], function (jQuery) { + 'use strict'; + + return function (config) { + var tree, + options = { + dataUrl: config.dataUrl, + divId: config.divId, + rootVisible: config.rootVisible, + useAjax: config.useAjax, + currentNodeId: config.currentNodeId, + jsFormObject: window[config.jsFormObject], + name: config.name, + checked: config.checked, + allowDrop: config.allowDrop, + rootId: config.rootId, + expanded: config.expanded, + categoryId: config.categoryId, + treeJson: config.treeJson + }, + data = {}, + parameters = {}, + root = {}, + len = 0, + key = ''; + + /** + * Fix ext compatibility with prototype 1.6 + */ + Ext.lib.Event.getTarget = function (e) { + var ee = e.browserEvent || e; + + return ee.target ? Event.element(ee) : null; + }; + + /** + * @param {Object} el + * @param {Object} nodeConfig + */ + Ext.tree.TreePanel.Enhanced = function (el, nodeConfig) { + Ext.tree.TreePanel.Enhanced.superclass.constructor.call(this, el, nodeConfig); + }; + + Ext.extend(Ext.tree.TreePanel.Enhanced, Ext.tree.TreePanel, { + /** + * @param {Object} treeConfig + * @param {Boolean} firstLoad + */ + loadTree: function (treeConfig, firstLoad) { + parameters = treeConfig.parameters, + data = treeConfig.data, + root = new Ext.tree.TreeNode(parameters); + + if (typeof parameters.rootVisible !== 'undefined') { + this.rootVisible = parameters.rootVisible * 1; + } + + this.nodeHash = {}; + this.setRootNode(root); + + if (firstLoad) { + this.addListener('click', this.categoryClick.createDelegate(this)); + } + + this.loader.buildCategoryTree(root, data); + this.el.dom.innerHTML = ''; + // render the tree + this.render(); + }, + + /** + * @param {Object} node + */ + categoryClick: function (node) { + node.getUI().check(!node.getUI().checked()); + } + }); + + jQuery(function () { + var categoryLoader = new Ext.tree.TreeLoader({ + dataUrl: config.dataUrl + }); + + /** + * @param {Object} response + * @param {Object} parent + * @param {Function} callback + */ + categoryLoader.processResponse = function (response, parent, callback) { + config = JSON.parse(response.responseText); + + this.buildCategoryTree(parent, config); + + if (typeof callback === 'function') { + callback(this, parent); + } + }; + + /** + * @param {Object} nodeConfig + * @returns {Object} + */ + categoryLoader.createNode = function (nodeConfig) { + var node; + + nodeConfig.uiProvider = Ext.tree.CheckboxNodeUI; + + if (nodeConfig.children && !nodeConfig.children.length) { + delete nodeConfig.children; + node = new Ext.tree.AsyncTreeNode(nodeConfig); + } else { + node = new Ext.tree.TreeNode(nodeConfig); + } + + return node; + }; + + /** + * @param {Object} parent + * @param {Object} nodeConfig + * @param {Integer} i + */ + categoryLoader.processCategoryTree = function (parent, nodeConfig, i) { + var node, + _node = {}; + + nodeConfig[i].uiProvider = Ext.tree.CheckboxNodeUI; + + _node = Object.clone(nodeConfig[i]); + + if (_node.children && !_node.children.length) { + delete _node.children; + node = new Ext.tree.AsyncTreeNode(_node); + } else { + node = new Ext.tree.TreeNode(nodeConfig[i]); + } + parent.appendChild(node); + node.loader = node.getOwnerTree().loader; + + if (_node.children) { + categoryLoader.buildCategoryTree(node, _node.children); + } + }; + + /** + * @param {Object} parent + * @param {Object} nodeConfig + * @returns {void} + */ + categoryLoader.buildCategoryTree = function (parent, nodeConfig) { + var j = 0; + + if (!nodeConfig) { + return null; + } + + if (parent && nodeConfig && nodeConfig.length) { + for (j = 0; j < nodeConfig.length; j++) { + categoryLoader.processCategoryTree(parent, nodeConfig, j); + } + } + }; + + /** + * + * @param {Object} hash + * @param {Object} node + * @returns {Object} + */ + categoryLoader.buildHashChildren = function (hash, node) { + var j = 0; + + if (node.childNodes.length > 0 || node.loaded === false && node.loading === false) { + hash.children = []; + + for (j = 0, len = node.childNodes.length; j < len; j++) { + hash.children = hash.children ? hash.children : []; + hash.children.push(this.buildHash(node.childNodes[j])); + } + } + + return hash; + }; + + /** + * @param {Object} node + * @returns {Object} + */ + categoryLoader.buildHash = function (node) { + var hash = {}; + + hash = this.toArray(node.attributes); + + return categoryLoader.buildHashChildren(hash, node); + }; + + /** + * @param {Object} attributes + * @returns {Object} + */ + categoryLoader.toArray = function (attributes) { + data = {}; + + for (key in attributes) { + + if (attributes[key]) { + data[key] = attributes[key]; + } + } + + return data; + }; + + categoryLoader.on('beforeload', function (treeLoader, node) { + treeLoader.baseParams.id = node.attributes.id; + }); + + categoryLoader.on('load', function () { + varienWindowOnload(); + }); + + tree = new Ext.tree.TreePanel.Enhanced(options.divId, { + animate: false, + loader: categoryLoader, + enableDD: false, + containerScroll: true, + selModel: new Ext.tree.CheckNodeMultiSelectionModel(), + rootVisible: options.rootVisible, + useAjax: options.useAjax, + currentNodeId: options.currentNodeId, + addNodeTo: false, + rootUIProvider: Ext.tree.CheckboxNodeUI + }); + + tree.on('check', function (node) { + options.jsFormObject.updateElement.value = this.getChecked().join(', '); + varienElementMethods.setHasChanges(node.getUI().checkbox); + }, tree); + + // set the root node + //jscs:disable requireCamelCaseOrUpperCaseIdentifiers + parameters = { + text: options.name, + draggable: false, + checked: options.checked, + uiProvider: Ext.tree.CheckboxNodeUI, + allowDrop: options.allowDrop, + id: options.rootId, + expanded: options.expanded, + category_id: options.categoryId + }; + //jscs:enable requireCamelCaseOrUpperCaseIdentifiers + + tree.loadTree({ + parameters: parameters, data: options.treeJson + }, true); + }); + }; +}); diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/category-tree.js b/app/code/Magento/Catalog/view/adminhtml/web/js/category-tree.js index 99b1252b8f781..9c66ce577a2fd 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/js/category-tree.js +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/category-tree.js @@ -5,9 +5,10 @@ define([ 'jquery', + 'mageUtils', 'jquery/ui', 'jquery/jstree/jquery.jstree' -], function ($) { +], function ($, utils) { 'use strict'; $.widget('mage.categoryTree', { @@ -90,7 +91,7 @@ define([ } result = { data: { - title: node.name + ' (' + node['product_count'] + ')' + title: utils.unescape(node.name) + ' (' + node['product_count'] + ')' }, attr: { 'class': node.cls + (!!node.disabled ? ' disabled' : '') //eslint-disable-line no-extra-boolean-cast diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/components/product-ui-select.js b/app/code/Magento/Catalog/view/adminhtml/web/js/components/product-ui-select.js new file mode 100644 index 0000000000000..fb7ea7a5bcd69 --- /dev/null +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/components/product-ui-select.js @@ -0,0 +1,78 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'Magento_Ui/js/form/element/ui-select', + 'jquery', + 'underscore' +], function (Select, $, _) { + 'use strict'; + + return Select.extend({ + defaults: { + validationUrl: false, + loadedOption: [], + validationLoading: true + }, + + /** @inheritdoc */ + initialize: function () { + this._super(); + + this.validateInitialValue(); + + return this; + }, + + /** + * Validate initial value actually exists + */ + validateInitialValue: function () { + if (!_.isEmpty(this.value())) { + $.ajax({ + url: this.validationUrl, + type: 'GET', + dataType: 'json', + context: this, + data: { + productId: this.value() + }, + + /** @param {Object} response */ + success: function (response) { + if (!_.isEmpty(response)) { + this.options([response]); + this.loadedOption = response; + } + }, + + /** set empty array if error occurs */ + error: function () { + this.options([]); + }, + + /** stop loader */ + complete: function () { + this.validationLoading(false); + this.setCaption(); + } + }); + } else { + this.validationLoading(false); + } + }, + + /** @inheritdoc */ + getSelected: function () { + var options = this._super(); + + if (!_.isEmpty(this.loadedOption)) { + return this.value() === this.loadedOption.value ? [this.loadedOption] : options; + } + + return options; + } + }); +}); diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/form/element/input.js b/app/code/Magento/Catalog/view/adminhtml/web/js/form/element/input.js index 51ffeaea0fc0c..2f6703cc92eac 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/js/form/element/input.js +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/form/element/input.js @@ -54,9 +54,16 @@ define([ if (!_.isEmpty(this.suffixName) || _.isNumber(this.suffixName)) { suffixName = '.' + this.suffixName; } - this.dataScope = 'data.' + this.prefixName + '.' + this.elementName + suffixName; - this.links.value = this.provider + ':' + this.dataScope; + this.exportDataLink = 'data.' + this.prefixName + '.' + this.elementName + suffixName; + this.exports.value = this.provider + ':' + this.exportDataLink; + }, + + /** @inheritdoc */ + destroy: function () { + this._super(); + + this.source.remove(this.exportDataLink); }, /** diff --git a/app/code/Magento/Catalog/view/adminhtml/web/template/field-wysiwyg.html b/app/code/Magento/Catalog/view/adminhtml/web/template/field-wysiwyg.html deleted file mode 100644 index c9340eab2f2e6..0000000000000 --- a/app/code/Magento/Catalog/view/adminhtml/web/template/field-wysiwyg.html +++ /dev/null @@ -1,46 +0,0 @@ - -
- -
- - -
- - - - -
- - - - - -
-
- - diff --git a/app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html b/app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html index d4cfb02611416..9a52dcefa3042 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html +++ b/app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html @@ -7,7 +7,7 @@
- - getItems() as $_item): ?> - + + getItems() as $item): ?> + - - - helper('Magento\Catalog\Helper\Output'); ?> - - getItems() as $_item): ?> - + + helper('Magento\Catalog\Helper\Output'); ?> + + getItems() as $item): ?> + - getAttributes() as $_attribute): ?> - - - getItems() as $_item): ?> - - - - + getItems() as $item): ?> + + + + - - + } ?> + + + + +
+ helper('Magento\Catalog\Helper\Product\Compare');?> - @@ -41,35 +41,35 @@
- - getImage($_item, 'product_comparison_list')->toHtml() ?> + + getImage($item, 'product_comparison_list')->toHtml() ?> - - productAttribute($_item, $_item->getName(), 'name') ?> + + productAttribute($item, $item->getName(), 'name') ?> - getReviewsSummaryHtml($_item, 'short') ?> - getProductPrice($_item, '-compare-list-top') ?> -
+ getReviewsSummaryHtml($item, 'short') ?> + getProductPrice($item, '-compare-list-top') ?> +
- isSaleable()): ?> -
+ isSaleable()): ?> + getBlockHtml('formkey') ?>
- getIsSalable()): ?> + getIsSalable()): ?>
@@ -78,7 +78,7 @@
helper('Magento\Wishlist\Helper\Data')->isAllow()) : ?> @@ -89,39 +89,41 @@
- - escapeHtml($_attribute->getStoreLabel() ? $_attribute->getStoreLabel() : __($_attribute->getFrontendLabel())) ?> - - -
- getAttributeCode()) { - case "price": ?> - getProductPrice( - $_item, - '-compare-list-' . $_attribute->getCode() - ) - ?> - - getImage($_item, 'product_small_image')->toHtml(); ?> + getAttributes() as $attribute): ?> + + hasAttributeValueForProducts($attribute)): ?> +
+ + escapeHtml($attribute->getStoreLabel() ? $attribute->getStoreLabel() : __($attribute->getFrontendLabel())) ?> + + +
+ getAttributeCode()) { + case "price": ?> + getProductPrice( + $item, + '-compare-list-' . $attribute->getCode() + ) + ?> + + getImage($item, 'product_small_image')->toHtml(); ?> + + productAttribute($item, $block->getProductAttributeValue($item, $attribute), $attribute->getAttributeCode()) ?> - productAttribute($_item, $block->getProductAttributeValue($_item, $_attribute), $_attribute->getAttributeCode()) ?> - -
-
diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/gallery.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/gallery.phtml index 6133d55d676c3..c7abb0525b302 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/gallery.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/gallery.phtml @@ -25,10 +25,7 @@

helper('Magento\Catalog\Helper\Image') - ->init($block->getProduct(), 'product_page_image_large') - ->setImageFile($block->getImageFile()) - ->getUrl(); + $imageUrl = $block->getImageUrl(); ?> width="" alt="escapeHtml($block->getCurrentImage()->getLabel()) ?>" title="escapeHtml($block->getCurrentImage()->getLabel()) ?>" id="product-gallery-image" class="image" data-mage-init='{"catalogGallery":{}}'/>
diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml index 0352f7f276630..74a0b2d7cf1a3 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml @@ -13,7 +13,7 @@ getCustomAttributes() ?> src="getImageUrl() ?>" - width="getResizedImageWidth() ?>" - height="getResizedImageHeight() ?>" + max-width="getWidth() ?>" + max-height="getHeight() ?>" alt="stripTags($block->getLabel(), null, true) ?>"/> diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/list.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/list.phtml index b3f59991bed57..f7799b30436be 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/list.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/list.phtml @@ -27,12 +27,12 @@ $_helper = $this->helper('Magento\Catalog\Helper\Output'); getMode() == 'grid') { $viewMode = 'grid'; - $image = 'category_page_grid'; + $imageDisplayArea = 'category_page_grid'; $showDescription = false; $templateType = \Magento\Catalog\Block\Product\ReviewRendererInterface::SHORT_VIEW; } else { $viewMode = 'list'; - $image = 'category_page_list'; + $imageDisplayArea = 'category_page_list'; $showDescription = true; $templateType = \Magento\Catalog\Block\Product\ReviewRendererInterface::FULL_VIEW; } @@ -48,7 +48,7 @@ $_helper = $this->helper('Magento\Catalog\Helper\Output');
  • getImage($_product, $image); + $productImage = $block->getImage($_product, $imageDisplayArea); if ($pos != null) { $position = ' style="left:' . $productImage->getWidth() . 'px;' . 'top:' . $productImage->getHeight() . 'px;"'; @@ -77,7 +77,7 @@ $_helper = $this->helper('Magento\Catalog\Helper\Output');
    > isSaleable()): ?> getAddToCartPostParams($_product); ?> -
    + getBlockHtml('formkey') ?> diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/view/addtocart.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/view/addtocart.phtml index c7f577107095f..9c18a18ff5837 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/view/addtocart.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/view/addtocart.phtml @@ -40,17 +40,6 @@
    -isRedirectToCartEnabled()) : ?> - - - diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml index c8c915a3140da..9c5cce7865532 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/view/form.phtml @@ -16,12 +16,13 @@ getProduct(); ?>
    - getOptions()): ?> enctype="multipart/form-data"> + getBlockHtml('formkey') ?> getChildHtml('form_top') ?> hasOptions()):?> diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml index 5a064b33355a4..1bfa30478df8a 100644 --- a/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml +++ b/app/code/Magento/Catalog/view/frontend/templates/product/view/gallery.phtml @@ -47,21 +47,11 @@ "data": getGalleryImagesJson() ?>, "options": { "nav": "getVar("gallery/nav") ?>", - getVar("gallery/loop"))): ?> - "loop": getVar("gallery/loop") ?>, - - getVar("gallery/keyboard"))): ?> - "keyboard": getVar("gallery/keyboard") ?>, - - getVar("gallery/arrows"))): ?> - "arrows": getVar("gallery/arrows") ?>, - - getVar("gallery/allowfullscreen"))): ?> - "allowfullscreen": getVar("gallery/allowfullscreen") ?>, - - getVar("gallery/caption"))): ?> - "showCaption": getVar("gallery/caption") ?>, - + "loop": getVar("gallery/loop") ? 'true' : 'false' ?>, + "keyboard": getVar("gallery/keyboard") ? 'true' : 'false' ?>, + "arrows": getVar("gallery/arrows") ? 'true' : 'false' ?>, + "allowfullscreen": getVar("gallery/allowfullscreen") ? 'true' : 'false' ?>, + "showCaption": getVar("gallery/caption") ? 'true' : 'false' ?>, "width": "getImageAttribute('product_page_image_medium', 'width') ?>", "thumbwidth": "getImageAttribute('product_page_image_small', 'width') ?>", getImageAttribute('product_page_image_small', 'height') || $block->getImageAttribute('product_page_image_small', 'width')): ?> @@ -76,28 +66,18 @@ "transitionduration": getVar("gallery/transition/duration") ?>, "transition": "getVar("gallery/transition/effect") ?>", - getVar("gallery/navarrows"))): ?> - "navarrows": getVar("gallery/navarrows") ?>, - + "navarrows": getVar("gallery/navarrows") ? 'true' : 'false' ?>, "navtype": "getVar("gallery/navtype") ?>", "navdir": "getVar("gallery/navdir") ?>" }, "fullscreen": { "nav": "getVar("gallery/fullscreen/nav") ?>", - getVar("gallery/fullscreen/loop")): ?> - "loop": getVar("gallery/fullscreen/loop") ?>, - + "loop": getVar("gallery/fullscreen/loop") ? 'true' : 'false' ?>, "navdir": "getVar("gallery/fullscreen/navdir") ?>", - getVar("gallery/transition/navarrows")): ?> - "navarrows": getVar("gallery/fullscreen/navarrows") ?>, - + "navarrows": getVar("gallery/fullscreen/navarrows") ? 'true' : 'false' ?>, "navtype": "getVar("gallery/fullscreen/navtype") ?>", - getVar("gallery/fullscreen/arrows")): ?> - "arrows": getVar("gallery/fullscreen/arrows") ?>, - - getVar("gallery/fullscreen/caption")): ?> - "showCaption": getVar("gallery/fullscreen/caption") ?>, - + "arrows": getVar("gallery/fullscreen/arrows") ? 'true' : 'false' ?>, + "showCaption": getVar("gallery/fullscreen/caption") ? 'true' : 'false' ?>, getVar("gallery/fullscreen/transition/duration")): ?> "transitionduration": getVar("gallery/fullscreen/transition/duration") ?>, diff --git a/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js b/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js index eb54d8af001b3..b2da91c3b55c1 100644 --- a/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js +++ b/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js @@ -97,7 +97,11 @@ define([ success: function (res) { var eventData, parameters; - $(document).trigger('ajax:addToCart', form.data().productSku, form, res); + $(document).trigger('ajax:addToCart', { + 'sku': form.data().productSku, + 'form': form, + 'response': res + }); if (self.isLoaderEnabled()) { $('body').trigger(self.options.processStop); diff --git a/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js b/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js new file mode 100644 index 0000000000000..d3596cdd100ec --- /dev/null +++ b/app/code/Magento/Catalog/view/frontend/web/js/product/breadcrumbs.js @@ -0,0 +1,205 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'Magento_Theme/js/model/breadcrumb-list' +], function ($, breadcrumbList) { + 'use strict'; + + return function (widget) { + + $.widget('mage.breadcrumbs', widget, { + options: { + categoryUrlSuffix: '', + useCategoryPathInUrl: false, + product: '', + categoryItemSelector: '.category-item', + menuContainer: '[data-action="navigation"] > ul' + }, + + /** @inheritdoc */ + _init: function () { + var menu, + originalInit = this._super.bind(this); + + // render breadcrumbs after navigation menu is loaded. + menu = $(this.options.menuContainer).data('mageMenu'); + + if (typeof menu === 'undefined') { + $(this.options.menuContainer).on('menucreate', function () { + originalInit(); + }); + } else { + this._super(); + } + }, + + /** @inheritdoc */ + _render: function () { + this._appendCatalogCrumbs(); + this._super(); + }, + + /** + * Append category and product crumbs. + * + * @private + */ + _appendCatalogCrumbs: function () { + var categoryCrumbs = this._resolveCategoryCrumbs(); + + categoryCrumbs.forEach(function (crumbInfo) { + breadcrumbList.push(crumbInfo); + }); + + if (this.options.product) { + breadcrumbList.push(this._getProductCrumb()); + } + }, + + /** + * Resolve categories crumbs. + * + * @return Array + * @private + */ + _resolveCategoryCrumbs: function () { + var menuItem = this._resolveCategoryMenuItem(), + categoryCrumbs = []; + + if (menuItem !== null && menuItem.length) { + categoryCrumbs.unshift(this._getCategoryCrumb(menuItem)); + + while ((menuItem = this._getParentMenuItem(menuItem)) !== null) { + categoryCrumbs.unshift(this._getCategoryCrumb(menuItem)); + } + } + + return categoryCrumbs; + }, + + /** + * Returns crumb data. + * + * @param {Object} menuItem + * @return {Object} + * @private + */ + _getCategoryCrumb: function (menuItem) { + var categoryId = /(\d+)/i.exec(menuItem.attr('id'))[0], + categoryName = menuItem.text(), + categoryUrl = menuItem.attr('href'); + + return { + 'name': 'category' + categoryId, + 'label': categoryName, + 'link': categoryUrl, + 'title': '' + }; + }, + + /** + * Returns product crumb. + * + * @return {Object} + * @private + */ + _getProductCrumb: function () { + return { + 'name': 'product', + 'label': this.options.product, + 'link': '', + 'title': '' + }; + }, + + /** + * Find parent menu item for current. + * + * @param {Object} menuItem + * @return {Object|null} + * @private + */ + _getParentMenuItem: function (menuItem) { + var classes, + classNav, + parentClass, + parentMenuItem = null; + + if (!menuItem) { + return null; + } + + classes = menuItem.parent().attr('class'); + classNav = classes.match(/(nav\-)[0-9]+(\-[0-9]+)+/gi); + + if (classNav) { + classNav = classNav[0]; + parentClass = classNav.substr(0, classNav.lastIndexOf('-')); + + if (parentClass.lastIndexOf('-') !== -1) { + parentMenuItem = $(this.options.menuContainer).find('.' + parentClass + ' > a'); + parentMenuItem = parentMenuItem.length ? parentMenuItem : null; + } + } + + return parentMenuItem; + }, + + /** + * Returns category menu item. + * + * Tries to resolve category from url or from referrer as fallback and + * find menu item from navigation menu by category url. + * + * @return {Object|null} + * @private + */ + _resolveCategoryMenuItem: function () { + var categoryUrl = this._resolveCategoryUrl(), + menu = $(this.options.menuContainer), + categoryMenuItem = null; + + if (categoryUrl && menu.length) { + categoryMenuItem = menu.find( + this.options.categoryItemSelector + + ' > a[href="' + categoryUrl + '"]' + ); + } + + return categoryMenuItem; + }, + + /** + * Returns category url. + * + * @return {String} + * @private + */ + _resolveCategoryUrl: function () { + var categoryUrl; + + if (this.options.useCategoryPathInUrl) { + // In case category path is used in product url - resolve category url from current url. + categoryUrl = window.location.href.split('?')[0]; + categoryUrl = categoryUrl.substring(0, categoryUrl.lastIndexOf('/')) + + this.options.categoryUrlSuffix; + } else { + // In other case - try to resolve it from referrer (without parameters). + categoryUrl = document.referrer; + + if (categoryUrl.indexOf('?') > 0) { + categoryUrl = categoryUrl.substr(0, categoryUrl.indexOf('?')); + } + } + + return categoryUrl; + } + }); + + return $.mage.breadcrumbs; + }; +}); diff --git a/app/code/Magento/Catalog/view/frontend/web/js/product/storage/data-storage.js b/app/code/Magento/Catalog/view/frontend/web/js/product/storage/data-storage.js index 00cb7d2db6885..ab566a70a756d 100644 --- a/app/code/Magento/Catalog/view/frontend/web/js/product/storage/data-storage.js +++ b/app/code/Magento/Catalog/view/frontend/web/js/product/storage/data-storage.js @@ -228,7 +228,7 @@ define([ this.updateRequestConfig.data = queryBuilder.buildQuery(prepareAjaxParams); this.updateRequestConfig.data['store_id'] = store; this.updateRequestConfig.data['currency_code'] = currency; - $.ajax(this.updateRequestConfig).success(function (data) { + $.ajax(this.updateRequestConfig).done(function (data) { this.request = {}; this.providerHandler(getParsedDataFromServer(data)); }.bind(this)); diff --git a/app/code/Magento/CatalogAnalytics/composer.json b/app/code/Magento/CatalogAnalytics/composer.json index 35ebf21515875..5c97261d483d8 100644 --- a/app/code/Magento/CatalogAnalytics/composer.json +++ b/app/code/Magento/CatalogAnalytics/composer.json @@ -2,12 +2,11 @@ "name": "magento/module-catalog-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogGraphQl/Model/AttributesJoiner.php b/app/code/Magento/CatalogGraphQl/Model/AttributesJoiner.php new file mode 100644 index 0000000000000..a1f581743a645 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/AttributesJoiner.php @@ -0,0 +1,36 @@ +selectionSet->selections; + + /** @var FieldNode $field */ + foreach ($query as $field) { + if (!$collection->isAttributeAdded($field->name->value)) { + $collection->addAttributeToSelect($field->name->value); + } + } + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/CatalogProductTypeResolver.php b/app/code/Magento/CatalogGraphQl/Model/CatalogProductTypeResolver.php index 2fe2b18b19a67..f68f2c06a95e2 100644 --- a/app/code/Magento/CatalogGraphQl/Model/CatalogProductTypeResolver.php +++ b/app/code/Magento/CatalogGraphQl/Model/CatalogProductTypeResolver.php @@ -3,10 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model; -use Magento\Framework\GraphQl\Config\Data\TypeResolverInterface; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; /** * {@inheritdoc} @@ -16,7 +17,7 @@ class CatalogProductTypeResolver implements TypeResolverInterface /** * {@inheritdoc} */ - public function resolveType(array $data) + public function resolveType(array $data) : string { if (isset($data['type_id'])) { if ($data['type_id'] == 'simple') { @@ -25,5 +26,6 @@ public function resolveType(array $data) return 'VirtualProduct'; } } + return ''; } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Category/DepthCalculator.php b/app/code/Magento/CatalogGraphQl/Model/Category/DepthCalculator.php new file mode 100644 index 0000000000000..baa456c7821ed --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Category/DepthCalculator.php @@ -0,0 +1,34 @@ +selectionSet->selections ?? []; + $depth = count($selections) ? 1 : 0; + $childrenDepth = [0]; + foreach ($selections as $node) { + $childrenDepth[] = $this->calculate($node); + } + + return $depth + max($childrenDepth); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Category/Hydrator.php b/app/code/Magento/CatalogGraphQl/Model/Category/Hydrator.php new file mode 100644 index 0000000000000..da4ec37c51da4 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Category/Hydrator.php @@ -0,0 +1,56 @@ +flattener = $flattener; + $this->dataObjectProcessor = $dataObjectProcessor; + } + + /** + * Hydrate and flatten category object to flat array + * + * @param CategoryInterface $category + * @return array + */ + public function hydrateCategory(CategoryInterface $category) : array + { + $categoryData = $this->dataObjectProcessor->buildOutputDataArray($category, CategoryInterface::class); + $categoryData['id'] = $category->getId(); + $categoryData['product_count'] = $category->getProductCount(); + $categoryData['children'] = []; + $categoryData['available_sort_by'] = $category->getAvailableSortBy(); + return $this->flattener->flatten($categoryData); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Category/LevelCalculator.php b/app/code/Magento/CatalogGraphQl/Model/Category/LevelCalculator.php new file mode 100644 index 0000000000000..eb57873850b80 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Category/LevelCalculator.php @@ -0,0 +1,44 @@ +resourceConnection = $resourceConnection; + $this->resourceCategory = $resourceCategory; + } + + /** + * Calculate level data for root category ID specified in GraphQL request + * + * @param int $rootCategoryId + * @return int + */ + public function calculate(int $rootCategoryId) : int + { + $connection = $this->resourceConnection->getConnection(); + $select = $connection->select() + ->from($connection->getTableName('catalog_category_entity'), 'level') + ->where($this->resourceCategory->getLinkField() . " = ?", $rootCategoryId); + return (int) $connection->fetchOne($select); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/CategoryInterfaceTypeResolver.php b/app/code/Magento/CatalogGraphQl/Model/CategoryInterfaceTypeResolver.php new file mode 100644 index 0000000000000..9a3b4bcb0cf8d --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/CategoryInterfaceTypeResolver.php @@ -0,0 +1,26 @@ +mapper = $mapper; $this->typeLocator = $typeLocator; - $this->collectionFactory = $collectionFactory; + $this->collection = $collection; } /** @@ -57,24 +56,20 @@ public function __construct( * @throws GraphQlInputException * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function read($scope = null) + public function read($scope = null) : array { - $targetStructures = $this->mapper->getMappedTypes(\Magento\Catalog\Model\Product::ENTITY); + $typeNames = $this->mapper->getMappedTypes(\Magento\Catalog\Model\Product::ENTITY); $config =[]; - /** @var Collection $collection */ - $collection = $this->collectionFactory->create(); - $collection->addFieldToFilter('is_user_defined', '1'); - $collection->addFieldToFilter('attribute_code', ['neq' => 'cost']); /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute */ - foreach ($collection as $attribute) { + foreach ($this->collection->getAttributes() as $attribute) { $attributeCode = $attribute->getAttributeCode(); $locatedType = $this->typeLocator->getType( $attributeCode, \Magento\Catalog\Model\Product::ENTITY ) ?: 'String'; $locatedType = $locatedType === TypeProcessor::NORMALIZED_ANY_TYPE ? 'String' : ucfirst($locatedType); - foreach ($targetStructures as $structure) { - $config[$structure]['fields'][$attributeCode] = [ + foreach ($typeNames as $typeName) { + $config[$typeName]['fields'][$attributeCode] = [ 'name' => $attributeCode, 'type' => $locatedType, 'arguments' => [] diff --git a/app/code/Magento/CatalogGraphQl/Model/Config/CategoryAttributeReader.php b/app/code/Magento/CatalogGraphQl/Model/Config/CategoryAttributeReader.php new file mode 100644 index 0000000000000..0ca72d9ff9519 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Config/CategoryAttributeReader.php @@ -0,0 +1,103 @@ +typeLocator = $typeLocator; + $this->collectionFactory = $collectionFactory; + } + + /** + * Read configuration scope + * + * @param string|null $scope + * @return array + * @throws GraphQlInputException + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function read($scope = null) : array + { + $config =[]; + $data = []; + /** @var Collection $collection */ + $collection = $this->collectionFactory->create(); + /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute */ + foreach ($collection as $attribute) { + $attributeCode = $attribute->getAttributeCode(); + + if (in_array($attributeCode, self::$bannedSystemAttributes)) { + continue; + } + + $locatedType = $this->typeLocator->getType( + $attributeCode, + 'catalog_category' + ) ?: 'String'; + $locatedType = $locatedType === TypeProcessor::NORMALIZED_ANY_TYPE ? 'String' : ucfirst($locatedType); + $data['fields'][$attributeCode]['name'] = $attributeCode; + $data['fields'][$attributeCode]['type'] = $locatedType; + $data['fields'][$attributeCode]['arguments'] = []; + } + + $config['CategoryInterface'] = $data; + $config['CategoryTree'] = $data; + + return $config; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/CustomizableOptionTypeResolver.php b/app/code/Magento/CatalogGraphQl/Model/CustomizableOptionTypeResolver.php index ea8ea203c7f65..7fa3a87b9167b 100644 --- a/app/code/Magento/CatalogGraphQl/Model/CustomizableOptionTypeResolver.php +++ b/app/code/Magento/CatalogGraphQl/Model/CustomizableOptionTypeResolver.php @@ -3,11 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model; -use Magento\Framework\GraphQl\Config\Data\TypeResolverInterface; -use Magento\Framework\GraphQl\Type\Entity\MapperInterface; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\Entity\MapperInterface; /** * Resolve the CustomizableOptionType for graphql schema @@ -32,11 +33,12 @@ public function __construct(MapperInterface $mapper) /** * {@inheritDoc} */ - public function resolveType(array $data) + public function resolveType(array $data) : string { $map = $this->mapper->getMappedTypes(self::ENTITY_TYPE); if (isset($map[$data['type']])) { return $map[$data['type']]; } + return ''; } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Layer/CollectionProvider.php b/app/code/Magento/CatalogGraphQl/Model/Layer/CollectionProvider.php new file mode 100644 index 0000000000000..86645b0d36fdb --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Layer/CollectionProvider.php @@ -0,0 +1,56 @@ +collectionProcessor = $collectionProcessor; + $this->collectionFactory = $collectionFactory; + } + + /** + * @param \Magento\Catalog\Model\Category $category + * @return Collection + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function getCollection(\Magento\Catalog\Model\Category $category) : Collection + { + if (!$this->collection) { + $this->collection = $this->collectionFactory->create(); + } + return $this->collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Layer/Context.php b/app/code/Magento/CatalogGraphQl/Model/Layer/Context.php new file mode 100644 index 0000000000000..7298089c73e65 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Layer/Context.php @@ -0,0 +1,72 @@ +collectionProvider = $collectionProvider; + $this->stateKey = $stateKey; + $this->collectionFilter = $collectionFilter; + } + + /** + * @return ItemCollectionProviderInterface + */ + public function getCollectionProvider() : ItemCollectionProviderInterface + { + return $this->collectionProvider; + } + + /** + * @return StateKeyInterface + */ + public function getStateKey() : StateKeyInterface + { + return $this->stateKey; + } + + /** + * @return CollectionFilterInterface + */ + public function getCollectionFilter() : CollectionFilterInterface + { + return $this->collectionFilter; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/LayerFilterItemTypeResolver.php b/app/code/Magento/CatalogGraphQl/Model/LayerFilterItemTypeResolver.php new file mode 100644 index 0000000000000..44c5a08d0aa77 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/LayerFilterItemTypeResolver.php @@ -0,0 +1,29 @@ +typeResolvers = $typeResolvers; + } + + /** + * {@inheritdoc} + */ + public function resolveType(array $data) : string + { + /** @var TypeResolverInterface $typeResolver */ + foreach ($this->typeResolvers as $typeResolver) { + $resolvedType = $typeResolver->resolveType($data); + if ($resolvedType) { + return $resolvedType; + } + } + if (empty($resolvedType)) { + throw new GraphQlInputException( + __('Concrete type for %1 not implemented', ['ProductLinksInterface']) + ); + } + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/ProductInterfaceTypeResolverComposite.php b/app/code/Magento/CatalogGraphQl/Model/ProductInterfaceTypeResolverComposite.php index 22cd279ff0c3d..26dd3c8b4e683 100644 --- a/app/code/Magento/CatalogGraphQl/Model/ProductInterfaceTypeResolverComposite.php +++ b/app/code/Magento/CatalogGraphQl/Model/ProductInterfaceTypeResolverComposite.php @@ -3,11 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model; -use Magento\Framework\GraphQl\Config\Data\TypeResolverInterface; use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; /** * {@inheritdoc} @@ -31,7 +32,7 @@ public function __construct(array $productTypeNameResolvers = []) * {@inheritdoc} * @throws GraphQlInputException */ - public function resolveType(array $data) + public function resolveType(array $data) : string { $resolvedType = null; @@ -42,15 +43,13 @@ public function resolveType(array $data) ); } $resolvedType = $productTypeNameResolver->resolveType($data); - if ($resolvedType) { + if (!empty($resolvedType)) { return $resolvedType; } } - if (!$resolvedType) { - throw new GraphQlInputException( - __('Concrete type for %1 not implemented', ['ProductInterface']) - ); - } + throw new GraphQlInputException( + __('Concrete type for %1 not implemented', ['ProductInterface']) + ); } } diff --git a/app/code/Magento/CatalogGraphQl/Model/ProductLinkTypeResolverComposite.php b/app/code/Magento/CatalogGraphQl/Model/ProductLinkTypeResolverComposite.php index 70e0fb6804b10..937e3921758dc 100644 --- a/app/code/Magento/CatalogGraphQl/Model/ProductLinkTypeResolverComposite.php +++ b/app/code/Magento/CatalogGraphQl/Model/ProductLinkTypeResolverComposite.php @@ -3,11 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model; -use Magento\Framework\GraphQl\Config\Data\TypeResolverInterface; use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; /** * {@inheritdoc} @@ -31,7 +32,7 @@ public function __construct(array $productLinksTypeNameResolvers = []) * {@inheritdoc} * @throws GraphQlInputException */ - public function resolveType(array $data) + public function resolveType(array $data) : string { $resolvedType = null; diff --git a/app/code/Magento/CatalogGraphQl/Model/ProductLinksTypeResolver.php b/app/code/Magento/CatalogGraphQl/Model/ProductLinksTypeResolver.php index 635de09e52fbd..5a230ceed0ca4 100644 --- a/app/code/Magento/CatalogGraphQl/Model/ProductLinksTypeResolver.php +++ b/app/code/Magento/CatalogGraphQl/Model/ProductLinksTypeResolver.php @@ -3,10 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model; -use Magento\Framework\GraphQl\Config\Data\TypeResolverInterface; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; /** * {@inheritdoc} @@ -21,13 +22,14 @@ class ProductLinksTypeResolver implements TypeResolverInterface /** * {@inheritdoc} */ - public function resolveType(array $data) + public function resolveType(array $data) : string { - if (isset($data['type_id'])) { + if (isset($data['link_type'])) { $linkType = $data['link_type']; if (in_array($linkType, $this->linkTypes)) { return 'ProductLinks'; } } + return ''; } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category.php new file mode 100644 index 0000000000000..a17de7374534b --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category.php @@ -0,0 +1,121 @@ +collection = $collectionFactory->create(); + $this->dataObjectProcessor = $dataObjectProcessor; + $this->attributesJoiner = $attributesJoiner; + $this->customAttributesFlattener = $customAttributesFlattener; + $this->valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + $this->categoryIds = array_merge($this->categoryIds, $value[self::PRODUCT_CATEGORY_IDS_KEY]); + $that = $this; + + return $this->valueFactory->create(function () use ($that, $value, $info) { + $categories = []; + if (empty($that->categoryIds)) { + return []; + } + + if (!$this->collection->isLoaded()) { + $that->attributesJoiner->join($info->fieldASTs[0], $this->collection); + $this->collection->addIdFilter($this->categoryIds); + } + /** @var CategoryInterface | \Magento\Catalog\Model\Category $item */ + foreach ($this->collection as $item) { + if (in_array($item->getId(), $value[$that::PRODUCT_CATEGORY_IDS_KEY])) { + $categories[$item->getId()] = $this->dataObjectProcessor->buildOutputDataArray( + $item, + CategoryInterface::class + ); + $categories[$item->getId()] = $this->customAttributesFlattener + ->flatten($categories[$item->getId()]); + $categories[$item->getId()]['product_count'] = $item->getProductCount(); + } + } + + return $categories; + }); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php new file mode 100644 index 0000000000000..5927e747c2238 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php @@ -0,0 +1,107 @@ +productRepository = $productRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->filterQuery = $filterQuery; + $this->valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + $args['filter'] = [ + 'category_ids' => [ + 'eq' => $value['id'] + ] + ]; + $searchCriteria = $this->searchCriteriaBuilder->build($field->getName(), $args); + $searchCriteria->setCurrentPage($args['currentPage']); + $searchCriteria->setPageSize($args['pageSize']); + $searchResult = $this->filterQuery->getResult($searchCriteria, $info); + + //possible division by 0 + if ($searchCriteria->getPageSize()) { + $maxPages = ceil($searchResult->getTotalCount() / $searchCriteria->getPageSize()); + } else { + $maxPages = 0; + } + + $currentPage = $searchCriteria->getCurrentPage(); + if ($searchCriteria->getCurrentPage() > $maxPages && $searchResult->getTotalCount() > 0) { + $currentPage = new GraphQlInputException( + __( + 'currentPage value %1 specified is greater than the number of pages available.', + [$maxPages] + ) + ); + } + + $data = [ + 'total_count' => $searchResult->getTotalCount(), + 'items' => $searchResult->getProductsSearchResult(), + 'page_info' => [ + 'page_size' => $searchCriteria->getPageSize(), + 'current_page' => $currentPage + ] + ]; + + $result = function () use ($data) { + return $data; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/SortFields.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/SortFields.php new file mode 100644 index 0000000000000..ca68b29910118 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/SortFields.php @@ -0,0 +1,82 @@ +valueFactory = $valueFactory; + $this->catalogConfig = $catalogConfig; + $this->storeManager = $storeManager; + $this->sortbyAttributeSource = $sortbyAttributeSource; + } + + /** + * {@inheritDoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + $sortFieldsOptions = $this->sortbyAttributeSource->getAllOptions(); + array_walk( + $sortFieldsOptions, + function (&$option) { + $option['label'] = (string)$option['label']; + } + ); + $data = [ + 'default' => $this->catalogConfig->getProductListDefaultSortBy($this->storeManager->getStore()->getId()), + 'options' => $sortFieldsOptions, + ]; + + $result = function () use ($data) { + return $data; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php new file mode 100644 index 0000000000000..f631e5ff61d2e --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php @@ -0,0 +1,84 @@ +categoryTree = $categoryTree; + $this->valueFactory = $valueFactory; + } + + /** + * Assert that filters from search criteria are valid and retrieve root category id + * + * @param array $args + * @return int + * @throws GraphQlInputException + */ + private function assertFiltersAreValidAndGetCategoryRootIds(array $args) : int + { + if (!isset($args['id'])) { + throw new GraphQlInputException(__('"id for category should be specified')); + } + + return (int) $args['id']; + } + + /** + * {@inheritdoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + return $this->valueFactory->create(function () use ($value, $args, $field, $info) { + if (isset($value[$field->getName()])) { + return $value[$field->getName()]; + } + + $rootCategoryId = $this->assertFiltersAreValidAndGetCategoryRootIds($args); + $categoriesTree = $this->categoryTree->getTree($info, $rootCategoryId); + if (!empty($categoriesTree)) { + return current($categoriesTree); + } else { + return null; + } + }); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/DataProvider/Filters.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/DataProvider/Filters.php new file mode 100644 index 0000000000000..786d4f1ab867c --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/DataProvider/Filters.php @@ -0,0 +1,63 @@ +filtersProvider = $filtersProvider; + } + + /** + * Get layered navigation filters data + * + * @param string $layerType + * @return array + */ + public function getData(string $layerType) : array + { + $filtersData = []; + /** @var AbstractFilter $filter */ + foreach ($this->filtersProvider->getFilters($layerType) as $filter) { + if ($filter->getItemsCount()) { + $filterGroup = [ + 'name' => (string)$filter->getName(), + 'filter_items_count' => $filter->getItemsCount(), + 'request_var' => $filter->getRequestVar(), + ]; + /** @var \Magento\Catalog\Model\Layer\Filter\Item $filterItem */ + foreach ($filter->getItems() as $filterItem) { + $filterGroup['filter_items'][] = [ + 'label' => (string)$filterItem->getLabel(), + 'value_string' => $filterItem->getValueString(), + 'items_count' => $filterItem->getCount(), + ]; + } + $filtersData[] = $filterGroup; + } + } + return $filtersData; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/FilterableAttributesListFactory.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/FilterableAttributesListFactory.php new file mode 100644 index 0000000000000..86f40af2b2ed4 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/FilterableAttributesListFactory.php @@ -0,0 +1,51 @@ +objectManager = $objectManager; + } + + /** + * Create class instance with specified parameters + * + * @param string $type + * @param array $data + * @return FilterableAttributeListInterface + */ + public function create(string $type, array $data = []) : FilterableAttributeListInterface + { + if ($type === Resolver::CATALOG_LAYER_CATEGORY) { + return $this->objectManager->create(CategoryFilterableAttributeList::class, $data); + } elseif ($type === Resolver::CATALOG_LAYER_SEARCH) { + return $this->objectManager->create(FilterableAttributeList::class, $data); + } + throw new \InvalidArgumentException('Unknown filterable attribtues list type: ' . $type); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/FiltersProvider.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/FiltersProvider.php new file mode 100644 index 0000000000000..27b008005960b --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Layer/FiltersProvider.php @@ -0,0 +1,66 @@ +layerResolver = $layerResolver; + $this->filterableAttributesListFactory = $filterableAttributesListFactory; + $this->filterListFactory = $filterListFactory; + } + + /** + * Get layer type filters. + * + * @param string $layerType + * @return array + */ + public function getFilters(string $layerType) : array + { + $filterableAttributesList = $this->filterableAttributesListFactory->create( + $layerType + ); + $filterList = $this->filterListFactory->create( + [ + 'filterableAttributes' => $filterableAttributesList + ] + ); + return $filterList->getFilters($this->layerResolver->get()); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product.php new file mode 100644 index 0000000000000..8bf3335bbb9d8 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product.php @@ -0,0 +1,119 @@ +productDataProvider = $productDataProvider; + $this->valueFactory = $valueFactory; + $this->fieldTranslator = $fieldTranslator; + } + + /** + * {@inheritDoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + if (!isset($value['sku'])) { + throw new GraphQlInputException(__('No child sku found for product link.')); + } + $this->productDataProvider->addProductSku($value['sku']); + $fields = $this->getProductFields($info); + $this->productDataProvider->addEavAttributes($fields); + + $result = function () use ($value) { + $data = $this->productDataProvider->getProductBySku($value['sku']); + if (empty($data)) { + return null; + } + $productModel = $data['model']; + /** @var \Magento\Catalog\Model\Product $productModel */ + $data = $productModel->getData(); + $data['model'] = $productModel; + + if (!empty($productModel->getCustomAttributes())) { + foreach ($productModel->getCustomAttributes() as $customAttribute) { + if (!isset($data[$customAttribute->getAttributeCode()])) { + $data[$customAttribute->getAttributeCode()] = $customAttribute->getValue(); + } + } + } + + return array_replace($value, $data); + }; + + return $this->valueFactory->create($result); + } + + /** + * Return field names for all requested product fields. + * + * @param ResolveInfo $info + * @return string[] + */ + private function getProductFields(ResolveInfo $info) : array + { + $fieldNames = []; + foreach ($info->fieldNodes as $node) { + if ($node->name->value !== 'product') { + continue; + } + foreach ($node->selectionSet->selections as $selectionNode) { + if ($selectionNode->kind === 'InlineFragment') { + foreach ($selectionNode->selectionSet->selections as $inlineSelection) { + if ($inlineSelection->kind === 'InlineFragment') { + continue; + } + $fieldNames[] = $this->fieldTranslator->translate($inlineSelection->name->value); + } + continue; + } + $fieldNames[] = $this->fieldTranslator->translate($selectionNode->name->value); + } + } + + return $fieldNames; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CanonicalUrl.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CanonicalUrl.php new file mode 100644 index 0000000000000..d2675848c2d2a --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CanonicalUrl.php @@ -0,0 +1,62 @@ +valueFactory = $valueFactory; + } + + /** + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /* @var $product Product */ + $product = $value['model']; + $url = $product->getUrlModel()->getUrl($product, ['_ignore_category' => true]); + $result = function () use ($url) { + return $url; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/EntityIdToId.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/EntityIdToId.php new file mode 100644 index 0000000000000..4c101f68eb4da --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/EntityIdToId.php @@ -0,0 +1,76 @@ +metadataPool = $metadataPool; + $this->valueFactory = $valueFactory; + } + + /** + * {@inheritDoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /** @var Product $product */ + $product = $value['model']; + + $productId = $product->getData( + $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField() + ); + + $result = function () use ($productId) { + return $productId; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/MediaGalleryEntries.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/MediaGalleryEntries.php new file mode 100644 index 0000000000000..ac028eef1fb1d --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/MediaGalleryEntries.php @@ -0,0 +1,74 @@ +valueFactory = $valueFactory; + } + + /** + * Format product's media gallery entry data to conform to GraphQL schema + * + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /** @var Product $product */ + $product = $value['model']; + + $mediaGalleryEntries = []; + if (!empty($product->getMediaGalleryEntries())) { + foreach ($product->getMediaGalleryEntries() as $key => $entry) { + $mediaGalleryEntries[$key] = $entry->getData(); + if ($entry->getExtensionAttributes() && $entry->getExtensionAttributes()->getVideoContent()) { + $mediaGalleryEntries[$key]['video_content'] + = $entry->getExtensionAttributes()->getVideoContent()->getData(); + } + } + } + + $result = function () use ($mediaGalleryEntries) { + return $mediaGalleryEntries; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/NewFromTo.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/NewFromTo.php new file mode 100644 index 0000000000000..34fb58b97b156 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/NewFromTo.php @@ -0,0 +1,69 @@ +valueFactory = $valueFactory; + } + + /** + * Transfer data from legacy news_from_date and news_to_date to new names corespondent fields + * + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /** @var Product $product */ + $product = $value['model']; + $attributeName = substr_replace($field->getName(), 's', 3, 0); + + $data = null; + if ($product->getData($attributeName)) { + $data = $product->getData($attributeName); + } + + $result = function () use ($data) { + return $data; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Options.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Options.php new file mode 100644 index 0000000000000..8e06877452ff4 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Options.php @@ -0,0 +1,89 @@ +valueFactory = $valueFactory; + } + + /** + * Format product's option data to conform to GraphQL schema + * + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /** @var Product $product */ + $product = $value['model']; + + $options = null; + if (!empty($product->getOptions())) { + $options = []; + /** @var Option $option */ + foreach ($product->getOptions() as $key => $option) { + $options[$key] = $option->getData(); + $options[$key]['required'] = $option->getIsRequire(); + $options[$key]['product_sku'] = $option->getProductSku(); + + $values = $option->getValues() ?: []; + /** @var Option\Value $value */ + foreach ($values as $valueKey => $value) { + $options[$key]['value'][$valueKey] = $value->getData(); + $options[$key]['value'][$valueKey]['price_type'] + = $value->getPriceType() !== null ? strtoupper($value->getPriceType()) : 'DYNAMIC'; + } + + if (empty($values)) { + $options[$key]['value'] = $option->getData(); + $options[$key]['value']['price_type'] + = $option->getPriceType() !== null ? strtoupper($option->getPriceType()) : 'DYNAMIC'; + } + } + } + + $result = function () use ($options) { + return $options; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Price.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Price.php new file mode 100644 index 0000000000000..29b693abc2661 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Price.php @@ -0,0 +1,136 @@ +storeManager = $storeManager; + $this->priceInfoFactory = $priceInfoFactory; + $this->valueFactory = $valueFactory; + } + + /** + * Format product's tier price data to conform to GraphQL schema + * + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /** @var Product $product */ + $product = $value['model']; + $product->unsetData('minimal_price'); + $priceInfo = $this->priceInfoFactory->create($product); + /** @var \Magento\Catalog\Pricing\Price\FinalPriceInterface $finalPrice */ + $finalPrice = $priceInfo->getPrice(FinalPrice::PRICE_CODE); + $minimalPriceAmount = $finalPrice->getMinimalPrice(); + $maximalPriceAmount = $finalPrice->getMaximalPrice(); + $regularPriceAmount = $priceInfo->getPrice(RegularPrice::PRICE_CODE)->getAmount(); + + $prices = [ + 'minimalPrice' => $this->createAdjustmentsArray($priceInfo->getAdjustments(), $minimalPriceAmount), + 'regularPrice' => $this->createAdjustmentsArray($priceInfo->getAdjustments(), $regularPriceAmount), + 'maximalPrice' => $this->createAdjustmentsArray($priceInfo->getAdjustments(), $maximalPriceAmount) + ]; + + $result = function () use ($prices) { + return $prices; + }; + + return $this->valueFactory->create($result); + } + + /** + * Fill a price with an adjustment array structure with amounts from an amount type + * + * @param AdjustmentInterface[] $adjustments + * @param AmountInterface $amount + * @return array + */ + private function createAdjustmentsArray(array $adjustments, AmountInterface $amount) : array + { + /** @var \Magento\Store\Model\Store $store */ + $store = $this->storeManager->getStore(); + + $priceArray = [ + 'amount' => [ + 'value' => $amount->getValue(), + 'currency' => $store->getCurrentCurrencyCode() + ], + 'adjustments' => [] + ]; + $priceAdjustmentsArray = []; + foreach ($adjustments as $adjustmentCode => $adjustment) { + if ($amount->hasAdjustment($adjustmentCode) && $amount->getAdjustmentAmount($adjustmentCode)) { + $priceAdjustmentsArray[] = [ + 'code' => strtoupper($adjustmentCode), + 'amount' => [ + 'value' => $amount->getAdjustmentAmount($adjustmentCode), + 'currency' => $store->getCurrentCurrencyCode(), + ], + 'description' => $adjustment->isIncludedInDisplayPrice() ? + 'INCLUDED' : 'EXCLUDED' + ]; + } + } + $priceArray['adjustments'] = $priceAdjustmentsArray; + return $priceArray; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductLinks.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductLinks.php new file mode 100644 index 0000000000000..181371f16d3ad --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductLinks.php @@ -0,0 +1,82 @@ +valueFactory = $valueFactory; + } + + /** + * Format product links data to conform to GraphQL schema + * + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /** @var Product $product */ + $product = $value['model']; + + $links = null; + if ($product->getProductLinks()) { + $links = []; + /** @var Link $productLink */ + foreach ($product->getProductLinks() as $productLink) { + if (in_array($productLink->getLinkType(), $this->linkTypes)) { + $links[] = $productLink->getData(); + } + } + } + + $result = function () use ($links) { + return $links; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/TierPrices.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/TierPrices.php new file mode 100644 index 0000000000000..0b7811d4f743e --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/TierPrices.php @@ -0,0 +1,75 @@ +valueFactory = $valueFactory; + } + + /** + * Format product's tier price data to conform to GraphQL schema + * + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['model'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + /** @var Product $product */ + $product = $value['model']; + + $tierPrices = null; + if ($product->getTierPrices()) { + $tierPrices = []; + /** @var TierPrice $tierPrice */ + foreach ($product->getTierPrices() as $tierPrice) { + $tierPrices[] = $tierPrice->getData(); + } + } + + $result = function () use ($tierPrices) { + return $tierPrices; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Websites.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Websites.php new file mode 100644 index 0000000000000..867f1fa0d0b0d --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Websites.php @@ -0,0 +1,63 @@ +valueFactory = $valueFactory; + $this->productWebsitesCollection = $productWebsitesCollection; + } + + /** + * {@inheritDoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + if (!isset($value['entity_id'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + $this->productWebsitesCollection->addIdFilters((int)$value['entity_id']); + $result = function () use ($value) { + return $this->productWebsitesCollection->getWebsiteForProductId((int)$value['entity_id']); + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Websites/Collection.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Websites/Collection.php new file mode 100644 index 0000000000000..3091cffb619c2 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Websites/Collection.php @@ -0,0 +1,136 @@ +websiteCollection = $websiteCollectionFactory->create(); + $this->productCollection = $productCollectionFactory->create(); + } + + /** + * Add product and id filter to filter for fetch. + * + * @param int $productId + * @return void + */ + public function addIdFilters(int $productId) : void + { + if (!in_array($productId, $this->productIds)) { + $this->productIds[] = $productId; + } + } + + /** + * Retrieve website for passed in product id. + * + * @param int $productId + * @return array + */ + public function getWebsiteForProductId(int $productId) : array + { + $websiteList = $this->fetch(); + + if (!isset($websiteList[$productId])) { + return []; + } + + return $websiteList[$productId]; + } + + /** + * Fetch website data and return in array format. Keys for links will be their product Ids. + * + * @return array + */ + private function fetch() : array + { + if (empty($this->productIds) || !empty($this->websites)) { + return $this->websites; + } + + $selectUnique = $this->productCollection->getConnection()->select()->from( + ['product_website' => $this->productCollection->getResource()->getTable('catalog_product_website')] + )->where( + 'product_website.product_id IN (?)', + $this->productIds + )->where( + 'website_id > ?', + 0 + )->group('website_id'); + + $websiteDataUnique = $this->productCollection->getConnection()->fetchAll($selectUnique); + + $websiteIds = []; + foreach ($websiteDataUnique as $websiteData) { + $websiteIds[] = $websiteData['website_id']; + } + $this->websiteCollection->addIdFilter($websiteIds); + + $siteData = $this->websiteCollection->getItems(); + + $select = $this->productCollection->getConnection()->select()->from( + ['product_website' => $this->productCollection->getResource()->getTable('catalog_product_website')] + )->where( + 'product_website.product_id IN (?)', + $this->productIds + )->where( + 'website_id > ?', + 0 + ); + + foreach ($this->productCollection->getConnection()->fetchAll($select) as $row) { + $website = $siteData[$row['website_id']]; + $this->websites[$row['product_id']][$row['website_id']] = [ + 'id' => $row['website_id'], + 'name' => $website->getData('name'), + 'code' => $website->getData('code'), + 'sort_order' => $website->getData('sort_order'), + 'default_group_id' => $website->getData('default_group_id'), + 'is_default' => $website->getData('is_default'), + ]; + } + return $this->websites; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php index 4b0712f48cd8e..cc791ce780c14 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php @@ -3,15 +3,21 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model\Resolver; -use Magento\GraphQl\Model\ResolverContextInterface; -use Magento\GraphQl\Model\ResolverInterface; -use Magento\Framework\GraphQl\Argument\SearchCriteria\Builder; -use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\CatalogGraphQl\Model\Resolver\Products\Query\Filter; use Magento\CatalogGraphQl\Model\Resolver\Products\Query\Search; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\Builder; +use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\SearchFilter; +use Magento\Framework\GraphQl\Query\Resolver\Value; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Catalog\Model\Layer\Resolver; /** * Products field resolver, used for GraphQL request processing. @@ -33,38 +39,68 @@ class Products implements ResolverInterface */ private $filterQuery; + /** + * @var SearchFilter + */ + private $searchFilter; + + /** + * @var ValueFactory + */ + private $valueFactory; + + /** + * @var Layer\DataProvider\Filters + */ + private $filtersDataProvider; + /** * @param Builder $searchCriteriaBuilder * @param Search $searchQuery * @param Filter $filterQuery + * @param ValueFactory $valueFactory */ public function __construct( Builder $searchCriteriaBuilder, Search $searchQuery, - Filter $filterQuery + Filter $filterQuery, + SearchFilter $searchFilter, + ValueFactory $valueFactory, + \Magento\CatalogGraphQl\Model\Resolver\Layer\DataProvider\Filters $filtersDataProvider ) { $this->searchCriteriaBuilder = $searchCriteriaBuilder; $this->searchQuery = $searchQuery; $this->filterQuery = $filterQuery; + $this->searchFilter = $searchFilter; + $this->valueFactory = $valueFactory; + $this->filtersDataProvider = $filtersDataProvider; } /** * {@inheritdoc} */ - public function resolve(array $args, ResolverContextInterface $context) - { - $searchCriteria = $this->searchCriteriaBuilder->build($args); - + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) : Value { + $searchCriteria = $this->searchCriteriaBuilder->build($field->getName(), $args); + $searchCriteria->setCurrentPage($args['currentPage']); + $searchCriteria->setPageSize($args['pageSize']); if (!isset($args['search']) && !isset($args['filter'])) { throw new GraphQlInputException( __("'search' or 'filter' input argument is required.") ); } elseif (isset($args['search'])) { - $searchResult = $this->searchQuery->getResult($searchCriteria); + $layerType = Resolver::CATALOG_LAYER_SEARCH; + $this->searchFilter->add($args['search'], $searchCriteria); + $searchResult = $this->searchQuery->getResult($searchCriteria, $info); } else { - $searchResult = $this->filterQuery->getResult($searchCriteria); + $layerType = Resolver::CATALOG_LAYER_CATEGORY; + $searchResult = $this->filterQuery->getResult($searchCriteria, $info); } - //possible division by 0 if ($searchCriteria->getPageSize()) { $maxPages = ceil($searchResult->getTotalCount() / $searchCriteria->getPageSize()); @@ -82,13 +118,20 @@ public function resolve(array $args, ResolverContextInterface $context) ); } - return [ + $data = [ 'total_count' => $searchResult->getTotalCount(), 'items' => $searchResult->getProductsSearchResult(), 'page_info' => [ 'page_size' => $searchCriteria->getPageSize(), 'current_page' => $currentPage - ] + ], + 'filters' => $this->filtersDataProvider->getData($layerType) ]; + + $result = function () use ($data) { + return $data; + }; + + return $this->valueFactory->create($result); } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Attributes/Collection.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Attributes/Collection.php new file mode 100644 index 0000000000000..d0413813aab37 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Attributes/Collection.php @@ -0,0 +1,80 @@ +collectionFactory = $collectionFactory; + } + + /** + * Return all custom and eav attributes configured for products. + * + * @return AttributeCollection + */ + public function getAttributes() : AttributeCollection + { + if (!$this->collection) { + $this->collection = $this->collectionFactory->create(); + $this->collection->addFieldToFilter('is_user_defined', '1'); + $this->collection->addFieldToFilter('attribute_code', ['neq' => 'cost']); + } + + return $this->collection->load(); + } + + /** + * Find EAV names based on passed in field names from GraphQL request, match to all known EAV attribute codes. + * + * @param string[] $fieldNames + * @return string[] + */ + public function getRequestAttributes(array $fieldNames) : array + { + $attributes = $this->getAttributes(); + $attributeNames = []; + /** @var Attribute $attribute */ + foreach ($attributes as $attribute) { + $attributeNames[] = $attribute->getAttributeCode(); + } + + $matchedAttributes = []; + foreach ($fieldNames as $name) { + if (!in_array($name, $attributeNames)) { + continue; + } + + $matchedAttributes[] = $name; + } + + return $matchedAttributes; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CategoryTree.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CategoryTree.php new file mode 100644 index 0000000000000..3c01579410638 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CategoryTree.php @@ -0,0 +1,149 @@ +collectionFactory = $collectionFactory; + $this->attributesJoiner = $attributesJoiner; + $this->depthCalculator = $depthCalculator; + $this->levelCalculator = $levelCalculator; + $this->metadata = $metadata; + $this->hydrator = $hydrator; + } + + /** + * @param ResolveInfo $resolveInfo + * @param int $rootCategoryId + * @return array + */ + public function getTree(ResolveInfo $resolveInfo, int $rootCategoryId) : array + { + $categoryQuery = $resolveInfo->fieldASTs[0]; + $collection = $this->collectionFactory->create(); + $this->joinAttributesRecursively($collection, $categoryQuery); + $depth = $this->depthCalculator->calculate($categoryQuery); + $level = $this->levelCalculator->calculate($rootCategoryId); + //Search for desired part of category tree + $collection->addPathFilter(sprintf('.*/%s/[/0-9]*$', $rootCategoryId)); + $collection->addFieldToFilter('level', ['gt' => $level]); + $collection->addFieldToFilter('level', ['lteq' => $level + $depth - self::DEPTH_OFFSET]); + $collection->setOrder('level'); + $collection->getSelect()->orWhere( + $this->metadata->getMetadata(CategoryInterface::class)->getLinkField() . ' = ?', + $rootCategoryId + ); + return $this->processTree($collection->getIterator()); + } + + /** + * @param \Iterator $iterator + * @return array + */ + private function processTree(\Iterator $iterator) : array + { + $tree = []; + while ($iterator->valid()) { + /** @var CategoryInterface $category */ + $category = $iterator->current(); + $iterator->next(); + $nextCategory = $iterator->current(); + $tree[$category->getId()] = $this->hydrator->hydrateCategory($category); + if ($nextCategory && (int) $nextCategory->getLevel() !== (int) $category->getLevel()) { + $tree[$category->getId()]['children'] = $this->processTree($iterator); + } + } + + return $tree; + } + + /** + * @param Collection $collection + * @param FieldNode $fieldNode + * @return void + */ + private function joinAttributesRecursively(Collection $collection, FieldNode $fieldNode) : void + { + if (!isset($fieldNode->selectionSet->selections)) { + return; + } + + $subSelection = $fieldNode->selectionSet->selections; + $this->attributesJoiner->join($fieldNode, $collection); + + /** @var FieldNode $node */ + foreach ($subSelection as $node) { + $this->joinAttributesRecursively($collection, $node); + } + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CustomAttributesFlattener.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CustomAttributesFlattener.php new file mode 100644 index 0000000000000..3f7af2610db1f --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/CustomAttributesFlattener.php @@ -0,0 +1,35 @@ +productDataProvider = $productDataProvider; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + } + + /** + * Add product sku to result set at fetch time. + * + * @param string $sku + * @return void + */ + public function addProductSku(string $sku) : void + { + if (!in_array($sku, $this->productSkus) && !empty($this->productList)) { + $this->productList = []; + $this->productSkus[] = $sku; + } elseif (!in_array($sku, $this->productSkus)) { + $this->productSkus[] = $sku; + } + } + + /** + * Add product skus to result set at fetch time. + * + * @param array $skus + * @return void + */ + public function addProductSkus(array $skus) : void + { + foreach ($skus as $sku) { + if (!in_array($sku, $this->productSkus) && !empty($this->productList)) { + $this->productList = []; + $this->productSkus[] = $sku; + } elseif (!in_array($sku, $this->productSkus)) { + $this->productSkus[] = $sku; + } + } + } + + /** + * Add attributes to collection filter + * + * @param array $attributeCodes + * @return void + */ + public function addEavAttributes(array $attributeCodes) : void + { + $this->attributeCodes = array_unique(array_merge($this->attributeCodes, $attributeCodes)); + } + + /** + * Get product from result set. + * + * @param string $sku + * @return array + */ + public function getProductBySku(string $sku) : array + { + $products = $this->fetch(); + + if (!isset($products[$sku])) { + return []; + } + + return $products[$sku]; + } + + /** + * Fetch product data and return in array format. Keys for products will be their skus. + * + * @return array + */ + private function fetch() : array + { + if (empty($this->productSkus) || !empty($this->productList)) { + return $this->productList; + } + + $this->searchCriteriaBuilder->addFilter(ProductInterface::SKU, $this->productSkus, 'in'); + $result = $this->productDataProvider->getList( + $this->searchCriteriaBuilder->create(), + $this->attributeCodes, + false, + true + ); + + /** @var \Magento\Catalog\Model\Product $product */ + foreach ($result->getItems() as $product) { + $this->productList[$product->getSku()] = ['model' => $product]; + } + + return $this->productList; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php index 7604a4f4acfc2..f2020cbeca88e 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php @@ -3,22 +3,16 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider; -use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\Catalog\Model\Product\Option; -use Magento\Catalog\Model\Product\TierPrice; +use Magento\Catalog\Model\Product\Visibility; use Magento\Framework\Api\SearchCriteriaInterface; -use Magento\Framework\Data\SearchResultInterface; -use Magento\Framework\Exception\NoSuchEntityException; -use Magento\Framework\Serialize\SerializerInterface; -use Magento\Framework\Webapi\ServiceOutputProcessor; use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; -use Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface; -use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Catalog\Api\Data\ProductSearchResultsInterfaceFactory; -use Magento\GraphQl\Model\EntityAttributeList; +use Magento\Framework\Api\SearchResultsInterface; +use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessorInterface; /** * Product field data provider, used for GraphQL resolver processing. @@ -31,9 +25,9 @@ class Product private $collectionFactory; /** - * @var JoinProcessorInterface + * @var ProductSearchResultsInterfaceFactory */ - private $joinProcessor; + private $searchResultsFactory; /** * @var CollectionProcessorInterface @@ -41,62 +35,64 @@ class Product private $collectionProcessor; /** - * @var ProductSearchResultsInterfaceFactory + * @var Visibility */ - private $searchResultsFactory; + private $visibility; /** * @param CollectionFactory $collectionFactory - * @param JoinProcessorInterface $joinProcessor - * @param CollectionProcessorInterface $collectionProcessor * @param ProductSearchResultsInterfaceFactory $searchResultsFactory + * @param Visibility $visibility + * @param CollectionProcessorInterface $collectionProcessor */ public function __construct( CollectionFactory $collectionFactory, - JoinProcessorInterface $joinProcessor, - CollectionProcessorInterface $collectionProcessor, - ProductSearchResultsInterfaceFactory $searchResultsFactory + ProductSearchResultsInterfaceFactory $searchResultsFactory, + Visibility $visibility, + CollectionProcessorInterface $collectionProcessor ) { $this->collectionFactory = $collectionFactory; - $this->joinProcessor = $joinProcessor; - $this->collectionProcessor = $collectionProcessor; $this->searchResultsFactory = $searchResultsFactory; + $this->visibility = $visibility; + $this->collectionProcessor = $collectionProcessor; } /** - * Gets list of product data with full data set + * Gets list of product data with full data set. Adds eav attributes to result set from passed in array * * @param SearchCriteriaInterface $searchCriteria - * @return SearchResultInterface + * @param string[] $attributes + * @param bool $isSearch + * @param bool $isChildSearch + * @return SearchResultsInterface */ - public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria) - { + public function getList( + SearchCriteriaInterface $searchCriteria, + array $attributes = [], + bool $isSearch = false, + bool $isChildSearch = false + ): SearchResultsInterface { /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $collection */ $collection = $this->collectionFactory->create(); - $this->joinProcessor->process($collection); - - $collection->addAttributeToSelect('*'); - $collection->joinAttribute('status', 'catalog_product/status', 'entity_id', null, 'inner'); - $collection->joinAttribute('visibility', 'catalog_product/visibility', 'entity_id', null, 'inner'); - $this->collectionProcessor->process($searchCriteria, $collection); + $this->collectionProcessor->process($collection, $searchCriteria, $attributes); + if (!$isChildSearch) { + $visibilityIds + = $isSearch ? $this->visibility->getVisibleInSearchIds() : $this->visibility->getVisibleInCatalogIds(); + $collection->setVisibility($visibilityIds); + } $collection->load(); + // Methods that perform extra fetches post-load $collection->addCategoryIds(); - $collection->addFinalPrice(); $collection->addMediaGalleryData(); - $collection->addMinimalPrice(); - $collection->addPriceData(); - $collection->addWebsiteNamesToResult(); $collection->addOptionsToResult(); - $collection->addTaxPercents(); - $collection->addWebsiteNamesToResult(); + $searchResult = $this->searchResultsFactory->create(); $searchResult->setSearchCriteria($searchCriteria); $searchResult->setItems($collection->getItems()); $searchResult->setTotalCount($collection->getSize()); - return $searchResult; } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/AttributeProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/AttributeProcessor.php new file mode 100644 index 0000000000000..f4cefeb3f3638 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/AttributeProcessor.php @@ -0,0 +1,35 @@ +addAttributeToSelect($name); + } + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/ExtensibleEntityProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/ExtensibleEntityProcessor.php new file mode 100644 index 0000000000000..365d4f018ef4a --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/ExtensibleEntityProcessor.php @@ -0,0 +1,44 @@ +joinProcessor = $joinProcessor; + } + + public function process( + Collection $collection, + SearchCriteriaInterface $searchCriteria, + array $attributeNames + ): Collection { + $this->joinProcessor->process($collection); + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/RequiredColumnsProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/RequiredColumnsProcessor.php new file mode 100644 index 0000000000000..4c5b657874713 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/RequiredColumnsProcessor.php @@ -0,0 +1,36 @@ +addAttributeToSelect('special_price'); + $collection->addAttributeToSelect('special_price_from'); + $collection->addAttributeToSelect('special_price_to'); + $collection->addAttributeToSelect('tax_class_id'); + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/SearchCriteriaProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/SearchCriteriaProcessor.php new file mode 100644 index 0000000000000..e4c338f599577 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/SearchCriteriaProcessor.php @@ -0,0 +1,47 @@ +searchCriteriaApplier = $searchCriteriaApplier; + } + + /** + * {@inheritdoc} + */ + public function process( + Collection $collection, + SearchCriteriaInterface $searchCriteria, + array $attributeNames + ): Collection { + $this->searchCriteriaApplier->process($searchCriteria, $collection); + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/StockProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/StockProcessor.php new file mode 100644 index 0000000000000..e68136f64e5cf --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/StockProcessor.php @@ -0,0 +1,57 @@ +stockConfig = $stockConfig; + $this->stockStatusResource = $stockStatusResource; + } + + /** + * {@inheritdoc} + */ + public function process( + Collection $collection, + SearchCriteriaInterface $searchCriteria, + array $attributeNames + ): Collection { + if (!$this->stockConfig->isShowOutOfStock()) { + $this->stockStatusResource->addIsInStockFilterToCollection($collection); + } + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/VisibilityStatusProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/VisibilityStatusProcessor.php new file mode 100644 index 0000000000000..30174a94aaba0 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessor/VisibilityStatusProcessor.php @@ -0,0 +1,34 @@ +joinAttribute('status', 'catalog_product/status', 'entity_id', null, 'inner'); + $collection->joinAttribute('visibility', 'catalog_product/visibility', 'entity_id', null, 'inner'); + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessorInterface.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessorInterface.php new file mode 100644 index 0000000000000..62501a1a2382b --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionProcessorInterface.php @@ -0,0 +1,31 @@ +collectionProcessors = $collectionProcessors; + } + + /** + * {@inheritdoc} + */ + public function process( + Collection $collection, + SearchCriteriaInterface $searchCriteria, + array $attributeNames + ): Collection { + foreach ($this->collectionProcessors as $collectionProcessor) { + $collection = $collectionProcessor->process($collection, $searchCriteria, $attributeNames); + } + + return $collection; + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/BaseModelData.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/BaseModelData.php deleted file mode 100644 index b0b5e97a308c9..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/BaseModelData.php +++ /dev/null @@ -1,26 +0,0 @@ -getData(); - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/CustomAttributes.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/CustomAttributes.php deleted file mode 100644 index 04b314139608a..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/CustomAttributes.php +++ /dev/null @@ -1,31 +0,0 @@ -getCustomAttributes() as $customAttribute) { - if (!isset($productData[$customAttribute->getAttributeCode()])) { - $productData[$customAttribute->getAttributeCode()] = $customAttribute->getValue(); - } - } - return $productData; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/EntityIdToId.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/EntityIdToId.php deleted file mode 100644 index 72e2e022734f3..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/EntityIdToId.php +++ /dev/null @@ -1,29 +0,0 @@ -getId(); - unset($productData['entity_id']); - - return $productData; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/MediaGalleryEntries.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/MediaGalleryEntries.php deleted file mode 100644 index 0844d6a7f0d17..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/MediaGalleryEntries.php +++ /dev/null @@ -1,36 +0,0 @@ -getMediaGalleryEntries())) { - foreach ($product->getMediaGalleryEntries() as $key => $entry) { - $productData['media_gallery_entries'][$key] = $entry->getData(); - if ($entry->getExtensionAttributes() && $entry->getExtensionAttributes()->getVideoContent()) { - $productData['media_gallery_entries'][$key]['video_content'] - = $entry->getExtensionAttributes()->getVideoContent()->getData(); - } - } - } - - return $productData; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/NewFromTo.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/NewFromTo.php deleted file mode 100644 index 64ca1bbea0e7d..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/NewFromTo.php +++ /dev/null @@ -1,34 +0,0 @@ -getData('news_from_date')) { - $productData['new_from_date'] = $product->getData('news_from_date'); - } - - if ($product->getData('news_to_date')) { - $productData['new_to_date'] = $product->getData('news_to_date'); - } - - return $productData; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/Options.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/Options.php deleted file mode 100644 index 3b9d23460df89..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/Options.php +++ /dev/null @@ -1,51 +0,0 @@ -getOptions())) { - /** @var Option $option */ - foreach ($product->getOptions() as $key => $option) { - unset($productData['options'][$key]); - $productData['options'][$key] = $option->getData(); - $productData['options'][$key]['required'] = $option->getIsRequire(); - $productData['options'][$key]['product_sku'] = $option->getProductSku(); - - $values = $option->getValues() ?: []; - /** @var Option\Value $value */ - foreach ($values as $valueKey => $value) { - $productData['options'][$key]['value'][$valueKey] = $value->getData(); - $productData['options'][$key]['value'][$valueKey]['price_type'] - = $value->getPriceType() !== null ? strtoupper($value->getPriceType()) : 'DYNAMIC'; - } - - if (empty($values)) { - $productData['options'][$key]['value'] = $option->getData(); - $productData['options'][$key]['value']['price_type'] - = $option->getPriceType() !== null ? strtoupper($option->getPriceType()) : 'DYNAMIC'; - } - } - } - - return $productData; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/Price.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/Price.php deleted file mode 100644 index 6de041a5631c2..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/Price.php +++ /dev/null @@ -1,100 +0,0 @@ -storeManager = $storeManager; - $this->priceInfoFactory = $priceInfoFactory; - } - - /** - * Format product's tier price data to conform to GraphQL schema - * - * {@inheritdoc} - */ - public function format(Product $product, array $productData = []) - { - $priceInfo = $this->priceInfoFactory->create($product); - /** @var \Magento\Catalog\Pricing\Price\FinalPriceInterface $finalPrice */ - $finalPrice = $priceInfo->getPrice(FinalPrice::PRICE_CODE); - $minimalPriceAmount = $finalPrice->getMinimalPrice(); - $maximalPriceAmount = $finalPrice->getMaximalPrice(); - $regularPriceAmount = $priceInfo->getPrice(RegularPrice::PRICE_CODE)->getAmount(); - - $productData['price'] = [ - 'minimalPrice' => $this->createAdjustmentsArray($priceInfo->getAdjustments(), $minimalPriceAmount), - 'regularPrice' => $this->createAdjustmentsArray($priceInfo->getAdjustments(), $regularPriceAmount), - 'maximalPrice' => $this->createAdjustmentsArray($priceInfo->getAdjustments(), $maximalPriceAmount) - ]; - - return $productData; - } - - /** - * Fill a price with an adjustment array structure with amounts from an amount type - * - * @param AdjustmentInterface[] $adjustments - * @param AmountInterface $amount - * @return array - */ - private function createAdjustmentsArray(array $adjustments, AmountInterface $amount) - { - /** @var \Magento\Store\Model\Store $store */ - $store = $this->storeManager->getStore(); - - $priceArray = [ - 'amount' => [ - 'value' => $amount->getValue(), - 'currency' => $store->getCurrentCurrencyCode() - ], - 'adjustments' => [] - ]; - $priceAdjustmentsArray = []; - foreach ($adjustments as $adjustmentCode => $adjustment) { - if ($amount->hasAdjustment($adjustmentCode) && $amount->getAdjustmentAmount($adjustmentCode)) { - $priceAdjustmentsArray[] = [ - 'code' => strtoupper($adjustmentCode), - 'amount' => [ - 'value' => $amount->getAdjustmentAmount($adjustmentCode), - 'currency' => $store->getCurrentCurrencyCode(), - ], - 'description' => $adjustment->isIncludedInDisplayPrice() ? - 'INCLUDED' : 'EXCLUDED' - ]; - } - } - $priceArray['adjustments'] = $priceAdjustmentsArray; - return $priceArray; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/ProductLinks.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/ProductLinks.php deleted file mode 100644 index 0c8e61e169f13..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/ProductLinks.php +++ /dev/null @@ -1,44 +0,0 @@ -getProductLinks(); - if ($productLinks) { - /** @var Link $productLink */ - foreach ($productLinks as $productLinkKey => $productLink) { - if (in_array($productLink->getLinkType(), $this->linkTypes)) { - $productData['product_links'][$productLinkKey] = $productLink->getData(); - } - } - } else { - $productData['product_links'] = null; - } - - return $productData; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/TierPrices.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/TierPrices.php deleted file mode 100644 index 7b2f6f5a2b68b..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/TierPrices.php +++ /dev/null @@ -1,37 +0,0 @@ -getTierPrices(); - if ($tierPrices) { - /** @var TierPrice $tierPrice */ - foreach ($tierPrices as $tierPrice) { - $productData['tier_prices'][] = $tierPrice->getData(); - } - } else { - $productData['tier_prices'] = null; - } - - return $productData; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/FormatterComposite.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/FormatterComposite.php deleted file mode 100644 index 60da664074a9f..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/FormatterComposite.php +++ /dev/null @@ -1,43 +0,0 @@ -formatterInstances = $formatterInstances; - } - - /** - * Format single product data from object to an array - * - * {@inheritdoc} - */ - public function format(Product $product, array $productData = []) - { - foreach ($this->formatterInstances as $formatterInstance) { - $productData = $formatterInstance->format($product, $productData); - } - - return $productData; - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/FormatterInterface.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/FormatterInterface.php deleted file mode 100644 index 7e09198bb6e23..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/FormatterInterface.php +++ /dev/null @@ -1,24 +0,0 @@ -clauseFactory = $clauseFactory; - $this->connectiveFactory = $connectiveFactory; - $this->referenceTypeFactory = $referenceTypeFactory; - $this->entityAttributeList = $entityAttributeList; - $this->config = $config; - } - - /** - * Get a clause from an AST - * - * @param ReferenceType $referenceType - * @param array $arguments - * @return array - */ - private function getClausesFromAst(ReferenceType $referenceType, array $arguments) - { - $entityInfo = ['attributes' => $this->getCatalogProductFields()]; - $attributes = array_keys($entityInfo['attributes']); - $conditions = []; - foreach ($arguments as $argumentName => $argument) { - if (in_array($argumentName, $attributes)) { - foreach ($argument as $clauseType => $clause) { - if (is_array($clause)) { - $value = []; - foreach ($clause as $item) { - $value[] = $item; - } - } else { - $value = $clause; - } - $conditions[] = $this->clauseFactory->create( - $referenceType, - $argumentName, - $clauseType, - $value - ); - } - } else { - $conditions[] = - $this->connectiveFactory->create( - $this->getClausesFromAst($referenceType, $argument), - $argumentName - ); - } - } - return $conditions; - } - - /** - * Get the fields from catalog product - * - * @return array - * @throws \LogicException - */ - private function getCatalogProductFields() - { - $productTypeSchema = $this->config->getTypeStructure('SimpleProduct'); - if (!$productTypeSchema instanceof Type) { - throw new \LogicException(__("SimpleProduct type not defined in schema.")); - } - - $fields = []; - foreach ($productTypeSchema->getInterfaces() as $interface) { - /** @var InterfaceType $interfaceStructure */ - $interfaceStructure = $this->config->getTypeStructure($interface['interface']); - - foreach ($interfaceStructure->getFields() as $field) { - $fields[$field->getName()] = 'String'; - } - } - - return $fields; - } - - /** - * Get a connective filter from an AST input - * - * @param string $entityType - * @param array $arguments - * @return Connective - */ - public function getFilterFromAst(string $entityType, $arguments) - { - $filters = $this->getClausesFromAst( - $this->referenceTypeFactory->create($entityType), - $arguments - ); - return $this->connectiveFactory->create($filters); - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php new file mode 100644 index 0000000000000..96bef3ffc09c4 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php @@ -0,0 +1,68 @@ +config = $config; + $this->additionalAttributes = $additionalAttributes; + } + + /** + * {@inheritdoc} + */ + public function getEntityAttributes() : array + { + $productTypeSchema = $this->config->getConfigElement('SimpleProduct'); + if (!$productTypeSchema instanceof Type) { + throw new \LogicException(__("SimpleProduct type not defined in schema.")); + } + + $fields = []; + foreach ($productTypeSchema->getInterfaces() as $interface) { + /** @var InterfaceType $configElement */ + $configElement = $this->config->getConfigElement($interface['interface']); + + foreach ($configElement->getFields() as $field) { + $fields[$field->getName()] = 'String'; + } + } + + foreach ($this->additionalAttributes as $attribute) { + $fields[$attribute] = 'String'; + } + + return array_keys($fields); + } +} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ValueParser.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ValueParser.php deleted file mode 100644 index 7ba76851cd81a..0000000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ValueParser.php +++ /dev/null @@ -1,42 +0,0 @@ -clauseConverter = $clauseConverter; - $this->filterArgumentValueFactory = $filterArgumentValueFactory; - } - - /** - * {@inheritdoc} - */ - public function parse($value) - { - $filters = $this->clauseConverter->getFilterFromAst(\Magento\Catalog\Model\Product::ENTITY, $value); - return $this->filterArgumentValueFactory->create($filters); - } -} diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php index 1f59bd8a2811a..62e2f0c488c6c 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php @@ -3,120 +3,118 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model\Resolver\Products\Query; -use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\Framework\Api\SearchCriteriaInterface; -use Magento\Framework\GraphQl\Query\PostFetchProcessorInterface; use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResult; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResultFactory; -use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\FormatterInterface; +use Magento\Framework\GraphQl\Query\FieldTranslator; /** * Retrieve filtered product data based off given search criteria in a format that GraphQL can interpret. */ class Filter { - /** - * @var ProductRepositoryInterface - */ - private $productRepository; - /** * @var SearchResultFactory */ private $searchResultFactory; /** - * @var Product + * @var \Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product */ private $productDataProvider; /** - * @var SearchCriteriaBuilder + * @var FieldTranslator */ - private $searchCriteriaBuilder; + private $fieldTranslator; /** - * @var FormatterInterface + * @var \Magento\Catalog\Model\Layer\Resolver */ - private $formatter; + private $layerResolver; /** - * @var PostFetchProcessorInterface[] - */ - private $postProcessors; - - /** - * @param ProductRepositoryInterface $productRepository * @param SearchResultFactory $searchResultFactory * @param Product $productDataProvider - * @param SearchCriteriaBuilder $searchCriteriaBuilder - * @param FormatterInterface $formatter - * @param PostFetchProcessorInterface[] $postProcessors + * @param \Magento\Catalog\Model\Layer\Resolver $layerResolver + * @param FieldTranslator $fieldTranslator */ public function __construct( - ProductRepositoryInterface $productRepository, SearchResultFactory $searchResultFactory, Product $productDataProvider, - SearchCriteriaBuilder $searchCriteriaBuilder, - FormatterInterface $formatter, - array $postProcessors = [] + \Magento\Catalog\Model\Layer\Resolver $layerResolver, + FieldTranslator $fieldTranslator ) { - $this->productRepository = $productRepository; $this->searchResultFactory = $searchResultFactory; $this->productDataProvider = $productDataProvider; - $this->searchCriteriaBuilder = $searchCriteriaBuilder; - $this->postProcessors = $postProcessors; - $this->formatter = $formatter; + $this->fieldTranslator = $fieldTranslator; + $this->layerResolver = $layerResolver; } /** * Filter catalog product data based off given search criteria * * @param SearchCriteriaInterface $searchCriteria + * @param ResolveInfo $info + * @param bool $isSearch * @return SearchResult */ - public function getResult(SearchCriteriaInterface $searchCriteria) - { - $realPageSize = $searchCriteria->getPageSize(); - $realCurrentPage = $searchCriteria->getCurrentPage(); - // Current page must be set to 0 and page size to max for search to grab all ID's as temporary workaround for - // inaccurate search - $searchCriteria->setPageSize(PHP_INT_MAX); - $searchCriteria->setCurrentPage(1); - $products = $this->productDataProvider->getList($searchCriteria); + public function getResult( + SearchCriteriaInterface $searchCriteria, + ResolveInfo $info, + bool $isSearch = false + ): SearchResult { + $fields = $this->getProductFields($info); + $products = $this->productDataProvider->getList($searchCriteria, $fields, $isSearch); $productArray = []; - $searchCriteria->setPageSize($realPageSize); - $searchCriteria->setCurrentPage($realCurrentPage); - $paginatedProducts = $this->paginateList($products->getItems(), $searchCriteria); /** @var \Magento\Catalog\Model\Product $product */ - foreach ($paginatedProducts as $product) { - $productArray[] = $this->formatter->format($product); - } - - foreach ($this->postProcessors as $postProcessor) { - $productArray = $postProcessor->process($productArray); + foreach ($products->getItems() as $product) { + $productArray[$product->getId()] = $product->getData(); + $productArray[$product->getId()]['model'] = $product; } return $this->searchResultFactory->create($products->getTotalCount(), $productArray); } /** - * Paginates array of Ids pulled back in search based off search criteria and total count. + * Return field names for all requested product fields. * - * @param array $ids - * @param SearchCriteriaInterface $searchCriteria - * @return int[] + * @param ResolveInfo $info + * @return string[] */ - private function paginateList(array $ids, SearchCriteriaInterface $searchCriteria) + private function getProductFields(ResolveInfo $info) : array { - $length = $searchCriteria->getPageSize(); - // Search starts pages from 0 - $offset = $length * ($searchCriteria->getCurrentPage() - 1); - return array_slice($ids, $offset, $length); + $fieldNames = []; + foreach ($info->fieldNodes as $node) { + if ($node->name->value !== 'products') { + continue; + } + foreach ($node->selectionSet->selections as $selection) { + if ($selection->name->value !== 'items') { + continue; + } + + foreach ($selection->selectionSet->selections as $itemSelection) { + if ($itemSelection->kind === 'InlineFragment') { + foreach ($itemSelection->selectionSet->selections as $inlineSelection) { + if ($inlineSelection->kind === 'InlineFragment') { + continue; + } + $fieldNames[] = $this->fieldTranslator->translate($inlineSelection->name->value); + } + continue; + } + $fieldNames[] = $this->fieldTranslator->translate($itemSelection->name->value); + } + } + } + + return $fieldNames; } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php index 4e7047d2daad7..c4da59fd2cedf 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php @@ -3,13 +3,16 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model\Resolver\Products\Query; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\Framework\Api\Search\SearchCriteriaInterface; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchCriteria\Helper\Filter as FilterHelper; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResult; use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResultFactory; +use Magento\Framework\EntityManager\EntityManager; use Magento\Search\Api\SearchInterface; /** @@ -37,6 +40,11 @@ class Search */ private $searchResultFactory; + /** + * @var \Magento\Framework\EntityManager\MetadataPool + */ + private $metadataPool; + /** * @param SearchInterface $search * @param FilterHelper $filterHelper @@ -47,12 +55,14 @@ public function __construct( SearchInterface $search, FilterHelper $filterHelper, Filter $filterQuery, - SearchResultFactory $searchResultFactory + SearchResultFactory $searchResultFactory, + \Magento\Framework\EntityManager\MetadataPool $metadataPool ) { $this->search = $search; $this->filterHelper = $filterHelper; $this->filterQuery = $filterQuery; $this->searchResultFactory = $searchResultFactory; + $this->metadataPool = $metadataPool; } /** @@ -61,12 +71,14 @@ public function __construct( * @param SearchCriteriaInterface $searchCriteria * @return SearchResult */ - public function getResult(SearchCriteriaInterface $searchCriteria) + public function getResult(SearchCriteriaInterface $searchCriteria, ResolveInfo $info) : SearchResult { + $idField = $this->metadataPool->getMetadata( + \Magento\Catalog\Api\Data\ProductInterface::class + )->getIdentifierField(); $realPageSize = $searchCriteria->getPageSize(); $realCurrentPage = $searchCriteria->getCurrentPage(); // Current page must be set to 0 and page size to max for search to grab all ID's as temporary workaround - // for MAGETWO-85611 $searchCriteria->setPageSize(PHP_INT_MAX); $searchCriteria->setCurrentPage(0); $itemsResults = $this->search->search($searchCriteria); @@ -77,25 +89,28 @@ public function getResult(SearchCriteriaInterface $searchCriteria) $ids[$item->getId()] = null; $searchIds[] = $item->getId(); } - $searchCriteria->setPageSize($realPageSize); - $searchCriteria->setCurrentPage($realCurrentPage); - $filter = $this->filterHelper->generate('entity_id', 'in', $searchIds); + $filter = $this->filterHelper->generate($idField, 'in', $searchIds); $searchCriteria = $this->filterHelper->remove($searchCriteria, 'search_term'); $searchCriteria = $this->filterHelper->add($searchCriteria, $filter); - $searchResult = $this->filterQuery->getResult($searchCriteria); + $searchResult = $this->filterQuery->getResult($searchCriteria, $info, true); + + $searchCriteria->setPageSize($realPageSize); + $searchCriteria->setCurrentPage($realCurrentPage); + $paginatedProducts = $this->paginateList($searchResult, $searchCriteria); $products = []; if (!isset($searchCriteria->getSortOrders()[0])) { - foreach ($searchResult->getProductsSearchResult() as $product) { - if (in_array($product['id'], $searchIds)) { - $ids[$product['id']] = $product; + foreach ($paginatedProducts as $product) { + if (in_array($product[$idField], $searchIds)) { + $ids[$product[$idField]] = $product; } } $products = array_filter($ids); } else { - foreach ($searchResult->getProductsSearchResult() as $product) { - if (in_array($product['id'], $searchIds)) { + foreach ($paginatedProducts as $product) { + $productId = isset($product['entity_id']) ? $product['entity_id'] : $product[$idField]; + if (in_array($productId, $searchIds)) { $products[] = $product; } } @@ -103,4 +118,29 @@ public function getResult(SearchCriteriaInterface $searchCriteria) return $this->searchResultFactory->create($searchResult->getTotalCount(), $products); } + + /** + * Paginate an array of Ids that get pulled back in search based off search criteria and total count. + * + * @param SearchResult $searchResult + * @param SearchCriteriaInterface $searchCriteria + * @return int[] + */ + private function paginateList(SearchResult $searchResult, SearchCriteriaInterface $searchCriteria) : array + { + $length = $searchCriteria->getPageSize(); + // Search starts pages from 0 + $offset = $length * ($searchCriteria->getCurrentPage() - 1); + + if ($searchCriteria->getPageSize()) { + $maxPages = ceil($searchResult->getTotalCount() / $searchCriteria->getPageSize()) - 1; + } else { + $maxPages = 0; + } + + if ($searchCriteria->getCurrentPage() > $maxPages && $searchResult->getTotalCount() > 0) { + $offset = (int)$maxPages; + } + return array_slice($searchResult->getProductsSearchResult(), $offset, $length); + } } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/Helper/Filter.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/Helper/Filter.php index 84b9632afc3aa..5658e086269da 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/Helper/Filter.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchCriteria/Helper/Filter.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model\Resolver\Products\SearchCriteria\Helper; @@ -44,7 +45,7 @@ public function __construct(FilterGroupBuilder $filterGroupBuilder, FilterBuilde * @param string|array $value * @return Item */ - public function generate(string $field, string $condition, $value) + public function generate(string $field, string $condition, $value) : Item { $this->filterBuilder->setField($field); $this->filterBuilder->setConditionType($condition); @@ -60,7 +61,7 @@ public function generate(string $field, string $condition, $value) * @param Item $filter * @return SearchCriteriaInterface */ - public function add(SearchCriteriaInterface $searchCriteria, Item $filter) + public function add(SearchCriteriaInterface $searchCriteria, Item $filter) : SearchCriteriaInterface { $filterGroups = $searchCriteria->getFilterGroups(); $filterGroups[] = $this->filterGroupBuilder->addFilter($filter)->create(); @@ -76,7 +77,7 @@ public function add(SearchCriteriaInterface $searchCriteria, Item $filter) * @param string $filterName * @return SearchCriteriaInterface */ - public function remove(SearchCriteriaInterface $searchCriteria, string $filterName) + public function remove(SearchCriteriaInterface $searchCriteria, string $filterName) : SearchCriteriaInterface { $filterGroups = []; foreach ($searchCriteria->getFilterGroups() as $filterGroup) { diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php index c0919d90b21f2..6e229bdc38a31 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php @@ -3,10 +3,10 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model\Resolver\Products; -use Magento\Catalog\Api\Data\ProductSearchResultsInterface; use Magento\Framework\Api\SearchResultsInterface; /** @@ -39,7 +39,7 @@ public function __construct(int $totalCount, array $productsSearchResult) * * @return int */ - public function getTotalCount() + public function getTotalCount() : int { return $this->totalCount; } @@ -49,7 +49,7 @@ public function getTotalCount() * * @return array */ - public function getProductsSearchResult() + public function getProductsSearchResult() : array { return $this->productsSearchResult; } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php index fa2a0e3473a19..aec9362f47c3a 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\CatalogGraphQl\Model\Resolver\Products; @@ -33,7 +34,7 @@ public function __construct(ObjectManagerInterface $objectManager) * @param array $productsSearchResult * @return SearchResult */ - public function create(int $totalCount, array $productsSearchResult) + public function create(int $totalCount, array $productsSearchResult) : SearchResult { return $this->objectManager->create( SearchResult::class, diff --git a/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Product/CanonicalUrlTest.php b/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Product/CanonicalUrlTest.php new file mode 100644 index 0000000000000..ae01c67eb5224 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Test/Unit/Model/Resolver/Product/CanonicalUrlTest.php @@ -0,0 +1,92 @@ +getMockBuilder(\Magento\Framework\GraphQl\Config\Element\Field::class) + ->disableOriginalConstructor() + ->getMock(); + $mockInfo = $this->getMockBuilder(\Magento\Framework\GraphQl\Schema\Type\ResolveInfo::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockValueFactory->method('create')->with( + $this->callback( + function ($param) { + return $param() === null; + } + ) + ); + + $this->subject->resolve($mockField, '', $mockInfo, [], []); + } + + protected function setUp() + { + parent::setUp(); + $this->objectManager = new ObjectManager($this); + $this->mockStoreManager = $this->getMockBuilder(StoreManagerInterface::class)->getMock(); + $this->mockValueFactory = $this->getMockBuilder(ValueFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->mockValueFactory->method('create')->willReturn( + $this->objectManager->getObject( + Value::class, + ['callback' => function () { + return ''; + }] + ) + ); + + $mockProductUrlPathGenerator = $this->getMockBuilder(ProductUrlPathGenerator::class) + ->disableOriginalConstructor() + ->getMock(); + $mockProductUrlPathGenerator->method('getUrlPathWithSuffix')->willReturn('product_url.html'); + + $this->subject = $this->objectManager->getObject( + CanonicalUrl::class, + [ + 'valueFactory' => $this->mockValueFactory, + 'storeManager' => $this->mockStoreManager, + 'productUrlPathGenerator' => $mockProductUrlPathGenerator + ] + ); + } +} diff --git a/app/code/Magento/CatalogGraphQl/composer.json b/app/code/Magento/CatalogGraphQl/composer.json index 18501ee7d2ccf..eb86ac634412e 100644 --- a/app/code/Magento/CatalogGraphQl/composer.json +++ b/app/code/Magento/CatalogGraphQl/composer.json @@ -2,16 +2,19 @@ "name": "magento/module-catalog-graph-ql", "description": "N/A", "type": "magento2-module", - "version": "100.0.0-dev", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/module-eav": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-graph-ql": "100.0.*", - "magento/module-eav-graph-ql": "100.0.*", - "magento/module-search": "100.3.*", - "magento/module-store": "100.3.*", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/module-eav": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-search": "*", + "magento/module-store": "*", + "magento/module-eav-graph-ql": "*", + "magento/framework": "*" + }, + "suggest": { + "magento/module-graph-ql": "*", + "magento/module-store-graph-ql": "*" }, "license": [ "OSL-3.0", diff --git a/app/code/Magento/CatalogGraphQl/etc/di.xml b/app/code/Magento/CatalogGraphQl/etc/di.xml index 5de68c235ac23..406d37b2ea200 100644 --- a/app/code/Magento/CatalogGraphQl/etc/di.xml +++ b/app/code/Magento/CatalogGraphQl/etc/di.xml @@ -6,12 +6,7 @@ */ --> - - - - Magento\Catalog\Model\Api\SearchCriteria\ProductCollectionProcessor - - + @@ -19,11 +14,44 @@ - + Magento\CatalogGraphQl\Model\Config\AttributeReader + Magento\CatalogGraphQl\Model\Config\CategoryAttributeReader + + + + news_from_date + news_to_date + + + + + + + Magento\CatalogGraphQl\Model\Resolver\Products\FilterArgument\ProductEntityAttributesForAst + + + + + + + Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\AttributeProcessor + Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\ExtensibleEntityProcessor + Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\RequiredColumnsProcessor + Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\SearchCriteriaProcessor + Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\StockProcessor + Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\VisibilityStatusProcessor + + + + + + Magento\Catalog\Model\Api\SearchCriteria\ProductCollectionProcessor + + diff --git a/app/code/Magento/CatalogGraphQl/etc/graphql.xml b/app/code/Magento/CatalogGraphQl/etc/graphql.xml deleted file mode 100644 index 6e5abe697a101..0000000000000 --- a/app/code/Magento/CatalogGraphQl/etc/graphql.xml +++ /dev/null @@ -1,477 +0,0 @@ - - - - - - - - - - - - - - AFN - ALL - AZN - DZD - AOA - ARS - AMD - AWG - AUD - BSD - BHD - BDT - BBD - BYR - BZD - BMD - BTN - BOB - BAM - BWP - BRL - GBP - BND - BGN - BUK - BIF - KHR - CAD - CVE - CZK - KYD - GQE - CLP - CNY - COP - KMF - CDF - CRC - HRK - CUP - DKK - DJF - DOP - XCD - EGP - SVC - ERN - EEK - ETB - EUR - FKP - FJD - GMD - GEK - GEL - GHS - GIP - GTQ - GNF - GYD - HTG - HNL - HKD - HUF - ISK - INR - IDR - IRR - IQD - ILS - JMD - JPY - JOD - KZT - KES - KWD - KGS - LAK - LVL - LBP - LSL - LRD - LYD - LTL - MOP - MKD - MGA - MWK - MYR - MVR - LSM - MRO - MUR - MXN - MDL - MNT - MAD - MZN - MMK - NAD - NPR - ANG - YTL - NZD - NIC - NGN - KPW - NOK - OMR - PKR - PAB - PGK - PYG - PEN - PHP - PLN - QAR - RHD - RON - RUB - RWF - SHP - STD - SAR - RSD - SCR - SLL - SGD - SKK - SBD - SOS - ZAR - KRW - LKR - SDG - SRD - SZL - SEK - CHF - SYP - TWD - TJS - TZS - THB - TOP - TTD - TND - TMM - USD - UGX - UAH - AED - UYU - UZS - VUV - VEB - VEF - VND - CHE - CHW - XOF - WST - YER - ZMK - ZWD - TRY - AZM - ROL - TRL - XPF - - - - INCLUDED - EXCLUDED - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - DY - - - - - - - - - - - - - - - - FIXED - PERCENT - DYNAMIC - - diff --git a/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml b/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml index e71b591839651..03631d049dafe 100644 --- a/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml @@ -6,38 +6,6 @@ */ --> - - - - - - Magento\CatalogGraphQl\Model\Resolver\Products\FilterArgument\ValueParser - - - 20 - - - 1 - - - - - - - - - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\BaseModelData - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\CustomAttributes - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\EntityIdToId - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\MediaGalleryEntries - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\Options - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\Price - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\TierPrices - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\NewFromTo - Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\ProductLinks - - - @@ -52,7 +20,14 @@ - + + + + Magento\CatalogGraphQl\Model\LayerFilterItemTypeResolver + + + + @@ -71,4 +46,31 @@ + + + Magento\CatalogGraphQl\Model\Layer\CollectionProvider + Magento\Catalog\Model\Layer\Category\StateKey + Magento\Catalog\Model\Layer\Category\CollectionFilter + + + + + Magento\CatalogGraphQl\Model\Layer\Context + + + + + Magento\Catalog\Model\Api\SearchCriteria\ProductCollectionProcessor + + + + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductPriceFilter + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductPriceFilter + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductPriceFilter + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\FilterProcessor\ProductCategoryFilter + + + diff --git a/app/code/Magento/CatalogGraphQl/etc/module.xml b/app/code/Magento/CatalogGraphQl/etc/module.xml index 1f7aca7667425..87696c129a714 100644 --- a/app/code/Magento/CatalogGraphQl/etc/module.xml +++ b/app/code/Magento/CatalogGraphQl/etc/module.xml @@ -9,7 +9,12 @@ + + + + + diff --git a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls new file mode 100644 index 0000000000000..5d9e174f169d1 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls @@ -0,0 +1,546 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +type Query { + products ( + search: String @doc(description: "Performs a full-text search using the specified key words."), + filter: ProductFilterInput @doc(description: "Identifies which product attributes to search for and return."), + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. This attribute is optional."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), + sort: ProductSortInput @doc(description: "Specifies which attribute to sort on, and whether to return the results in ascending or descending order.") + ): Products + @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Products") @doc(description: "The products query searches for products that match the criteria specified in the search and filter attributes") + category ( + id: Int @doc(description: "Id of the category") + ): CategoryTree + @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\CategoryTree") +} + +enum CurrencyEnum @doc(description: "The list of available currency codes") { + AFN + ALL + AZN + DZD + AOA + ARS + AMD + AWG + AUD + BSD + BHD + BDT + BBD + BYR + BZD + BMD + BTN + BOB + BAM + BWP + BRL + GBP + BND + BGN + BUK + BIF + KHR + CAD + CVE + CZK + KYD + GQE + CLP + CNY + COP + KMF + CDF + CRC + HRK + CUP + DKK + DJF + DOP + XCD + EGP + SVC + ERN + EEK + ETB + EUR + FKP + FJD + GMD + GEK + GEL + GHS + GIP + GTQ + GNF + GYD + HTG + HNL + HKD + HUF + ISK + INR + IDR + IRR + IQD + ILS + JMD + JPY + JOD + KZT + KES + KWD + KGS + LAK + LVL + LBP + LSL + LRD + LYD + LTL + MOP + MKD + MGA + MWK + MYR + MVR + LSM + MRO + MUR + MXN + MDL + MNT + MAD + MZN + MMK + NAD + NPR + ANG + YTL + NZD + NIC + NGN + KPW + NOK + OMR + PKR + PAB + PGK + PYG + PEN + PHP + PLN + QAR + RHD + RON + RUB + RWF + SHP + STD + SAR + RSD + SCR + SLL + SGD + SKK + SBD + SOS + ZAR + KRW + LKR + SDG + SRD + SZL + SEK + CHF + SYP + TWD + TJS + TZS + THB + TOP + TTD + TND + TMM + USD + UGX + UAH + AED + UYU + UZS + VUV + VEB + VEF + VND + CHE + CHW + XOF + WST + YER + ZMK + ZWD + TRY + AZM + ROL + TRL + XPF +} + +type Price @doc(description: "The Price object defines the price of a product as well as any tax-related adjustments.") { + amount: Money @doc(description: "The price of a product plus a three-letter currency code") + adjustments: [PriceAdjustment] @doc(description: "An array that provides information about tax, weee, or weee_tax adjustments") +} + +type PriceAdjustment @doc(description: "The PricedAdjustment object defines the amount of money to apply as an adjustment, the type of adjustment to apply, and whether the item is included or excluded from the adjustment.") { + amount: Money @doc(description: "The amount of the price adjustment and its currency code") + code: PriceAdjustmentCodesEnum @doc(description: "Indicates whether the adjustment involves tax, weee, or weee_tax") + description: PriceAdjustmentDescriptionEnum @doc(description: "Indicates whether the entity described by the code attribute is included or excluded from the adjustment") +} + +enum PriceAdjustmentCodesEnum @doc(description: "Note: This enumeration contains values defined in modules other than the Catalog module.") { +} + +enum PriceAdjustmentDescriptionEnum @doc(description: "This enumeration states whether a price adjustment is included or excluded.") { + INCLUDED + EXCLUDED +} + +enum PriceTypeEnum @doc(description: "This enumeration the price type.") { + FIXED + PERCENT + DYNAMIC +} + +type Money @doc(description: "A Money object defines a monetary value, including a numeric value and a currency code.") { + value: Float @doc(description: "A number expressing a monetary value") + currency: CurrencyEnum @doc(description: "A three-letter currency code, such as USD or EUR") +} + +type ProductPrices @doc(description: "The ProductPrices object contains the regular price of an item, as well as its minimum and maximum prices. Only composite products, which include bundle, configurable, and grouped products, can contain a minimum and maximum price.") { + minimalPrice: Price @doc(description: "The lowest possible final price for all the options defined within a composite product. If you are specifying a price range, this would be the from value.") + maximalPrice: Price @doc(description: "The highest possible final price for all the options defined within a composite product. If you are specifying a price range, this would be the to value.") + regularPrice: Price @doc(description: "The base price of a product.") +} + +type ProductLinks implements ProductLinksInterface @doc(description: "ProductLinks is an implementation of ProductLinksInterface.") { +} + +interface ProductLinksInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductLinkTypeResolverComposite") @doc(description:"ProductLinks contains information about linked products, including the link type and product type of each item.") { + sku: String @doc(description: "The identifier of the linked product") + link_type: String @doc(description: "One of related, associated, upsell, or crosssell") + linked_product_sku: String @doc(description: "The SKU of the linked product") + linked_product_type: String @doc(description: "The type of linked product (simple, virtual, bundle, downloadable, grouped, configurable)") + position: Int @doc(description: "The position within the list of product links") +} + +type ProductTierPrices @doc(description: "The ProductTierPrices object defines a tier price, which is a quantity discount offered to a specific customer group.") { + customer_group_id: String @doc(description: "The ID of the customer group") + qty: Float @doc(description: "The number of items that must be purchased to qualify for tier pricing") + value: Float @doc(description: "The price of the fixed price item") + percentage_value: Float @doc(description: "The percentage discount of the item") + website_id: Float @doc(description: "The ID assigned to the website") +} + +interface ProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "The ProductInterface contains attributes that are common to all types of products. Note that descriptions may not be available for custom and EAV attributes.") { + id: Int @doc(description: "The ID number assigned to the product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\EntityIdToId") + name: String @doc(description: "The product name. Customers use this name to identify the product.") + sku: String @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer") + description: String @doc(description: "Detailed information about the product. The value can include simple HTML tags.") + short_description: String @doc(description: "A short description of the product. Its use depends on the theme.") + special_price: Float @doc(description: "The discounted price of the product") + special_from_date: String @doc(description: "The beginning date that a product has a special price") + special_to_date: String @doc(description: "The end date that a product has a special price") + attribute_set_id: Int @doc(description: "The attribute set assigned to the product") + meta_title: String @doc(description: "A string that is displayed in the title bar and tab of the browser and in search results lists") + meta_keyword: String @doc(description: "A comma-separated list of keywords that are visible only to search engines") + meta_description: String @doc(description: "A brief overview of the product for search results listings, maximum 255 characters") + image: String @doc(description: "The relative path to the main image on the product page") + small_image: String @doc(description: "The relative path to the small image, which is used on catalog pages") + thumbnail: String @doc(description: "The relative path to the product's thumbnail image") + 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 @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") + image_label: String @doc(description: "The label assigned to a product image") + small_image_label: String @doc(description: "The label assigned to a product's small image") + thumbnail_label: String @doc(description: "The label assigned to a product's thumbnail image") + created_at: String @doc(description: "Timestamp indicating when the product was created") + updated_at: String @doc(description: "Timestamp indicating when the product was updated") + country_of_manufacture: String @doc(description: "The product's country of origin") + type_id: String @doc(description: "One of simple, virtual, bundle, downloadable, grouped, or configurable") + websites: [Website] @doc(description: "An array of websites in which the product is available") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Websites") + product_links: [ProductLinksInterface] @doc(description: "An array of ProductLinks objects") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductLinks") + media_gallery_entries: [MediaGalleryEntry] @doc(description: "An array of MediaGalleryEntry objects") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\MediaGalleryEntries") + tier_prices: [ProductTierPrices] @doc(description: "An array of ProductTierPrices objects") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\TierPrices") + price: ProductPrices @doc(description: "A ProductPrices object, indicating the price of an item") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Price") + gift_message_available: String @doc(description: "Indicates whether a gift message is available") + manufacturer: Int @doc(description: "A number representing the product's manufacturer") + categories: [CategoryInterface] @doc(description: "The categories assigned to a product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category") + canonical_url: String @doc(description: "Canonical URL") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CanonicalUrl") +} + +interface PhysicalProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "PhysicalProductInterface contains attributes specific to tangible products") { + weight: Float @doc(description: "The weight of the item, in units defined by the store") +} + +type CustomizableAreaOption implements CustomizableOptionInterface @doc(description: "CustomizableAreaOption contains information about a text area that is defined as part of a customizable option") { + value: CustomizableAreaValue @doc(description: "An object that defines a text area") + product_sku: String @doc(description: "The Stock Keeping Unit of the base product") +} + +type CustomizableAreaValue @doc(description: "CustomizableAreaValue defines the price and sku of a product whose page contains a customized text area") { + price: Float @doc(description: "The price assigned to this option") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC") + sku: String @doc(description: "The Stock Keeping Unit for this option") + max_characters: Int @doc(description: "The maximum number of characters that can be entered for this customizable option") +} + +type CategoryTree implements CategoryInterface @doc(description: "Category Tree implementation") { + children: [CategoryTree] @doc(description: "Child categories tree") @resolve(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\CategoryTree") +} + +type CustomizableDateOption implements CustomizableOptionInterface @doc(description: "CustomizableDateOption contains information about a date picker that is defined as part of a customizable option") { + value: CustomizableDateValue @doc(description: "An object that defines a date field in a customizable option.") + product_sku: String @doc(description: "The Stock Keeping Unit of the base product") +} + +type CustomizableDateValue @doc(description: "CustomizableDateValue defines the price and sku of a product whose page contains a customized date picker") { + price: Float @doc(description: "The price assigned to this option") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC") + sku: String @doc(description: "The Stock Keeping Unit for this option") +} + +type CustomizableDropDownOption implements CustomizableOptionInterface @doc(description: "CustomizableDropDownOption contains information about a drop down menu that is defined as part of a customizable option") { + value: [CustomizableDropDownValue] @doc(description: "An array that defines the set of options for a drop down menu") +} + +type CustomizableDropDownValue @doc(description: "CustomizableDropDownValue defines the price and sku of a product whose page contains a customized drop down menu") { + option_type_id: Int @doc(description: "The ID assigned to the value") + price: Float @doc(description: "The price assigned to this option") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC") + sku: String @doc(description: "The Stock Keeping Unit for this option") + title: String @doc(description: "The display name for this option") + sort_order: Int @doc(description: "The order in which the option is displayed") +} + +type CustomizableFieldOption implements CustomizableOptionInterface @doc(description: "CustomizableFieldOption contains information about a text field that is defined as part of a customizable option") { + value: CustomizableFieldValue @doc(description: "An object that defines a text field") + product_sku: String @doc(description: "The Stock Keeping Unit of the base product") +} + +type CustomizableFieldValue @doc(description: "CustomizableFieldValue defines the price and sku of a product whose page contains a customized text field") { + price: Float @doc(description: "The price of the custom value") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC") + sku: String @doc(description: "The Stock Keeping Unit for this option") + max_characters: Int @doc(description: "The maximum number of characters that can be entered for this customizable option") +} + +type CustomizableFileOption implements CustomizableOptionInterface @doc(description: "CustomizableFileOption contains information about a file picker that is defined as part of a customizable option") { + value: CustomizableFileValue @doc(description: "An object that defines a file value") + product_sku: String @doc(description: "The Stock Keeping Unit of the base product") +} + +type CustomizableFileValue @doc(description: "CustomizableFileValue defines the price and sku of a product whose page contains a customized file picker") { + price: Float @doc(description: "The price assigned to this option") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC") + sku: String @doc(description: "The Stock Keeping Unit for this option") + file_extension: String @doc(description: "The file extension to accept") + image_size_x: Int @doc(description: "The maximum width of an image") + image_size_y: Int @doc(description: "The maximum height of an image") +} + +interface CustomizableOptionInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\CustomizableOptionTypeResolver") @doc(description: "The CustomizableOptionInterface contains basic information about a customizable option. It can be implemented by several types of configurable options.") { + title: String @doc(description: "The display name for this option") + required: Boolean @doc(description: "Indicates whether the option is required") + sort_order: Int @doc(description: "The order in which the option is displayed") +} + +interface CustomizableProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "CustomizableProductInterface contains information about customizable product options.") { + options: [CustomizableOptionInterface] @doc(description: "An array of options for a customizable product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Options") +} + +interface CategoryInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\CategoryInterfaceTypeResolver") @doc(description: "CategoryInterface contains the full set of attributes that can be returned in a category search") { + id: Int @doc(description: "An ID that uniquely identifies the category") + description: String @doc(description: "An optional description of the category") + name: String @doc(description: "The display name of the category") + path: String @doc(description: "Category Path") + path_in_store: String @doc(description: "Category path in store") + url_key: String @doc(description: "The url key assigned to the category") + url_path: String @doc(description: "The url path assigned to the category") + position: Int @doc(description: "The position of the category relative to other categories at the same level in tree") + level: Int @doc(description: "Indicates the depth of the category within the tree") + created_at: String @doc(description: "Timestamp indicating when the category was created") + updated_at: String @doc(description: "Timestamp indicating when the category was updated") + product_count: Int @doc(description: "The number of products in the category") + default_sort_by: String @doc(description: "The attribute to use for sorting") + products( + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. This attribute is optional."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), + sort: ProductSortInput @doc(description: "Specifies which attribute to sort on, and whether to return the results in ascending or descending order.") + ): CategoryProducts @doc(description: "The list of products assigned to the category") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\Products") +} + +type CustomizableRadioOption implements CustomizableOptionInterface @doc(description: "CustomizableRadioOption contains information about a set of radio buttons that are defined as part of a customizable option") { + value: [CustomizableRadioValue] @doc(description: "An array that defines a set of radio buttons") +} + +type CustomizableRadioValue @doc(description: "CustomizableRadioValue defines the price and sku of a product whose page contains a customized set of radio buttons") { + option_type_id: Int @doc(description: "The ID assigned to the value") + price: Float @doc(description: "The price assigned to this option") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC") + sku: String @doc(description: "The Stock Keeping Unit for this option") + title: String @doc(description: "The display name for this option") + sort_order: Int @doc(description: "The order in which the radio button is displayed") +} + +type VirtualProduct implements ProductInterface, CustomizableProductInterface @doc(description: "A virtual product is non-tangible product that does not require shipping and is not kept in inventory") { +} + +type SimpleProduct implements ProductInterface, PhysicalProductInterface, CustomizableProductInterface @doc(description: "A simple product is tangible and are usually sold as single units or in fixed quantities") +{ +} + +type Products @doc(description: "The Products object is the top-level object returned in a product search") { + items: [ProductInterface] @doc(description: "An array of products that match the specified search criteria") + page_info: SearchResultPageInfo @doc(description: "An object that includes the page_info and currentPage values specified in the query") + total_count: Int @doc(description: "The number of products returned") + filters: [LayerFilter] @doc(description: "Layered navigation filters array") + sort_fields: SortFields @doc(description: "An object that includes the default sort field and all available sort fields") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\SortFields") +} + +type CategoryProducts @doc(description: "The category products object returned in the Category query") { + items: [ProductInterface] @doc(description: "An array of products that are assigned to the category") + page_info: SearchResultPageInfo @doc(description: "An object that includes the page_info and currentPage values specified in the query") + total_count: Int @doc(description: "The number of products returned") +} + +input ProductFilterInput @doc(description: "ProductFilterInput defines the filters to be used in the search. A filter contains at least one attribute, a comparison operator, and the value that is being searched for.") { + name: FilterTypeInput @doc(description: "The product name. Customers use this name to identify the product.") + sku: FilterTypeInput @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer") + description: FilterTypeInput @doc(description: "Detailed information about the product. The value can include simple HTML tags.") + short_description: FilterTypeInput @doc(description: "A short description of the product. Its use depends on the theme.") + price: FilterTypeInput @doc(description: "The price of an item") + special_price: FilterTypeInput @doc(description: "The discounted price of the product") + special_from_date: FilterTypeInput @doc(description: "The beginning date that a product has a special price") + special_to_date: FilterTypeInput @doc(description: "The end date that a product has a special price") + weight: FilterTypeInput @doc(description: "The weight of the item, in units defined by the store") + manufacturer: FilterTypeInput @doc(description: "A number representing the product's manufacturer") + meta_title: FilterTypeInput @doc(description: "A string that is displayed in the title bar and tab of the browser and in search results lists") + meta_keyword: FilterTypeInput @doc(description: "A comma-separated list of keywords that are visible only to search engines") + meta_description: FilterTypeInput @doc(description: "A brief overview of the product for search results listings, maximum 255 characters") + image: FilterTypeInput @doc(description: "The relative path to the main image on the product page") + small_image: FilterTypeInput @doc(description: "The relative path to the small image, which is used on catalog pages") + thumbnail: FilterTypeInput @doc(description: "The relative path to the product's thumbnail image") + tier_price: FilterTypeInput @doc(description: "The price when tier pricing is in effect and the items purchased threshold has been reached") + news_from_date: FilterTypeInput @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product") + news_to_date: FilterTypeInput @doc(description: "The end date for new product listings") + custom_layout_update: FilterTypeInput @doc(description: "XML code that is applied as a layout update to the product page") + min_price: FilterTypeInput @doc(description:"The numeric minimal price of the product. Do not include the currency code.") + max_price: FilterTypeInput @doc(description:"The numeric maximal price of the product. Do not include the currency code.") + special_price: FilterTypeInput @doc(description:"The numeric special price of the product. Do not include the currency code.") + category_ids: FilterTypeInput @doc(description: "An array of category IDs the product belongs to") + options_container: FilterTypeInput @doc(description: "If the product has multiple options, determines where they appear on the product page") + required_options: FilterTypeInput @doc(description: "Indicates whether the product has required options") + has_options: FilterTypeInput @doc(description: "Indicates whether additional attributes have been created for the product") + image_label: FilterTypeInput @doc(description: "The label assigned to a product image") + small_image_label: FilterTypeInput @doc(description: "The label assigned to a product's small image") + thumbnail_label: FilterTypeInput @doc(description: "The label assigned to a product's thumbnail image") + created_at: FilterTypeInput @doc(description: "Timestamp indicating when the product was created") + updated_at: FilterTypeInput @doc(description: "Timestamp indicating when the product was updated") + country_of_manufacture: FilterTypeInput @doc(description: "The product's country of origin") + custom_layout: FilterTypeInput @doc(description: "The name of a custom layout") + gift_message_available: FilterTypeInput @doc(description: "Indicates whether a gift message is available") + or: ProductFilterInput @doc(description: "The keyword required to perform a logical OR comparison") +} + +type ProductMediaGalleryEntriesContent @doc(description: "ProductMediaGalleryEntriesContent contains an image in base64 format and basic information about the image") { + base64_encoded_data: String @doc(description: "The image in base64 format") + type: String @doc(description: "The MIME type of the file, such as image/png") + name: String @doc(description: "The file name of the image") +} + +type ProductMediaGalleryEntriesVideoContent @doc(description: "ProductMediaGalleryEntriesVideoContent contains a link to a video file and basic information about the video") { + media_type: String @doc(description: "Must be external-video") + video_provider: String @doc(description: "Describes the video source") + video_url: String @doc(description: "The URL to the video") + video_title: String @doc(description: "The title of the video") + video_description: String @doc(description: "A description of the video") + video_metadata: String @doc(description: "Optional data about the video") +} + +input ProductSortInput @doc(description: "ProductSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order") { + name: SortEnum @doc(description: "The product name. Customers use this name to identify the product.") + sku: SortEnum @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer") + description: SortEnum @doc(description: "Detailed information about the product. The value can include simple HTML tags.") + short_description: SortEnum @doc(description: "A short description of the product. Its use depends on the theme.") + price: SortEnum @doc(description: "The price of the item") + special_price: SortEnum @doc(description: "The discounted price of the product") + special_from_date: SortEnum @doc(description: "The beginning date that a product has a special price") + special_to_date: SortEnum @doc(description: "The end date that a product has a special price") + weight: SortEnum @doc(description: "The weight of the item, in units defined by the store") + manufacturer: SortEnum @doc(description: "A number representing the product's manufacturer") + meta_title: SortEnum @doc(description: "A string that is displayed in the title bar and tab of the browser and in search results lists") + meta_keyword: SortEnum @doc(description: "A comma-separated list of keywords that are visible only to search engines") + meta_description: SortEnum @doc(description: "A brief overview of the product for search results listings, maximum 255 characters") + image: SortEnum @doc(description: "The relative path to the main image on the product page") + small_image: SortEnum @doc(description: "The relative path to the small image, which is used on catalog pages") + thumbnail: SortEnum @doc(description: "The relative path to the product's thumbnail image") + tier_price: SortEnum @doc(description: "The price when tier pricing is in effect and the items purchased threshold has been reached") + news_from_date: SortEnum @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product") + news_to_date: SortEnum @doc(description: "The end date for new product listings") + custom_layout_update: SortEnum @doc(description: "XML code that is applied as a layout update to the product page") + category_ids: SortEnum @doc(description: "An array of category IDs the product belongs to") + options_container: SortEnum @doc(description: "If the product has multiple options, determines where they appear on the product page") + required_options: SortEnum @doc(description: "Indicates whether the product has required options") + has_options: SortEnum @doc(description: "Indicates whether additional attributes have been created for the product") + image_label: SortEnum @doc(description: "The label assigned to a product image") + small_image_label: SortEnum @doc(description: "The label assigned to a product's small image") + thumbnail_label: SortEnum @doc(description: "The label assigned to a product's thumbnail image") + created_at: SortEnum @doc(description: "Timestamp indicating when the product was created") + updated_at: SortEnum @doc(description: "Timestamp indicating when the product was updated") + country_of_manufacture: SortEnum @doc(description: "The product's country of origin") + custom_layout: SortEnum @doc(description: "The name of a custom layout") + gift_message_available: SortEnum @doc(description: "Indicates whether a gift message is available") +} + +type MediaGalleryEntry @doc(description: "MediaGalleryEntry defines characteristics about images and videos associated with a specific product") { + id: Int @doc(description: "The identifier assigned to the object") + media_type: String @doc(description: "image or video") + label: String @doc(description: "The alt text displayed on the UI when the user points to the image") + position: Int @doc(description: "The media item's position after it has been sorted") + disabled: Boolean @doc(description: "Whether the image is hidden from vie") + types: [String] @doc(description: "Array of image types. It can have the following values: image, small_image, thumbnail") + file: String @doc(description: "The path of the image on the server") + content: ProductMediaGalleryEntriesContent @doc(description: "Contains a ProductMediaGalleryEntriesContent object") + video_content: ProductMediaGalleryEntriesVideoContent @doc(description: "Contains a ProductMediaGalleryEntriesVideoContent object") +} + +type LayerFilter { + name: String @doc(description: "Layered navigation filter name") + request_var: String @doc(description: "Request variable name for filter query") + filter_items_count: Int @doc(description: "Count of filter items in filter group") + filter_items: [LayerFilterItemInterface] @doc(description: "Array of filter items") +} + +interface LayerFilterItemInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\LayerFilterItemTypeResolverComposite") { + label: String @doc(description: "Filter label") + value_string: String @doc(description: "Value for filter request variable to be used in query") + items_count: Int @doc(description: "Count of items by filter") +} + +type LayerFilterItem implements LayerFilterItemInterface { + +} + +type SortField { + value: String @doc(description: "Attribute code of sort field") + label: String @doc(description: "Label of sort field") +} + +type SortFields @doc(description: "SortFields contains a default value for sort fields and all available sort fields") { + default: String @doc(description: "Default value of sort fields") + options: [SortField] @doc(description: "Available sort fields") +} diff --git a/app/code/Magento/CatalogImportExport/Model/Export/Product.php b/app/code/Magento/CatalogImportExport/Model/Export/Product.php index f6d85b57ab0ce..23aa8d65ddb0d 100644 --- a/app/code/Magento/CatalogImportExport/Model/Export/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Export/Product.php @@ -5,10 +5,12 @@ */ namespace Magento\CatalogImportExport\Model\Export; +use Magento\Catalog\Model\ResourceModel\Product\Option\Collection; use Magento\CatalogImportExport\Model\Import\Product\CategoryProcessor; use Magento\ImportExport\Model\Import; use \Magento\Store\Model\Store; use \Magento\CatalogImportExport\Model\Import\Product as ImportProduct; +use Magento\Catalog\Model\Product as ProductEntity; /** * Export entity product model @@ -202,7 +204,7 @@ class Product extends \Magento\ImportExport\Model\Export\Entity\AbstractEntity protected $_itemFactory; /** - * @var \Magento\Catalog\Model\ResourceModel\Product\Option\Collection + * @var Collection */ protected $_optionColFactory; @@ -917,6 +919,29 @@ protected function getExportData() return $exportData; } + /** + * Load products' data from the collection + * and filter it (if needed). + * + * @return array Keys are product IDs, values arrays with keys as store IDs + * and values as store-specific versions of Product entity. + */ + protected function loadCollection(): array + { + $data = []; + + $collection = $this->_getEntityCollection(); + foreach (array_keys($this->_storeIdToCode) as $storeId) { + $collection->setStoreId($storeId); + foreach ($collection as $itemId => $item) { + $data[$itemId][$storeId] = $item; + } + } + $collection->clear(); + + return $data; + } + /** * Collect export data for all products * @@ -927,14 +952,15 @@ protected function getExportData() protected function collectRawData() { $data = []; - $collection = $this->_getEntityCollection(); - foreach ($this->_storeIdToCode as $storeId => $storeCode) { - $collection->setStoreId($storeId); - /** - * @var int $itemId - * @var \Magento\Catalog\Model\Product $item - */ - foreach ($collection as $itemId => $item) { + $items = $this->loadCollection(); + + /** + * @var int $itemId + * @var ProductEntity[] $itemByStore + */ + foreach ($items as $itemId => $itemByStore) { + foreach ($this->_storeIdToCode as $storeId => $storeCode) { + $item = $itemByStore[$storeId]; $additionalAttributes = []; $productLinkId = $item->getData($this->getProductEntityLinkField()); foreach ($this->_getExportAttrCodes() as $code) { @@ -1012,7 +1038,6 @@ protected function collectRawData() $data[$itemId][$storeId]['product_id'] = $itemId; $data[$itemId][$storeId]['product_link_id'] = $productLinkId; } - $collection->clear(); } return $data; @@ -1325,6 +1350,12 @@ protected function optionRowToCellString($option) } /** + * Collect custom options data for products that will be exported. + * + * Option name and type will be collected for all store views, all other data (which can't be changed on store view + * level will be collected for DEFAULT_STORE_ID only. + * Store view specified data will be saved to the additional store view row. + * * @param int[] $productIds * @return array * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -1335,49 +1366,48 @@ protected function getCustomOptionsData($productIds) $customOptionsData = []; foreach (array_keys($this->_storeIdToCode) as $storeId) { - if (Store::DEFAULT_STORE_ID != $storeId) { - continue; - } $options = $this->_optionColFactory->create(); - /* @var \Magento\Catalog\Model\ResourceModel\Product\Option\Collection $options*/ - $options->reset(); - $options->addOrder('sort_order', 'ASC'); - $options->addTitleToResult($storeId); - $options->addPriceToResult($storeId); - $options->addProductToFilter($productIds); - $options->addValuesToResult($storeId); + /* @var Collection $options*/ + $options->reset() + ->addOrder('sort_order', Collection::SORT_ORDER_ASC) + ->addTitleToResult($storeId) + ->addPriceToResult($storeId) + ->addProductToFilter($productIds) + ->addValuesToResult($storeId); foreach ($options as $option) { $row = []; $productId = $option['product_id']; - $row['name'] = $option['title']; $row['type'] = $option['type']; - $row['required'] = $option['is_require']; - $row['price'] = $option['price']; - $row['price_type'] = ($option['price_type'] == 'percent') ? $option['price_type'] : 'fixed'; - $row['sku'] = $option['sku']; - if ($option['max_characters']) { - $row['max_characters'] = $option['max_characters']; - } - - foreach (['file_extension', 'image_size_x', 'image_size_y'] as $fileOptionKey) { - if (!isset($option[$fileOptionKey])) { - continue; + if (Store::DEFAULT_STORE_ID === $storeId) { + $row['required'] = $option['is_require']; + $row['price'] = $option['price']; + $row['price_type'] = ($option['price_type'] === 'percent') ? 'percent' : 'fixed'; + $row['sku'] = $option['sku']; + if ($option['max_characters']) { + $row['max_characters'] = $option['max_characters']; } - $row[$fileOptionKey] = $option[$fileOptionKey]; - } + foreach (['file_extension', 'image_size_x', 'image_size_y'] as $fileOptionKey) { + if (!isset($option[$fileOptionKey])) { + continue; + } + $row[$fileOptionKey] = $option[$fileOptionKey]; + } + } $values = $option->getValues(); if ($values) { foreach ($values as $value) { - $valuePriceType = ($value['price_type'] == 'percent') ? $value['price_type'] : 'fixed'; $row['option_title'] = $value['title']; - $row['price'] = $value['price']; - $row['price_type'] = $valuePriceType; - $row['sku'] = $value['sku']; + if (Store::DEFAULT_STORE_ID === $storeId) { + $row['option_title'] = $value['title']; + $row['price'] = $value['price']; + $row['price_type'] = ($value['price_type'] === 'percent') ? 'percent' : 'fixed'; + $row['sku'] = $value['sku']; + } $customOptionsData[$productId][$storeId][] = $this->optionRowToCellString($row); } } else { diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php index cf8707b472156..7d175d524e287 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php @@ -10,6 +10,7 @@ use Magento\CatalogImportExport\Model\Import\Product\MediaGalleryProcessor; use Magento\CatalogImportExport\Model\Import\Product\ImageTypeProcessor; use Magento\CatalogImportExport\Model\Import\Product\RowValidatorInterface as ValidatorInterface; +use Magento\CatalogImportExport\Model\StockItemImporterInterface; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; @@ -535,6 +536,7 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity /** * @var \Magento\CatalogInventory\Model\ResourceModel\Stock\ItemFactory + * @deprecated this variable isn't used anymore. */ protected $_stockResItemFac; @@ -703,6 +705,13 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity */ private $catalogConfig; + /** + * Stock Item Importer + * + * @var StockItemImporterInterface + */ + private $stockItemImporter; + /** * @var ImageTypeProcessor */ @@ -751,12 +760,13 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity * @param TransactionManagerInterface $transactionManager * @param Product\TaxClassProcessor $taxClassProcessor * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig + * @param \Magento\Catalog\Model\Product\Url $productUrl * @param array $data * @param array $dateAttrCodes * @param CatalogConfig $catalogConfig * @param ImageTypeProcessor $imageTypeProcessor * @param MediaGalleryProcessor $mediaProcessor - * @throws \Magento\Framework\Exception\LocalizedException + * @param StockItemImporterInterface|null $stockItemImporter * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -801,7 +811,8 @@ public function __construct( array $dateAttrCodes = [], CatalogConfig $catalogConfig = null, ImageTypeProcessor $imageTypeProcessor = null, - MediaGalleryProcessor $mediaProcessor = null + MediaGalleryProcessor $mediaProcessor = null, + StockItemImporterInterface $stockItemImporter = null ) { $this->_eventManager = $eventManager; $this->stockRegistry = $stockRegistry; @@ -835,7 +846,8 @@ public function __construct( $this->catalogConfig = $catalogConfig ?: ObjectManager::getInstance()->get(CatalogConfig::class); $this->imageTypeProcessor = $imageTypeProcessor ?: ObjectManager::getInstance()->get(ImageTypeProcessor::class); $this->mediaProcessor = $mediaProcessor ?: ObjectManager::getInstance()->get(MediaGalleryProcessor::class); - + $this->stockItemImporter = $stockItemImporter ?: ObjectManager::getInstance() + ->get(StockItemImporterInterface::class); parent::__construct( $jsonHelper, $importExportData, @@ -851,7 +863,6 @@ public function __construct( ) ? $data['option_entity'] : $optionFactory->create( ['data' => ['product_entity' => $this]] ); - $this->_initAttributeSets() ->_initTypeModels() ->_initSkus() @@ -1692,7 +1703,7 @@ protected function _saveProducts() $storeId = !empty($rowData[self::COL_STORE]) ? $this->getStoreIdByCode($rowData[self::COL_STORE]) : Store::DEFAULT_STORE_ID; - if (isset($rowData['_media_is_disabled'])) { + if (isset($rowData['_media_is_disabled']) && strlen(trim($rowData['_media_is_disabled']))) { $disabledImages = array_flip( explode($this->getMultipleValueSeparator(), $rowData['_media_is_disabled']) ); @@ -1712,7 +1723,7 @@ protected function _saveProducts() foreach ($rowImages as $column => $columnImages) { foreach ($columnImages as $columnImageKey => $columnImage) { if (!isset($uploadedImages[$columnImage])) { - $uploadedFile = $this->uploadMediaFiles($columnImage, true); + $uploadedFile = $this->uploadMediaFiles($columnImage); $uploadedFile = $uploadedFile ?: $this->getSystemFile($columnImage); if ($uploadedFile) { $uploadedImages[$columnImage] = $uploadedFile; @@ -2128,9 +2139,6 @@ protected function _saveProductWebsites(array $websiteData) */ protected function _saveStockItem() { - /** @var $stockResource \Magento\CatalogInventory\Model\ResourceModel\Stock\Item */ - $stockResource = $this->_stockResItemFac->create(); - $entityTable = $stockResource->getMainTable(); while ($bunch = $this->_dataSourceModel->getNextBunch()) { $stockData = []; $productIdsToReindex = []; @@ -2158,6 +2166,7 @@ protected function _saveStockItem() array_intersect_key($rowData, $this->defaultStockData), $row ); + $row['sku'] = $sku; if ($this->stockConfiguration->isQty( $this->skuProcessor->getNewSku($sku)['type_id'] @@ -2185,7 +2194,7 @@ protected function _saveStockItem() // Insert rows if (!empty($stockData)) { - $this->_connection->insertOnDuplicate($entityTable, array_values($stockData)); + $this->stockItemImporter->import($stockData); } $this->reindexProducts($productIdsToReindex); diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php index 2a5b009733d36..cbaf401f32982 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php @@ -12,6 +12,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\Collection as ProductOptionValueCollection; use Magento\Catalog\Model\ResourceModel\Product\Option\Value\CollectionFactory as ProductOptionValueCollectionFactory; +use Magento\Store\Model\Store; /** * Entity class which provide possibility to import product custom options @@ -495,7 +496,7 @@ protected function _initStores(array $data) if (isset($data['stores'])) { $this->_storeCodeToId = $data['stores']; } else { - /** @var $store \Magento\Store\Model\Store */ + /** @var $store Store */ foreach ($this->_storeManager->getStores(true) as $store) { $this->_storeCodeToId[$store->getCode()] = $store->getId(); } @@ -759,7 +760,9 @@ protected function _findExistingOptionId(array $newOptionData, array $newOptionT ksort($newOptionTitles); $existingOptions = $this->_oldCustomOptions[$productId]; foreach ($existingOptions as $optionId => $optionData) { - if ($optionData['type'] == $newOptionData['type'] && $optionData['titles'] == $newOptionTitles) { + if ($optionData['type'] == $newOptionData['type'] + && $optionData['titles'][Store::DEFAULT_STORE_ID] == $newOptionTitles[Store::DEFAULT_STORE_ID] + ) { return $optionId; } } @@ -854,7 +857,7 @@ protected function _saveNewOptionData(array $rowData, $rowNumber) $storeCode = $rowData[self::COLUMN_STORE]; $storeId = $this->_storeCodeToId[$storeCode]; } else { - $storeId = \Magento\Store\Model\Store::DEFAULT_STORE_ID; + $storeId = Store::DEFAULT_STORE_ID; } if (isset($this->_productsSkuToId[$this->_rowProductSku])) { // save in existing data array @@ -1121,17 +1124,44 @@ private function processOptionRow($name, $optionRow) { $result = [ self::COLUMN_TYPE => $name ? $optionRow['type'] : '', - self::COLUMN_IS_REQUIRED => $optionRow['required'], - self::COLUMN_ROW_SKU => $optionRow['sku'], - self::COLUMN_PREFIX . 'sku' => $optionRow['sku'], self::COLUMN_ROW_TITLE => '', self::COLUMN_ROW_PRICE => '' ]; + $result = $this->addPriceData($result, $optionRow); + + if (isset($optionRow['_custom_option_store'])) { + $result[self::COLUMN_STORE] = $optionRow['_custom_option_store']; + } + if (isset($optionRow['required'])) { + $result[self::COLUMN_IS_REQUIRED] = $optionRow['required']; + } + if (isset($optionRow['sku'])) { + $result[self::COLUMN_ROW_SKU] = $optionRow['sku']; + $result[self::COLUMN_PREFIX . 'sku'] = $optionRow['sku']; + } if (isset($optionRow['option_title'])) { $result[self::COLUMN_ROW_TITLE] = $optionRow['option_title']; } + if (isset($optionRow['max_characters'])) { + $result[$this->columnMaxCharacters] = $optionRow['max_characters']; + } + + $result = $this->addFileOptions($result, $optionRow); + + return $result; + } + + /** + * Adds price data. + * + * @param array $result + * @param array $optionRow + * @return array + */ + private function addPriceData(array $result, array $optionRow): array + { if (isset($optionRow['price'])) { $percent_suffix = ''; if (isset($optionRow['price_type']) && $optionRow['price_type'] == 'percent') { @@ -1142,12 +1172,6 @@ private function processOptionRow($name, $optionRow) $result[self::COLUMN_PREFIX . 'price'] = $result[self::COLUMN_ROW_PRICE]; - if (isset($optionRow['max_characters'])) { - $result[$this->columnMaxCharacters] = $optionRow['max_characters']; - } - - $result = $this->addFileOptions($result, $optionRow); - return $result; } @@ -1172,7 +1196,8 @@ private function addFileOptions($result, $optionRow) } /** - * Import data rows + * Import data rows. + * Additional store view data (option titles) will be sought in store view specified import file rows * * @return boolean * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -1186,7 +1211,8 @@ protected function _importData() $this->_tables['catalog_product_option_type_value'] ); $prevOptionId = 0; - + $optionId = null; + $valueId = null; while ($bunch = $this->_dataSourceModel->getNextBunch()) { $products = []; $options = []; @@ -1199,6 +1225,12 @@ protected function _importData() $childCount = []; foreach ($bunch as $rowNumber => $rowData) { + if (isset($optionId, $valueId) && empty($rowData[PRODUCT::COL_STORE_VIEW_CODE])) { + $nextOptionId = $optionId; + $nextValueId = $valueId; + } + $optionId = $nextOptionId; + $valueId = $nextValueId; $multiRowData = $this->_getMultiRowFormat($rowData); foreach ($multiRowData as $optionData) { @@ -1213,7 +1245,7 @@ protected function _importData() $optionData = $this->_collectOptionMainData( $combinedData, $prevOptionId, - $nextOptionId, + $optionId, $products, $prices ); @@ -1223,7 +1255,7 @@ protected function _importData() $this->_collectOptionTypeData( $combinedData, $prevOptionId, - $nextValueId, + $valueId, $typeValues, $typePrices, $typeTitles, @@ -1306,7 +1338,9 @@ protected function _collectOptionMainData( $optionData = null; if ($this->_rowIsMain) { - $optionData = $this->_getOptionData($rowData, $this->_rowProductId, $nextOptionId, $this->_rowType); + $optionData = empty($rowData[Product::COL_STORE_VIEW_CODE]) + ? $this->_getOptionData($rowData, $this->_rowProductId, $nextOptionId, $this->_rowType) + : ''; if (!$this->_isRowHasSpecificType($this->_rowType) && ($priceData = $this->_getPriceData($rowData, $nextOptionId, $this->_rowType)) @@ -1337,6 +1371,7 @@ protected function _collectOptionMainData( * @param array &$childCount * @return void * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ protected function _collectOptionTypeData( array $rowData, @@ -1355,43 +1390,28 @@ protected function _collectOptionTypeData( $typeValues[$prevOptionId][] = $specificTypeData['value']; // ensure default title is set - if (!isset($typeTitles[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID])) { - $typeTitles[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = - $specificTypeData['title']; + if (!isset($typeTitles[$nextValueId][Store::DEFAULT_STORE_ID])) { + $typeTitles[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['title']; } if ($specificTypeData['price']) { if ($this->_isPriceGlobal) { - $typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = - $specificTypeData['price']; + $typePrices[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['price']; } else { // ensure default price is set - if (!isset($typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID])) { - $typePrices[$nextValueId][\Magento\Store\Model\Store::DEFAULT_STORE_ID] = - $specificTypeData['price']; + if (!isset($typePrices[$nextValueId][Store::DEFAULT_STORE_ID])) { + $typePrices[$nextValueId][Store::DEFAULT_STORE_ID] = $specificTypeData['price']; } $typePrices[$nextValueId][$this->_rowStoreId] = $specificTypeData['price']; } } $nextValueId++; - if (isset($parentCount[$prevOptionId])) { - $parentCount[$prevOptionId]++; - } else { - $parentCount[$prevOptionId] = 1; - } - } - - if (!isset($childCount[$this->_rowStoreId][$prevOptionId])) { - $childCount[$this->_rowStoreId][$prevOptionId] = 0; } - $parentValueId = $nextValueId - $parentCount[$prevOptionId] - + $childCount[$this->_rowStoreId][$prevOptionId]; - $specificTypeData = $this->_getSpecificTypeData($rowData, $parentValueId, false); + $specificTypeData = $this->_getSpecificTypeData($rowData, 0, false); //For others stores if ($specificTypeData) { - $typeTitles[$parentValueId][$this->_rowStoreId] = $specificTypeData['title']; - $childCount[$this->_rowStoreId][$prevOptionId]++; + $typeTitles[$nextValueId++][$this->_rowStoreId] = $specificTypeData['title']; } } } @@ -1406,7 +1426,7 @@ protected function _collectOptionTypeData( */ protected function _collectOptionTitle(array $rowData, $prevOptionId, array &$titles) { - $defaultStoreId = \Magento\Store\Model\Store::DEFAULT_STORE_ID; + $defaultStoreId = Store::DEFAULT_STORE_ID; if (!empty($rowData[self::COLUMN_TITLE])) { if (!isset($titles[$prevOptionId][$defaultStoreId])) { // ensure default title is set @@ -1530,7 +1550,7 @@ protected function _parseRequiredData(array $rowData) } $this->_rowStoreId = $this->_storeCodeToId[$rowData[self::COLUMN_STORE]]; } else { - $this->_rowStoreId = \Magento\Store\Model\Store::DEFAULT_STORE_ID; + $this->_rowStoreId = Store::DEFAULT_STORE_ID; } // Init option type and set param which tell that row is main if (!empty($rowData[self::COLUMN_TYPE])) { @@ -1644,7 +1664,7 @@ protected function _getPriceData(array $rowData, $optionId, $type) ) { $priceData = [ 'option_id' => $optionId, - 'store_id' => \Magento\Store\Model\Store::DEFAULT_STORE_ID, + 'store_id' => Store::DEFAULT_STORE_ID, 'price_type' => 'fixed', ]; diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php index 939d6b2de67ee..17d084002926a 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php @@ -503,7 +503,7 @@ public function prepareAttributesWithDefaultValueForSave(array $rowData, $withDe if ($attrParams['is_static']) { continue; } - if (isset($rowData[$attrCode]) && strlen($rowData[$attrCode])) { + if (isset($rowData[$attrCode]) && strlen(trim($rowData[$attrCode]))) { if (in_array($attrParams['type'], ['select', 'boolean'])) { $resultAttrs[$attrCode] = $attrParams['options'][strtolower($rowData[$attrCode])]; } elseif ('multiselect' == $attrParams['type']) { diff --git a/app/code/Magento/CatalogImportExport/Model/Indexer/Product/Flat/Plugin/Import.php b/app/code/Magento/CatalogImportExport/Model/Indexer/Product/Flat/Plugin/Import.php index 07cf5a3dfc8d7..320b737b77087 100644 --- a/app/code/Magento/CatalogImportExport/Model/Indexer/Product/Flat/Plugin/Import.php +++ b/app/code/Magento/CatalogImportExport/Model/Indexer/Product/Flat/Plugin/Import.php @@ -5,8 +5,15 @@ */ namespace Magento\CatalogImportExport\Model\Indexer\Product\Flat\Plugin; +use Magento\Catalog\Model\Indexer\Product\Flat\State as FlatState; + class Import { + /** + * @var \Magento\Catalog\Model\Indexer\Product\Flat\State + */ + private $flatState; + /** * @var \Magento\Catalog\Model\Indexer\Product\Flat\Processor */ @@ -14,10 +21,14 @@ class Import /** * @param \Magento\Catalog\Model\Indexer\Product\Flat\Processor $productFlatIndexerProcessor + * @param \Magento\Catalog\Model\Indexer\Product\Flat\State $flatState */ - public function __construct(\Magento\Catalog\Model\Indexer\Product\Flat\Processor $productFlatIndexerProcessor) - { + public function __construct( + \Magento\Catalog\Model\Indexer\Product\Flat\Processor $productFlatIndexerProcessor, + FlatState $flatState + ) { $this->_productFlatIndexerProcessor = $productFlatIndexerProcessor; + $this->flatState = $flatState; } /** @@ -31,7 +42,10 @@ public function __construct(\Magento\Catalog\Model\Indexer\Product\Flat\Processo */ public function afterImportSource(\Magento\ImportExport\Model\Import $subject, $import) { - $this->_productFlatIndexerProcessor->markIndexerAsInvalid(); + if ($this->flatState->isFlatEnabled() && !$this->_productFlatIndexerProcessor->isIndexerScheduled()) { + $this->_productFlatIndexerProcessor->markIndexerAsInvalid(); + } + return $import; } } diff --git a/app/code/Magento/CatalogImportExport/Model/StockItemImporter.php b/app/code/Magento/CatalogImportExport/Model/StockItemImporter.php new file mode 100644 index 0000000000000..0e8d3c5fa4cc5 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Model/StockItemImporter.php @@ -0,0 +1,65 @@ +stockResourceItemFactory = $stockResourceItemFactory; + $this->logger = $logger; + } + + /** + * @inheritdoc + */ + public function import(array $stockData) + { + /** @var $stockItemResource Item */ + $stockItemResource = $this->stockResourceItemFactory->create(); + $entityTable = $stockItemResource->getMainTable(); + try { + $stockImportData = array_map( + function ($stockItemData) { + unset($stockItemData['sku']); + return $stockItemData; + }, + array_values($stockData) + ); + $stockItemResource->getConnection()->insertOnDuplicate($entityTable, $stockImportData); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + throw new CouldNotSaveException(__('Invalid Stock data for insert'), $e); + } + } +} diff --git a/app/code/Magento/CatalogImportExport/Model/StockItemImporterInterface.php b/app/code/Magento/CatalogImportExport/Model/StockItemImporterInterface.php new file mode 100644 index 0000000000000..bc314d825ba3e --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Model/StockItemImporterInterface.php @@ -0,0 +1,27 @@ +createPartialMock( - \Magento\Catalog\Model\Indexer\Product\Flat\Processor::class, - ['markIndexerAsInvalid'] + $this->processorMock = $this->getMockBuilder(\Magento\Catalog\Model\Indexer\Product\Flat\Processor::class) + ->disableOriginalConstructor() + ->setMethods(['markIndexerAsInvalid', 'isIndexerScheduled']) + ->getMock(); + + $this->flatStateMock = $this->getMockBuilder(\Magento\Catalog\Model\Indexer\Product\Flat\State::class) + ->disableOriginalConstructor() + ->setMethods(['isFlatEnabled']) + ->getMock(); + + $this->subjectMock = $this->getMockBuilder(\Magento\ImportExport\Model\Import::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->model = (new ObjectManager($this))->getObject( + \Magento\CatalogImportExport\Model\Indexer\Product\Flat\Plugin\Import::class, + [ + 'productFlatIndexerProcessor' => $this->processorMock, + 'flatState' => $this->flatStateMock + ] ); + } - $subjectMock = $this->createMock(\Magento\ImportExport\Model\Import::class); - $processorMock->expects($this->once())->method('markIndexerAsInvalid'); + public function testAfterImportSourceWithFlatEnabledAndIndexerScheduledDisabled() + { + $this->flatStateMock->expects($this->once())->method('isFlatEnabled')->willReturn(true); + $this->processorMock->expects($this->once())->method('isIndexerScheduled')->willReturn(false); + $this->processorMock->expects($this->once())->method('markIndexerAsInvalid'); + $someData = [1, 2, 3]; + $this->assertEquals($someData, $this->model->afterImportSource($this->subjectMock, $someData)); + } + public function testAfterImportSourceWithFlatDisabledAndIndexerScheduledDisabled() + { + $this->flatStateMock->expects($this->once())->method('isFlatEnabled')->willReturn(false); + $this->processorMock->expects($this->never())->method('isIndexerScheduled')->willReturn(false); + $this->processorMock->expects($this->never())->method('markIndexerAsInvalid'); $someData = [1, 2, 3]; + $this->assertEquals($someData, $this->model->afterImportSource($this->subjectMock, $someData)); + } - $model = new \Magento\CatalogImportExport\Model\Indexer\Product\Flat\Plugin\Import($processorMock); - $this->assertEquals($someData, $model->afterImportSource($subjectMock, $someData)); + public function testAfterImportSourceWithFlatEnabledAndIndexerScheduledEnabled() + { + $this->flatStateMock->expects($this->once())->method('isFlatEnabled')->willReturn(true); + $this->processorMock->expects($this->once())->method('isIndexerScheduled')->willReturn(true); + $this->processorMock->expects($this->never())->method('markIndexerAsInvalid'); + $someData = [1, 2, 3]; + $this->assertEquals($someData, $this->model->afterImportSource($this->subjectMock, $someData)); } } diff --git a/app/code/Magento/CatalogImportExport/composer.json b/app/code/Magento/CatalogImportExport/composer.json index b86bb6be29b46..56307a01e1cb6 100644 --- a/app/code/Magento/CatalogImportExport/composer.json +++ b/app/code/Magento/CatalogImportExport/composer.json @@ -5,21 +5,20 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "ext-ctype": "*", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-catalog-url-rewrite": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-import-export": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*" + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-catalog-url-rewrite": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-import-export": "*", + "magento/module-media-storage": "*", + "magento/module-store": "*", + "magento/module-tax": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogImportExport/etc/di.xml b/app/code/Magento/CatalogImportExport/etc/di.xml index 53772c3b3360a..6906272b11d68 100644 --- a/app/code/Magento/CatalogImportExport/etc/di.xml +++ b/app/code/Magento/CatalogImportExport/etc/di.xml @@ -7,6 +7,7 @@ --> + diff --git a/app/code/Magento/CatalogInventory/Api/RegisterProductSaleInterface.php b/app/code/Magento/CatalogInventory/Api/RegisterProductSaleInterface.php new file mode 100644 index 0000000000000..9122fb0038646 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Api/RegisterProductSaleInterface.php @@ -0,0 +1,30 @@ +getMinSaleQty(); - if ($stockItem->getQtyMaxAllowed()) { - $params['maxAllowed'] = $stockItem->getQtyMaxAllowed(); + if ($stockItem->getMaxSaleQty()) { + $params['maxAllowed'] = (float)$stockItem->getMaxSaleQty(); } if ($stockItem->getQtyIncrements() > 0) { $params['qtyIncrements'] = (float)$stockItem->getQtyIncrements(); diff --git a/app/code/Magento/CatalogInventory/Helper/Stock.php b/app/code/Magento/CatalogInventory/Helper/Stock.php index 410e35096ee58..494d440eeed89 100644 --- a/app/code/Magento/CatalogInventory/Helper/Stock.php +++ b/app/code/Magento/CatalogInventory/Helper/Stock.php @@ -17,6 +17,7 @@ /** * Class Stock * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @api */ class Stock { @@ -156,7 +157,7 @@ public function addIsInStockFilterToCollection($collection) $resource = $this->getStockStatusResource(); $resource->addStockDataToCollection( $collection, - !$isShowOutOfStock || $collection->getFlag('require_stock_items') + !$isShowOutOfStock ); $collection->setFlag($stockFlag, true); } diff --git a/app/code/Magento/CatalogInventory/Model/Configuration.php b/app/code/Magento/CatalogInventory/Model/Configuration.php index 06cfde0b7d247..2f0415b40dc01 100644 --- a/app/code/Magento/CatalogInventory/Model/Configuration.php +++ b/app/code/Magento/CatalogInventory/Model/Configuration.php @@ -273,7 +273,7 @@ public function getEnableQtyIncrements($store = null) /** * @param null|string|bool|int|\Magento\Store\Model\Store $store - * @return int + * @return float */ public function getQtyIncrements($store = null) { diff --git a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php index 1e7c800ea1c38..85fee62eb4303 100644 --- a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php +++ b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php @@ -10,6 +10,7 @@ use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\EntityManager\MetadataPool; /** * Abstract action reindex class @@ -70,25 +71,33 @@ abstract class AbstractAction */ private $cacheCleaner; + /** + * @var MetadataPool + */ + private $metadataPool; + /** * @param ResourceConnection $resource * @param \Magento\CatalogInventory\Model\ResourceModel\Indexer\StockFactory $indexerFactory * @param \Magento\Catalog\Model\Product\Type $catalogProductType * @param \Magento\Framework\Indexer\CacheContext $cacheContext * @param \Magento\Framework\Event\ManagerInterface $eventManager + * @param MetadataPool|null $metadataPool */ public function __construct( ResourceConnection $resource, \Magento\CatalogInventory\Model\ResourceModel\Indexer\StockFactory $indexerFactory, \Magento\Catalog\Model\Product\Type $catalogProductType, \Magento\Framework\Indexer\CacheContext $cacheContext, - \Magento\Framework\Event\ManagerInterface $eventManager + \Magento\Framework\Event\ManagerInterface $eventManager, + MetadataPool $metadataPool = null ) { $this->_resource = $resource; $this->_indexerFactory = $indexerFactory; $this->_catalogProductType = $catalogProductType; $this->cacheContext = $cacheContext; $this->eventManager = $eventManager; + $this->metadataPool = $metadataPool ?: ObjectManager::getInstance()->get(MetadataPool::class); } /** @@ -154,10 +163,15 @@ protected function _getTable($entityName) public function getRelationsByChild($childIds) { $connection = $this->_getConnection(); - $select = $connection->select() - ->from($this->_getTable('catalog_product_relation'), 'parent_id') - ->where('child_id IN(?)', $childIds); - + $linkField = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class) + ->getLinkField(); + $select = $connection->select()->from( + ['cpe' => $this->_getTable('catalog_product_entity')], + 'entity_id' + )->join( + ['relation' => $this->_getTable('catalog_product_relation')], + 'relation.parent_id = cpe.' . $linkField + )->where('child_id IN(?)', $childIds); return $connection->fetchCol($select); } @@ -230,7 +244,8 @@ protected function _reindexRows($productIds = []) if (!is_array($productIds)) { $productIds = [$productIds]; } - + $parentIds = $this->getRelationsByChild($productIds); + $productIds = $parentIds ? array_unique(array_merge($parentIds, $productIds)) : $productIds; $this->getCacheCleaner()->clean($productIds, function () use ($productIds) { $this->doReindex($productIds); }); @@ -248,13 +263,10 @@ private function doReindex($productIds = []) { $connection = $this->_getConnection(); - $parentIds = $this->getRelationsByChild($productIds); - $processIds = $parentIds ? array_merge($parentIds, $productIds) : $productIds; - // retrieve product types by processIds $select = $connection->select() ->from($this->_getTable('catalog_product_entity'), ['entity_id', 'type_id']) - ->where('entity_id IN(?)', $processIds); + ->where('entity_id IN(?)', $productIds); $pairs = $connection->fetchPairs($select); $byType = []; diff --git a/app/code/Magento/CatalogInventory/Model/Plugin/ProductSearch.php b/app/code/Magento/CatalogInventory/Model/Plugin/ProductSearch.php new file mode 100644 index 0000000000000..ecb84fe26cd61 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Model/Plugin/ProductSearch.php @@ -0,0 +1,44 @@ +stockHelper = $stockHelper; + } + + /** + * Adds stock filter depends on configuration + * + * @param \Magento\Catalog\Model\ProductLink\Search $subject + * @param \Magento\Catalog\Model\ResourceModel\Product\Collection $collection + * @return \Magento\Catalog\Model\ResourceModel\Product\Collection + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterPrepareCollection( + \Magento\Catalog\Model\ProductLink\Search $subject, + \Magento\Catalog\Model\ResourceModel\Product\Collection $collection + ): \Magento\Catalog\Model\ResourceModel\Product\Collection { + $this->stockHelper->addIsInStockFilterToCollection($collection); + return $collection; + } +} diff --git a/app/code/Magento/CatalogInventory/Model/Plugin/ReindexUpdatedProducts.php b/app/code/Magento/CatalogInventory/Model/Plugin/ReindexUpdatedProducts.php new file mode 100644 index 0000000000000..f10afcd4ea329 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Model/Plugin/ReindexUpdatedProducts.php @@ -0,0 +1,46 @@ +indexerProcessor = $indexerProcessor; + } + + /** + * Reindex on product attribute mass change + * + * @param ProductAction $subject + * @param ProductAction $action + * @param array $productIds + * @return ProductAction + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterUpdateAttributes( + ProductAction $subject, + ProductAction $action, + $productIds + ) { + $this->indexerProcessor->reindexList(array_unique($productIds)); + return $action; + } +} diff --git a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php index 8e0c749be2d1d..0cc77bb7caf36 100644 --- a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php +++ b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator.php @@ -104,8 +104,7 @@ public function validate(Observer $observer) $quoteItem = $observer->getEvent()->getItem(); if (!$quoteItem || !$quoteItem->getProductId() || - !$quoteItem->getQuote() || - $quoteItem->getQuote()->getIsSuperMode() + !$quoteItem->getQuote() ) { return; } @@ -118,6 +117,18 @@ public function validate(Observer $observer) throw new LocalizedException(__('The Product stock item is invalid. Verify the stock item and try again.')); } + if (($options = $quoteItem->getQtyOptions()) && $qty > 0) { + foreach ($options as $option) { + $this->optionInitializer->initialize($option, $quoteItem, $qty); + } + } else { + $this->stockItemInitializer->initialize($stockItem, $quoteItem, $qty); + } + + if ($quoteItem->getQuote()->getIsSuperMode()) { + return; + } + /* @var \Magento\CatalogInventory\Api\Data\StockStatusInterface $stockStatus */ $stockStatus = $this->stockRegistry->getStockStatus($product->getId(), $product->getStore()->getWebsiteId()); @@ -160,7 +171,7 @@ public function validate(Observer $observer) /** * Check item for options */ - if (($options = $quoteItem->getQtyOptions()) && $qty > 0) { + if ($options) { $qty = $product->getTypeInstance()->prepareQuoteItemQty($qty, $product); $quoteItem->setData('qty', $qty); if ($stockStatus) { @@ -194,7 +205,7 @@ public function validate(Observer $observer) $removeError = true; foreach ($options as $option) { - $result = $this->optionInitializer->initialize($option, $quoteItem, $qty); + $result = $option->getStockStateResult(); if ($result->getHasError()) { $option->setHasError(true); //Setting this to false, so no error statuses are cleared @@ -207,7 +218,7 @@ public function validate(Observer $observer) } } else { if ($quoteItem->getParentItem() === null) { - $result = $this->stockItemInitializer->initialize($stockItem, $quoteItem, $qty); + $result = $quoteItem->getStockStateResult(); if ($result->getHasError()) { $this->addErrorInfoToQuote($result, $quoteItem); } else { diff --git a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/Option.php b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/Option.php index 3e972a1b84203..b99e43d52f470 100644 --- a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/Option.php +++ b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/Option.php @@ -133,6 +133,8 @@ public function initialize( $stockItem->unsIsChildItem(); + $option->setStockStateResult($result); + return $result; } } diff --git a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/StockItem.php b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/StockItem.php index 6bdc4c67de658..6fb0a949941ec 100644 --- a/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/StockItem.php +++ b/app/code/Magento/CatalogInventory/Model/Quote/Item/QuantityValidator/Initializer/StockItem.php @@ -135,6 +135,8 @@ public function initialize( $quoteItem->setBackorders($result->getItemBackorders()); } + $quoteItem->setStockStateResult($result); + return $result; } } diff --git a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php index 9223fd32e3567..f1dc9715fd247 100644 --- a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php +++ b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock.php @@ -113,20 +113,20 @@ protected function _construct() } /** - * Lock Stock Item records + * Lock Stock Item records. * * @param int[] $productIds * @param int $websiteId * @return array */ - public function lockProductsStock($productIds, $websiteId) + public function lockProductsStock(array $productIds, int $websiteId) { if (empty($productIds)) { return []; } $itemTable = $this->getTable('cataloginventory_stock_item'); $select = $this->getConnection()->select()->from(['si' => $itemTable]) - ->where('website_id=?', $websiteId) + ->where('website_id = ?', $websiteId) ->where('product_id IN(?)', $productIds) ->forUpdate(true); @@ -136,12 +136,19 @@ public function lockProductsStock($productIds, $websiteId) ->columns( [ 'product_id' => 'entity_id', - 'type_id' => 'type_id' + 'type_id' => 'type_id', ] ); - $this->getConnection()->query($select); + $items = []; - return $this->getConnection()->fetchAll($selectProducts); + foreach ($this->getConnection()->query($select)->fetchAll() as $si) { + $items[$si['product_id']] = $si; + } + foreach ($this->getConnection()->fetchAll($selectProducts) as $p) { + $items[$p['product_id']]['type_id'] = $p['type_id']; + } + + return $items; } /** diff --git a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Status.php b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Status.php index 4e04ed059c8e2..bc5fda4939adc 100644 --- a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Status.php +++ b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Status.php @@ -12,6 +12,7 @@ /** * CatalogInventory Stock Status per website Resource Model * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @api */ class Status extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb { diff --git a/app/code/Magento/CatalogInventory/Model/Source/Stock.php b/app/code/Magento/CatalogInventory/Model/Source/Stock.php index f64026cce23a5..9ed891d1dcc0f 100644 --- a/app/code/Magento/CatalogInventory/Model/Source/Stock.php +++ b/app/code/Magento/CatalogInventory/Model/Source/Stock.php @@ -26,4 +26,23 @@ public function getAllOptions() ['value' => \Magento\CatalogInventory\Model\Stock::STOCK_OUT_OF_STOCK, 'label' => __('Out of Stock')] ]; } + + /** + * Add Value Sort To Collection Select. + * + * @param \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection + * @param string $dir + * + * @return $this + */ + public function addValueSortToCollection($collection, $dir = \Magento\Framework\Data\Collection::SORT_ORDER_DESC) + { + $collection->getSelect()->joinLeft( + ['stock_item_table' => 'cataloginventory_stock_item'], + "e.entity_id=stock_item_table.product_id", + [] + ); + $collection->getSelect()->order("stock_item_table.qty $dir"); + return $this; + } } diff --git a/app/code/Magento/CatalogInventory/Model/Stock/Item.php b/app/code/Magento/CatalogInventory/Model/Stock/Item.php index b4b70041ce148..bcab2c622a5bc 100644 --- a/app/code/Magento/CatalogInventory/Model/Stock/Item.php +++ b/app/code/Magento/CatalogInventory/Model/Stock/Item.php @@ -392,7 +392,7 @@ public function getUseConfigQtyIncrements() /** * Retrieve Quantity Increments * - * @return int|false + * @return int|float|false */ public function getQtyIncrements() { @@ -401,7 +401,13 @@ public function getQtyIncrements() if ($this->getUseConfigQtyIncrements()) { $this->qtyIncrements = $this->stockConfiguration->getQtyIncrements($this->getStoreId()); } else { - $this->qtyIncrements = (int) $this->getData(static::QTY_INCREMENTS); + $this->qtyIncrements = $this->getData(static::QTY_INCREMENTS); + } + + if ($this->getIsQtyDecimal()) { // Cast accordingly to decimal qty usage + $this->qtyIncrements = (float) $this->qtyIncrements; + } else { + $this->qtyIncrements = (int) $this->qtyIncrements; } } if ($this->qtyIncrements <= 0) { diff --git a/app/code/Magento/CatalogInventory/Model/StockManagement.php b/app/code/Magento/CatalogInventory/Model/StockManagement.php index 06599446a9ea9..b3939f2e5149b 100644 --- a/app/code/Magento/CatalogInventory/Model/StockManagement.php +++ b/app/code/Magento/CatalogInventory/Model/StockManagement.php @@ -6,6 +6,8 @@ namespace Magento\CatalogInventory\Model; use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\RegisterProductSaleInterface; +use Magento\CatalogInventory\Api\RevertProductSaleInterface; use Magento\CatalogInventory\Api\StockConfigurationInterface; use Magento\CatalogInventory\Api\StockManagementInterface; use Magento\CatalogInventory\Model\ResourceModel\QtyCounterInterface; @@ -14,9 +16,9 @@ use Magento\CatalogInventory\Model\ResourceModel\Stock as ResourceStock; /** - * Class StockManagement + * Implements a few interfaces for backward compatibility */ -class StockManagement implements StockManagementInterface +class StockManagement implements StockManagementInterface, RegisterProductSaleInterface, RevertProductSaleInterface { /** * @var StockRegistryProviderInterface @@ -48,6 +50,11 @@ class StockManagement implements StockManagementInterface */ private $qtyCounter; + /** + * @var StockRegistryStorage + */ + private $stockRegistryStorage; + /** * @param ResourceStock $stockResource * @param StockRegistryProviderInterface $stockRegistryProvider @@ -55,6 +62,7 @@ class StockManagement implements StockManagementInterface * @param StockConfigurationInterface $stockConfiguration * @param ProductRepositoryInterface $productRepository * @param QtyCounterInterface $qtyCounter + * @param StockRegistryStorage|null $stockRegistryStorage */ public function __construct( ResourceStock $stockResource, @@ -62,7 +70,8 @@ public function __construct( StockState $stockState, StockConfigurationInterface $stockConfiguration, ProductRepositoryInterface $productRepository, - QtyCounterInterface $qtyCounter + QtyCounterInterface $qtyCounter, + StockRegistryStorage $stockRegistryStorage = null ) { $this->stockRegistryProvider = $stockRegistryProvider; $this->stockState = $stockState; @@ -70,11 +79,13 @@ public function __construct( $this->productRepository = $productRepository; $this->qtyCounter = $qtyCounter; $this->resource = $stockResource; + $this->stockRegistryStorage = $stockRegistryStorage ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(StockRegistryStorage::class); } /** * Subtract product qtys from stock. - * Return array of items that require full save + * Return array of items that require full save. * * @param string[] $items * @param int $websiteId @@ -92,9 +103,12 @@ public function registerProductsSale($items, $websiteId = null) $fullSaveItems = $registeredItems = []; foreach ($lockedItems as $lockedItemRecord) { $productId = $lockedItemRecord['product_id']; + $this->stockRegistryStorage->removeStockItem($productId, $websiteId); + /** @var StockItemInterface $stockItem */ $orderedQty = $items[$productId]; $stockItem = $this->stockRegistryProvider->getStockItem($productId, $websiteId); + $stockItem->setQty($lockedItemRecord['qty']); // update data from locked item $canSubtractQty = $stockItem->getItemId() && $this->canSubtractQty($stockItem); if (!$canSubtractQty || !$this->stockConfiguration->isQty($lockedItemRecord['type_id'])) { continue; @@ -102,7 +116,7 @@ public function registerProductsSale($items, $websiteId = null) if (!$stockItem->hasAdminArea() && !$this->stockState->checkQty($productId, $orderedQty, $stockItem->getWebsiteId()) ) { - $this->getResource()->rollBack(); + $this->getResource()->commit(); throw new \Magento\Framework\Exception\LocalizedException( __('Not all of your products are available in the requested quantity.') ); @@ -122,6 +136,7 @@ public function registerProductsSale($items, $websiteId = null) } $this->qtyCounter->correctItemsQty($registeredItems, $websiteId, '-'); $this->getResource()->commit(); + return $fullSaveItems; } diff --git a/app/code/Magento/CatalogInventory/Model/StockRegistryStorage.php b/app/code/Magento/CatalogInventory/Model/StockRegistryStorage.php index af417954a8412..0a54adfe91c51 100644 --- a/app/code/Magento/CatalogInventory/Model/StockRegistryStorage.php +++ b/app/code/Magento/CatalogInventory/Model/StockRegistryStorage.php @@ -130,4 +130,16 @@ public function removeStockStatus($productId, $scopeId = null) unset($this->stockStatuses[$productId][$scopeId]); } } + + /** + * Clear cached entities + * + * @return void + */ + public function clean() + { + $this->stockItems = []; + $this->stocks = []; + $this->stockStatuses = []; + } } diff --git a/app/code/Magento/CatalogInventory/Observer/ItemsForReindex.php b/app/code/Magento/CatalogInventory/Observer/ItemsForReindex.php index 1055afd263da3..88dab3026c945 100644 --- a/app/code/Magento/CatalogInventory/Observer/ItemsForReindex.php +++ b/app/code/Magento/CatalogInventory/Observer/ItemsForReindex.php @@ -11,7 +11,7 @@ class ItemsForReindex /** * @var array */ - protected $itemsForReindex; + protected $itemsForReindex = []; /** * @param array $items diff --git a/app/code/Magento/CatalogInventory/Observer/ProcessInventoryDataObserver.php b/app/code/Magento/CatalogInventory/Observer/ProcessInventoryDataObserver.php index cea19c098b928..b831af53d4af3 100644 --- a/app/code/Magento/CatalogInventory/Observer/ProcessInventoryDataObserver.php +++ b/app/code/Magento/CatalogInventory/Observer/ProcessInventoryDataObserver.php @@ -63,11 +63,11 @@ public function execute(EventObserver $observer) */ private function processStockData(Product $product) { - /** @var Item $stockItem */ - $stockItem = $this->stockRegistry->getStockItem($product->getId(), $product->getStore()->getWebsiteId()); - $quantityAndStockStatus = $product->getData('quantity_and_stock_status'); if (is_array($quantityAndStockStatus)) { + /** @var Item $stockItem */ + $stockItem = $this->stockRegistry->getStockItem($product->getId(), $product->getStore()->getWebsiteId()); + $quantityAndStockStatus = $this->prepareQuantityAndStockStatus($stockItem, $quantityAndStockStatus); if ($quantityAndStockStatus) { diff --git a/app/code/Magento/CatalogInventory/Observer/SubtractQuoteInventoryObserver.php b/app/code/Magento/CatalogInventory/Observer/SubtractQuoteInventoryObserver.php index 6fbec08e4805b..01689eb7c90b4 100644 --- a/app/code/Magento/CatalogInventory/Observer/SubtractQuoteInventoryObserver.php +++ b/app/code/Magento/CatalogInventory/Observer/SubtractQuoteInventoryObserver.php @@ -74,8 +74,9 @@ public function execute(EventObserver $observer) $items, $quote->getStore()->getWebsiteId() ); - $this->itemsForReindex->setItems($itemsForReindex); - + if (count($itemsForReindex)) { + $this->itemsForReindex->setItems($itemsForReindex); + } $quote->setInventoryProcessed(true); return $this; } diff --git a/app/code/Magento/CatalogInventory/Setup/Patch/Data/ConvertSerializedDataToJson.php b/app/code/Magento/CatalogInventory/Setup/Patch/Data/ConvertSerializedDataToJson.php index 07edb435743c0..d0ea3da59c51d 100644 --- a/app/code/Magento/CatalogInventory/Setup/Patch/Data/ConvertSerializedDataToJson.php +++ b/app/code/Magento/CatalogInventory/Setup/Patch/Data/ConvertSerializedDataToJson.php @@ -11,8 +11,8 @@ use Magento\Framework\DB\FieldDataConverterFactory; use Magento\Framework\DB\Select\QueryModifierFactory; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ConvertSerializedDataToJson diff --git a/app/code/Magento/CatalogInventory/Setup/Patch/Data/CreateDefaultStock.php b/app/code/Magento/CatalogInventory/Setup/Patch/Data/CreateDefaultStock.php index 179e7a88b3172..ceb353a8091a7 100644 --- a/app/code/Magento/CatalogInventory/Setup/Patch/Data/CreateDefaultStock.php +++ b/app/code/Magento/CatalogInventory/Setup/Patch/Data/CreateDefaultStock.php @@ -10,8 +10,8 @@ use Magento\Eav\Setup\EavSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class CreateDefaultStock diff --git a/app/code/Magento/CatalogInventory/Setup/Patch/Data/UpdateStockItemsWebsite.php b/app/code/Magento/CatalogInventory/Setup/Patch/Data/UpdateStockItemsWebsite.php index b5d1471435cb8..9c73da8915b64 100644 --- a/app/code/Magento/CatalogInventory/Setup/Patch/Data/UpdateStockItemsWebsite.php +++ b/app/code/Magento/CatalogInventory/Setup/Patch/Data/UpdateStockItemsWebsite.php @@ -9,8 +9,8 @@ use Magento\CatalogInventory\Model\Indexer\Stock\Processor; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateStockItemsWebsite diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Block/Plugin/ProductViewTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Block/Plugin/ProductViewTest.php index 4ef2e78e590fb..4ec795daf86aa 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Block/Plugin/ProductViewTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Block/Plugin/ProductViewTest.php @@ -28,7 +28,7 @@ protected function setUp() $this->stockItem = $this->getMockBuilder(\Magento\CatalogInventory\Model\Stock\Item::class) ->disableOriginalConstructor() - ->setMethods(['getMinSaleQty', 'getQtyMaxAllowed', 'getQtyIncrements']) + ->setMethods(['getMinSaleQty', 'getMaxSaleQty', 'getQtyIncrements']) ->getMock(); $this->stockRegistry = $this->getMockBuilder(\Magento\CatalogInventory\Api\StockRegistryInterface::class) @@ -48,8 +48,8 @@ public function testAfterGetQuantityValidators() 'validate-item-quantity' => [ 'minAllowed' => 0.5, - 'maxAllowed' => 5, - 'qtyIncrements' => 3 + 'maxAllowed' => 5.0, + 'qtyIncrements' => 3.0 ] ]; $validators = []; @@ -74,7 +74,7 @@ public function testAfterGetQuantityValidators() ->with('productId', 'websiteId') ->willReturn($this->stockItem); $this->stockItem->expects($this->once())->method('getMinSaleQty')->willReturn(0.5); - $this->stockItem->expects($this->any())->method('getQtyMaxAllowed')->willReturn(5); + $this->stockItem->expects($this->any())->method('getMaxSaleQty')->willReturn(5); $this->stockItem->expects($this->any())->method('getQtyIncrements')->willReturn(3); $this->assertEquals($result, $this->block->afterGetQuantityValidators($productViewBlock, $validators)); diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php index e1a19bf10ecd4..3590c96bd1532 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Indexer/Stock/Action/FullTest.php @@ -44,7 +44,8 @@ public function testExecuteWithAdapterErrorThrowsException() ] ); - $this->expectException(\Magento\Framework\Exception\LocalizedException::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage($exceptionMessage); $model->execute(); } diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/QuantityValidatorTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/QuantityValidatorTest.php index 86a021768a6b3..11a04d26994ae 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/QuantityValidatorTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/QuantityValidatorTest.php @@ -278,8 +278,11 @@ public function testValidateWithOptions() { $optionMock = $this->getMockBuilder(OptionItem::class) ->disableOriginalConstructor() - ->setMethods(['setHasError']) + ->setMethods(['setHasError', 'getStockStateResult']) ->getMock(); + $optionMock->expects($this->once()) + ->method('getStockStateResult') + ->willReturn($this->resultMock); $this->stockRegistryMock->expects($this->at(0)) ->method('getStockItem') ->willReturn($this->stockItemMock); @@ -316,7 +319,7 @@ public function testValidateWithOptionsAndError() { $optionMock = $this->getMockBuilder(OptionItem::class) ->disableOriginalConstructor() - ->setMethods(['setHasError']) + ->setMethods(['setHasError', 'getStockStateResult']) ->getMock(); $this->stockRegistryMock->expects($this->at(0)) ->method('getStockItem') @@ -324,6 +327,9 @@ public function testValidateWithOptionsAndError() $this->stockRegistryMock->expects($this->at(1)) ->method('getStockStatus') ->willReturn($this->stockStatusMock); + $optionMock->expects($this->once()) + ->method('getStockStateResult') + ->willReturn($this->resultMock); $options = [$optionMock]; $this->createInitialStub(1); $this->setUpStubForQuantity(1, true); @@ -354,12 +360,15 @@ public function testValidateAndRemoveErrorsFromQuote() { $optionMock = $this->getMockBuilder(OptionItem::class) ->disableOriginalConstructor() - ->setMethods(['setHasError']) + ->setMethods(['setHasError', 'getStockStateResult']) ->getMock(); $quoteItem = $this->getMockBuilder(Item::class) ->disableOriginalConstructor() ->setMethods(['getItemId', 'getErrorInfos']) ->getMock(); + $optionMock->expects($this->once()) + ->method('getStockStateResult') + ->willReturn($this->resultMock); $this->stockRegistryMock->expects($this->at(0)) ->method('getStockItem') ->willReturn($this->stockItemMock); diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/StockItemTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/StockItemTest.php index d60a1f3e400dd..8c9a1aa7715ec 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/StockItemTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Quote/Item/QuantityValidator/Initializer/StockItemTest.php @@ -84,6 +84,7 @@ public function testInitializeWithSubitem() 'setMessage', 'setBackorders', '__wakeup', + 'setStockStateResult' ] ) ->disableOriginalConstructor() @@ -178,6 +179,7 @@ public function testInitializeWithSubitem() $quoteItem->expects($this->once())->method('setMessage')->with('message')->will($this->returnSelf()); $result->expects($this->exactly(2))->method('getItemBackorders')->will($this->returnValue('backorders')); $quoteItem->expects($this->once())->method('setBackorders')->with('backorders')->will($this->returnSelf()); + $quoteItem->expects($this->once())->method('setStockStateResult')->with($result)->will($this->returnSelf()); $this->model->initialize($stockItem, $quoteItem, $qty); } diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php new file mode 100644 index 0000000000000..c9a148f3d8869 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/ResourceModel/StockTest.php @@ -0,0 +1,209 @@ +selectMock = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor() + ->getMock(); + $this->contextMock = $objectManager->getObject(Context::class); + $this->scopeConfigMock = $this->getMockBuilder(Config::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dateTimeMock = $this->getMockBuilder(DateTime::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockConfigurationMock = $this->getMockBuilder(StockConfiguration::class) + ->setMethods(['getIsQtyTypeIds', 'getDefaultScopeId']) + ->disableOriginalConstructor() + ->getMock(); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->connectionMock = $this->getMockBuilder(Mysql::class) + ->disableOriginalConstructor() + ->getMock(); + $this->statementMock = $this->getMockForAbstractClass(\Zend_Db_Statement_Interface::class); + $this->stock = $this->getMockBuilder(Stock::class) + ->setMethods(['getTable', 'getConnection']) + ->setConstructorArgs( + [ + 'context' => $this->contextMock, + 'scopeConfig' => $this->scopeConfigMock, + 'dateTime' => $this->dateTimeMock, + 'stockConfiguration' => $this->stockConfigurationMock, + 'storeManager' => $this->storeManagerMock, + ] + )->getMock(); + } + + /** + * Test Save Product Status per website with product ids. + * + * @dataProvider productsDataProvider + * @param int $websiteId + * @param array $productIds + * @param array $products + * @param array $result + * + * @return void + */ + public function testLockProductsStock(int $websiteId, array $productIds, array $products, array $result) + { + $this->selectMock->expects($this->exactly(2)) + ->method('from') + ->withConsecutive( + [$this->identicalTo(['si' => self::ITEM_TABLE])], + [$this->identicalTo(['p' => self::PRODUCT_TABLE]), $this->identicalTo([])] + ) + ->willReturnSelf(); + $this->selectMock->expects($this->exactly(3)) + ->method('where') + ->withConsecutive( + [$this->identicalTo('website_id = ?'), $this->identicalTo($websiteId)], + [$this->identicalTo('product_id IN(?)'), $this->identicalTo($productIds)], + [$this->identicalTo('entity_id IN (?)'), $this->identicalTo($productIds)] + ) + ->willReturnSelf(); + $this->selectMock->expects($this->once()) + ->method('forUpdate') + ->with($this->identicalTo(true)) + ->willReturnSelf(); + $this->selectMock->expects($this->once()) + ->method('columns') + ->with($this->identicalTo(['product_id' => 'entity_id', 'type_id' => 'type_id'])) + ->willReturnSelf(); + $this->connectionMock->expects($this->exactly(2)) + ->method('select') + ->willReturn($this->selectMock); + $this->connectionMock->expects($this->once()) + ->method('query') + ->with($this->identicalTo($this->selectMock)) + ->willReturn($this->statementMock); + $this->statementMock->expects($this->once()) + ->method('fetchAll') + ->willReturn($products); + $this->connectionMock->expects($this->once()) + ->method('fetchAll') + ->with($this->identicalTo($this->selectMock)) + ->willReturn($result); + $this->stock->expects($this->exactly(2)) + ->method('getTable') + ->withConsecutive( + [$this->identicalTo('cataloginventory_stock_item')], + [$this->identicalTo('catalog_product_entity')] + )->will($this->onConsecutiveCalls( + self::ITEM_TABLE, + self::PRODUCT_TABLE + )); + $this->stock->expects($this->exactly(4)) + ->method('getConnection') + ->willReturn($this->connectionMock); + + $lockResult = $this->stock->lockProductsStock($productIds, $websiteId); + + $this->assertEquals($result, $lockResult); + } + + /** + * @return array + */ + public function productsDataProvider(): array + { + return [ + [ + 0, + [1, 2, 3], + [ + 1 => ['product_id' => 1], + 2 => ['product_id' => 2], + 3 => ['product_id' => 3], + ], + [ + 1 => [ + 'product_id' => 1, + 'type_id' => 'simple', + ], + 2 => [ + 'product_id' => 2, + 'type_id' => 'simple', + ], + 3 => [ + 'product_id' => 3, + 'type_id' => 'simple', + ], + ], + ], + ]; + } +} diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Source/StockTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Source/StockTest.php new file mode 100644 index 0000000000000..11f41fcaf6d01 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Source/StockTest.php @@ -0,0 +1,44 @@ +model = new \Magento\CatalogInventory\Model\Source\Stock(); + } + + public function testAddValueSortToCollection() + { + $selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + $collectionMock = $this->createMock(\Magento\Eav\Model\Entity\Collection\AbstractCollection::class); + $collectionMock->expects($this->atLeastOnce())->method('getSelect')->willReturn($selectMock); + + $selectMock->expects($this->once()) + ->method('joinLeft') + ->with( + ['stock_item_table' => 'cataloginventory_stock_item'], + "e.entity_id=stock_item_table.product_id", + [] + ) + ->willReturnSelf(); + $selectMock->expects($this->once()) + ->method('order') + ->with("stock_item_table.qty DESC") + ->willReturnSelf(); + + $this->model->addValueSortToCollection($collectionMock); + } +} diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php index bbc7823b13e01..e63a573b2f3a4 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/ItemTest.php @@ -394,6 +394,7 @@ public function testGetQtyIncrements($config, $expected) $this->setDataArrayValue('qty_increments', $config['qty_increments']); $this->setDataArrayValue('enable_qty_increments', $config['enable_qty_increments']); $this->setDataArrayValue('use_config_qty_increments', $config['use_config_qty_increments']); + $this->setDataArrayValue('is_qty_decimal', $config['is_qty_decimal']); if ($config['use_config_qty_increments']) { $this->stockConfiguration->expects($this->once()) ->method('getQtyIncrements') @@ -415,7 +416,26 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => 1, 'enable_qty_increments' => true, - 'use_config_qty_increments' => true + 'use_config_qty_increments' => true, + 'is_qty_decimal' => false, + ], + 1 + ], + [ + [ + 'qty_increments' => 1.5, + 'enable_qty_increments' => true, + 'use_config_qty_increments' => true, + 'is_qty_decimal' => true, + ], + 1.5 + ], + [ + [ + 'qty_increments' => 1.5, + 'enable_qty_increments' => true, + 'use_config_qty_increments' => true, + 'is_qty_decimal' => false, ], 1 ], @@ -423,7 +443,8 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => -2, 'enable_qty_increments' => true, - 'use_config_qty_increments' => true + 'use_config_qty_increments' => true, + 'is_qty_decimal' => false, ], false ], @@ -431,7 +452,8 @@ public function getQtyIncrementsDataProvider() [ 'qty_increments' => 3, 'enable_qty_increments' => true, - 'use_config_qty_increments' => false + 'use_config_qty_increments' => false, + 'is_qty_decimal' => false, ], 3 ], diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php new file mode 100644 index 0000000000000..f1d546d17675b --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockManagementTest.php @@ -0,0 +1,292 @@ +stockResourceMock = $this->getMockBuilder(ResourceStock::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockRegistryProviderMock = $this->getMockBuilder(StockRegistryProviderInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockStateMock = $this->getMockBuilder(StockState::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockConfigurationMock = $this->getMockBuilder(StockConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->productRepositoryMock = $this->getMockBuilder(ProductRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->qtyCounterMock = $this->getMockBuilder(QtyCounterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockRegistryStorageMock = $this->getMockBuilder(StockRegistryStorage::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockItemInterfaceMock = $this->getMockBuilder(StockItemInterface::class) + ->setMethods(['hasAdminArea','getWebsiteId']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->stockManagement = $this->getMockBuilder(StockManagement::class) + ->setMethods(['getResource', 'canSubtractQty']) + ->setConstructorArgs( + [ + 'stockResource' => $this->stockResourceMock, + 'stockRegistryProvider' => $this->stockRegistryProviderMock, + 'stockState' => $this->stockStateMock, + 'stockConfiguration' => $this->stockConfigurationMock, + 'productRepository' => $this->productRepositoryMock, + 'qtyCounter' => $this->qtyCounterMock, + 'stockRegistryStorage' => $this->stockRegistryStorageMock, + ] + )->getMock(); + + $this->stockConfigurationMock + ->expects($this->once()) + ->method('getDefaultScopeId') + ->willReturn($this->websiteId); + $this->stockManagement + ->expects($this->any()) + ->method('getResource') + ->willReturn($this->stockResourceMock); + $this->stockRegistryProviderMock + ->expects($this->any()) + ->method('getStockItem') + ->willReturn($this->stockItemInterfaceMock); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('hasAdminArea') + ->willReturn(false); + } + + /** + * @dataProvider productsWithCorrectQtyDataProvider + * + * @param array $items + * @param array $lockedItems + * @param bool $canSubtract + * @param bool $isQty + * @param bool $verifyStock + * + * @return void + */ + public function testRegisterProductsSale( + array $items, + array $lockedItems, + bool $canSubtract, + bool $isQty, + bool $verifyStock = true + ) { + $this->stockResourceMock + ->expects($this->once()) + ->method('beginTransaction'); + $this->stockResourceMock + ->expects($this->once()) + ->method('lockProductsStock') + ->willReturn([$lockedItems]); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getItemId') + ->willReturn($lockedItems['product_id']); + $this->stockManagement + ->expects($this->any()) + ->method('canSubtractQty') + ->willReturn($canSubtract); + $this->stockConfigurationMock + ->expects($this->any()) + ->method('isQty') + ->willReturn($isQty); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($this->websiteId); + $this->stockStateMock + ->expects($this->any()) + ->method('checkQty') + ->willReturn(true); + $this->stockStateMock + ->expects($this->any()) + ->method('verifyStock') + ->willReturn($verifyStock); + $this->stockStateMock + ->expects($this->any()) + ->method('verifyNotification') + ->willReturn(false); + $this->stockResourceMock + ->expects($this->once()) + ->method('commit'); + + $this->stockManagement->registerProductsSale($items, $this->websiteId); + } + + /** + * @dataProvider productsWithIncorrectQtyDataProvider + * + * @param array $items + * @param array $lockedItems + * @expectedException \Magento\Framework\Exception\LocalizedException + * @expectedExceptionMessage Not all of your products are available in the requested quantity. + * + * @return void + */ + public function testRegisterProductsSaleException(array $items, array $lockedItems) + { + $this->stockResourceMock + ->expects($this->once()) + ->method('beginTransaction'); + $this->stockResourceMock + ->expects($this->once()) + ->method('lockProductsStock') + ->willReturn([$lockedItems]); + $this->stockItemInterfaceMock + ->expects($this->any()) + ->method('getItemId') + ->willReturn($lockedItems['product_id']); + $this->stockManagement + ->expects($this->any()) + ->method('canSubtractQty') + ->willReturn(true); + $this->stockConfigurationMock + ->expects($this->any()) + ->method('isQty') + ->willReturn(true); + $this->stockStateMock + ->expects($this->any()) + ->method('checkQty') + ->willReturn(false); + $this->stockResourceMock + ->expects($this->once()) + ->method('commit'); + + $this->stockManagement->registerProductsSale($items, $this->websiteId); + } + + /** + * @return array + */ + public function productsWithCorrectQtyDataProvider(): array + { + return [ + [ + [1 => 3], + [ + 'product_id' => 1, + 'qty' => 10, + 'type_id' => 'simple', + ], + false, + false, + ], + [ + [2 => 4], + [ + 'product_id' => 2, + 'qty' => 10, + 'type_id' => 'simple', + ], + true, + true, + ], + [ + [3 => 5], + [ + 'product_id' => 3, + 'qty' => 10, + 'type_id' => 'simple', + ], + true, + true, + false, + ], + ]; + } + + /** + * @return array + */ + public function productsWithIncorrectQtyDataProvider(): array + { + return [ + [ + [2 => 4], + [ + 'product_id' => 2, + 'qty' => 2, + 'type_id' => 'simple', + ], + ], + ]; + } +} diff --git a/app/code/Magento/CatalogInventory/Ui/DataProvider/Product/Form/Modifier/AdvancedInventory.php b/app/code/Magento/CatalogInventory/Ui/DataProvider/Product/Form/Modifier/AdvancedInventory.php index c5d18ea727534..d15f17530ffbc 100644 --- a/app/code/Magento/CatalogInventory/Ui/DataProvider/Product/Form/Modifier/AdvancedInventory.php +++ b/app/code/Magento/CatalogInventory/Ui/DataProvider/Product/Form/Modifier/AdvancedInventory.php @@ -210,7 +210,6 @@ private function prepareMeta() 'scopeLabel' => '[GLOBAL]', ] ); - $container['arguments']['data']['config'] = [ 'formElement' => 'container', 'componentType' => 'container', @@ -227,6 +226,7 @@ private function prepareMeta() ]; $qty['arguments']['data']['config'] = [ 'component' => 'Magento_CatalogInventory/js/components/qty-validator-changer', + 'group' => 'quantity_and_stock_status_qty', 'dataType' => 'number', 'formElement' => 'input', 'componentType' => 'field', diff --git a/app/code/Magento/CatalogInventory/composer.json b/app/code/Magento/CatalogInventory/composer.json index 1f8608192bbd3..8b55b6f327988 100644 --- a/app/code/Magento/CatalogInventory/composer.json +++ b/app/code/Magento/CatalogInventory/composer.json @@ -5,18 +5,17 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-ui": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-quote": "*", + "magento/module-store": "*", + "magento/module-ui": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogInventory/etc/adminhtml/di.xml b/app/code/Magento/CatalogInventory/etc/adminhtml/di.xml index 803a6dae492a0..3397ef25918cd 100644 --- a/app/code/Magento/CatalogInventory/etc/adminhtml/di.xml +++ b/app/code/Magento/CatalogInventory/etc/adminhtml/di.xml @@ -39,4 +39,7 @@ + + + diff --git a/app/code/Magento/CatalogInventory/etc/db_schema.xml b/app/code/Magento/CatalogInventory/etc/db_schema.xml index a5395ae0a2c85..8a6ae8d2d93c6 100644 --- a/app/code/Magento/CatalogInventory/etc/db_schema.xml +++ b/app/code/Magento/CatalogInventory/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> @@ -84,10 +84,6 @@ - - - - diff --git a/app/code/Magento/CatalogInventory/etc/di.xml b/app/code/Magento/CatalogInventory/etc/di.xml index 2a55d745e1185..65bc277121429 100644 --- a/app/code/Magento/CatalogInventory/etc/di.xml +++ b/app/code/Magento/CatalogInventory/etc/di.xml @@ -78,6 +78,14 @@ + + + + + + Magento\CatalogInventory\Model\Indexer\Stock\Processor + + diff --git a/app/code/Magento/CatalogInventory/i18n/en_US.csv b/app/code/Magento/CatalogInventory/i18n/en_US.csv index 93406163cbe1b..19b73f847b46d 100644 --- a/app/code/Magento/CatalogInventory/i18n/en_US.csv +++ b/app/code/Magento/CatalogInventory/i18n/en_US.csv @@ -55,11 +55,7 @@ Inventory,Inventory "Only X left Threshold","Only X left Threshold" "Display Products Availability in Stock on Storefront","Display Products Availability in Stock on Storefront" "Product Stock Options","Product Stock Options" -" - Please note that these settings apply to individual items in the cart, not to the entire cart. - "," - Please note that these settings apply to individual items in the cart, not to the entire cart. - " +"Please note that these settings apply to individual items in the cart, not to the entire cart.","Please note that these settings apply to individual items in the cart, not to the entire cart." "Manage Stock","Manage Stock" Backorders,Backorders "Maximum Qty Allowed in Shopping Cart","Maximum Qty Allowed in Shopping Cart" diff --git a/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml b/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml index 3472f4368d617..d82b4a97ddbf6 100644 --- a/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml +++ b/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml @@ -99,6 +99,9 @@ quantity_and_stock_status.qty + + ns = ${ $.ns }, index = qty, group = quantity_and_stock_status_qty:value + ${$.provider}:data.product.stock_data.is_qty_decimal ${$.provider}:data.product.stock_data.manage_stock @@ -568,7 +571,6 @@ [GLOBAL] - true true diff --git a/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php b/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php index 333ee845798ec..306d3b9a347b4 100644 --- a/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php +++ b/app/code/Magento/CatalogRule/Block/Adminhtml/Promo/Widget/Chooser/Sku.php @@ -207,7 +207,7 @@ protected function _prepareColumns() public function getGridUrl() { return $this->getUrl( - 'catalog_rule/*/chooser', + '*/*/chooser', ['_current' => true, 'current_grid_id' => $this->getId(), 'collapse' => null] ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php index 34b4bff5a060e..1f62200fc6b1b 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder.php @@ -11,6 +11,8 @@ use Magento\CatalogRule\Model\Rule; use Magento\Framework\App\ObjectManager; use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\CatalogRule\Model\Indexer\IndexBuilder\ProductLoader; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; /** * @api @@ -101,32 +103,32 @@ class IndexBuilder protected $connection; /** - * @var \Magento\CatalogRule\Model\Indexer\ProductPriceCalculator + * @var ProductPriceCalculator */ private $productPriceCalculator; /** - * @var \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct + * @var ReindexRuleProduct */ private $reindexRuleProduct; /** - * @var \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite + * @var ReindexRuleGroupWebsite */ private $reindexRuleGroupWebsite; /** - * @var \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder + * @var RuleProductsSelectBuilder */ private $ruleProductsSelectBuilder; /** - * @var \Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice + * @var ReindexRuleProductPrice */ private $reindexRuleProductPrice; /** - * @var \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor + * @var RuleProductPricesPersistor */ private $pricesPersistor; @@ -135,6 +137,16 @@ class IndexBuilder */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + + /** + * @var ProductLoader + */ + private $productLoader; + /** * @param RuleCollectionFactory $ruleCollectionFactory * @param PriceCurrencyInterface $priceCurrency @@ -146,13 +158,15 @@ class IndexBuilder * @param \Magento\Framework\Stdlib\DateTime\DateTime $dateTime * @param \Magento\Catalog\Model\ProductFactory $productFactory * @param int $batchCount - * @param \Magento\CatalogRule\Model\Indexer\ProductPriceCalculator|null $productPriceCalculator - * @param \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct|null $reindexRuleProduct - * @param \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite|null $reindexRuleGroupWebsite - * @param \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder|null $ruleProductsSelectBuilder - * @param \Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice|null $reindexRuleProductPrice - * @param \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor|null $pricesPersistor + * @param ProductPriceCalculator|null $productPriceCalculator + * @param ReindexRuleProduct|null $reindexRuleProduct + * @param ReindexRuleGroupWebsite|null $reindexRuleGroupWebsite + * @param RuleProductsSelectBuilder|null $ruleProductsSelectBuilder + * @param ReindexRuleProductPrice|null $reindexRuleProductPrice + * @param RuleProductPricesPersistor|null $pricesPersistor * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|null $activeTableSwitcher + * @param ProductLoader|null $productLoader + * @param TableSwapper|null $tableSwapper * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -166,13 +180,15 @@ public function __construct( \Magento\Framework\Stdlib\DateTime\DateTime $dateTime, \Magento\Catalog\Model\ProductFactory $productFactory, $batchCount = 1000, - \Magento\CatalogRule\Model\Indexer\ProductPriceCalculator $productPriceCalculator = null, - \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct $reindexRuleProduct = null, - \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite $reindexRuleGroupWebsite = null, - \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder $ruleProductsSelectBuilder = null, - \Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice $reindexRuleProductPrice = null, - \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor $pricesPersistor = null, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher = null + ProductPriceCalculator $productPriceCalculator = null, + ReindexRuleProduct $reindexRuleProduct = null, + ReindexRuleGroupWebsite $reindexRuleGroupWebsite = null, + RuleProductsSelectBuilder $ruleProductsSelectBuilder = null, + ReindexRuleProductPrice $reindexRuleProductPrice = null, + RuleProductPricesPersistor $pricesPersistor = null, + \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher = null, + ProductLoader $productLoader = null, + TableSwapper $tableSwapper = null ) { $this->resource = $resource; $this->connection = $resource->getConnection(); @@ -186,27 +202,32 @@ public function __construct( $this->productFactory = $productFactory; $this->batchCount = $batchCount; - $this->productPriceCalculator = $productPriceCalculator ?: ObjectManager::getInstance()->get( - \Magento\CatalogRule\Model\Indexer\ProductPriceCalculator::class + $this->productPriceCalculator = $productPriceCalculator ?? ObjectManager::getInstance()->get( + ProductPriceCalculator::class ); - $this->reindexRuleProduct = $reindexRuleProduct ?: ObjectManager::getInstance()->get( - \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct::class + $this->reindexRuleProduct = $reindexRuleProduct ?? ObjectManager::getInstance()->get( + ReindexRuleProduct::class ); - $this->reindexRuleGroupWebsite = $reindexRuleGroupWebsite ?: ObjectManager::getInstance()->get( - \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite::class + $this->reindexRuleGroupWebsite = $reindexRuleGroupWebsite ?? ObjectManager::getInstance()->get( + ReindexRuleGroupWebsite::class ); - $this->ruleProductsSelectBuilder = $ruleProductsSelectBuilder ?: ObjectManager::getInstance()->get( - \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder::class + $this->ruleProductsSelectBuilder = $ruleProductsSelectBuilder ?? ObjectManager::getInstance()->get( + RuleProductsSelectBuilder::class ); - $this->reindexRuleProductPrice = $reindexRuleProductPrice ?: ObjectManager::getInstance()->get( - \Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice::class + $this->reindexRuleProductPrice = $reindexRuleProductPrice ?? ObjectManager::getInstance()->get( + ReindexRuleProductPrice::class ); - $this->pricesPersistor = $pricesPersistor ?: ObjectManager::getInstance()->get( - \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor::class + $this->pricesPersistor = $pricesPersistor ?? ObjectManager::getInstance()->get( + RuleProductPricesPersistor::class ); - $this->activeTableSwitcher = $activeTableSwitcher ?: ObjectManager::getInstance()->get( + $this->activeTableSwitcher = $activeTableSwitcher ?? ObjectManager::getInstance()->get( \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class ); + $this->productLoader = $productLoader ?? ObjectManager::getInstance()->get( + ProductLoader::class + ); + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -251,9 +272,10 @@ protected function doReindexByIds($ids) { $this->cleanByIds($ids); + $products = $this->productLoader->getProducts($ids); foreach ($this->getActiveRules() as $rule) { - foreach ($ids as $productId) { - $this->applyRule($rule, $this->getProduct($productId)); + foreach ($products as $product) { + $this->applyRule($rule, $product); } } } @@ -284,13 +306,6 @@ public function reindexFull() */ protected function doReindexFull() { - $this->connection->truncateTable( - $this->getTable($this->activeTableSwitcher->getAdditionalTableName('catalogrule_product')) - ); - $this->connection->truncateTable( - $this->getTable($this->activeTableSwitcher->getAdditionalTableName('catalogrule_product_price')) - ); - foreach ($this->getAllRules() as $rule) { $this->reindexRuleProduct->execute($rule, $this->batchCount, true); } @@ -298,8 +313,7 @@ protected function doReindexFull() $this->reindexRuleProductPrice->execute($this->batchCount, null, true); $this->reindexRuleGroupWebsite->execute(true); - $this->activeTableSwitcher->switchTable( - $this->connection, + $this->tableSwapper->swapIndexTables( [ $this->getTable('catalogrule_product'), $this->getTable('catalogrule_product_price'), @@ -420,7 +434,7 @@ protected function getTable($tableName) * @param Rule $rule * @return $this * @deprecated 100.2.0 - * @see \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct::execute + * @see ReindexRuleProduct::execute */ protected function updateRuleProductData(Rule $rule) { @@ -446,8 +460,8 @@ protected function updateRuleProductData(Rule $rule) * @throws \Exception * @return $this * @deprecated 100.2.0 - * @see \Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice::execute - * @see \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite::execute + * @see ReindexRuleProductPrice::execute + * @see ReindexRuleGroupWebsite::execute */ protected function applyAllRules(Product $product = null) { @@ -461,7 +475,7 @@ protected function applyAllRules(Product $product = null) * * @return $this * @deprecated 100.2.0 - * @see \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite::execute + * @see ReindexRuleGroupWebsite::execute */ protected function updateCatalogRuleGroupWebsiteData() { @@ -485,7 +499,7 @@ protected function deleteOldData() * @param null $productData * @return float * @deprecated 100.2.0 - * @see \Magento\CatalogRule\Model\Indexer\ProductPriceCalculator::calculate + * @see ProductPriceCalculator::calculate */ protected function calcRuleProductPrice($ruleData, $productData = null) { @@ -498,7 +512,7 @@ protected function calcRuleProductPrice($ruleData, $productData = null) * @return \Zend_Db_Statement_Interface * @throws \Magento\Framework\Exception\LocalizedException * @deprecated 100.2.0 - * @see \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder::build + * @see RuleProductsSelectBuilder::build */ protected function getRuleProductsStmt($websiteId, Product $product = null) { @@ -510,7 +524,7 @@ protected function getRuleProductsStmt($websiteId, Product $product = null) * @return $this * @throws \Exception * @deprecated 100.2.0 - * @see \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor::execute + * @see RuleProductPricesPersistor::execute */ protected function saveRuleProductPrices($arrData) { diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder/ProductLoader.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder/ProductLoader.php new file mode 100644 index 0000000000000..61c4791549935 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexBuilder/ProductLoader.php @@ -0,0 +1,55 @@ +productRepository = $productRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + } + + /** + * Get products by ids + * + * @param array $productIds + * @return ProductInterface[] + */ + public function getProducts(array $productIds): array + { + $this->searchCriteriaBuilder->addFilter('entity_id', $productIds, 'in'); + $searchCriteria = $this->searchCriteriaBuilder->create(); + $products = $this->productRepository->getList($searchCriteria)->getItems(); + + return $products; + } +} diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php new file mode 100644 index 0000000000000..f99f8c50a7f9a --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapper.php @@ -0,0 +1,125 @@ +resourceConnection = $resource; + } + + /** + * Create temporary table based on given table to use instead of original. + * + * @param string $originalTableName + * + * @return string Created table name. + */ + private function createTemporaryTable(string $originalTableName): string + { + $temporaryTableName = $this->resourceConnection->getTableName( + $originalTableName . '__temp' . $this->generateRandomSuffix() + ); + + $this->resourceConnection->getConnection()->query( + sprintf( + 'create table %s like %s', + $temporaryTableName, + $this->resourceConnection->getTableName($originalTableName) + ) + ); + + return $temporaryTableName; + } + + /** + * Random suffix for temporary tables not to conflict with each other. + * + * @return string + */ + private function generateRandomSuffix(): string + { + return bin2hex(random_bytes(4)); + } + + /** + * @inheritDoc + */ + public function getWorkingTableName(string $originalTable): string + { + $originalTable = $this->resourceConnection->getTableName($originalTable); + if (!array_key_exists($originalTable, $this->temporaryTables)) { + $this->temporaryTables[$originalTable] = $this->createTemporaryTable($originalTable); + } + + return $this->temporaryTables[$originalTable]; + } + + /** + * @inheritDoc + */ + public function swapIndexTables(array $originalTablesNames) + { + $toRename = []; + /** @var string[] $toDrop */ + $toDrop = []; + /** @var string[] $temporaryTablesRenamed */ + $temporaryTablesRenamed = []; + //Renaming temporary tables to original tables' names, dropping old + //tables. + foreach ($originalTablesNames as $tableName) { + $tableName = $this->resourceConnection->getTableName($tableName); + $temporaryOriginalName = $this->resourceConnection->getTableName( + $tableName . $this->generateRandomSuffix() + ); + $temporaryTableName = $this->getWorkingTableName($tableName); + $toRename[] = [ + 'oldName' => $tableName, + 'newName' => $temporaryOriginalName, + ]; + $toRename[] = [ + 'oldName' => $temporaryTableName, + 'newName' => $tableName, + ]; + $toDrop[] = $temporaryOriginalName; + $temporaryTablesRenamed[] = $tableName; + } + + //Swapping tables. + $this->resourceConnection->getConnection()->renameTablesBatch($toRename); + //Cleaning up. + foreach ($temporaryTablesRenamed as $tableName) { + unset($this->temporaryTables[$tableName]); + } + //Removing old ones. + foreach ($toDrop as $tableName) { + $this->resourceConnection->getConnection()->dropTable($tableName); + } + } +} diff --git a/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php new file mode 100644 index 0000000000000..2f37e680949ae --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Indexer/IndexerTableSwapperInterface.php @@ -0,0 +1,33 @@ +dateTime = $dateTime; $this->resource = $resource; $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -61,10 +74,10 @@ public function execute($useAdditionalTable = false) $ruleProductTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_group_website') + $this->tableSwapper->getWorkingTableName('catalogrule_group_website') ); $ruleProductTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php index 534061d593123..55a234bb8ae27 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Reindex rule relations with products. */ @@ -17,20 +21,29 @@ class ReindexRuleProduct private $resource; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var ActiveTableSwitcher */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + /** * @param \Magento\Framework\App\ResourceConnection $resource - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper */ public function __construct( \Magento\Framework\App\ResourceConnection $resource, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->resource = $resource; $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -65,7 +78,7 @@ public function execute( $indexTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php index 853be1888b5b9..0b1264a216257 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductPricesPersistor.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Persist product prices to index table. */ @@ -22,23 +26,32 @@ class RuleProductPricesPersistor private $dateFormat; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var ActiveTableSwitcher */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + /** * @param \Magento\Framework\Stdlib\DateTime $dateFormat * @param \Magento\Framework\App\ResourceConnection $resource - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper */ public function __construct( \Magento\Framework\Stdlib\DateTime $dateFormat, \Magento\Framework\App\ResourceConnection $resource, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->dateFormat = $dateFormat; $this->resource = $resource; $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -59,7 +72,7 @@ public function execute(array $priceData, $useAdditionalTable = false) $indexTable = $this->resource->getTableName('catalogrule_product_price'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product_price') + $this->tableSwapper->getWorkingTableName('catalogrule_product_price') ); } diff --git a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php index 25d164aeee5c3..6989a33535ad8 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/RuleProductsSelectBuilder.php @@ -6,6 +6,10 @@ namespace Magento\CatalogRule\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\Framework\App\ObjectManager; + /** * Build select for rule relation with product. */ @@ -32,29 +36,38 @@ class RuleProductsSelectBuilder private $metadataPool; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher + * @var ActiveTableSwitcher */ private $activeTableSwitcher; + /** + * @var TableSwapper + */ + private $tableSwapper; + /** * @param \Magento\Framework\App\ResourceConnection $resource * @param \Magento\Eav\Model\Config $eavConfig * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool - * @param \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + * @param ActiveTableSwitcher $activeTableSwitcher + * @param TableSwapper|null $tableSwapper */ public function __construct( \Magento\Framework\App\ResourceConnection $resource, \Magento\Eav\Model\Config $eavConfig, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Framework\EntityManager\MetadataPool $metadataPool, - \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher $activeTableSwitcher + ActiveTableSwitcher $activeTableSwitcher, + TableSwapper $tableSwapper = null ) { $this->eavConfig = $eavConfig; $this->storeManager = $storeManager; $this->metadataPool = $metadataPool; $this->resource = $resource; $this->activeTableSwitcher = $activeTableSwitcher; + $this->tableSwapper = $tableSwapper ?? + ObjectManager::getInstance()->get(TableSwapper::class); } /** @@ -74,7 +87,7 @@ public function build( $indexTable = $this->resource->getTableName('catalogrule_product'); if ($useAdditionalTable) { $indexTable = $this->resource->getTableName( - $this->activeTableSwitcher->getAdditionalTableName('catalogrule_product') + $this->tableSwapper->getWorkingTableName('catalogrule_product') ); } diff --git a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php new file mode 100644 index 0000000000000..13809a381ecb9 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplier.php @@ -0,0 +1,78 @@ +conditionsToSearchCriteriaMapper = $conditionsToSearchCriteriaMapper; + $this->searchCriteriaProcessor = $searchCriteriaProcessor; + $this->mappableConditionsProcessor = $mappableConditionsProcessor; + } + + /** + * Transforms catalog rule conditions to search criteria + * and applies them on product collection + * + * @param Combine $conditions + * @param ProductCollection $productCollection + * @return ProductCollection + * @throws InputException + */ + public function applyConditionsToCollection( + Combine $conditions, + ProductCollection $productCollection + ): ProductCollection { + // rebuild conditions to have only those that we know how to map them to product collection + $mappableConditions = $this->mappableConditionsProcessor->rebuildConditionsTree($conditions); + + // transform conditions to search criteria + $searchCriteria = $this->conditionsToSearchCriteriaMapper->mapConditionsToSearchCriteria($mappableConditions); + + $mappedProductCollection = clone $productCollection; + + // apply search criteria to new version of product collection + $this->searchCriteriaProcessor->process($searchCriteria, $mappedProductCollection); + + return $mappedProductCollection; + } +} diff --git a/app/code/Magento/CatalogRule/Model/Rule.php b/app/code/Magento/CatalogRule/Model/Rule.php index 715b7a2f3903b..7696569cb26da 100644 --- a/app/code/Magento/CatalogRule/Model/Rule.php +++ b/app/code/Magento/CatalogRule/Model/Rule.php @@ -6,10 +6,34 @@ namespace Magento\CatalogRule\Model; use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ProductFactory; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\CatalogRule\Api\Data\RuleExtensionInterface; use Magento\CatalogRule\Api\Data\RuleInterface; +use Magento\CatalogRule\Helper\Data; +use Magento\CatalogRule\Model\Data\Condition\Converter; +use Magento\CatalogRule\Model\Indexer\Rule\RuleProductProcessor; +use Magento\CatalogRule\Model\ResourceModel\Rule as RuleResourceModel; +use Magento\CatalogRule\Model\Rule\Action\CollectionFactory as RuleCollectionFactory; +use Magento\CatalogRule\Model\Rule\Condition\CombineFactory; +use Magento\Customer\Model\Session; use Magento\Framework\Api\AttributeValueFactory; use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Framework\App\Cache\TypeListInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Data\Collection\AbstractDb; +use Magento\Framework\Data\FormFactory; +use Magento\Framework\DataObject; use Magento\Framework\DataObject\IdentityInterface; +use Magento\Framework\Model\Context; +use Magento\Framework\Model\ResourceModel\AbstractResource; +use Magento\Framework\Model\ResourceModel\Iterator; +use Magento\Framework\Registry; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Stdlib\DateTime; +use Magento\Framework\Stdlib\DateTime\TimezoneInterface; +use Magento\Store\Model\StoreManagerInterface; +use Magento\CatalogRule\Model\ResourceModel\Product\ConditionsToCollectionApplier; /** * Catalog Rule data model @@ -136,6 +160,21 @@ class Rule extends \Magento\Rule\Model\AbstractModel implements RuleInterface, I */ protected $ruleConditionConverter; + /** + * @var ConditionsToCollectionApplier + */ + private $conditionsToCollectionApplier; + + /** + * @var array + */ + private $websitesMap; + + /** + * @var RuleResourceModel + */ + private $ruleResourceModel; + /** * Rule constructor * @@ -161,32 +200,36 @@ class Rule extends \Magento\Rule\Model\AbstractModel implements RuleInterface, I * @param ExtensionAttributesFactory|null $extensionFactory * @param AttributeValueFactory|null $customAttributeFactory * @param \Magento\Framework\Serialize\Serializer\Json $serializer + * @param \Magento\CatalogRule\Model\ResourceModel\RuleResourceModel|null $ruleResourceModel + * @param ConditionsToCollectionApplier $conditionsToCollectionApplier * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( - \Magento\Framework\Model\Context $context, - \Magento\Framework\Registry $registry, - \Magento\Framework\Data\FormFactory $formFactory, - \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, - \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory, - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\CatalogRule\Model\Rule\Condition\CombineFactory $combineFactory, - \Magento\CatalogRule\Model\Rule\Action\CollectionFactory $actionCollectionFactory, - \Magento\Catalog\Model\ProductFactory $productFactory, - \Magento\Framework\Model\ResourceModel\Iterator $resourceIterator, - \Magento\Customer\Model\Session $customerSession, - \Magento\CatalogRule\Helper\Data $catalogRuleData, - \Magento\Framework\App\Cache\TypeListInterface $cacheTypesList, - \Magento\Framework\Stdlib\DateTime $dateTime, - \Magento\CatalogRule\Model\Indexer\Rule\RuleProductProcessor $ruleProductProcessor, - \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, - \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, + Context $context, + Registry $registry, + FormFactory $formFactory, + TimezoneInterface $localeDate, + CollectionFactory $productCollectionFactory, + StoreManagerInterface $storeManager, + CombineFactory $combineFactory, + RuleCollectionFactory $actionCollectionFactory, + ProductFactory $productFactory, + Iterator $resourceIterator, + Session $customerSession, + Data $catalogRuleData, + TypeListInterface $cacheTypesList, + DateTime $dateTime, + RuleProductProcessor $ruleProductProcessor, + AbstractResource $resource = null, + AbstractDb $resourceCollection = null, array $relatedCacheTypes = [], array $data = [], ExtensionAttributesFactory $extensionFactory = null, AttributeValueFactory $customAttributeFactory = null, - \Magento\Framework\Serialize\Serializer\Json $serializer = null + Json $serializer = null, + RuleResourceModel $ruleResourceModel = null, + ConditionsToCollectionApplier $conditionsToCollectionApplier = null ) { $this->_productCollectionFactory = $productCollectionFactory; $this->_storeManager = $storeManager; @@ -200,6 +243,10 @@ public function __construct( $this->_relatedCacheTypes = $relatedCacheTypes; $this->dateTime = $dateTime; $this->_ruleProductProcessor = $ruleProductProcessor; + $this->ruleResourceModel = $ruleResourceModel ?: ObjectManager::getInstance()->get(RuleResourceModel::class); + + $this->conditionsToCollectionApplier = $conditionsToCollectionApplier + ?? ObjectManager::getInstance()->get(ConditionsToCollectionApplier::class); parent::__construct( $context, @@ -223,7 +270,7 @@ public function __construct( protected function _construct() { parent::_construct(); - $this->_init(\Magento\CatalogRule\Model\ResourceModel\Rule::class); + $this->_init(RuleResourceModel::class); $this->setIdFieldName('rule_id'); } @@ -255,7 +302,7 @@ public function getActionsInstance() public function getCustomerGroupIds() { if (!$this->hasCustomerGroupIds()) { - $customerGroupIds = $this->_getResource()->getCustomerGroupIds($this->getId()); + $customerGroupIds = $this->ruleResourceModel->getCustomerGroupIds($this->getId()); $this->setData('customer_group_ids', (array)$customerGroupIds); } return $this->_getData('customer_group_ids'); @@ -269,7 +316,7 @@ public function getCustomerGroupIds() public function getNow() { if (!$this->_now) { - return (new \DateTime())->format(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT); + return (new \DateTime())->format(DateTime::DATETIME_PHP_FORMAT); } return $this->_now; } @@ -306,6 +353,11 @@ public function getMatchingProductIds() } $this->getConditions()->collectValidatedAttributes($productCollection); + if ($this->canPreMapProducts()) { + $productCollection = $this->conditionsToCollectionApplier + ->applyConditionsToCollection($this->getConditions(), $productCollection); + } + $this->_resourceIterator->walk( $productCollection->getSelect(), [[$this, 'callbackValidateProduct']], @@ -320,6 +372,23 @@ public function getMatchingProductIds() return $this->_productIds; } + /** + * Check if we can use mapping for rule conditions + * + * @return bool + */ + private function canPreMapProducts() + { + $conditions = $this->getConditions(); + + // No need to map products if there is no conditions in rule + if (!$conditions || !$conditions->getConditions()) { + return false; + } + + return true; + } + /** * Callback function for product matching * @@ -348,22 +417,25 @@ public function callbackValidateProduct($args) */ protected function _getWebsitesMap() { - $map = []; - $websites = $this->_storeManager->getWebsites(); - foreach ($websites as $website) { - // Continue if website has no store to be able to create catalog rule for website without store - if ($website->getDefaultStore() === null) { - continue; + if ($this->websitesMap === null) { + $this->websitesMap = []; + $websites = $this->_storeManager->getWebsites(); + foreach ($websites as $website) { + // Continue if website has no store to be able to create catalog rule for website without store + if ($website->getDefaultStore() === null) { + continue; + } + $this->websitesMap[$website->getId()] = $website->getDefaultStore()->getId(); } - $map[$website->getId()] = $website->getDefaultStore()->getId(); } - return $map; + + return $this->websitesMap; } /** * {@inheritdoc} */ - public function validateData(\Magento\Framework\DataObject $dataObject) + public function validateData(DataObject $dataObject) { $result = parent::validateData($dataObject); if ($result === true) { @@ -470,7 +542,7 @@ public function calcProductPriceRule(Product $product, $price) */ protected function _getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId) { - return $this->_getResource()->getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId); + return $this->ruleResourceModel->getRulesFromProduct($dateTs, $websiteId, $customerGroupId, $productId); } /** @@ -516,10 +588,10 @@ protected function _invalidateCache() */ public function afterSave() { - if ($this->isObjectNew()) { - $this->getMatchingProductIds(); - if (!empty($this->_productIds) && is_array($this->_productIds)) { - $this->_getResource()->addCommitCallback([$this, 'reindex']); + if ($this->isObjectNew() && !$this->_ruleProductProcessor->isIndexerScheduled()) { + $productIds = $this->getMatchingProductIds(); + if (!empty($productIds) && is_array($productIds)) { + $this->ruleResourceModel->addCommitCallback([$this, 'reindex']); } } else { $this->_ruleProductProcessor->getIndexer()->invalidate(); @@ -778,7 +850,7 @@ public function getExtensionAttributes() * @param \Magento\CatalogRule\Api\Data\RuleExtensionInterface $extensionAttributes * @return $this */ - public function setExtensionAttributes(\Magento\CatalogRule\Api\Data\RuleExtensionInterface $extensionAttributes) + public function setExtensionAttributes(RuleExtensionInterface $extensionAttributes) { return $this->_setExtensionAttributes($extensionAttributes); } @@ -790,8 +862,8 @@ public function setExtensionAttributes(\Magento\CatalogRule\Api\Data\RuleExtensi private function getRuleConditionConverter() { if (null === $this->ruleConditionConverter) { - $this->ruleConditionConverter = \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\CatalogRule\Model\Data\Condition\Converter::class); + $this->ruleConditionConverter = ObjectManager::getInstance() + ->get(Converter::class); } return $this->ruleConditionConverter; } diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php new file mode 100644 index 0000000000000..6d343fe149d21 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php @@ -0,0 +1,308 @@ +searchCriteriaBuilderFactory = $searchCriteriaBuilderFactory; + $this->combinedFilterGroupFactory = $combinedFilterGroupFactory; + $this->filterFactory = $filterFactory; + } + + /** + * Maps catalog price rule conditions to search criteria + * + * @param CombinedCondition $conditions + * @return SearchCriteria + * @throws InputException + */ + public function mapConditionsToSearchCriteria(CombinedCondition $conditions): SearchCriteria + { + $filterGroup = $this->mapCombinedConditionToFilterGroup($conditions); + + $searchCriteriaBuilder = $this->searchCriteriaBuilderFactory->create(); + + if ($filterGroup !== null) { + $searchCriteriaBuilder->setFilterGroups([$filterGroup]); + } + + return $searchCriteriaBuilder->create(); + } + + /** + * @param ConditionInterface $condition + * @return null|\Magento\Framework\Api\CombinedFilterGroup|\Magento\Framework\Api\Filter + * @throws InputException + */ + private function mapConditionToFilterGroup(ConditionInterface $condition) + { + if ($condition->getType() === CombinedCondition::class) { + return $this->mapCombinedConditionToFilterGroup($condition); + } elseif ($condition->getType() === SimpleCondition::class) { + return $this->mapSimpleConditionToFilterGroup($condition); + } + + throw new InputException( + __('Undefined condition type "%1" passed in.', $condition->getType()) + ); + } + + /** + * @param Combine $combinedCondition + * @return null|\Magento\Framework\Api\CombinedFilterGroup + * @throws InputException + */ + private function mapCombinedConditionToFilterGroup(CombinedCondition $combinedCondition) + { + $filters = []; + + foreach ($combinedCondition->getConditions() as $condition) { + $filter = $this->mapConditionToFilterGroup($condition); + + if ($filter === null) { + continue; + } + + // This required to solve cases when condition is configured like: + // "If ALL/ANY of these conditions are FALSE" - we need to reverse SQL operator for this "FALSE" + if ((bool)$combinedCondition->getValue() === false) { + $this->reverseSqlOperatorInFilter($filter); + } + + $filters[] = $filter; + } + + if (count($filters) === 0) { + return null; + } + + return $this->createCombinedFilterGroup($filters, $combinedCondition->getAggregator()); + } + + /** + * @param ConditionInterface $productCondition + * @return FilterGroup|Filter + * @throws InputException + */ + private function mapSimpleConditionToFilterGroup(ConditionInterface $productCondition) + { + if (is_array($productCondition->getValue())) { + return $this->processSimpleConditionWithArrayValue($productCondition); + } + + return $this->createFilter( + $productCondition->getAttribute(), + (string) $productCondition->getValue(), + $productCondition->getOperator() + ); + } + + /** + * @param ConditionInterface $productCondition + * @return FilterGroup + * @throws InputException + */ + private function processSimpleConditionWithArrayValue(ConditionInterface $productCondition): FilterGroup + { + $filters = []; + + foreach ($productCondition->getValue() as $subValue) { + $filters[] = $this->createFilter( + $productCondition->getAttribute(), + (string) $subValue, + $productCondition->getOperator() + ); + } + + $combinationMode = $this->getGlueForArrayValues($productCondition->getOperator()); + + return $this->createCombinedFilterGroup($filters, $combinationMode); + } + + /** + * @param string $operator + * @return string + */ + private function getGlueForArrayValues(string $operator): string + { + if (in_array($operator, ['!=', '!{}', '!()'], true)) { + return 'all'; + } + + return 'any'; + } + + /** + * Reverse sql conditions to their corresponding negative analog + * + * @param Filter $filter + * @return void + * @throws InputException + */ + private function reverseSqlOperatorInFilter(Filter $filter) + { + $operatorsMap = [ + 'eq' => 'neq', + 'neq' => 'eq', + 'gteq' => 'lt', + 'lteq' => 'gt', + 'gt' => 'lteq', + 'lt' => 'gteq', + 'like' => 'nlike', + 'nlike' => 'like', + 'in' => 'nin', + 'nin' => 'in', + ]; + + if (!array_key_exists($filter->getConditionType(), $operatorsMap)) { + throw new InputException( + __( + 'Undefined SQL operator "%1" passed in. Valid operators are: %2', + $filter->getConditionType(), + implode(',', array_keys($operatorsMap)) + ) + ); + } + + $filter->setConditionType( + $operatorsMap[$filter->getConditionType()] + ); + } + + /** + * @param array $filters + * @param string $combinationMode + * @return FilterGroup + * @throws InputException + */ + private function createCombinedFilterGroup(array $filters, string $combinationMode): FilterGroup + { + return $this->combinedFilterGroupFactory->create([ + 'data' => [ + FilterGroup::FILTERS => $filters, + FilterGroup::COMBINATION_MODE => $this->mapRuleAggregatorToSQLAggregator($combinationMode) + ] + ]); + } + + /** + * @param string $field + * @param string $value + * @param string $conditionType + * @return Filter + * @throws InputException + */ + private function createFilter(string $field, string $value, string $conditionType): Filter + { + return $this->filterFactory->create([ + 'data' => [ + Filter::KEY_FIELD => $field, + Filter::KEY_VALUE => $value, + Filter::KEY_CONDITION_TYPE => $this->mapRuleOperatorToSQLCondition($conditionType) + ] + ]); + } + + /** + * Maps catalog price rule operators to their corresponding operators in SQL + * + * @param string $ruleOperator + * @return string + * @throws InputException + */ + private function mapRuleOperatorToSQLCondition(string $ruleOperator): string + { + $operatorsMap = [ + '==' => 'eq', // is + '!=' => 'neq', // is not + '>=' => 'gteq', // equals or greater than + '<=' => 'lteq', // equals or less than + '>' => 'gt', // greater than + '<' => 'lt', // less than + '{}' => 'like', // contains + '!{}' => 'nlike', // does not contains + '()' => 'in', // is one of + '!()' => 'nin', // is not one of + ]; + + if (!array_key_exists($ruleOperator, $operatorsMap)) { + throw new InputException( + __( + 'Undefined rule operator "%1" passed in. Valid operators are: %2', + $ruleOperator, + implode(',', array_keys($operatorsMap)) + ) + ); + } + + return $operatorsMap[$ruleOperator]; + } + + /** + * Map rule combine aggregations to corresponding SQL operator + * + * @param string $ruleAggregator + * @return string + * @throws InputException + */ + private function mapRuleAggregatorToSQLAggregator(string $ruleAggregator): string + { + $operatorsMap = [ + 'all' => 'AND', + 'any' => 'OR', + ]; + + if (!array_key_exists(strtolower($ruleAggregator), $operatorsMap)) { + throw new InputException( + __( + 'Undefined rule aggregator "%1" passed in. Valid operators are: %2', + $ruleAggregator, + implode(',', array_keys($operatorsMap)) + ) + ); + } + + return $operatorsMap[$ruleAggregator]; + } +} diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php new file mode 100644 index 0000000000000..63c3f62ad0590 --- /dev/null +++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/MappableConditionsProcessor.php @@ -0,0 +1,139 @@ +customConditionProvider = $customConditionProvider; + $this->eavConfig = $eavConfig; + } + + /** + * @param Combine $conditions + * @return Combine + */ + public function rebuildConditionsTree(CombinedCondition $conditions): CombinedCondition + { + return $this->rebuildCombinedCondition($conditions); + } + + /** + * @param Combine $originalConditions + * @return Combine + * @throws InputException + */ + private function rebuildCombinedCondition(CombinedCondition $originalConditions): CombinedCondition + { + $validConditions = []; + $invalidConditions = []; + + foreach ($originalConditions->getConditions() as $condition) { + if ($condition->getType() === CombinedCondition::class) { + $rebuildSubCondition = $this->rebuildCombinedCondition($condition); + + if (count($rebuildSubCondition->getConditions()) > 0) { + $validConditions[] = $rebuildSubCondition; + } else { + $invalidConditions[] = $rebuildSubCondition; + } + + continue; + } + + if ($condition->getType() === SimpleCondition::class) { + if ($this->validateSimpleCondition($condition)) { + $validConditions[] = $condition; + } else { + $invalidConditions[] = $condition; + } + + continue; + } + + throw new InputException( + __('Undefined condition type "%1" passed in.', $condition->getType()) + ); + } + + // if resulted condition group has left no mappable conditions - we can remove it at all + if (count($invalidConditions) > 0 && strtolower($originalConditions->getAggregator()) === 'any') { + $validConditions = []; + } + + $rebuildCondition = clone $originalConditions; + $rebuildCondition->setConditions($validConditions); + + return $rebuildCondition; + } + + /** + * @param Product $originalConditions + * @return bool + */ + private function validateSimpleCondition(SimpleCondition $originalConditions): bool + { + return $this->canUseFieldForMapping($originalConditions->getAttribute()); + } + + /** + * Checks if condition field is mappable + * + * @param string $fieldName + * @return bool + */ + private function canUseFieldForMapping(string $fieldName): bool + { + // We can map field to search criteria if we have custom processor for it + if ($this->customConditionProvider->hasProcessorForField($fieldName)) { + return true; + } + + // Also we can map field to search criteria if it is an EAV attribute + $attribute = $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $fieldName); + + // We have this weird check for getBackendType() to verify that attribute really exists + // because due to eavConfig behavior even if pass non existing attribute code we still receive AbstractAttribute + // getAttributeId() is not sufficient too because some attributes don't have it - e.g. attribute_set_id + if ($attribute && $attribute->getBackendType() !== null) { + return true; + } + + // In any other cases we can't map field to search criteria + return false; + } +} diff --git a/app/code/Magento/CatalogRule/Plugin/Indexer/Category.php b/app/code/Magento/CatalogRule/Plugin/Indexer/Category.php index 8bcc1fd8d6834..50e3703087680 100644 --- a/app/code/Magento/CatalogRule/Plugin/Indexer/Category.php +++ b/app/code/Magento/CatalogRule/Plugin/Indexer/Category.php @@ -35,8 +35,8 @@ public function afterSave( \Magento\Catalog\Model\Category $result ) { /** @var \Magento\Catalog\Model\Category $result */ - $productIds = $result->getAffectedProductIds(); - if ($productIds) { + $productIds = $result->getChangedProductIds(); + if (!empty($productIds) && !$this->productRuleProcessor->isIndexerScheduled()) { $this->productRuleProcessor->reindexList($productIds); } return $result; diff --git a/app/code/Magento/CatalogRule/Setup/Patch/Data/ConvertSerializedDataToJson.php b/app/code/Magento/CatalogRule/Setup/Patch/Data/ConvertSerializedDataToJson.php index eb5ed43806aa2..111d7acd53099 100644 --- a/app/code/Magento/CatalogRule/Setup/Patch/Data/ConvertSerializedDataToJson.php +++ b/app/code/Magento/CatalogRule/Setup/Patch/Data/ConvertSerializedDataToJson.php @@ -8,8 +8,8 @@ use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Framework\DB\AggregatedFieldDataConverter; use Magento\Framework\DB\DataConverter\SerializedToJson; use Magento\Framework\DB\FieldToConvert; diff --git a/app/code/Magento/CatalogRule/Setup/Patch/Data/UpdateClassAliasesForCatalogRules.php b/app/code/Magento/CatalogRule/Setup/Patch/Data/UpdateClassAliasesForCatalogRules.php index 17920a997014f..ce1d76876f690 100644 --- a/app/code/Magento/CatalogRule/Setup/Patch/Data/UpdateClassAliasesForCatalogRules.php +++ b/app/code/Magento/CatalogRule/Setup/Patch/Data/UpdateClassAliasesForCatalogRules.php @@ -7,8 +7,8 @@ namespace Magento\CatalogRule\Setup\Patch\Data; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateClassAliasesForCatalogRules diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilder/ProductLoaderTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilder/ProductLoaderTest.php new file mode 100644 index 0000000000000..0560bc616f9ca --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilder/ProductLoaderTest.php @@ -0,0 +1,97 @@ +productRepository = $this->getMockBuilder(ProductRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->searchCriteriaBuilder = $this->getMockBuilder(SearchCriteriaBuilder::class) + ->disableOriginalConstructor() + ->getMock(); + $this->productSearchResultsInterface = $this->getMockBuilder(ProductSearchResultsInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->searchCriteria = $this->getMockBuilder(SearchCriteria::class) + ->disableOriginalConstructor() + ->getMock(); + $this->product = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->productLoader = new ProductLoader( + $this->productRepository, + $this->searchCriteriaBuilder + ); + } + + public function testGetProducts() + { + $this->searchCriteriaBuilder->expects($this->once()) + ->method('addFilter') + ->willReturnSelf(); + $this->searchCriteriaBuilder->expects($this->once()) + ->method('create') + ->willReturn($this->searchCriteria); + $this->productRepository->expects($this->once()) + ->method('getList') + ->with($this->searchCriteria) + ->willReturn($this->productSearchResultsInterface); + $iterator = [$this->product]; + $this->productSearchResultsInterface->expects($this->once()) + ->method('getItems') + ->willReturn($iterator); + + $this->assertSame($iterator, $this->productLoader->getProducts([1])); + } +} diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilderTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilderTest.php index 997cf704a8657..521e4e1d59897 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilderTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilderTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\CatalogRule\Model\Indexer\IndexBuilder\ProductLoader; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyFields) @@ -122,10 +125,16 @@ class IndexBuilderTest extends \PHPUnit\Framework\TestCase */ private $reindexRuleGroupWebsite; + /** + * @var ProductLoader|\PHPUnit_Framework_MockObject_MockObject + */ + private $productLoader; + /** * Set up test * * @return void + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ protected function setUp() { @@ -171,36 +180,47 @@ protected function setUp() $this->rules->expects($this->any())->method('getId')->will($this->returnValue(1)); $this->rules->expects($this->any())->method('getWebsiteIds')->will($this->returnValue([1])); $this->rules->expects($this->any())->method('getCustomerGroupIds')->will($this->returnValue([1])); + $this->ruleCollectionFactory->expects($this->any())->method('create')->will($this->returnSelf()); $this->ruleCollectionFactory->expects($this->any())->method('addFieldToFilter')->will( $this->returnValue([$this->rules]) ); + $this->product->expects($this->any())->method('load')->will($this->returnSelf()); $this->product->expects($this->any())->method('getId')->will($this->returnValue(1)); $this->product->expects($this->any())->method('getWebsiteIds')->will($this->returnValue([1])); + $this->rules->expects($this->any())->method('validate')->with($this->product)->willReturn(true); $this->attribute->expects($this->any())->method('getBackend')->will($this->returnValue($this->backend)); $this->productFactory->expects($this->any())->method('create')->will($this->returnValue($this->product)); + $this->productLoader = $this->getMockBuilder(ProductLoader::class) + ->disableOriginalConstructor() + ->getMock(); - $this->indexBuilder = new \Magento\CatalogRule\Model\Indexer\IndexBuilder( - $this->ruleCollectionFactory, - $this->priceCurrency, - $this->resource, - $this->storeManager, - $this->logger, - $this->eavConfig, - $this->dateFormat, - $this->dateTime, - $this->productFactory + $this->indexBuilder = (new ObjectManager($this))->getObject( + \Magento\CatalogRule\Model\Indexer\IndexBuilder::class, + [ + 'ruleCollectionFactory' => $this->ruleCollectionFactory, + 'priceCurrency' => $this->priceCurrency, + 'resource' => $this->resource, + 'storeManager' => $this->storeManager, + 'logger' => $this->logger, + 'eavConfig' => $this->eavConfig, + 'dateFormat' => $this->dateFormat, + 'dateTime' => $this->dateTime, + 'productFactory' => $this->productFactory, + 'productLoader' => $this->productLoader, + ] ); + $this->reindexRuleProductPrice = $this->getMockBuilder(\Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice::class) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->reindexRuleGroupWebsite = $this->getMockBuilder(\Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite::class) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->setProperties($this->indexBuilder, [ 'metadataPool' => $this->metadataPool, 'reindexRuleProductPrice' => $this->reindexRuleProductPrice, @@ -235,6 +255,11 @@ public function testUpdateCatalogRuleGroupWebsiteData() ->method('getBackend') ->will($this->returnValue($backendModelMock)); + $iterator = [$this->product]; + $this->productLoader->expects($this->once()) + ->method('getProducts') + ->willReturn($iterator); + $this->reindexRuleProductPrice->expects($this->once())->method('execute')->willReturn(true); $this->reindexRuleGroupWebsite->expects($this->once())->method('execute')->willReturn(true); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexerTableSwapperTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexerTableSwapperTest.php new file mode 100644 index 0000000000000..654a1180d8717 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexerTableSwapperTest.php @@ -0,0 +1,168 @@ +resourceConnectionMock = $this->createMock(ResourceConnection::class); + + $this->adapterInterfaceMock = $this->getMockBuilder(AdapterInterface::class)->getMockForAbstractClass(); + /** @var \Zend_Db_Statement_Interface $statementInterfaceMock */ + $this->statementInterfaceMock = $this->getMockBuilder(\Zend_Db_Statement_Interface::class) + ->getMockForAbstractClass(); + /** @var Table $tableMock */ + $this->tableMock = $this->createMock(Table::class); + $this->resourceConnectionMock->expects($this->any()) + ->method('getConnection') + ->willReturn($this->adapterInterfaceMock); + } + + /** + * @return void + */ + public function testGetWorkingTableNameWithExistingTemporaryTable(): void + { + $model = new IndexerTableSwapper($this->resourceConnectionMock); + $originalTableName = 'catalogrule_product'; + $temporaryTableNames = ['catalogrule_product' => 'catalogrule_product__temp9604']; + $this->setObjectProperty($model, 'temporaryTables', $temporaryTableNames); + + $this->resourceConnectionMock->expects($this->once()) + ->method('getTableName') + ->with($originalTableName) + ->willReturn($originalTableName); + + $this->assertEquals( + $temporaryTableNames[$originalTableName], + $model->getWorkingTableName($originalTableName) + ); + } + + /** + * @return void + */ + public function testGetWorkingTableNameWithoutExistingTemporaryTable(): void + { + $model = new IndexerTableSwapper($this->resourceConnectionMock); + $originalTableName = 'catalogrule_product'; + $temporaryTableName = 'catalogrule_product__temp9604'; + $this->setObjectProperty($model, 'temporaryTables', []); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getTableName') + ->with($originalTableName) + ->willReturn($originalTableName); + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getTableName') + ->with($this->stringStartsWith($originalTableName . '__temp')) + ->willReturn($temporaryTableName); + + $this->assertEquals( + $temporaryTableName, + $model->getWorkingTableName($originalTableName) + ); + } + + /** + * Sets object non-public property. + * + * @param mixed $object + * @param string $propertyName + * @param mixed $value + * + * @return void + */ + private function setObjectProperty($object, string $propertyName, $value): void + { + $reflectionClass = new \ReflectionClass($object); + $reflectionProperty = $reflectionClass->getProperty($propertyName); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($object, $value); + } + + /** + * @return void + */ + public function testSwapIndexTables(): void + { + $model = $this->getMockBuilder(IndexerTableSwapper::class) + ->setMethods(['getWorkingTableName']) + ->setConstructorArgs([$this->resourceConnectionMock]) + ->getMock(); + $originalTableName = 'catalogrule_product'; + $temporaryOriginalTableName = 'catalogrule_product9604'; + $temporaryTableName = 'catalogrule_product__temp9604'; + $toRename = [ + [ + 'oldName' => $originalTableName, + 'newName' => $temporaryOriginalTableName, + ], + [ + 'oldName' => $temporaryTableName, + 'newName' => $originalTableName, + ], + ]; + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getTableName') + ->with($originalTableName) + ->willReturn($originalTableName); + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getTableName') + ->with($this->stringStartsWith($originalTableName)) + ->willReturn($temporaryOriginalTableName); + $model->expects($this->once()) + ->method('getWorkingTableName') + ->with($originalTableName) + ->willReturn($temporaryTableName); + $this->adapterInterfaceMock->expects($this->once()) + ->method('renameTablesBatch') + ->with($toRename) + ->willReturn(true); + $this->adapterInterfaceMock->expects($this->once()) + ->method('dropTable') + ->with($temporaryOriginalTableName) + ->willReturn(true); + + $model->swapIndexTables([$originalTableName]); + } +} diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php index d60a662193e54..f02c2c643f809 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleGroupWebsiteTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class ReindexRuleGroupWebsiteTest extends \PHPUnit\Framework\TestCase { /** @@ -24,10 +27,15 @@ class ReindexRuleGroupWebsiteTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject */ private $activeTableSwitcherMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->dateTimeMock = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime\DateTime::class) @@ -37,13 +45,17 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) + $this->getMockBuilder(ActiveTableSwitcher::class) ->disableOriginalConstructor() ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\ReindexRuleGroupWebsite( $this->dateTimeMock, $this->resourceMock, - $this->activeTableSwitcherMock + $this->activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -55,31 +67,25 @@ public function testExecute() $this->resourceMock->expects($this->at(0))->method('getConnection')->willReturn($connectionMock); $this->dateTimeMock->expects($this->once())->method('gmtTimestamp')->willReturn($timeStamp); - $this->activeTableSwitcherMock->expects($this->at(0)) - ->method('getAdditionalTableName') - ->with('catalogrule_group_website') - ->willReturn('catalogrule_group_website_replica'); - $this->activeTableSwitcherMock->expects($this->at(1)) - ->method('getAdditionalTableName') - ->with('catalogrule_product') - ->willReturn('catalogrule_product_replica'); + $this->tableSwapperMock->expects($this->any()) + ->method('getWorkingTableName') + ->willReturnMap( + [ + ['catalogrule_group_website', 'catalogrule_group_website_replica'], + ['catalogrule_product', 'catalogrule_product_replica'], + ] + ); - $this->resourceMock->expects($this->at(1)) - ->method('getTableName') - ->with('catalogrule_group_website') - ->willReturn('catalogrule_group_website'); - $this->resourceMock->expects($this->at(2)) - ->method('getTableName') - ->with('catalogrule_product') - ->willReturn('catalogrule_product'); - $this->resourceMock->expects($this->at(3)) - ->method('getTableName') - ->with('catalogrule_group_website_replica') - ->willReturn('catalogrule_group_website_replica'); - $this->resourceMock->expects($this->at(4)) + $this->resourceMock->expects($this->any()) ->method('getTableName') - ->with('catalogrule_product_replica') - ->willReturn('catalogrule_product_replica'); + ->willReturnMap( + [ + ['catalogrule_group_website', 'default', 'catalogrule_group_website'], + ['catalogrule_product', 'default', 'catalogrule_product'], + ['catalogrule_group_website_replica', 'default', 'catalogrule_group_website_replica'], + ['catalogrule_product_replica', 'default', 'catalogrule_product_replica'], + ] + ); $selectMock = $this->getMockBuilder(\Magento\Framework\DB\Select::class) ->disableOriginalConstructor() diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php index b829468396bf0..0dbbaee8d2871 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class ReindexRuleProductTest extends \PHPUnit\Framework\TestCase { /** @@ -19,22 +22,30 @@ class ReindexRuleProductTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject */ private $activeTableSwitcherMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) - ->disableOriginalConstructor() - ->getMock(); + $this->activeTableSwitcherMock = $this->getMockBuilder(ActiveTableSwitcher::class) + ->disableOriginalConstructor() + ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct( $this->resourceMock, - $this->activeTableSwitcherMock + $this->activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -71,8 +82,8 @@ public function testExecute() $ruleMock->expects($this->exactly(2))->method('getWebsiteIds')->willReturn(1); $ruleMock->expects($this->once())->method('getMatchingProductIds')->willReturn($productIds); - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product') ->willReturn('catalogrule_product_replica'); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php index 3efe26971627e..03163aa2d7c45 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductPricesPersistorTest.php @@ -6,6 +6,9 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; + class RuleProductPricesPersistorTest extends \PHPUnit\Framework\TestCase { /** @@ -24,10 +27,15 @@ class RuleProductPricesPersistorTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject */ private $activeTableSwitcherMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->dateTimeMock = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime::class) @@ -36,14 +44,17 @@ protected function setUp() $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) - ->disableOriginalConstructor() - ->getMock(); + $this->activeTableSwitcherMock = $this->getMockBuilder(ActiveTableSwitcher::class) + ->disableOriginalConstructor() + ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor( $this->dateTimeMock, $this->resourceMock, - $this->activeTableSwitcherMock + $this->activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -64,8 +75,8 @@ public function testExecute() ]; $tableName = 'catalogrule_product_price_replica'; - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product_price') ->willReturn($tableName); @@ -120,8 +131,8 @@ public function testExecuteWithException() ]; $tableName = 'catalogrule_product_price_replica'; - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with('catalogrule_product_price') ->willReturn($tableName); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php index 92b4bb353f046..e43fe41dc2127 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/RuleProductsSelectBuilderTest.php @@ -7,6 +7,8 @@ namespace Magento\CatalogRule\Test\Unit\Model\Indexer; use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher; +use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface; use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\DB\Select; use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; @@ -35,7 +37,7 @@ class RuleProductsSelectBuilderTest extends \PHPUnit\Framework\TestCase private $resourceMock; /** - * @var \Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject */ private $activeTableSwitcherMock; @@ -49,6 +51,11 @@ class RuleProductsSelectBuilderTest extends \PHPUnit\Framework\TestCase */ private $metadataPoolMock; + /** + * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $tableSwapperMock; + protected function setUp() { $this->storeManagerMock = $this->getMockBuilder(\Magento\Store\Model\StoreManagerInterface::class) @@ -56,8 +63,7 @@ protected function setUp() $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) ->disableOriginalConstructor() ->getMock(); - $this->activeTableSwitcherMock = - $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher::class) + $this->activeTableSwitcherMock = $this->getMockBuilder(ActiveTableSwitcher::class) ->disableOriginalConstructor() ->getMock(); $this->eavConfigMock = $this->getMockBuilder(\Magento\Eav\Model\Config::class) @@ -66,13 +72,17 @@ protected function setUp() $this->metadataPoolMock = $this->getMockBuilder(\Magento\Framework\EntityManager\MetadataPool::class) ->disableOriginalConstructor() ->getMock(); + $this->tableSwapperMock = $this->getMockForAbstractClass( + IndexerTableSwapperInterface::class + ); $this->model = new \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder( $this->resourceMock, $this->eavConfigMock, $this->storeManagerMock, $this->metadataPoolMock, - $this->activeTableSwitcherMock + $this->activeTableSwitcherMock, + $this->tableSwapperMock ); } @@ -92,8 +102,8 @@ public function testBuild() $connectionMock = $this->getMockBuilder(AdapterInterface::class)->disableOriginalConstructor()->getMock(); $this->resourceMock->expects($this->at(0))->method('getConnection')->willReturn($connectionMock); - $this->activeTableSwitcherMock->expects($this->once()) - ->method('getAdditionalTableName') + $this->tableSwapperMock->expects($this->once()) + ->method('getWorkingTableName') ->with($ruleTable) ->willReturn($rplTable); diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php new file mode 100644 index 0000000000000..e28c443e46fed --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/MappableConditionProcessorTest.php @@ -0,0 +1,1037 @@ +eavConfigMock = $this->getMockBuilder(EavConfig::class) + ->disableOriginalConstructor() + ->setMethods(['getAttribute']) + ->getMock(); + + $this->customConditionProcessorBuilderMock = $this->getMockBuilder( + CustomConditionProviderInterface::class + )->disableOriginalConstructor() + ->setMethods(['hasProcessorForField']) + ->getMockForAbstractClass(); + + $this->objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + + $this->mappableConditionProcessor = $this->objectManagerHelper->getObject( + MappableConditionsProcessor::class, + [ + 'customConditionProvider' => $this->customConditionProcessorBuilderMock, + 'eavConfig' => $this->eavConfigMock, + ] + ); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-2 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + * ] + */ + public function testConditionV1() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-2 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * ] + * ] + * ] + */ + public function testConditionV2() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition( + [ + $simpleCondition1 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-2 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => [] + * ] + * ] + */ + public function testConditionV3() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition([], 'all'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + */ + public function testConditionV4() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'all' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'any' + ); + + $validSubCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition2 + ], + 'all' + ); + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition1, + $validSubCondition2 + ], + 'any' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 is not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + */ + public function testConditionV5() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'all' + ); + + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition2 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when all condition are mappable there must not be any changes to input + */ + public function testConditionV6() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2, + $subCondition1 + ], + 'all' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, true], + [$field3, true], + [$field4, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($inputCondition, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-2 => [ attribute => field-2 ] + * condition-3 => [ attribute => field-3 ] + * ] + * ] + * ] + * ] + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-4 => [ attribute => field-4 ] + * condition-5 => [ attribute => field-5 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-6 => [ attribute => field-6 ] + * condition-7 => [ attribute => field-7 ] + * ] + * ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-3 and condition-5 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => all + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-6 => [ attribute => field-6 ] + * condition-7 => [ attribute => field-7 ] + * ] + * ] + * ] + * ] + * ] + * ] + * ] + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testConditionV7() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + $field3 = 'field-3'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition2, + $simpleCondition3 + ], + 'any' + ); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $subCondition1 + ], + 'all' + ); + + $field4 = 'field-4'; + $field5 = 'field-5'; + $field6 = 'field-6'; + $field7 = 'field-7'; + + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $simpleCondition5 = $this->getMockForSimpleCondition($field5); + $simpleCondition6 = $this->getMockForSimpleCondition($field6); + $simpleCondition7 = $this->getMockForSimpleCondition($field7); + + $subCondition3 = $this->getMockForCombinedCondition( + [ + $simpleCondition4, + $simpleCondition5 + ], + 'any' + ); + $subCondition4 = $this->getMockForCombinedCondition( + [ + $simpleCondition6, + $simpleCondition7 + ], + 'any' + ); + $subCondition5 = $this->getMockForCombinedCondition( + [ + $subCondition3, + $subCondition4 + ], + 'all' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition2, + $subCondition5 + ], + 'any' + ); + + $validSubCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition1 + ], + 'all' + ); + $validSubCondition4 = $this->getMockForCombinedCondition( + [ + $subCondition4 + ], + 'all' + ); + + $validResult = $this->getMockForCombinedCondition( + [ + $validSubCondition2, + $validSubCondition4 + ], + 'any' + ); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, true], + [$field2, true], + [$field3, false], + [$field4, true], + [$field5, false], + [$field6, true], + [$field7, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-4 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + */ + public function testConditionV8() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, false], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * input condition tree: + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-1 => [ attribute => field-1 ] + * condition-2 => [ attribute => field-2 ] + * ] + * ] + * combined-condition => + * [ + * aggregation => any + * conditions => + * [ + * condition-3 => [ attribute => field-3 ] + * condition-4 => [ attribute => field-4 ] + * ] + * ] + * condition-5 => [ attribute => field-5 ] + * ] + * ] + * ] + * + * in case when condition-1 and condition-4 are not mappable the result must be next: + * + * [ + * combined-condition => + * [ + * aggregation => any + * conditions => [] + * ] + */ + public function testConditionV9() + { + $field1 = 'field-1'; + $field2 = 'field-2'; + + $simpleCondition1 = $this->getMockForSimpleCondition($field1); + $simpleCondition2 = $this->getMockForSimpleCondition($field2); + $subCondition1 = $this->getMockForCombinedCondition( + [ + $simpleCondition1, + $simpleCondition2 + ], + 'any' + ); + + $field3 = 'field-3'; + $field4 = 'field-4'; + + $simpleCondition3 = $this->getMockForSimpleCondition($field3); + $simpleCondition4 = $this->getMockForSimpleCondition($field4); + $subCondition2 = $this->getMockForCombinedCondition( + [ + $simpleCondition3, + $simpleCondition4 + ], + 'any' + ); + + $field5 = 'field-5'; + $simpleCondition5 = $this->getMockForSimpleCondition($field5); + + $inputCondition = $this->getMockForCombinedCondition( + [ + $subCondition1, + $subCondition2, + $simpleCondition5 + ], + 'any' + ); + + $validResult = $this->getMockForCombinedCondition([], 'any'); + + $this->customConditionProcessorBuilderMock + ->method('hasProcessorForField') + ->will( + $this->returnValueMap( + [ + [$field1, false], + [$field2, true], + [$field3, true], + [$field4, false], + [$field5, true], + ] + ) + ); + + $this->eavConfigMock + ->method('getAttribute') + ->willReturn(null); + + $result = $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + + $this->assertEquals($validResult, $result); + } + + /** + * @expectedException \Magento\Framework\Exception\InputException + * @expectedExceptionMessage Undefined condition type "olo-lo" passed in. + */ + public function testException() + { + $simpleCondition = $this->getMockForSimpleCondition('field'); + $simpleCondition->setType('olo-lo'); + $inputCondition = $this->getMockForCombinedCondition([$simpleCondition], 'any'); + + $this->mappableConditionProcessor->rebuildConditionsTree($inputCondition); + } + + protected function getMockForCombinedCondition($subConditions, $aggregator) + { + $mock = $this->getMockBuilder(CombinedCondition::class) + ->disableOriginalConstructor() + ->setMethods() + ->getMock(); + + $mock->setConditions($subConditions); + $mock->setAggregator($aggregator); + $mock->setType(CombinedCondition::class); + + return $mock; + } + + protected function getMockForSimpleCondition($attribute) + { + $mock = $this->getMockBuilder(SimpleCondition::class) + ->disableOriginalConstructor() + ->setMethods() + ->getMock(); + + $mock->setAttribute($attribute); + $mock->setType(SimpleCondition::class); + + return $mock; + } +} diff --git a/app/code/Magento/CatalogRule/Test/Unit/Plugin/Indexer/CategoryTest.php b/app/code/Magento/CatalogRule/Test/Unit/Plugin/Indexer/CategoryTest.php index 5822e01853deb..71e2093b0e325 100644 --- a/app/code/Magento/CatalogRule/Test/Unit/Plugin/Indexer/CategoryTest.php +++ b/app/code/Magento/CatalogRule/Test/Unit/Plugin/Indexer/CategoryTest.php @@ -32,7 +32,7 @@ protected function setUp() ); $this->subject = $this->createPartialMock( \Magento\Catalog\Model\Category::class, - ['getAffectedProductIds', '__wakeUp'] + ['getChangedProductIds', '__wakeUp'] ); $this->plugin = (new ObjectManager($this))->getObject( @@ -46,7 +46,7 @@ protected function setUp() public function testAfterSaveWithoutAffectedProductIds() { $this->subject->expects($this->any()) - ->method('getAffectedProductIds') + ->method('getChangedProductIds') ->will($this->returnValue([])); $this->productRuleProcessor->expects($this->never()) @@ -60,7 +60,7 @@ public function testAfterSave() $productIds = [1, 2, 3]; $this->subject->expects($this->any()) - ->method('getAffectedProductIds') + ->method('getChangedProductIds') ->will($this->returnValue($productIds)); $this->productRuleProcessor->expects($this->once()) diff --git a/app/code/Magento/CatalogRule/composer.json b/app/code/Magento/CatalogRule/composer.json index 66fe04c126d8f..5b09765d9ae51 100644 --- a/app/code/Magento/CatalogRule/composer.json +++ b/app/code/Magento/CatalogRule/composer.json @@ -5,22 +5,21 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-rule": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-ui": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-rule": "*", + "magento/module-store": "*", + "magento/module-ui": "*" }, "suggest": { - "magento/module-import-export": "100.3.*", - "magento/module-catalog-rule-sample-data": "Sample Data version:100.3.*" + "magento/module-import-export": "*", + "magento/module-catalog-rule-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogRule/etc/db_schema.xml b/app/code/Magento/CatalogRule/etc/db_schema.xml index 726c92e252f6c..883a992d8c730 100644 --- a/app/code/Magento/CatalogRule/etc/db_schema.xml +++ b/app/code/Magento/CatalogRule/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/CatalogRule/etc/di.xml b/app/code/Magento/CatalogRule/etc/di.xml index 4b368b1cef89a..40893592c3d0f 100644 --- a/app/code/Magento/CatalogRule/etc/di.xml +++ b/app/code/Magento/CatalogRule/etc/di.xml @@ -126,4 +126,28 @@ + + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\ProductCategoryCondition + + + + + + Magento\Catalog\Model\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\DefaultCondition + CatalogRuleCustomConditionProvider + + + + + CatalogRuleAdvancedFilterProcessor + + + + + CatalogRuleCustomConditionProvider + + + diff --git a/app/code/Magento/CatalogRuleConfigurable/composer.json b/app/code/Magento/CatalogRuleConfigurable/composer.json index e07209cfbc075..657e6efb4e44c 100644 --- a/app/code/Magento/CatalogRuleConfigurable/composer.json +++ b/app/code/Magento/CatalogRuleConfigurable/composer.json @@ -5,18 +5,17 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", "magento/magento-composer-installer": "*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-rule": "100.3.*", - "magento/module-configurable-product": "100.3.*" + "magento/module-catalog": "*", + "magento/module-catalog-rule": "*", + "magento/module-configurable-product": "*" }, "suggest": { - "magento/module-catalog-rule": "100.3.*" + "magento/module-catalog-rule": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider.php index 5887c76e8ddc2..64a776e7354ca 100644 --- a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider.php @@ -3,13 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - namespace Magento\CatalogSearch\Model\Adapter\Mysql\Aggregation; use Magento\Catalog\Model\Product; -use Magento\CatalogSearch\Model\Adapter\Mysql\Aggregation\DataProvider\QueryBuilder; -use Magento\Customer\Model\Session; +use Magento\CatalogSearch\Model\Adapter\Mysql\Aggregation\DataProvider\SelectBuilderForAttribute; use Magento\Eav\Model\Config; +use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; use Magento\Framework\App\ScopeResolverInterface; use Magento\Framework\DB\Adapter\AdapterInterface; @@ -17,11 +16,7 @@ use Magento\Framework\DB\Select; use Magento\Framework\Search\Adapter\Mysql\Aggregation\DataProviderInterface; use Magento\Framework\Search\Request\BucketInterface; -use Magento\Framework\App\ObjectManager; -/** - * DataProvider for Catalog search Mysql. - */ class DataProvider implements DataProviderInterface { /** @@ -29,51 +24,42 @@ class DataProvider implements DataProviderInterface */ private $eavConfig; - /** - * @var Resource - */ - private $resource; - /** * @var ScopeResolverInterface */ private $scopeResolver; - /** - * @var Session - */ - private $customerSession; - /** * @var AdapterInterface */ private $connection; /** - * @var QueryBuilder; + * @var SelectBuilderForAttribute */ - private $queryBuilder; + private $selectBuilderForAttribute; /** * @param Config $eavConfig * @param ResourceConnection $resource * @param ScopeResolverInterface $scopeResolver - * @param Session $customerSession - * @param QueryBuilder|null $queryBuilder + * @param null $customerSession @deprecated + * @param SelectBuilderForAttribute|null $selectBuilderForAttribute + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( Config $eavConfig, ResourceConnection $resource, ScopeResolverInterface $scopeResolver, - Session $customerSession, - QueryBuilder $queryBuilder = null + $customerSession, + SelectBuilderForAttribute $selectBuilderForAttribute = null ) { $this->eavConfig = $eavConfig; - $this->resource = $resource; $this->connection = $resource->getConnection(); $this->scopeResolver = $scopeResolver; - $this->customerSession = $customerSession; - $this->queryBuilder = $queryBuilder ?: ObjectManager::getInstance()->get(QueryBuilder::class); + $this->selectBuilderForAttribute = $selectBuilderForAttribute + ?: ObjectManager::getInstance()->get(SelectBuilderForAttribute::class); } /** @@ -85,15 +71,15 @@ public function getDataSet( Table $entityIdsTable ) { $currentScope = $this->scopeResolver->getScope($dimensions['scope']->getValue())->getId(); - $attribute = $this->eavConfig->getAttribute(Product::ENTITY, $bucket->getField()); + $select = $this->getSelect(); - $select = $this->queryBuilder->build( - $attribute, - $entityIdsTable->getName(), - $currentScope, - $this->customerSession->getCustomerGroupId() + $select->joinInner( + ['entities' => $entityIdsTable->getName()], + 'main_table.entity_id = entities.entity_id', + [] ); + $select = $this->selectBuilderForAttribute->build($select, $attribute, $currentScope); return $select; } @@ -105,4 +91,12 @@ public function execute(Select $select) { return $this->connection->fetchAssoc($select); } + + /** + * @return Select + */ + private function getSelect() + { + return $this->connection->select(); + } } diff --git a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider/SelectBuilderForAttribute.php b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider/SelectBuilderForAttribute.php new file mode 100644 index 0000000000000..be0a983391349 --- /dev/null +++ b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider/SelectBuilderForAttribute.php @@ -0,0 +1,127 @@ +resource = $resource; + $this->scopeResolver = $scopeResolver; + $this->applyStockConditionToSelect = $applyStockConditionToSelect; + $this->customerSession = $customerSession; + $this->scopeConfig = $scopeConfig; + } + + /** + * @param Select $select + * @param AbstractAttribute $attribute + * @param int $currentScope + * + * @return Select + */ + public function build(Select $select, AbstractAttribute $attribute, int $currentScope): Select + { + if ($attribute->getAttributeCode() === 'price') { + /** @var Store $store */ + $store = $this->scopeResolver->getScope($currentScope); + if (!$store instanceof Store) { + throw new \RuntimeException('Illegal scope resolved'); + } + $table = $this->resource->getTableName('catalog_product_index_price'); + $select->from(['main_table' => $table], null) + ->columns([BucketInterface::FIELD_VALUE => 'main_table.min_price']) + ->where('main_table.customer_group_id = ?', $this->customerSession->getCustomerGroupId()) + ->where('main_table.website_id = ?', $store->getWebsiteId()); + } else { + $currentScopeId = $this->scopeResolver->getScope($currentScope)->getId(); + $table = $this->resource->getTableName( + 'catalog_product_index_eav' . ($attribute->getBackendType() === 'decimal' ? '_decimal' : '') + ); + $subSelect = $select; + $subSelect->from(['main_table' => $table], ['main_table.entity_id', 'main_table.value']) + ->distinct() + ->where('main_table.attribute_id = ?', $attribute->getAttributeId()) + ->where('main_table.store_id = ? ', $currentScopeId); + if ($this->isAddStockFilter()) { + $subSelect = $this->applyStockConditionToSelect->execute($subSelect); + } + + $parentSelect = $this->resource->getConnection()->select(); + $parentSelect->from(['main_table' => $subSelect], ['main_table.value']); + $select = $parentSelect; + } + + return $select; + } + + /** + * @return bool + */ + private function isAddStockFilter() + { + $isShowOutOfStock = $this->scopeConfig->isSetFlag( + 'cataloginventory/options/show_out_of_stock', + ScopeInterface::SCOPE_STORE + ); + + return false === $isShowOutOfStock; + } +} diff --git a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider/SelectBuilderForAttribute/ApplyStockConditionToSelect.php b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider/SelectBuilderForAttribute/ApplyStockConditionToSelect.php new file mode 100644 index 0000000000000..83f9c6f9c3043 --- /dev/null +++ b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Aggregation/DataProvider/SelectBuilderForAttribute/ApplyStockConditionToSelect.php @@ -0,0 +1,47 @@ +resource = $resource; + } + + /** + * @param Select $select + * @return Select + */ + public function execute(Select $select): Select + { + $select->joinInner( + ['stock_index' => $this->resource->getTableName('cataloginventory_stock_status')], + 'main_table.source_id = stock_index.product_id', + [] + )->where('stock_index.stock_status = ?', Stock::STOCK_IN_STOCK); + + return $select; + } +} diff --git a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php index 6bf735e2141cc..182ecf873d77a 100644 --- a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Plugin/Aggregation/Category/DataProvider.php @@ -12,7 +12,13 @@ use Magento\Framework\DB\Select; use Magento\Framework\Search\Request\BucketInterface; use Magento\Framework\Search\Request\Dimension; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver as TableResolver; +use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class DataProvider { /** @@ -32,20 +38,28 @@ class DataProvider */ protected $categoryFactory; + /** + * @var TableResolver + */ + private $tableResolver; + /** * DataProvider constructor. * @param ResourceConnection $resource * @param ScopeResolverInterface $scopeResolver * @param Resolver $layerResolver + * @param TableResolver|null $tableResolver */ public function __construct( ResourceConnection $resource, ScopeResolverInterface $scopeResolver, - Resolver $layerResolver + Resolver $layerResolver, + TableResolver $tableResolver = null ) { $this->resource = $resource; $this->scopeResolver = $scopeResolver; $this->layer = $layerResolver->get(); + $this->tableResolver = $tableResolver ?: ObjectManager::getInstance()->get(TableResolver::class); } /** @@ -69,9 +83,18 @@ public function aroundGetDataSet( $currentScopeId = $this->scopeResolver->getScope($dimensions['scope']->getValue())->getId(); $currentCategory = $this->layer->getCurrentCategory(); + $catalogCategoryProductDimension = new Dimension(\Magento\Store\Model\Store::ENTITY, $currentScopeId); + + $catalogCategoryProductTableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); + $derivedTable = $this->resource->getConnection()->select(); $derivedTable->from( - ['main_table' => $this->resource->getTableName('catalog_category_product_index')], + ['main_table' => $catalogCategoryProductTableName], [ 'value' => 'category_id' ] diff --git a/app/code/Magento/CatalogSearch/Model/Autocomplete/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Autocomplete/DataProvider.php index c4413d002e19c..c1c9997bc83ea 100644 --- a/app/code/Magento/CatalogSearch/Model/Autocomplete/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Autocomplete/DataProvider.php @@ -10,9 +10,16 @@ use Magento\Search\Model\QueryFactory; use Magento\Search\Model\Autocomplete\DataProviderInterface; use Magento\Search\Model\Autocomplete\ItemFactory; +use Magento\Framework\App\Config\ScopeConfigInterface as ScopeConfig; +use Magento\Store\Model\ScopeInterface; class DataProvider implements DataProviderInterface { + /** + * Autocomplete limit + */ + const CONFIG_AUTOCOMPLETE_LIMIT = 'catalog/search/autocomplete_limit'; + /** * Query factory * @@ -27,16 +34,29 @@ class DataProvider implements DataProviderInterface */ protected $itemFactory; + /** + * Limit + * + * @var int + */ + protected $limit; + /** * @param QueryFactory $queryFactory * @param ItemFactory $itemFactory */ public function __construct( QueryFactory $queryFactory, - ItemFactory $itemFactory + ItemFactory $itemFactory, + ScopeConfig $scopeConfig ) { $this->queryFactory = $queryFactory; $this->itemFactory = $itemFactory; + + $this->limit = (int) $scopeConfig->getValue( + self::CONFIG_AUTOCOMPLETE_LIMIT, + ScopeInterface::SCOPE_STORE + ); } /** @@ -58,7 +78,7 @@ public function getItems() $result[] = $resultItem; } } - return $result; + return ($this->limit) ? array_splice($result, 0, $this->limit) : $result; } /** diff --git a/app/code/Magento/CatalogSearch/Model/Fulltext.php b/app/code/Magento/CatalogSearch/Model/Fulltext.php index 838d0aa906730..1792fd21fb8d0 100644 --- a/app/code/Magento/CatalogSearch/Model/Fulltext.php +++ b/app/code/Magento/CatalogSearch/Model/Fulltext.php @@ -73,6 +73,8 @@ protected function _construct() * Reset search results cache * * @return $this + * @deprecated Not used anymore + * @see \Magento\CatalogSearch\Model\ResourceModel\Fulltext::resetSearchResultsByStore */ public function resetSearchResults() { diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php index 9009a40c19dff..d51be12f01db5 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php @@ -6,12 +6,10 @@ namespace Magento\CatalogSearch\Model\Indexer; use Magento\CatalogSearch\Model\Indexer\Fulltext\Action\FullFactory; -use Magento\CatalogSearch\Model\Indexer\Scope\State; +use Magento\CatalogSearch\Model\Indexer\Scope\StateFactory; use Magento\CatalogSearch\Model\ResourceModel\Fulltext as FulltextResource; -use Magento\Framework\App\ObjectManager; -use Magento\Framework\Search\Request\Config as SearchRequestConfig; -use Magento\Framework\Search\Request\DimensionFactory; -use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\Indexer\Dimension\DimensionProviderInterface; +use Magento\Store\Model\StoreDimensionProvider; /** * Provide functionality for Fulltext Search indexing. @@ -19,7 +17,10 @@ * @api * @since 100.0.2 */ -class Fulltext implements \Magento\Framework\Indexer\ActionInterface, \Magento\Framework\Mview\ActionInterface +class Fulltext implements + \Magento\Framework\Indexer\ActionInterface, + \Magento\Framework\Mview\ActionInterface, + \Magento\Framework\Indexer\Dimension\DimensionalIndexerInterface { /** * Indexer ID in configuration @@ -36,16 +37,6 @@ class Fulltext implements \Magento\Framework\Indexer\ActionInterface, \Magento\F */ private $indexerHandlerFactory; - /** - * @var StoreManagerInterface - */ - private $storeManager; - - /** - * @var \Magento\Framework\Search\Request\DimensionFactory - */ - private $dimensionFactory; - /** * @var \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\Full */ @@ -56,11 +47,6 @@ class Fulltext implements \Magento\Framework\Indexer\ActionInterface, \Magento\F */ private $fulltextResource; - /** - * @var \Magento\Framework\Search\Request\Config - */ - private $searchRequestConfig; - /** * @var IndexSwitcherInterface */ @@ -71,90 +57,97 @@ class Fulltext implements \Magento\Framework\Indexer\ActionInterface, \Magento\F */ private $indexScopeState; + /** + * @var DimensionProviderInterface + */ + private $dimensionProvider; + /** * @param FullFactory $fullActionFactory * @param IndexerHandlerFactory $indexerHandlerFactory - * @param StoreManagerInterface $storeManager - * @param DimensionFactory $dimensionFactory * @param FulltextResource $fulltextResource - * @param SearchRequestConfig $searchRequestConfig * @param array $data * @param IndexSwitcherInterface $indexSwitcher - * @param Scope\State $indexScopeState + * @param StateFactory $indexScopeStateFactory + * @param DimensionProviderInterface $dimensionProvider */ public function __construct( FullFactory $fullActionFactory, IndexerHandlerFactory $indexerHandlerFactory, - StoreManagerInterface $storeManager, - DimensionFactory $dimensionFactory, FulltextResource $fulltextResource, - SearchRequestConfig $searchRequestConfig, - array $data, - IndexSwitcherInterface $indexSwitcher = null, - State $indexScopeState = null + IndexSwitcherInterface $indexSwitcher, + StateFactory $indexScopeStateFactory, + DimensionProviderInterface $dimensionProvider, + array $data ) { $this->fullAction = $fullActionFactory->create(['data' => $data]); $this->indexerHandlerFactory = $indexerHandlerFactory; - $this->storeManager = $storeManager; - $this->dimensionFactory = $dimensionFactory; $this->fulltextResource = $fulltextResource; - $this->searchRequestConfig = $searchRequestConfig; $this->data = $data; - if (null === $indexSwitcher) { - $indexSwitcher = ObjectManager::getInstance()->get(IndexSwitcherInterface::class); - } - if (null === $indexScopeState) { - $indexScopeState = ObjectManager::getInstance()->get(State::class); - } $this->indexSwitcher = $indexSwitcher; - $this->indexScopeState = $indexScopeState; + $this->indexScopeState = $indexScopeStateFactory->create(); + $this->dimensionProvider = $dimensionProvider; } /** * Execute materialization on ids entities * - * @param int[] $ids + * @param int[] $entityIds * @return void + * @throws \InvalidArgumentException */ - public function execute($ids) + public function execute($entityIds) { - $storeIds = array_keys($this->storeManager->getStores()); - /** @var IndexerHandler $saveHandler */ - $saveHandler = $this->indexerHandlerFactory->create([ - 'data' => $this->data - ]); - foreach ($storeIds as $storeId) { - $dimension = $this->dimensionFactory->create(['name' => 'scope', 'value' => $storeId]); - $productIds = array_unique(array_merge($ids, $this->fulltextResource->getRelationsByChild($ids))); - $saveHandler->deleteIndex([$dimension], new \ArrayObject($productIds)); - $saveHandler->saveIndex([$dimension], $this->fullAction->rebuildStoreIndex($storeId, $ids)); + foreach ($this->dimensionProvider->getIterator() as $dimension) { + $this->executeByDimension($dimension, new \ArrayIterator($entityIds)); } } /** - * Execute full indexation - * - * @return void + * {@inheritdoc} + * @throws \InvalidArgumentException */ - public function executeFull() + public function executeByDimension(array $dimensions, \Traversable $entityIds = null) { - $storeIds = array_keys($this->storeManager->getStores()); - /** @var IndexerHandler $saveHandler */ + if (count($dimensions) > 1 || !isset($dimensions[StoreDimensionProvider::DIMENSION_NAME])) { + throw new \InvalidArgumentException('Indexer "' . self::INDEXER_ID . '" support only Store dimension'); + } + $storeId = $dimensions[StoreDimensionProvider::DIMENSION_NAME]->getValue(); $saveHandler = $this->indexerHandlerFactory->create([ 'data' => $this->data ]); - foreach ($storeIds as $storeId) { - $dimensions = [$this->dimensionFactory->create(['name' => 'scope', 'value' => $storeId])]; - $this->indexScopeState->useTemporaryIndex(); + if (null === $entityIds) { + $this->indexScopeState->useTemporaryIndex(); $saveHandler->cleanIndex($dimensions); $saveHandler->saveIndex($dimensions, $this->fullAction->rebuildStoreIndex($storeId)); $this->indexSwitcher->switchIndex($dimensions); $this->indexScopeState->useRegularIndex(); + + $this->fulltextResource->resetSearchResultsByStore($storeId); + } else { + // internal implementation works only with array + $entityIds = iterator_to_array($entityIds); + $productIds = array_unique( + array_merge($entityIds, $this->fulltextResource->getRelationsByChild($entityIds)) + ); + $saveHandler->deleteIndex($dimensions, new \ArrayIterator($productIds)); + $saveHandler->saveIndex($dimensions, $this->fullAction->rebuildStoreIndex($storeId, $productIds)); + } + } + + /** + * Execute full indexation + * + * @return void + * @throws \InvalidArgumentException + */ + public function executeFull() + { + foreach ($this->dimensionProvider->getIterator() as $dimension) { + $this->executeByDimension($dimension); } - $this->fulltextResource->resetSearchResults(); - $this->searchRequestConfig->reset(); } /** diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php index 708f8b9163a38..cd419902831b8 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php @@ -6,10 +6,14 @@ namespace Magento\CatalogSearch\Model\Indexer\Fulltext\Action; use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Select; +use Magento\Store\Model\Store; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyFields) * @api * @since 100.0.3 */ @@ -101,6 +105,23 @@ class DataProvider */ private $attributeOptions = []; + /** + * Cache searchable attributes by backend type + * + * @var array + */ + private $searchableAttributesByBackendType = []; + + /** + * Adjusts a size of filtered rows for searchable products. Filtered rows counts by the following condition: + * entity_id > X AND entity_id < X + BatchSize * antiGapMultiplier + * It will help in case a lot of gaps between entity_id in product table, when selected amount of products will be + * less than batch size + * + * @var int + */ + private $antiGapMultiplier; + /** * @param ResourceConnection $resource * @param \Magento\Catalog\Model\Product\Type $catalogProductType @@ -110,6 +131,7 @@ class DataProvider * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool + * @param int $antiGapMultiplier */ public function __construct( ResourceConnection $resource, @@ -119,7 +141,8 @@ public function __construct( \Magento\CatalogSearch\Model\ResourceModel\EngineProvider $engineProvider, \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\Framework\EntityManager\MetadataPool $metadataPool + \Magento\Framework\EntityManager\MetadataPool $metadataPool, + int $antiGapMultiplier = 5 ) { $this->resource = $resource; $this->connection = $resource->getConnection(); @@ -130,6 +153,7 @@ public function __construct( $this->storeManager = $storeManager; $this->engine = $engineProvider->get(); $this->metadata = $metadataPool->getMetadata(ProductInterface::class); + $this->antiGapMultiplier = $antiGapMultiplier; } /** @@ -150,7 +174,7 @@ private function getTable($table) * @param array $staticFields * @param array|int $productIds * @param int $lastProductId - * @param int $limit + * @param int $batch * @return array * @since 100.0.3 */ @@ -159,9 +183,47 @@ public function getSearchableProducts( array $staticFields, $productIds = null, $lastProductId = 0, - $limit = 100 + $batch = 100 + ) { + + $select = $this->getSelectForSearchableProducts($storeId, $staticFields, $productIds, $lastProductId, $batch); + if ($productIds === null) { + $select->where( + 'e.entity_id < ?', + $lastProductId ? $this->antiGapMultiplier * $batch + $lastProductId + 1 : $batch + 1 + ); + } + $products = $this->connection->fetchAll($select); + if ($productIds === null && !$products) { + // try to search without limit entity_id by batch size for cover case with a big gap between entity ids + $products = $this->connection->fetchAll( + $this->getSelectForSearchableProducts($storeId, $staticFields, $productIds, $lastProductId, $batch) + ); + } + + return $products; + } + + /** + * Get Select object for searchable products + * + * @param int $storeId + * @param array $staticFields + * @param array|int $productIds + * @param int $lastProductId + * @param int $batch + * @return Select + */ + private function getSelectForSearchableProducts( + $storeId, + array $staticFields, + $productIds, + $lastProductId, + $batch ) { $websiteId = $this->storeManager->getStore($storeId)->getWebsiteId(); + $lastProductId = (int) $lastProductId; + $select = $this->connection->select() ->useStraightJoin(true) ->from( @@ -174,15 +236,65 @@ public function getSearchableProducts( [] ); + $this->joinAttribute($select, 'visibility', $storeId, $this->engine->getAllowedVisibility()); + $this->joinAttribute($select, 'status', $storeId, [Status::STATUS_ENABLED]); + if ($productIds !== null) { $select->where('e.entity_id IN (?)', $productIds); } + $select->where('e.entity_id > ?', $lastProductId); + $select->order('e.entity_id'); + $select->limit($batch); - $select->where('e.entity_id > ?', $lastProductId)->limit($limit)->order('e.entity_id'); + return $select; + } - $result = $this->connection->fetchAll($select); + /** + * Join attribute to searchable product for filtration + * + * @param Select $select + * @param string $attributeCode + * @param int $storeId + * @param array $whereValue + */ + private function joinAttribute(Select $select, $attributeCode, $storeId, array $whereValue) + { + $linkField = $this->metadata->getLinkField(); + $attribute = $this->getSearchableAttribute($attributeCode); + $attributeTable = $this->getTable('catalog_product_entity_' . $attribute->getBackendType()); + $defaultAlias = $attributeCode . '_default'; + $storeAlias = $attributeCode . '_store'; + + $whereCondition = $this->connection->getCheckSql( + $storeAlias . '.value_id > 0', + $storeAlias . '.value', + $defaultAlias . '.value' + ); - return $result; + $select->join( + [$defaultAlias => $attributeTable], + $this->connection->quoteInto( + $defaultAlias . '.' . $linkField . '= e.' . $linkField . ' AND ' . $defaultAlias . '.attribute_id = ?', + $attribute->getAttributeId() + ) . $this->connection->quoteInto( + ' AND ' . $defaultAlias . '.store_id = ?', + Store::DEFAULT_STORE_ID + ), + [] + )->joinLeft( + [$storeAlias => $attributeTable], + $this->connection->quoteInto( + $storeAlias . '.' . $linkField . '= e.' . $linkField . ' AND ' . $storeAlias . '.attribute_id = ?', + $attribute->getAttributeId() + ) . $this->connection->quoteInto( + ' AND ' . $storeAlias . '.store_id = ?', + $storeId + ), + [] + )->where( + $whereCondition . ' IN (?)', + $whereValue + ); } /** @@ -219,20 +331,23 @@ public function getSearchableAttributes($backendType = null) foreach ($attributes as $attribute) { $attribute->setEntity($entity); + $this->searchableAttributes[$attribute->getAttributeId()] = $attribute; + $this->searchableAttributes[$attribute->getAttributeCode()] = $attribute; } - - $this->searchableAttributes = $attributes; } if ($backendType !== null) { - $attributes = []; - foreach ($this->searchableAttributes as $attributeId => $attribute) { + if (isset($this->searchableAttributesByBackendType[$backendType])) { + return $this->searchableAttributesByBackendType[$backendType]; + } + $this->searchableAttributesByBackendType[$backendType] = []; + foreach ($this->searchableAttributes as $attribute) { if ($attribute->getBackendType() == $backendType) { - $attributes[$attributeId] = $attribute; + $this->searchableAttributesByBackendType[$backendType][$attribute->getAttributeId()] = $attribute; } } - return $attributes; + return $this->searchableAttributesByBackendType[$backendType]; } return $this->searchableAttributes; @@ -248,16 +363,8 @@ public function getSearchableAttributes($backendType = null) public function getSearchableAttribute($attribute) { $attributes = $this->getSearchableAttributes(); - if (is_numeric($attribute)) { - if (isset($attributes[$attribute])) { - return $attributes[$attribute]; - } - } elseif (is_string($attribute)) { - foreach ($attributes as $attributeModel) { - if ($attributeModel->getAttributeCode() == $attribute) { - return $attributeModel; - } - } + if (isset($attributes[$attribute])) { + return $attributes[$attribute]; } return $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $attribute); @@ -307,33 +414,45 @@ public function getProductAttributes($storeId, array $productIds, array $attribu foreach ($attributeTypes as $backendType => $attributeIds) { if ($attributeIds) { $tableName = $this->getTable('catalog_product_entity_' . $backendType); - $selects[] = $this->connection->select()->from( - ['t_default' => $tableName], - [$linkField, 'attribute_id'] + + $select = $this->connection->select()->from( + ['t' => $tableName], + [ + $linkField => 't.' . $linkField, + 'attribute_id' => 't.attribute_id', + 'value' => $this->unifyField($ifStoreValue, $backendType), + ] )->joinLeft( ['t_store' => $tableName], $this->connection->quoteInto( - 't_default.' . $linkField . '=t_store.' . $linkField . - ' AND t_default.attribute_id=t_store.attribute_id' . + 't.' . $linkField . '=t_store.' . $linkField . + ' AND t.attribute_id=t_store.attribute_id' . ' AND t_store.store_id = ?', $storeId ), - ['value' => $this->unifyField($ifStoreValue, $backendType)] - )->where( - 't_default.store_id = ?', - 0 + [] + )->joinLeft( + ['t_default' => $tableName], + $this->connection->quoteInto( + 't.' . $linkField . '=t_default.' . $linkField . + ' AND t.attribute_id=t_default.attribute_id' . + ' AND t_default.store_id = ?', + 0 + ), + [] )->where( - 't_default.attribute_id IN (?)', + 't.attribute_id IN (?)', $attributeIds )->where( - 't_default.' . $linkField . ' IN (?)', + 't.' . $linkField . ' IN (?)', array_keys($productLinkFieldsToEntityIdMap) - ); + )->distinct(); + $selects[] = $select; } } if ($selects) { - $select = $this->connection->select()->union($selects, \Magento\Framework\DB\Select::SQL_UNION_ALL); + $select = $this->connection->select()->union($selects, Select::SQL_UNION_ALL); $query = $this->connection->query($select); while ($row = $query->fetch()) { $entityId = $productLinkFieldsToEntityIdMap[$row[$linkField]]; @@ -371,9 +490,9 @@ private function getProductTypeInstance($typeId) public function getProductChildIds($productId, $typeId) { $typeInstance = $this->getProductTypeInstance($typeId); - $relation = $typeInstance->isComposite( - $this->getProductEmulator($typeId) - ) ? $typeInstance->getRelationInfo() : false; + $relation = $typeInstance->isComposite($this->getProductEmulator($typeId)) + ? $typeInstance->getRelationInfo() + : false; if ($relation && $relation->getTable() && $relation->getParentFieldName() && $relation->getChildFieldName()) { $select = $this->connection->select()->from( @@ -448,7 +567,6 @@ public function prepareProductIndex($indexData, $productData, $storeId) } } } - foreach ($indexData as $entityId => $attributeData) { foreach ($attributeData as $attributeId => $attributeValue) { $value = $this->getAttributeValue($attributeId, $attributeValue, $storeId); @@ -490,33 +608,58 @@ private function getAttributeValue($attributeId, $valueId, $storeId) { $attribute = $this->getSearchableAttribute($attributeId); $value = $this->engine->processAttributeValue($attribute, $valueId); + if (false !== $value) { + $optionValue = $this->getAttributeOptionValue($attributeId, $valueId, $storeId); + if (null === $optionValue) { + $value = $this->filterAttributeValue($value); + } else { + $value = implode($this->separator, array_filter([$value, $optionValue])); + } + } - if (false !== $value - && $attribute->getIsSearchable() - && $attribute->usesSource() - && $this->engine->allowAdvancedIndex() + return $value; + } + + /** + * Get attribute option value + * + * @param int $attributeId + * @param int $valueId + * @param int $storeId + * @return null|string + */ + private function getAttributeOptionValue($attributeId, $valueId, $storeId) + { + $optionKey = $attributeId . '-' . $storeId; + if (!array_key_exists($optionKey, $this->attributeOptions) ) { - if (!isset($this->attributeOptions[$attributeId][$storeId])) { + $attribute = $this->getSearchableAttribute($attributeId); + if ($this->engine->allowAdvancedIndex() + && $attribute->getIsSearchable() + && $attribute->usesSource() + ) { $attribute->setStoreId($storeId); $options = $attribute->getSource()->toOptionArray(); - $this->attributeOptions[$attributeId][$storeId] = array_combine( - array_column($options, 'value'), - array_column($options, 'label') - ); - } - - $valueText = ''; - if (isset($this->attributeOptions[$attributeId][$storeId][$valueId])) { - $valueText = $this->attributeOptions[$attributeId][$storeId][$valueId]; + $this->attributeOptions[$optionKey] = array_column($options, 'label', 'value'); + $this->attributeOptions[$optionKey] = array_map(function ($value) { + return $this->filterAttributeValue($value); + }, $this->attributeOptions[$optionKey]); + } else { + $this->attributeOptions[$optionKey] = null; } - - $pieces = array_filter(array_merge([$value], [$valueText])); - - $value = implode($this->separator, $pieces); } - $value = preg_replace('/\\s+/siu', ' ', trim(strip_tags($value))); + return $this->attributeOptions[$optionKey][$valueId] ?? null; + } - return $value; + /** + * Remove whitespaces and tags from attribute value + * + * @param string $value + * @return string + */ + private function filterAttributeValue($value) + { + return preg_replace('/\s+/iu', ' ', trim(strip_tags($value))); } } diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php index 639c0e8ca66f0..8a18c1bfcc576 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/Full.php @@ -5,6 +5,7 @@ */ namespace Magento\CatalogSearch\Model\Indexer\Fulltext\Action; +use Magento\Catalog\Api\Data\ProductInterface; use Magento\CatalogSearch\Model\Indexer\Fulltext; use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; @@ -197,6 +198,13 @@ class Full */ private $dataProvider; + /** + * Batch size for searchable product ids + * + * @var int + */ + private $batchSize; + /** * @param ResourceConnection $resource * @param \Magento\Catalog\Model\Product\Type $catalogProductType @@ -218,6 +226,7 @@ class Full * @param \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\IndexIteratorFactory $indexIteratorFactory * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool * @param DataProvider $dataProvider + * @param int $batchSize * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -240,7 +249,8 @@ public function __construct( \Magento\Framework\Indexer\ConfigInterface $indexerConfig, \Magento\CatalogSearch\Model\Indexer\Fulltext\Action\IndexIteratorFactory $indexIteratorFactory, \Magento\Framework\EntityManager\MetadataPool $metadataPool = null, - DataProvider $dataProvider = null + DataProvider $dataProvider = null, + $batchSize = 500 ) { $this->resource = $resource; $this->connection = $resource->getConnection(); @@ -264,6 +274,7 @@ public function __construct( $this->metadataPool = $metadataPool ?: ObjectManager::getInstance() ->get(\Magento\Framework\EntityManager\MetadataPool::class); $this->dataProvider = $dataProvider ?: ObjectManager::getInstance()->get(DataProvider::class); + $this->batchSize = $batchSize; } /** @@ -297,27 +308,30 @@ protected function getTable($table) /** * Get parents IDs of product IDs to be re-indexed * + * @deprecated as it not used in the class anymore and duplicates another API method + * @see \Magento\CatalogSearch\Model\ResourceModel\Fulltext::getRelationsByChild() + * * @param int[] $entityIds * @return int[] + * @throws \Exception */ protected function getProductIdsFromParents(array $entityIds) { - /** @var \Magento\Framework\EntityManager\EntityMetadataInterface $metadata */ - $metadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); - $fieldForParent = $metadata->getLinkField(); + $connection = $this->connection; + $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); - $select = $this->connection + $select = $connection ->select() ->from(['relation' => $this->getTable('catalog_product_relation')], []) ->distinct(true) ->where('child_id IN (?)', $entityIds) - ->where('parent_id NOT IN (?)', $entityIds) ->join( ['cpe' => $this->getTable('catalog_product_entity')], - 'relation.parent_id = cpe.' . $fieldForParent, + 'relation.parent_id = cpe.' . $linkField, ['cpe.entity_id'] ); - return $this->connection->fetchCol($select); + + return $connection->fetchCol($select); } /** @@ -335,7 +349,7 @@ protected function getProductIdsFromParents(array $entityIds) public function rebuildStoreIndex($storeId, $productIds = null) { if ($productIds !== null) { - $productIds = array_unique(array_merge($productIds, $this->getProductIdsFromParents($productIds))); + $productIds = array_unique($productIds); } // prepare searchable attributes @@ -354,7 +368,7 @@ public function rebuildStoreIndex($storeId, $productIds = null) $lastProductId = 0; $products = $this->dataProvider - ->getSearchableProducts($storeId, $staticFields, $productIds, $lastProductId); + ->getSearchableProducts($storeId, $staticFields, $productIds, $lastProductId, $this->batchSize); while (count($products) > 0) { $productsIds = array_column($products, 'entity_id'); $relatedProducts = $this->getRelatedProducts($products); @@ -365,12 +379,6 @@ public function rebuildStoreIndex($storeId, $productIds = null) foreach ($products as $productData) { $lastProductId = $productData['entity_id']; - if (!$this->isProductVisible($productData['entity_id'], $productsAttributes) || - !$this->isProductEnabled($productData['entity_id'], $productsAttributes) - ) { - continue; - } - $productIndex = [$productData['entity_id'] => $productsAttributes[$productData['entity_id']]]; if (isset($relatedProducts[$productData['entity_id']])) { $childProductsIndex = $this->getChildProductsIndex( @@ -388,7 +396,7 @@ public function rebuildStoreIndex($storeId, $productIds = null) yield $productData['entity_id'] => $index; } $products = $this->dataProvider - ->getSearchableProducts($storeId, $staticFields, $productIds, $lastProductId); + ->getSearchableProducts($storeId, $staticFields, $productIds, $lastProductId, $this->batchSize); }; } @@ -412,25 +420,6 @@ private function getRelatedProducts($products) return array_filter($relatedProducts); } - /** - * Performs check that product is visible on Store Front - * - * Check that product is visible on Store Front using visibility attribute - * and allowed visibility values. - * - * @param int $productId - * @param array $productsAttributes - * @return bool - */ - private function isProductVisible($productId, array $productsAttributes) - { - $visibility = $this->dataProvider->getSearchableAttribute('visibility'); - $allowedVisibility = $this->engine->getAllowedVisibility(); - return isset($productsAttributes[$productId]) && - isset($productsAttributes[$productId][$visibility->getId()]) && - in_array($productsAttributes[$productId][$visibility->getId()], $allowedVisibility); - } - /** * Performs check that product is enabled on Store Front * @@ -445,8 +434,7 @@ private function isProductEnabled($productId, array $productsAttributes) { $status = $this->dataProvider->getSearchableAttribute('status'); $allowedStatuses = $this->catalogProductStatus->getVisibleStatusIds(); - return isset($productsAttributes[$productId]) && - isset($productsAttributes[$productId][$status->getId()]) && + return isset($productsAttributes[$productId][$status->getId()]) && in_array($productsAttributes[$productId][$status->getId()], $allowedStatuses); } diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/IndexerHandler.php b/app/code/Magento/CatalogSearch/Model/Indexer/IndexerHandler.php index ea5bb8be17c74..931d7571a9014 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/IndexerHandler.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/IndexerHandler.php @@ -75,7 +75,7 @@ public function __construct( Batch $batch, IndexScopeResolverInterface $indexScopeResolver, array $data, - $batchSize = 100 + $batchSize = 500 ) { $this->indexScopeResolver = $indexScopeResolver; $this->indexStructure = $indexStructure; diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php index 4212912af9930..ffba417eb3ac7 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php @@ -70,6 +70,13 @@ public function allowAdvancedIndex() return true; } + /** + * Is attribute filterable as term cache + * + * @var array + */ + private $termFilterableAttributeAttributeCache = []; + /** * Is Attribute Filterable as Term * @@ -78,10 +85,16 @@ public function allowAdvancedIndex() */ private function isTermFilterableAttribute($attribute) { - return ($attribute->getIsVisibleInAdvancedSearch() - || $attribute->getIsFilterable() - || $attribute->getIsFilterableInSearch()) - && in_array($attribute->getFrontendInput(), ['select', 'multiselect']); + $attributeId = $attribute->getAttributeId(); + if (!isset($this->termFilterableAttributeAttributeCache[$attributeId])) { + $this->termFilterableAttributeAttributeCache[$attributeId] = + in_array($attribute->getFrontendInput(), ['select', 'multiselect'], true) + && ($attribute->getIsVisibleInAdvancedSearch() + || $attribute->getIsFilterable() + || $attribute->getIsFilterableInSearch()); + } + + return $this->termFilterableAttributeAttributeCache[$attributeId]; } /** diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext.php index 15349e91c3fe9..0835fb66f876a 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext.php @@ -62,6 +62,8 @@ protected function _construct() * Reset search results * * @return $this + * @deprecated Not used anymore + * @see Fulltext::resetSearchResultsByStore */ public function resetSearchResults() { @@ -71,6 +73,25 @@ public function resetSearchResults() return $this; } + /** + * Reset search results by store + * + * @param int $storeId + * @return $this + */ + public function resetSearchResultsByStore($storeId) + { + $storeId = (int) $storeId; + $connection = $this->getConnection(); + $connection->update( + $this->getTable('search_query'), + ['is_processed' => 0], + ['is_processed != ?' => 0, 'store_id = ?' => $storeId] + ); + $this->_eventManager->dispatch('catalogsearch_reset_search_result', ['store_id' => $storeId]); + return $this; + } + /** * Retrieve product relations by children. * @@ -82,17 +103,20 @@ public function getRelationsByChild($childIds) { $connection = $this->getConnection(); $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); - $select = $connection->select()->from( - ['relation' => $this->getTable('catalog_product_relation')], - [] - )->join( - ['cpe' => $this->getTable('catalog_product_entity')], - 'cpe.' . $linkField . ' = relation.parent_id', - ['cpe.entity_id'] - )->where( - 'relation.child_id IN (?)', - $childIds - )->distinct(true); + $select = $connection + ->select() + ->from( + ['relation' => $this->getTable('catalog_product_relation')], + [] + )->distinct(true) + ->join( + ['cpe' => $this->getTable('catalog_product_entity')], + 'cpe.' . $linkField . ' = relation.parent_id', + ['cpe.entity_id'] + )->where( + 'relation.child_id IN (?)', + $childIds + ); return $connection->fetchCol($select); } diff --git a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php index dadce2ed0240c..66e0457e7fadd 100644 --- a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php +++ b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/ExclusionStrategy.php @@ -7,6 +7,10 @@ namespace Magento\CatalogSearch\Model\Search\FilterMapper; use Magento\CatalogSearch\Model\Adapter\Mysql\Filter\AliasResolver; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Indexer\ScopeResolver\IndexScopeResolver as TableResolver; +use Magento\Framework\Search\Request\Dimension; +use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; /** * Strategy which processes exclusions from general rules @@ -34,19 +38,27 @@ class ExclusionStrategy implements FilterStrategyInterface */ private $validFields = ['price', 'category_ids']; + /** + * @var TableResolver + */ + private $tableResolver; + /** * @param \Magento\Framework\App\ResourceConnection $resourceConnection * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param AliasResolver $aliasResolver + * @param TableResolver|null $tableResolver */ public function __construct( \Magento\Framework\App\ResourceConnection $resourceConnection, \Magento\Store\Model\StoreManagerInterface $storeManager, - AliasResolver $aliasResolver + AliasResolver $aliasResolver, + TableResolver $tableResolver = null ) { $this->resourceConnection = $resourceConnection; $this->storeManager = $storeManager; $this->aliasResolver = $aliasResolver; + $this->tableResolver = $tableResolver ?: ObjectManager::getInstance()->get(TableResolver::class); } /** @@ -112,7 +124,18 @@ private function applyCategoryFilter( \Magento\Framework\DB\Select $select ) { $alias = $this->aliasResolver->getAlias($filter); - $tableName = $this->resourceConnection->getTableName('catalog_category_product_index'); + + $catalogCategoryProductDimension = new Dimension( + \Magento\Store\Model\Store::ENTITY, + $this->storeManager->getStore()->getId() + ); + + $tableName = $this->tableResolver->resolve( + AbstractAction::MAIN_INDEX_TABLE, + [ + $catalogCategoryProductDimension + ] + ); $mainTableAlias = $this->extractTableAliasFromSelect($select); $select->joinInner( diff --git a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy.php b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy.php index 9bd26d799fd5d..6b5dce5fde4e9 100644 --- a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy.php +++ b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy.php @@ -7,11 +7,9 @@ namespace Magento\CatalogSearch\Model\Search\FilterMapper; use Magento\CatalogSearch\Model\Adapter\Mysql\Filter\AliasResolver; +use Magento\CatalogSearch\Model\Search\FilterMapper\TermDropdownStrategy\SelectBuilder; use Magento\Eav\Model\Config as EavConfig; -use Magento\Framework\App\Config\ScopeConfigInterface; -use Magento\Framework\App\ResourceConnection; -use Magento\Store\Model\ScopeInterface; -use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\App\ObjectManager; /** * This strategy handles attributes which comply with two criteria: @@ -25,46 +23,37 @@ class TermDropdownStrategy implements FilterStrategyInterface */ private $aliasResolver; - /** - * @var StoreManagerInterface - */ - private $storeManager; - /** * @var EavConfig */ private $eavConfig; /** - * @var ResourceConnection - */ - private $resourceConnection; - - /** - * @var ScopeConfigInterface + * @var SelectBuilder */ - private $scopeConfig; + private $selectBuilder; /** - * @param StoreManagerInterface $storeManager - * @param ResourceConnection $resourceConnection + * @param null $storeManager @deprecated + * @param null $resourceConnection @deprecated * @param EavConfig $eavConfig - * @param ScopeConfigInterface $scopeConfig + * @param null $scopeConfig @deprecated * @param AliasResolver $aliasResolver - * @SuppressWarnings(Magento.TypeDuplication) + * @param SelectBuilder|null $selectBuilder + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( - StoreManagerInterface $storeManager, - ResourceConnection $resourceConnection, + $storeManager, + $resourceConnection, EavConfig $eavConfig, - ScopeConfigInterface $scopeConfig, - AliasResolver $aliasResolver + $scopeConfig, + AliasResolver $aliasResolver, + SelectBuilder $selectBuilder = null ) { - $this->storeManager = $storeManager; - $this->resourceConnection = $resourceConnection; $this->eavConfig = $eavConfig; - $this->scopeConfig = $scopeConfig; $this->aliasResolver = $aliasResolver; + $this->selectBuilder = $selectBuilder ?: ObjectManager::getInstance()->get(SelectBuilder::class); } /** @@ -77,27 +66,7 @@ public function apply( ) { $alias = $this->aliasResolver->getAlias($filter); $attribute = $this->getAttributeByCode($filter->getField()); - $joinCondition = sprintf( - 'search_index.entity_id = %1$s.entity_id AND %1$s.attribute_id = %2$d AND %1$s.store_id = %3$d', - $alias, - $attribute->getId(), - $this->storeManager->getStore()->getId() - ); - $select->joinLeft( - [$alias => $this->resourceConnection->getTableName('catalog_product_index_eav')], - $joinCondition, - [] - ); - if ($this->isAddStockFilter()) { - $stockAlias = $alias . AliasResolver::STOCK_FILTER_SUFFIX; - $select->joinLeft( - [ - $stockAlias => $this->resourceConnection->getTableName('cataloginventory_stock_status'), - ], - sprintf('%2$s.product_id = %1$s.source_id', $alias, $stockAlias), - [] - ); - } + $this->selectBuilder->execute((int)$attribute->getId(), $alias, $select); return true; } @@ -111,17 +80,4 @@ private function getAttributeByCode($field) { return $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $field); } - - /** - * @return bool - */ - private function isAddStockFilter() - { - $isShowOutOfStock = $this->scopeConfig->isSetFlag( - 'cataloginventory/options/show_out_of_stock', - ScopeInterface::SCOPE_STORE - ); - - return false === $isShowOutOfStock; - } } diff --git a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/ApplyStockConditionToSelect.php b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/ApplyStockConditionToSelect.php new file mode 100644 index 0000000000000..dee8b09a051ec --- /dev/null +++ b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/ApplyStockConditionToSelect.php @@ -0,0 +1,56 @@ +resourceConnection = $resourceConnection; + } + + /** + * @param string $alias + * @param string $stockAlias + * @param Select $select + * + * @return void + */ + public function execute( + string $alias, + string $stockAlias, + Select $select + ) { + $select->joinInner( + [$stockAlias => $this->resourceConnection->getTableName('cataloginventory_stock_status')], + sprintf( + '%2$s.product_id = %1$s.source_id AND %2$s.stock_status = %3$d', + $alias, + $stockAlias, + Status::STATUS_IN_STOCK + ), + [] + ); + } +} diff --git a/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/SelectBuilder.php b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/SelectBuilder.php new file mode 100644 index 0000000000000..fccbfa364d896 --- /dev/null +++ b/app/code/Magento/CatalogSearch/Model/Search/FilterMapper/TermDropdownStrategy/SelectBuilder.php @@ -0,0 +1,99 @@ +resourceConnection = $resourceConnection; + $this->scopeConfig = $scopeConfig; + $this->storeManager = $storeManager; + $this->applyStockConditionToSelect = $applyStockConditionToSelect; + } + + /** + * @param int $attributeId + * @param string $alias + * @param Select $select + */ + public function execute( + int $attributeId, + string $alias, + Select $select + ) { + $joinCondition = sprintf( + 'search_index.entity_id = %1$s.entity_id AND %1$s.attribute_id = %2$d AND %1$s.store_id = %3$d', + $alias, + $attributeId, + $this->storeManager->getStore()->getId() + ); + $select->joinLeft( + [$alias => $this->resourceConnection->getTableName('catalog_product_index_eav')], + $joinCondition, + [] + ); + if ($this->isAddStockFilter()) { + $stockAlias = $alias . AliasResolver::STOCK_FILTER_SUFFIX; + $this->applyStockConditionToSelect->execute($alias, $stockAlias, $select); + } + } + + /** + * @return bool + */ + private function isAddStockFilter() + { + $isShowOutOfStock = $this->scopeConfig->isSetFlag( + 'cataloginventory/options/show_out_of_stock', + ScopeInterface::SCOPE_STORE + ); + + return false === $isShowOutOfStock; + } +} diff --git a/app/code/Magento/CatalogSearch/Setup/Patch/Data/SetInitialSearchWeightForAttributes.php b/app/code/Magento/CatalogSearch/Setup/Patch/Data/SetInitialSearchWeightForAttributes.php index 31cc70b05083c..e266e67804e88 100644 --- a/app/code/Magento/CatalogSearch/Setup/Patch/Data/SetInitialSearchWeightForAttributes.php +++ b/app/code/Magento/CatalogSearch/Setup/Patch/Data/SetInitialSearchWeightForAttributes.php @@ -6,8 +6,8 @@ namespace Magento\CatalogSearch\Setup\Patch\Data; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Framework\Indexer\IndexerInterfaceFactory; use Magento\Catalog\Api\ProductAttributeRepositoryInterface; diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Aggregation/DataProviderTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Aggregation/DataProviderTest.php index 7c558f60b7433..8eeceba8209bf 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Aggregation/DataProviderTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Aggregation/DataProviderTest.php @@ -7,7 +7,7 @@ namespace Magento\CatalogSearch\Test\Unit\Model\Adapter\Mysql\Aggregation; use Magento\CatalogSearch\Model\Adapter\Mysql\Aggregation\DataProvider; -use Magento\CatalogSearch\Model\Adapter\Mysql\Aggregation\DataProvider\QueryBuilder; +use Magento\CatalogSearch\Model\Adapter\Mysql\Aggregation\DataProvider\SelectBuilderForAttribute; use Magento\Eav\Model\Config; use Magento\Customer\Model\Session; use Magento\Framework\App\ResourceConnection; @@ -22,8 +22,6 @@ use Magento\Framework\DB\Ddl\Table; /** - * Test for Magento\CatalogSearch\Model\Adapter\Mysql\Aggregation\DataProvider. - * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class DataProviderTest extends \PHPUnit\Framework\TestCase @@ -59,9 +57,9 @@ class DataProviderTest extends \PHPUnit\Framework\TestCase private $adapterMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var \PHPUnit_Framework_MockObject_MockObject|SelectBuilderForAttribute */ - private $queryBuilderMock; + private $selectBuilderForAttribute; protected function setUp() { @@ -71,53 +69,64 @@ protected function setUp() $this->sessionMock = $this->createMock(Session::class); $this->adapterMock = $this->createMock(AdapterInterface::class); $this->resourceConnectionMock->expects($this->once())->method('getConnection')->willReturn($this->adapterMock); - $this->queryBuilderMock = $this->createMock(QueryBuilder::class); - + $this->selectBuilderForAttribute = $this->createMock(SelectBuilderForAttribute::class); $this->model = new DataProvider( $this->eavConfigMock, $this->resourceConnectionMock, $this->scopeResolverMock, $this->sessionMock, - $this->queryBuilderMock + $this->selectBuilderForAttribute ); } - public function testGetDataSet() + public function testGetDataSetUsesFrontendPriceIndexerTableIfAttributeIsPrice() { $storeId = 1; - $attributeCode = 'my_decimal'; + $attributeCode = 'price'; $scopeMock = $this->createMock(Store::class); - $scopeMock->expects($this->any())->method('getId')->willReturn($storeId); - + $scopeMock->expects($this->atLeastOnce())->method('getId')->willReturn($storeId); $dimensionMock = $this->createMock(Dimension::class); - $dimensionMock->expects($this->any())->method('getValue')->willReturn($storeId); - + $dimensionMock->expects($this->atLeastOnce())->method('getValue')->willReturn($storeId); $this->scopeResolverMock->expects($this->any())->method('getScope')->with($storeId)->willReturn($scopeMock); $bucketMock = $this->createMock(BucketInterface::class); $bucketMock->expects($this->once())->method('getField')->willReturn($attributeCode); - $attributeMock = $this->createMock(Attribute::class); - $this->eavConfigMock->expects($this->once())->method('getAttribute') - ->with(Product::ENTITY, $attributeCode)->willReturn($attributeMock); + $this->eavConfigMock->expects($this->once()) + ->method('getAttribute')->with(Product::ENTITY, $attributeCode) + ->willReturn($attributeMock); + $selectMock = $this->createMock(Select::class); + $this->adapterMock->expects($this->atLeastOnce())->method('select')->willReturn($selectMock); $tableMock = $this->createMock(Table::class); - $tableMock->expects($this->once())->method('getName')->willReturn('test'); - - $this->sessionMock->expects($this->once())->method('getCustomerGroupId')->willReturn(1); - - $this->queryBuilderMock->expects($this->once())->method('build') - ->with($attributeMock, 'test', $storeId, 1); $this->model->getDataSet($bucketMock, ['scope' => $dimensionMock], $tableMock); } - public function testExecute() + public function testGetDataSetUsesFrontendPriceIndexerTableForDecimalAttributes() { - $selectMock = $this->createMock(Select::class); - $this->adapterMock->expects($this->once())->method('fetchAssoc')->with($selectMock); + $storeId = 1; + $attributeCode = 'my_decimal'; + + $scopeMock = $this->createMock(Store::class); + $scopeMock->expects($this->atLeastOnce())->method('getId')->willReturn($storeId); + $dimensionMock = $this->createMock(Dimension::class); + $dimensionMock->expects($this->atLeastOnce())->method('getValue')->willReturn($storeId); + $this->scopeResolverMock->expects($this->atLeastOnce())->method('getScope')->with($storeId) + ->willReturn($scopeMock); - $this->model->execute($selectMock); + $bucketMock = $this->createMock(BucketInterface::class); + $bucketMock->expects($this->once())->method('getField')->willReturn($attributeCode); + $attributeMock = $this->createMock(Attribute::class); + $this->eavConfigMock->expects($this->once()) + ->method('getAttribute')->with(Product::ENTITY, $attributeCode) + ->willReturn($attributeMock); + + $selectMock = $this->createMock(Select::class); + $this->selectBuilderForAttribute->expects($this->once())->method('build')->willReturn($selectMock); + $this->adapterMock->expects($this->atLeastOnce())->method('select')->willReturn($selectMock); + $tableMock = $this->createMock(Table::class); + $this->model->getDataSet($bucketMock, ['scope' => $dimensionMock], $tableMock); } } diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Autocomplete/DataProviderTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Autocomplete/DataProviderTest.php index 75daf438f7bf2..bb8fc848bb2b7 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Autocomplete/DataProviderTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Autocomplete/DataProviderTest.php @@ -30,6 +30,11 @@ class DataProviderTest extends \PHPUnit\Framework\TestCase */ private $suggestCollection; + /** + * @var integer + */ + private $limit = 3; + protected function setUp() { $helper = new ObjectManager($this); @@ -60,11 +65,20 @@ protected function setUp() ->setMethods(['create']) ->getMock(); + $scopeConfig = $this->getMockBuilder(\Magento\Framework\App\Config\ScopeConfigInterface::class) + ->setMethods(['getValue']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $scopeConfig->expects($this->any()) + ->method('getValue') + ->willReturn($this->limit); + $this->model = $helper->getObject( \Magento\CatalogSearch\Model\Autocomplete\DataProvider::class, [ 'queryFactory' => $queryFactory, - 'itemFactory' => $this->itemFactory + 'itemFactory' => $this->itemFactory, + 'scopeConfig' => $scopeConfig ] ); } @@ -103,8 +117,10 @@ public function testGetItems() ->will($this->returnValue($expected)); $this->itemFactory->expects($this->any())->method('create')->willReturn($itemMock); + $result = $this->model->getItems(); $this->assertEquals($expected, $result[0]->toArray()); + $this->assertEquals($this->limit, count($result)); } private function buildCollection(array $data) diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php index 60d4ef5f55f02..f2cacd74fddfb 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Indexer/FulltextTest.php @@ -5,8 +5,8 @@ */ namespace Magento\CatalogSearch\Test\Unit\Model\Indexer; -use Magento\Framework\Search\Request\Dimension; -use Magento\Framework\Search\Request\DimensionFactory; +use \Magento\Framework\Indexer\Dimension; +use Magento\Framework\Indexer\Dimension\DimensionProviderInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; /** @@ -24,11 +24,6 @@ class FulltextTest extends \PHPUnit\Framework\TestCase */ protected $fullAction; - /** - * @var \Magento\Store\Model\StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject - */ - protected $storeManager; - /** * @var \Magento\CatalogSearch\Model\Indexer\IndexerHandler|\PHPUnit_Framework_MockObject_MockObject */ @@ -40,19 +35,14 @@ class FulltextTest extends \PHPUnit\Framework\TestCase protected $fulltextResource; /** - * @var \Magento\Framework\Search\Request\Config|\PHPUnit_Framework_MockObject_MockObject - */ - protected $searchRequestConfig; - - /** - * @var \Magento\Framework\Search\Request\DimensionFactory|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\CatalogSearch\Model\Indexer\Scope\IndexSwitcher|\PHPUnit_Framework_MockObject_MockObject */ - private $dimensionFactory; + private $indexSwitcher; /** - * @var \Magento\CatalogSearch\Model\Indexer\Scope\IndexSwitcher|\PHPUnit_Framework_MockObject_MockObject + * @var DimensionProviderInterface|\PHPUnit_Framework_MockObject_MockObject */ - private $indexSwitcher; + private $dimensionProviderMock; protected function setUp() { @@ -69,38 +59,27 @@ protected function setUp() ); $indexerHandlerFactory->expects($this->any())->method('create')->willReturn($this->saveHandler); - $this->storeManager = $this->getMockForAbstractClass( - \Magento\Store\Model\StoreManagerInterface::class, - [], - '', - false, - false, - true, - [] - ); - - $this->dimensionFactory = $this->createPartialMock(DimensionFactory::class, ['create']); - $this->fulltextResource = $this->getClassMock(\Magento\CatalogSearch\Model\ResourceModel\Fulltext::class); - $this->searchRequestConfig = $this->getClassMock(\Magento\Framework\Search\Request\Config::class); $this->indexSwitcher = $this->getMockBuilder(\Magento\CatalogSearch\Model\Indexer\Scope\IndexSwitcher::class) ->disableOriginalConstructor() ->setMethods(['switchIndex']) ->getMock(); + $this->dimensionProviderMock = $this->getMockBuilder(DimensionProviderInterface::class)->getMock(); + $stateMock = $this->getMockBuilder(\Magento\CatalogSearch\Model\Indexer\Scope\State::class) + ->getMock(); $objectManagerHelper = new ObjectManagerHelper($this); $this->model = $objectManagerHelper->getObject( \Magento\CatalogSearch\Model\Indexer\Fulltext::class, [ 'fullActionFactory' => $fullActionFactory, 'indexerHandlerFactory' => $indexerHandlerFactory, - 'storeManager' => $this->storeManager, - 'dimensionFactory' => $this->dimensionFactory, 'fulltextResource' => $this->fulltextResource, - 'searchRequestConfig' => $this->searchRequestConfig, 'data' => [], 'indexSwitcher' => $this->indexSwitcher, + 'dimensionProvider' => $this->dimensionProviderMock, + 'indexScopeState' => $stateMock, ] ); } @@ -118,61 +97,66 @@ public function testExecute() { $ids = [1, 2, 3]; $stores = [0 => 'Store 1', 1 => 'Store 2']; + $this->setupDataProvider($stores); + $indexData = new \ArrayObject([]); $this->fulltextResource->expects($this->exactly(2)) ->method('getRelationsByChild') ->willReturn($ids); - $this->storeManager->expects($this->once())->method('getStores')->willReturn($stores); $this->saveHandler->expects($this->exactly(count($stores)))->method('deleteIndex'); $this->saveHandler->expects($this->exactly(2))->method('saveIndex'); + $consecutiveStoreRebuildArguments = array_map( + function ($store) use ($ids) { + return [$store, $ids]; + }, + $stores + ); $this->fullAction->expects($this->exactly(2)) ->method('rebuildStoreIndex') + ->withConsecutive(...$consecutiveStoreRebuildArguments) ->willReturn(new \ArrayObject([$indexData, $indexData])); $this->model->execute($ids); } + private function setupDataProvider($stores) + { + $this->dimensionProviderMock->expects($this->once())->method('getIterator')->willReturn( + (function () use ($stores) { + foreach ($stores as $storeId) { + $dimension = $this->getMockBuilder(Dimension::class)->disableOriginalConstructor()->getMock(); + $dimension->expects($this->once()) + ->method('getValue') + ->willReturn($storeId); + + yield ['scope' => $dimension]; + } + })() + ); + } + public function testExecuteFull() { $stores = [0 => 'Store 1', 1 => 'Store 2']; $indexData = new \ArrayObject([new \ArrayObject([]), new \ArrayObject([])]); - $this->storeManager->expects($this->once())->method('getStores')->willReturn($stores); + $this->setupDataProvider($stores); - $dimensionScope1 = $this->getMockBuilder(Dimension::class) - ->setConstructorArgs(['scope', '1']) - ->getMock(); - $dimensionScope2 = $this->getMockBuilder(Dimension::class) - ->setConstructorArgs(['scope', '2']) - ->getMock(); + $this->indexSwitcher->expects($this->exactly(2))->method('switchIndex'); - $this->dimensionFactory->expects($this->any())->method('create')->willReturnOnConsecutiveCalls( - $dimensionScope1, - $dimensionScope2 + $this->saveHandler->expects($this->exactly(count($stores)))->method('cleanIndex'); + $this->saveHandler->expects($this->exactly(2))->method('saveIndex'); + $consecutiveStoreRebuildArguments = array_map( + function ($store) { + return [$store]; + }, + $stores ); - $this->indexSwitcher->expects($this->exactly(2))->method('switchIndex') - ->withConsecutive( - [$this->equalTo([$dimensionScope1])], - [$this->equalTo([$dimensionScope2])] - ); - - $this->saveHandler->expects($this->exactly(count($stores)))->method('cleanIndex') - ->withConsecutive( - [$this->equalTo([$dimensionScope1])], - [$this->equalTo([$dimensionScope2])] - ); - - $this->saveHandler->expects($this->exactly(2))->method('saveIndex') - ->withConsecutive( - [$this->equalTo([$dimensionScope1]), $this->equalTo($indexData)], - [$this->equalTo([$dimensionScope2]), $this->equalTo($indexData)] - ); $this->fullAction->expects($this->exactly(2)) ->method('rebuildStoreIndex') - ->withConsecutive([0], [1]) + ->withConsecutive(...$consecutiveStoreRebuildArguments) ->willReturn($indexData); - $this->fulltextResource->expects($this->once())->method('resetSearchResults'); - $this->searchRequestConfig->expects($this->once())->method('reset'); + $this->fulltextResource->expects($this->exactly(2))->method('resetSearchResultsByStore'); $this->model->executeFull(); } @@ -181,11 +165,11 @@ public function testExecuteList() { $ids = [1, 2, 3]; $stores = [0 => 'Store 1', 1 => 'Store 2']; + $this->setupDataProvider($stores); $indexData = new \ArrayObject([]); $this->fulltextResource->expects($this->exactly(2)) ->method('getRelationsByChild') ->willReturn($ids); - $this->storeManager->expects($this->once())->method('getStores')->willReturn($stores); $this->saveHandler->expects($this->exactly(count($stores)))->method('deleteIndex'); $this->saveHandler->expects($this->exactly(2))->method('saveIndex'); $this->fullAction->expects($this->exactly(2)) @@ -199,11 +183,11 @@ public function testExecuteRow() { $id = 1; $stores = [0 => 'Store 1', 1 => 'Store 2']; + $this->setupDataProvider($stores); $indexData = new \ArrayObject([]); $this->fulltextResource->expects($this->exactly(2)) ->method('getRelationsByChild') ->willReturn([$id]); - $this->storeManager->expects($this->once())->method('getStores')->willReturn($stores); $this->saveHandler->expects($this->exactly(count($stores)))->method('deleteIndex'); $this->saveHandler->expects($this->exactly(2))->method('saveIndex'); $this->fullAction->expects($this->exactly(2)) diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/FulltextTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/FulltextTest.php index a49770a40f6fe..f30733fb0824c 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/FulltextTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/FulltextTest.php @@ -74,7 +74,7 @@ protected function setUp() ); } - public function testResetSearchResult() + public function testResetSearchResultByStore() { $this->resource->expects($this->once()) ->method('getTableName') @@ -82,9 +82,9 @@ public function testResetSearchResult() ->willReturn('table_name_search_query'); $this->connection->expects($this->once()) ->method('update') - ->with('table_name_search_query', ['is_processed' => 0], ['is_processed != 0']) + ->with('table_name_search_query', ['is_processed' => 0], ['is_processed != ?' => 0, 'store_id = ?' => 1]) ->willReturn(10); - $result = $this->target->resetSearchResults(); + $result = $this->target->resetSearchResultsByStore(1); $this->assertEquals($this->target, $result); } diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/FilterMapper/TermDropdownStrategyTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/FilterMapper/TermDropdownStrategyTest.php index af13f96f78d25..8771c92039f4d 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/FilterMapper/TermDropdownStrategyTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/FilterMapper/TermDropdownStrategyTest.php @@ -6,14 +6,14 @@ namespace Magento\CatalogSearch\Test\Unit\Model\Search\FilterMapper; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; -use Magento\Framework\Search\Request\FilterInterface; -use Magento\Store\Model\StoreManagerInterface; -use Magento\Framework\App\Config\ScopeConfigInterface; -use Magento\Store\Api\Data\StoreInterface; -use Magento\Framework\DB\Select; -use Magento\Eav\Model\Config as EavConfig; use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\CatalogSearch\Model\Adapter\Mysql\Filter\AliasResolver; +use Magento\CatalogSearch\Model\Search\FilterMapper\TermDropdownStrategy; +use Magento\CatalogSearch\Model\Search\FilterMapper\TermDropdownStrategy\SelectBuilder; +use Magento\Eav\Model\Config as EavConfig; +use Magento\Framework\DB\Select; +use Magento\Framework\Search\Request\FilterInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; /** * Class TermDropdownStrategyTest. @@ -27,90 +27,54 @@ class TermDropdownStrategyTest extends \PHPUnit\Framework\TestCase private $eavConfig; /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - private $storeManager; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var TermDropdownStrategy */ - private $scopeConfig; + private $termDropdownStrategy; /** - * @var \Magento\CatalogSearch\Model\Search\FilterMapper\TermDropdownStrategy + * @var AliasResolver|\PHPUnit_Framework_MockObject_MockObject */ - private $model; + private $aliasResolver; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * SelectBuilder|\PHPUnit_Framework_MockObject_MockObject */ - private $resourceMock; + private $selectBuilder; protected function setUp() { $objectManager = new ObjectManager($this); - $this->eavConfig = $this->getMockBuilder(EavConfig::class) - ->disableOriginalConstructor() - ->getMock(); - $this->storeManager = $this->getMockBuilder(StoreManagerInterface::class) - ->disableOriginalConstructor() - ->getMock(); - $this->scopeConfig = $this->getMockBuilder(ScopeConfigInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->model = $objectManager->getObject( - \Magento\CatalogSearch\Model\Search\FilterMapper\TermDropdownStrategy::class, + $this->eavConfig = $this->createMock(EavConfig::class); + $this->aliasResolver = $this->createMock(AliasResolver::class); + $this->selectBuilder = $this->createMock(SelectBuilder::class); + $this->termDropdownStrategy = $objectManager->getObject( + TermDropdownStrategy::class, [ - 'storeManager' => $this->storeManager, - 'scopeConfig' => $this->scopeConfig, 'eavConfig' => $this->eavConfig, - 'resourceConnection' => $this->resourceMock + 'aliasResolver' => $this->aliasResolver, + 'selectBuilder' => $this->selectBuilder ] ); } public function testApply() { - $searchFilter = $this->getMockBuilder(FilterInterface::class) - ->setMethods(['getField', 'getType', 'getName']) - ->getMock(); - $select = $this->getMockBuilder(Select::class) - ->disableOriginalConstructor() - ->getMock(); - $attribute = $this->getMockBuilder(Attribute::class) - ->disableOriginalConstructor() - ->getMock(); - $store = $this->getMockBuilder(StoreInterface::class) - ->disableOriginalConstructor() - ->getMock(); + $attributeId = 5; + $alias = 'some_alias'; + $this->aliasResolver->expects($this->once())->method('getAlias')->willReturn($alias); + $searchFilter = $this->createPartialMock( + FilterInterface::class, + ['getField', 'getType', 'getName'] + ); + + $select = $this->createMock(Select::class); + $attribute = $this->createMock(Attribute::class); - $this->resourceMock->expects($this->any()) - ->method('getTableName') - ->willReturn('cataloginventory_stock_status'); - $this->scopeConfig->expects($this->once()) - ->method('isSetFlag') - ->willReturn(false); - $this->eavConfig->expects($this->once()) - ->method('getAttribute') - ->willReturn($attribute); - $this->storeManager->expects($this->once()) - ->method('getStore') - ->willReturn($store); - $store->expects($this->once()) - ->method('getId') - ->willReturn(1); - $attribute->expects($this->once()) - ->method('getId') - ->willReturn(1); - $searchFilter->expects($this->once()) - ->method('getField') - ->willReturn('filed'); + $this->eavConfig->expects($this->once())->method('getAttribute')->willReturn($attribute); + $attribute->expects($this->once())->method('getId')->willReturn($attributeId); + $searchFilter->expects($this->once())->method('getField'); + $this->selectBuilder->expects($this->once())->method('execute')->with($attributeId, $alias, $select); - $this->assertTrue($this->model->apply($searchFilter, $select)); + $this->assertTrue($this->termDropdownStrategy->apply($searchFilter, $select)); } } diff --git a/app/code/Magento/CatalogSearch/composer.json b/app/code/Magento/CatalogSearch/composer.json index 057117448e78f..72bf2ec90a582 100644 --- a/app/code/Magento/CatalogSearch/composer.json +++ b/app/code/Magento/CatalogSearch/composer.json @@ -5,21 +5,20 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-search": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-search": "*", + "magento/module-store": "*", + "magento/module-theme": "*", + "magento/module-ui": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml b/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml index 7877ff04b24fd..d6c72d883fedf 100644 --- a/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml +++ b/app/code/Magento/CatalogSearch/etc/adminhtml/di.xml @@ -26,4 +26,9 @@ + + + Magento\CatalogSearch\Ui\DataProvider\Product\AddFulltextFilterToCollection + + diff --git a/app/code/Magento/CatalogSearch/etc/adminhtml/system.xml b/app/code/Magento/CatalogSearch/etc/adminhtml/system.xml index f5ebd3c6c9dc4..18d2cdf542799 100644 --- a/app/code/Magento/CatalogSearch/etc/adminhtml/system.xml +++ b/app/code/Magento/CatalogSearch/etc/adminhtml/system.xml @@ -32,6 +32,10 @@ Number of popular search terms to be cached for faster response. Use “0” to cache all results after a term is searched for the second time. validate-digits + + + validate-digits + diff --git a/app/code/Magento/CatalogSearch/etc/config.xml b/app/code/Magento/CatalogSearch/etc/config.xml index 9fb0118701d10..d2b50fe9f5336 100644 --- a/app/code/Magento/CatalogSearch/etc/config.xml +++ b/app/code/Magento/CatalogSearch/etc/config.xml @@ -16,6 +16,7 @@ 1 128 100 + 8 diff --git a/app/code/Magento/CatalogSearch/etc/db_schema.xml b/app/code/Magento/CatalogSearch/etc/db_schema.xml index 2f6c0ba2b72b7..ae9217b0632a7 100644 --- a/app/code/Magento/CatalogSearch/etc/db_schema.xml +++ b/app/code/Magento/CatalogSearch/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/CatalogSearch/etc/di.xml b/app/code/Magento/CatalogSearch/etc/di.xml index c470525230082..3d1c4470b1ae8 100644 --- a/app/code/Magento/CatalogSearch/etc/di.xml +++ b/app/code/Magento/CatalogSearch/etc/di.xml @@ -211,6 +211,8 @@ Magento\Catalog\Model\Layer\Filter\Price\Range\Proxy + + @@ -297,4 +299,21 @@ catalog_view_container + + + + + + + + + + + + + + Magento\Store\Model\StoreDimensionProvider + + + diff --git a/app/code/Magento/CatalogSearch/etc/module.xml b/app/code/Magento/CatalogSearch/etc/module.xml index db530edbdd7ef..68253014ec150 100644 --- a/app/code/Magento/CatalogSearch/etc/module.xml +++ b/app/code/Magento/CatalogSearch/etc/module.xml @@ -10,6 +10,8 @@ + + diff --git a/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php b/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php new file mode 100644 index 0000000000000..23d11686e8292 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Model/Category/Plugin/Category/UpdateUrlPath.php @@ -0,0 +1,123 @@ +categoryUrlPathGenerator = $categoryUrlPathGenerator; + $this->categoryUrlRewriteGenerator = $categoryUrlRewriteGenerator; + $this->urlPersist = $urlPersist; + $this->storeViewService = $storeViewService; + } + + /** + * Perform url updating for different stores. + * + * @param CategoryResource $subject + * @param CategoryResource $result + * @param AbstractModel $category + * @return CategoryResource + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterSave( + CategoryResource $subject, + CategoryResource $result, + AbstractModel $category + ): CategoryResource { + $parentCategoryId = $category->getParentId(); + if ($category->isObjectNew() + && !$category->isInRootCategoryList() + && !empty($parentCategoryId) + ) { + foreach ($category->getStoreIds() as $storeId) { + if (!$this->isGlobalScope((int)$storeId) + && $this->storeViewService->doesEntityHaveOverriddenUrlPathForStore( + $storeId, + $parentCategoryId, + Category::ENTITY + ) + ) { + $category->setStoreId($storeId); + $this->updateUrlPathForCategory($category, $subject); + $this->urlPersist->replace($this->categoryUrlRewriteGenerator->generate($category)); + } + } + } + + return $result; + } + + /** + * Check that store id is in global scope. + * + * @param int $storeId + * @return bool + */ + private function isGlobalScope(int $storeId): bool + { + return $storeId === Store::DEFAULT_STORE_ID; + } + + /** + * Updates category url path. + * + * @param Category $category + * @param CategoryResource $categoryResource + * @return void + */ + private function updateUrlPathForCategory(Category $category, CategoryResource $categoryResource): void + { + $category->unsUrlPath(); + $category->setUrlPath($this->categoryUrlPathGenerator->getUrlPath($category)); + $categoryResource->saveAttribute($category, 'url_path'); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php b/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php index 9c5c37b51f0b2..6b838f83d31e4 100644 --- a/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php +++ b/app/code/Magento/CatalogUrlRewrite/Model/ProductScopeRewriteGenerator.php @@ -208,7 +208,7 @@ public function generateForSpecificStoreView($storeId, $productCategories, Produ public function isCategoryProperForGenerating(Category $category, $storeId) { $parentIds = $category->getParentIds(); - if (count($parentIds) >= 2) { + if (is_array($parentIds) && count($parentIds) >= 2) { $rootCategoryId = $parentIds[1]; return $rootCategoryId == $this->storeManager->getStore($storeId)->getRootCategoryId(); } diff --git a/app/code/Magento/CatalogUrlRewrite/Model/Storage/DbStorage.php b/app/code/Magento/CatalogUrlRewrite/Model/Storage/DbStorage.php index 748589924d916..f0351467e5f0e 100644 --- a/app/code/Magento/CatalogUrlRewrite/Model/Storage/DbStorage.php +++ b/app/code/Magento/CatalogUrlRewrite/Model/Storage/DbStorage.php @@ -12,25 +12,37 @@ class DbStorage extends BaseDbStorage { /** - * @param array $data - * @return \Magento\Framework\DB\Select + * {@inheritDoc} */ protected function prepareSelect(array $data) { + $metadata = []; + if (array_key_exists(UrlRewrite::METADATA, $data)) { + $metadata = $data[UrlRewrite::METADATA]; + unset($data[UrlRewrite::METADATA]); + } + $select = $this->connection->select(); - $select->from(['url_rewrite' => $this->resource->getTableName('url_rewrite')]) - ->joinLeft( - ['relation' => $this->resource->getTableName(Product::TABLE_NAME)], - 'url_rewrite.url_rewrite_id = relation.url_rewrite_id' - ) - ->where('url_rewrite.entity_id IN (?)', $data['entity_id']) - ->where('url_rewrite.entity_type = ?', $data['entity_type']) - ->where('url_rewrite.store_id IN (?)', $data['store_id']); - if (empty($data[UrlRewrite::METADATA]['category_id'])) { + $select->from([ + 'url_rewrite' => $this->resource->getTableName(self::TABLE_NAME) + ]); + $select->joinLeft( + ['relation' => $this->resource->getTableName(Product::TABLE_NAME)], + 'url_rewrite.url_rewrite_id = relation.url_rewrite_id' + ); + + foreach ($data as $column => $value) { + $select->where('url_rewrite.' . $column . ' IN (?)', $value); + } + if (empty($metadata['category_id'])) { $select->where('relation.category_id IS NULL'); } else { - $select->where('relation.category_id = ?', $data[UrlRewrite::METADATA]['category_id']); + $select->where( + 'relation.category_id = ?', + $metadata['category_id'] + ); } + return $select; } } diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php index 92a46facbe71c..5d7e323e8b2d8 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\CatalogUrlRewrite\Observer; use Magento\Catalog\Model\Category; @@ -12,6 +13,9 @@ use Magento\CatalogUrlRewrite\Model\Map\DataProductUrlRewriteDatabaseMap; use Magento\CatalogUrlRewrite\Model\UrlRewriteBunchReplacer; use Magento\Framework\Event\ObserverInterface; +use Magento\Store\Model\ResourceModel\Group\CollectionFactory; +use Magento\Store\Model\ResourceModel\Group\Collection as StoreGroupCollection; +use Magento\Framework\App\ObjectManager; /** * Generates Category Url Rewrites after save and Products Url Rewrites assigned to the category that's being saved @@ -43,12 +47,18 @@ class CategoryProcessUrlRewriteSavingObserver implements ObserverInterface */ private $dataUrlRewriteClassNames; + /** + * @var CollectionFactory + */ + private $storeGroupFactory; + /** * @param CategoryUrlRewriteGenerator $categoryUrlRewriteGenerator * @param UrlRewriteHandler $urlRewriteHandler * @param UrlRewriteBunchReplacer $urlRewriteBunchReplacer * @param DatabaseMapPool $databaseMapPool * @param string[] $dataUrlRewriteClassNames + * @param CollectionFactory|null $storeGroupFactory */ public function __construct( CategoryUrlRewriteGenerator $categoryUrlRewriteGenerator, @@ -56,15 +66,18 @@ public function __construct( UrlRewriteBunchReplacer $urlRewriteBunchReplacer, DatabaseMapPool $databaseMapPool, $dataUrlRewriteClassNames = [ - DataCategoryUrlRewriteDatabaseMap::class, - DataProductUrlRewriteDatabaseMap::class - ] + DataCategoryUrlRewriteDatabaseMap::class, + DataProductUrlRewriteDatabaseMap::class + ], + CollectionFactory $storeGroupFactory = null ) { $this->categoryUrlRewriteGenerator = $categoryUrlRewriteGenerator; $this->urlRewriteHandler = $urlRewriteHandler; $this->urlRewriteBunchReplacer = $urlRewriteBunchReplacer; $this->databaseMapPool = $databaseMapPool; $this->dataUrlRewriteClassNames = $dataUrlRewriteClassNames; + $this->storeGroupFactory = $storeGroupFactory + ?: ObjectManager::getInstance()->get(CollectionFactory::class); } /** @@ -82,10 +95,14 @@ public function execute(\Magento\Framework\Event\Observer $observer) return; } + if (!$category->hasData('store_id')) { + $this->setCategoryStoreId($category); + } + $mapsGenerated = false; if ($category->dataHasChangedFor('url_key') || $category->dataHasChangedFor('is_anchor') - || $category->getIsChangedProductList() + || !empty($category->getChangedProductIds()) ) { if ($category->dataHasChangedFor('url_key')) { $categoryUrlRewriteResult = $this->categoryUrlRewriteGenerator->generate($category); @@ -102,6 +119,29 @@ public function execute(\Magento\Framework\Event\Observer $observer) } } + /** + * In case store_id is not set for category then we can assume that it was passed through product import. + * Store group must have only one root category, so receiving category's path and checking if one of it parts + * is the root category for store group, we can set default_store_id value from it to category. + * it prevents urls duplication for different stores + * ("Default Category/category/sub" and "Default Category2/category/sub") + * + * @param Category $category + * @return void + */ + private function setCategoryStoreId($category) + { + /** @var StoreGroupCollection $storeGroupCollection */ + $storeGroupCollection = $this->storeGroupFactory->create(); + + foreach ($storeGroupCollection as $storeGroup) { + /** @var \Magento\Store\Model\Group $storeGroup */ + if (in_array($storeGroup->getRootCategoryId(), explode('/', $category->getPath()))) { + $category->setStoreId($storeGroup->getDefaultStoreId()); + } + } + } + /** * Resets used data maps to free up memory and temporary tables * diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/UrlRewriteHandler.php b/app/code/Magento/CatalogUrlRewrite/Observer/UrlRewriteHandler.php index 5a6777f94e2d5..9892465d1538a 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/UrlRewriteHandler.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/UrlRewriteHandler.php @@ -104,7 +104,8 @@ public function generateProductUrlRewrites(\Magento\Catalog\Model\Category $cate $this->isSkippedProduct[$category->getEntityId()] = []; $saveRewriteHistory = $category->getData('save_rewrites_history'); $storeId = $category->getStoreId(); - if ($category->getAffectedProductIds()) { + + if ($category->getChangedProductIds()) { $this->isSkippedProduct[$category->getEntityId()] = $category->getAffectedProductIds(); /* @var \Magento\Catalog\Model\ResourceModel\Product\Collection $collection */ $collection = $this->productCollectionFactory->create() @@ -140,6 +141,7 @@ public function generateProductUrlRewrites(\Magento\Catalog\Model\Category $cate ) ); } + foreach ($this->childrenCategoriesProvider->getChildren($category, true) as $childCategory) { $mergeDataProvider->merge( $this->getCategoryProductsUrlRewrites( diff --git a/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/CreateUrlAttributes.php b/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/CreateUrlAttributes.php index dfbbb6f6f31f5..22e87174e1f11 100644 --- a/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/CreateUrlAttributes.php +++ b/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/CreateUrlAttributes.php @@ -10,8 +10,8 @@ use Magento\Eav\Setup\EavSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class CreateUrlAttributes diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php new file mode 100644 index 0000000000000..1d1f9c7fad91b --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/Plugin/Category/UpdateUrlPathTest.php @@ -0,0 +1,172 @@ +objectManager = new ObjectManager($this); + $this->categoryUrlPathGenerator = $this->getMockBuilder(CategoryUrlPathGenerator::class) + ->disableOriginalConstructor() + ->setMethods(['getUrlPath']) + ->getMock(); + $this->categoryUrlRewriteGenerator = $this->getMockBuilder(CategoryUrlRewriteGenerator::class) + ->disableOriginalConstructor() + ->setMethods(['generate']) + ->getMock(); + $this->categoryResource = $this->getMockBuilder(CategoryResource::class) + ->disableOriginalConstructor() + ->setMethods(['saveAttribute']) + ->getMock(); + $this->category = $this->getMockBuilder(Category::class) + ->disableOriginalConstructor() + ->setMethods( + [ + 'getStoreId', + 'getParentId', + 'isObjectNew', + 'isInRootCategoryList', + 'getStoreIds', + 'setStoreId', + 'unsUrlPath', + 'setUrlPath', + ] + ) + ->getMock(); + $this->storeViewService = $this->getMockBuilder(StoreViewService::class) + ->disableOriginalConstructor() + ->setMethods(['doesEntityHaveOverriddenUrlPathForStore']) + ->getMock(); + $this->urlPersist = $this->getMockBuilder(UrlPersistInterface::class) + ->disableOriginalConstructor() + ->setMethods(['replace']) + ->getMockForAbstractClass(); + + $this->updateUrlPathPlugin = $this->objectManager->getObject( + \Magento\CatalogUrlRewrite\Model\Category\Plugin\Category\UpdateUrlPath::class, + [ + 'categoryUrlPathGenerator' => $this->categoryUrlPathGenerator, + 'categoryUrlRewriteGenerator' => $this->categoryUrlRewriteGenerator, + 'urlPersist' => $this->urlPersist, + 'storeViewService' => $this->storeViewService, + ] + ); + } + + public function testAroundSaveWithoutRootCategory() + { + $this->category->expects($this->atLeastOnce())->method('getParentId')->willReturn(0); + $this->category->expects($this->atLeastOnce())->method('isObjectNew')->willReturn(true); + $this->category->expects($this->atLeastOnce())->method('isInRootCategoryList')->willReturn(false); + $this->category->expects($this->never())->method('getStoreIds'); + + $this->assertEquals( + $this->categoryResource, + $this->updateUrlPathPlugin->afterSave($this->categoryResource, $this->categoryResource, $this->category) + ); + } + + public function testAroundSaveWithRootCategory() + { + $parentId = 1; + $categoryStoreIds = [0,1,2]; + $generatedUrlPath = 'parent_category/child_category'; + + $this->categoryUrlPathGenerator + ->expects($this->once()) + ->method('getUrlPath') + ->with($this->category) + ->willReturn($generatedUrlPath); + $this->category->expects($this->atLeastOnce())->method('getParentId')->willReturn($parentId); + $this->category->expects($this->atLeastOnce())->method('isObjectNew')->willReturn(true); + $this->category->expects($this->atLeastOnce())->method('isInRootCategoryList')->willReturn(false); + $this->category->expects($this->atLeastOnce())->method('getStoreIds')->willReturn($categoryStoreIds); + $this->category->expects($this->once())->method('setStoreId')->with($categoryStoreIds[2])->willReturnSelf(); + $this->category->expects($this->once())->method('unsUrlPath')->willReturnSelf(); + $this->category->expects($this->once())->method('setUrlPath')->with($generatedUrlPath)->willReturnSelf(); + $this->storeViewService->expects($this->exactly(2))->method('doesEntityHaveOverriddenUrlPathForStore') + ->willReturnMap( + [ + [$categoryStoreIds[1], $parentId, 'catalog_category', false], + [$categoryStoreIds[2], $parentId, 'catalog_category', true], + ] + ); + $this->categoryResource + ->expects($this->once()) + ->method('saveAttribute') + ->with($this->category, 'url_path') + ->willReturnSelf(); + $generatedUrlRewrite = $this->getMockBuilder(\Magento\UrlRewrite\Service\V1\Data\UrlRewrite::class) + ->disableOriginalConstructor() + ->getMock(); + $this->categoryUrlRewriteGenerator->expects($this->once())->method('generate')->with($this->category) + ->willReturn([$generatedUrlRewrite]); + $this->urlPersist->expects($this->once())->method('replace')->with([$generatedUrlRewrite])->willReturnSelf(); + + $this->assertEquals( + $this->categoryResource, + $this->updateUrlPathPlugin->afterSave($this->categoryResource, $this->categoryResource, $this->category) + ); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php new file mode 100644 index 0000000000000..afdb548887577 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php @@ -0,0 +1,229 @@ +observer = $this->createPartialMock( + \Magento\Framework\Event\Observer::class, + ['getEvent', 'getData'] + ); + $this->category = $this->createPartialMock(Category::class, [ + 'hasData', + 'getParentId', + 'dataHasChangedFor', + 'getChangedProductIds', + ]); + $this->observer->expects($this->any()) + ->method('getEvent') + ->willReturnSelf(); + $this->observer->expects($this->any()) + ->method('getData') + ->with('category') + ->willReturn($this->category); + + $this->categoryUrlRewriteGeneratorMock = $this->getMockBuilder(CategoryUrlRewriteGenerator::class) + ->disableOriginalConstructor() + ->getMock(); + $this->urlRewriteBunchReplacerMock = $this->getMockBuilder(UrlRewriteBunchReplacer::class) + ->disableOriginalConstructor() + ->getMock(); + $this->urlRewriteHandlerMock = $this->getMockBuilder(UrlRewriteHandler::class) + ->disableOriginalConstructor() + ->getMock(); + $this->databaseMapPoolMock = $this->getMockBuilder(DatabaseMapPool::class) + ->disableOriginalConstructor() + ->getMock(); + $this->storeGroupFactory = $this->getMockBuilder(CollectionFactory::class) + ->setMethods(['create']) + ->disableOriginalConstructor() + ->getMock(); + + $this->categoryProcessUrlRewriteSavingObserver = (new ObjectManagerHelper($this))->getObject( + CategoryProcessUrlRewriteSavingObserver::class, + [ + 'categoryUrlRewriteGenerator' => $this->categoryUrlRewriteGeneratorMock, + 'urlRewriteHandler' => $this->urlRewriteHandlerMock, + 'urlRewriteBunchReplacer' => $this->urlRewriteBunchReplacerMock, + 'databaseMapPool' => $this->databaseMapPoolMock, + 'storeGroupFactory' => $this->storeGroupFactory, + ] + ); + } + + public function testExecuteForRootDirectory() + { + $this->category->expects($this->once()) + ->method('getParentId') + ->willReturn(Category::TREE_ROOT_ID); + $this->category->expects($this->never()) + ->method('hasData'); + + $this->categoryProcessUrlRewriteSavingObserver->execute($this->observer); + } + + public function testExecuteHasStoreId() + { + $this->category->expects($this->once()) + ->method('getParentId') + ->willReturn(2); + $this->category->expects($this->once()) + ->method('hasData') + ->with('store_id') + ->willReturn(true); + $this->storeGroupFactory->expects($this->never()) + ->method('create'); + $this->category->expects($this->any()) + ->method('dataHasChangedFor') + ->willReturnMap( + [ + ['url_key', false], + ['is_anchor', false], + ] + ); + $this->category->expects($this->once()) + ->method('getChangedProductIds') + ->willReturn([]); + + $this->categoryProcessUrlRewriteSavingObserver->execute($this->observer); + } + + public function testExecuteHasNotChanges() + { + $this->category->expects($this->once()) + ->method('getParentId') + ->willReturn(2); + $this->category->expects($this->once()) + ->method('hasData') + ->willReturn(false); + $this->storeGroupFactory->expects($this->once()) + ->method('create') + ->willReturn([]); + $this->category->expects($this->any()) + ->method('dataHasChangedFor') + ->willReturnMap( + [ + ['url_key', false], + ['is_anchor', false], + ] + ); + $this->category->expects($this->once()) + ->method('getChangedProductIds') + ->willReturn([]); + $this->databaseMapPoolMock->expects($this->never()) + ->method('resetMap'); + + $this->categoryProcessUrlRewriteSavingObserver->execute($this->observer); + } + + public function testExecuteHasChanges() + { + $this->category->expects($this->once()) + ->method('getParentId') + ->willReturn(2); + $this->category->expects($this->once()) + ->method('hasData') + ->willReturn(false); + $this->storeGroupFactory->expects($this->once()) + ->method('create') + ->willReturn([]); + $this->category->expects($this->any()) + ->method('dataHasChangedFor') + ->willReturnMap( + [ + ['url_key', true], + ['is_anchor', false], + ] + ); + $this->category->expects($this->any()) + ->method('getChangedProductIds') + ->willReturn([]); + + $result1 = ['test']; + $this->categoryUrlRewriteGeneratorMock->expects($this->once()) + ->method('generate') + ->with($this->category) + ->willReturn($result1); + $this->urlRewriteBunchReplacerMock->expects($this->at(0)) + ->method('doBunchReplace') + ->with($result1) + ->willReturn(null); + + $result2 = ['test2']; + $this->urlRewriteHandlerMock->expects($this->once()) + ->method('generateProductUrlRewrites') + ->with($this->category) + ->willReturn($result2); + $this->urlRewriteBunchReplacerMock->expects($this->at(1)) + ->method('doBunchReplace') + ->with($result2) + ->willReturn(null); + + $this->databaseMapPoolMock->expects($this->any()) + ->method('resetMap'); + + $this->categoryProcessUrlRewriteSavingObserver->execute($this->observer); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/UrlRewriteHandlerTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/UrlRewriteHandlerTest.php index 747e0cfa771fd..b18597a42bf94 100644 --- a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/UrlRewriteHandlerTest.php +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/UrlRewriteHandlerTest.php @@ -127,7 +127,7 @@ public function testGenerateProductUrlRewrites() { /* @var \Magento\Catalog\Model\Category|\PHPUnit_Framework_MockObject_MockObject $category */ $category = $this->getMockBuilder(\Magento\Catalog\Model\Category::class) - ->setMethods(['getEntityId', 'getStoreId', 'getData', 'getAffectedProductIds']) + ->setMethods(['getEntityId', 'getStoreId', 'getData', 'getChangedProductIds']) ->disableOriginalConstructor() ->getMock(); $category->expects($this->any()) diff --git a/app/code/Magento/CatalogUrlRewrite/composer.json b/app/code/Magento/CatalogUrlRewrite/composer.json index d5d9babce9cd1..e373d8c8c1756 100644 --- a/app/code/Magento/CatalogUrlRewrite/composer.json +++ b/app/code/Magento/CatalogUrlRewrite/composer.json @@ -5,19 +5,18 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-import-export": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-import-export": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-url-rewrite": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-import-export": "*", + "magento/module-eav": "*", + "magento/module-import-export": "*", + "magento/module-store": "*", + "magento/module-ui": "*", + "magento/module-url-rewrite": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogUrlRewrite/etc/db_schema.xml b/app/code/Magento/CatalogUrlRewrite/etc/db_schema.xml index bb83af6a75c97..174173fa2019f 100644 --- a/app/code/Magento/CatalogUrlRewrite/etc/db_schema.xml +++ b/app/code/Magento/CatalogUrlRewrite/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    + diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json b/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json index 6de3783c1a9d2..ab91f745c5d0c 100644 --- a/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json +++ b/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json @@ -2,15 +2,14 @@ "name": "magento/module-catalog-url-rewrite-graph-ql", "description": "N/A", "type": "magento2-module", - "version": "100.0.0-dev", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*" }, "suggest": { - "magento/module-catalog-url-rewrite": "100.3.*", - "magento/module-catalog-graph-ql": "100.0.*", - "magento/module-url-rewrite-graph-ql": "100.0.*" + "magento/module-catalog-url-rewrite": "*", + "magento/module-catalog-graph-ql": "*", + "magento/module-url-rewrite-graph-ql": "*" }, "license": [ "OSL-3.0", diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/graphql.xml b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/graphql.xml deleted file mode 100644 index 58acbffb31987..0000000000000 --- a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/graphql.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - PRODUCT - CATEGORY - - diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls new file mode 100644 index 0000000000000..b96cfcb03d41f --- /dev/null +++ b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls @@ -0,0 +1,22 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +interface ProductInterface { + url_key: String @doc(description: "The part of the URL that identifies the product") + url_path: String @doc(description: "The part of the URL that precedes the url_key") +} + +input ProductFilterInput { + url_key: FilterTypeInput @doc(description: "The part of the URL that identifies the product") + url_path: FilterTypeInput @doc(description: "The part of the URL that precedes the url_key") +} + +input ProductSortInput { + url_key: SortEnum @doc(description: "The part of the URL that identifies the product") + url_path: SortEnum @doc(description: "The part of the URL that precedes the url_key") +} + +enum UrlRewriteEntityTypeEnum @doc(description: "This enumeration defines the entity type.") { + PRODUCT + CATEGORY +} diff --git a/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php b/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php index 373b88049c7b5..9a55f981b7607 100644 --- a/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php +++ b/app/code/Magento/CatalogWidget/Block/Product/ProductsList.php @@ -160,14 +160,16 @@ public function getCacheKeyInfo() return [ 'CATALOG_PRODUCTS_LIST_WIDGET', - $this->getPriceCurrency()->getCurrencySymbol(), + $this->getPriceCurrency()->getCurrency()->getCode(), $this->_storeManager->getStore()->getId(), $this->_design->getDesignTheme()->getId(), $this->httpContext->getValue(\Magento\Customer\Model\Context::CONTEXT_GROUP), intval($this->getRequest()->getParam($this->getData('page_var_name'), 1)), $this->getProductsPerPage(), $conditions, - $this->json->serialize($this->getRequest()->getParams()) + $this->json->serialize($this->getRequest()->getParams()), + $this->getTemplate(), + $this->getTitle() ]; } diff --git a/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php b/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php index e871ed4359d5c..3039066ad1388 100644 --- a/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php +++ b/app/code/Magento/CatalogWidget/Test/Unit/Block/Product/ProductsListTest.php @@ -87,8 +87,8 @@ protected function setUp() { $this->collectionFactory = $this->getMockBuilder(\Magento\Catalog\Model\ResourceModel\Product\CollectionFactory::class) - ->setMethods(['create']) - ->disableOriginalConstructor()->getMock(); + ->setMethods(['create']) + ->disableOriginalConstructor()->getMock(); $this->visibility = $this->getMockBuilder(\Magento\Catalog\Model\Product\Visibility::class) ->setMethods(['getVisibleInCatalogIds']) ->disableOriginalConstructor() @@ -144,10 +144,14 @@ public function testGetCacheKeyInfo() $this->productsList->setData('conditions', 'some_serialized_conditions'); $this->productsList->setData('page_var_name', 'page_number'); + $this->productsList->setTemplate('test_template'); + $this->productsList->setData('title', 'test_title'); $this->request->expects($this->once())->method('getParam')->with('page_number')->willReturn(1); $this->request->expects($this->once())->method('getParams')->willReturn('request_params'); - $this->priceCurrency->expects($this->once())->method('getCurrencySymbol')->willReturn('$'); + $currency = $this->createMock(\Magento\Directory\Model\Currency::class); + $currency->expects($this->once())->method('getCode')->willReturn('USD'); + $this->priceCurrency->expects($this->once())->method('getCurrency')->willReturn($currency); $this->serializer->expects($this->any()) ->method('serialize') @@ -157,14 +161,16 @@ public function testGetCacheKeyInfo() $cacheKey = [ 'CATALOG_PRODUCTS_LIST_WIDGET', - '$', + 'USD', 1, 'blank', 'context_group', 1, 5, 'some_serialized_conditions', - json_encode('request_params') + json_encode('request_params'), + 'test_template', + 'test_title' ]; $this->assertEquals($cacheKey, $this->productsList->getCacheKeyInfo()); } @@ -249,9 +255,10 @@ public function testGetPagerHtml() * Test public `createCollection` method and protected `getPageSize` method via `createCollection` * * @param bool $pagerEnable - * @param int $productsCount - * @param int $productsPerPage - * @param int $expectedPageSize + * @param int $productsCount + * @param int $productsPerPage + * @param int $expectedPageSize + * * @dataProvider createCollectionDataProvider */ public function testCreateCollection($pagerEnable, $productsCount, $productsPerPage, $expectedPageSize) @@ -380,6 +387,7 @@ public function testGetIdentities() /** * @param $collection + * * @return \PHPUnit_Framework_MockObject_MockObject */ private function getConditionsForCollection($collection) diff --git a/app/code/Magento/CatalogWidget/composer.json b/app/code/Magento/CatalogWidget/composer.json index 11920e94e7e47..3998e58c99baa 100644 --- a/app/code/Magento/CatalogWidget/composer.json +++ b/app/code/Magento/CatalogWidget/composer.json @@ -5,19 +5,18 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-rule": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-widget": "100.3.*", - "magento/module-wishlist": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-rule": "*", + "magento/module-store": "*", + "magento/module-widget": "*", + "magento/module-wishlist": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogWidget/etc/widget.xml b/app/code/Magento/CatalogWidget/etc/widget.xml index 3d54c314c6622..bcc1b623da02e 100644 --- a/app/code/Magento/CatalogWidget/etc/widget.xml +++ b/app/code/Magento/CatalogWidget/etc/widget.xml @@ -40,7 +40,11 @@ - 86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache. + + If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
    Widget will not show products that begin to match the specified conditions until cache is refreshed.]]> +
    diff --git a/app/code/Magento/CatalogWidget/i18n/en_US.csv b/app/code/Magento/CatalogWidget/i18n/en_US.csv index 9ecde5cb1a062..4cccbdd926282 100644 --- a/app/code/Magento/CatalogWidget/i18n/en_US.csv +++ b/app/code/Magento/CatalogWidget/i18n/en_US.csv @@ -16,5 +16,11 @@ Title,Title Template,Template "Products Grid Template","Products Grid Template" "Cache Lifetime (Seconds)","Cache Lifetime (Seconds)" -"86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache.","86400 by default, if not set. To refresh instantly, clear the Blocks HTML Output cache." +"Time in seconds between the widget updates. +
    If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
    Widget will not show products that begin to match the specified conditions until cache is refreshed." +, +"Time in seconds between the widget updates. +
    If not set, equals to 86400 seconds (24 hours). To update widget instantly, go to Cache Management and clear Blocks HTML Output cache. +
    Widget will not show products that begin to match the specified conditions until cache is refreshed." Conditions,Conditions diff --git a/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php b/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php index 45e885d0dbd46..cad1c100c7e5b 100644 --- a/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php +++ b/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php @@ -9,7 +9,7 @@ * Interface PaymentDetailsInterface * @api */ -interface PaymentDetailsInterface +interface PaymentDetailsInterface extends \Magento\Framework\Api\ExtensibleDataInterface { /**#@+ * Constants defined for keys of array, makes typos less likely diff --git a/app/code/Magento/Checkout/Block/Cart/Item/Renderer.php b/app/code/Magento/Checkout/Block/Cart/Item/Renderer.php index 57ca4b7b2e606..06be39d0f3516 100644 --- a/app/code/Magento/Checkout/Block/Cart/Item/Renderer.php +++ b/app/code/Magento/Checkout/Block/Cart/Item/Renderer.php @@ -611,9 +611,6 @@ public function getActions(AbstractItem $item) */ public function getImage($product, $imageId, $attributes = []) { - return $this->imageBuilder->setProduct($product) - ->setImageId($imageId) - ->setAttributes($attributes) - ->create(); + return $this->imageBuilder->create($product, $imageId, $attributes); } } diff --git a/app/code/Magento/Checkout/Block/Cart/Shipping.php b/app/code/Magento/Checkout/Block/Cart/Shipping.php index 7b0ab1bc03e5b..c52b7fe18814f 100644 --- a/app/code/Magento/Checkout/Block/Cart/Shipping.php +++ b/app/code/Magento/Checkout/Block/Cart/Shipping.php @@ -74,7 +74,8 @@ public function getJsLayout() foreach ($this->layoutProcessors as $processor) { $this->jsLayout = $processor->process($this->jsLayout); } - return $this->serializer->serialize($this->jsLayout); + + return json_encode($this->jsLayout, JSON_HEX_TAG); } /** @@ -94,6 +95,6 @@ public function getBaseUrl() */ public function getSerializedCheckoutConfig() { - return $this->serializer->serialize($this->getCheckoutConfig()); + return json_encode($this->getCheckoutConfig(), JSON_HEX_TAG); } } diff --git a/app/code/Magento/Checkout/Block/Cart/Sidebar.php b/app/code/Magento/Checkout/Block/Cart/Sidebar.php index 5c237eecf0a9f..92ba6bf2bbbb1 100644 --- a/app/code/Magento/Checkout/Block/Cart/Sidebar.php +++ b/app/code/Magento/Checkout/Block/Cart/Sidebar.php @@ -100,9 +100,7 @@ public function getSerializedConfig() */ public function getImageHtmlTemplate() { - return $this->imageHelper->getFrame() - ? 'Magento_Catalog/product/image' - : 'Magento_Catalog/product/image_with_borders'; + return 'Magento_Catalog/product/image_with_borders'; } /** diff --git a/app/code/Magento/Checkout/Block/Cart/Totals.php b/app/code/Magento/Checkout/Block/Cart/Totals.php index d3d3adbe40f38..375c564f29059 100644 --- a/app/code/Magento/Checkout/Block/Cart/Totals.php +++ b/app/code/Magento/Checkout/Block/Cart/Totals.php @@ -69,7 +69,8 @@ public function getJsLayout() foreach ($this->layoutProcessors as $processor) { $this->jsLayout = $processor->process($this->jsLayout); } - return parent::getJsLayout(); + + return json_encode($this->jsLayout, JSON_HEX_TAG); } /** diff --git a/app/code/Magento/Checkout/Block/Cart/ValidationMessages.php b/app/code/Magento/Checkout/Block/Cart/ValidationMessages.php index 2cc07f49b6dfa..0ec2982b83c01 100644 --- a/app/code/Magento/Checkout/Block/Cart/ValidationMessages.php +++ b/app/code/Magento/Checkout/Block/Cart/ValidationMessages.php @@ -84,7 +84,7 @@ protected function _prepareLayout() protected function validateMinimumAmount() { if (!$this->cartHelper->getQuote()->validateMinimumAmount()) { - $this->messageManager->addNotice($this->getMinimumAmountErrorMessage()->getMessage()); + $this->messageManager->addNoticeMessage($this->getMinimumAmountErrorMessage()->getMessage()); } } diff --git a/app/code/Magento/Checkout/Block/Onepage.php b/app/code/Magento/Checkout/Block/Onepage.php index bc3cd43a024a6..ca6b045ddbb5d 100644 --- a/app/code/Magento/Checkout/Block/Onepage.php +++ b/app/code/Magento/Checkout/Block/Onepage.php @@ -77,7 +77,8 @@ public function getJsLayout() foreach ($this->layoutProcessors as $processor) { $this->jsLayout = $processor->process($this->jsLayout); } - return $this->serializer->serialize($this->jsLayout); + + return json_encode($this->jsLayout, JSON_HEX_TAG); } /** @@ -119,6 +120,6 @@ public function getBaseUrl() */ public function getSerializedCheckoutConfig() { - return $this->serializer->serialize($this->getCheckoutConfig()); + return json_encode($this->getCheckoutConfig(), JSON_HEX_TAG); } } diff --git a/app/code/Magento/Checkout/Block/Registration.php b/app/code/Magento/Checkout/Block/Registration.php index 91ec85c1db0ed..e880230f50a74 100644 --- a/app/code/Magento/Checkout/Block/Registration.php +++ b/app/code/Magento/Checkout/Block/Registration.php @@ -91,7 +91,7 @@ public function getEmailAddress() */ public function getCreateAccountUrl() { - return $this->getUrl('checkout/account/create'); + return $this->getUrl('checkout/account/delegateCreate'); } /** diff --git a/app/code/Magento/Checkout/Controller/Account/Create.php b/app/code/Magento/Checkout/Controller/Account/Create.php index 2ee5d6d5528c3..dae0bb98be453 100644 --- a/app/code/Magento/Checkout/Controller/Account/Create.php +++ b/app/code/Magento/Checkout/Controller/Account/Create.php @@ -9,6 +9,10 @@ use Magento\Framework\Exception\AlreadyExistsException; use Magento\Framework\Exception\NoSuchEntityException; +/** + * @deprecated + * @see DelegateCreate + */ class Create extends \Magento\Framework\App\Action\Action { /** diff --git a/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php b/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php new file mode 100644 index 0000000000000..6c4c8b053e2ae --- /dev/null +++ b/app/code/Magento/Checkout/Controller/Account/DelegateCreate.php @@ -0,0 +1,58 @@ +delegateService = $customerDelegation; + $this->session = $session; + } + + /** + * {@inheritdoc} + */ + public function execute() + { + /** @var string|null $orderId */ + $orderId = $this->session->getLastOrderId(); + if (!$orderId) { + return $this->resultRedirectFactory->create()->setPath('/'); + } + + return $this->delegateService->delegateNew((int)$orderId); + } +} diff --git a/app/code/Magento/Checkout/Controller/Action.php b/app/code/Magento/Checkout/Controller/Action.php index d985a7cd53cab..7ec5336001ce8 100644 --- a/app/code/Magento/Checkout/Controller/Action.php +++ b/app/code/Magento/Checkout/Controller/Action.php @@ -70,7 +70,7 @@ protected function _preDispatchValidateCustomer($redirect = true, $addErrors = t if (!$validationResult->isValid()) { if ($addErrors) { foreach ($validationResult->getMessages() as $error) { - $this->messageManager->addError($error); + $this->messageManager->addErrorMessage($error); } } if ($redirect) { diff --git a/app/code/Magento/Checkout/Controller/Cart/Add.php b/app/code/Magento/Checkout/Controller/Cart/Add.php index 8831b92f3ec86..92dd8dd8f251c 100644 --- a/app/code/Magento/Checkout/Controller/Cart/Add.php +++ b/app/code/Magento/Checkout/Controller/Cart/Add.php @@ -132,13 +132,13 @@ public function execute() } } catch (\Magento\Framework\Exception\LocalizedException $e) { if ($this->_checkoutSession->getUseNotice(true)) { - $this->messageManager->addNotice( + $this->messageManager->addNoticeMessage( $this->_objectManager->get(\Magento\Framework\Escaper::class)->escapeHtml($e->getMessage()) ); } else { $messages = array_unique(explode("\n", $e->getMessage())); foreach ($messages as $message) { - $this->messageManager->addError( + $this->messageManager->addErrorMessage( $this->_objectManager->get(\Magento\Framework\Escaper::class)->escapeHtml($message) ); } @@ -153,7 +153,10 @@ public function execute() return $this->goBack($url); } catch (\Exception $e) { - $this->messageManager->addException($e, __('We can\'t add this item to your shopping cart right now.')); + $this->messageManager->addExceptionMessage( + $e, + __('We can\'t add this item to your shopping cart right now.') + ); $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); return $this->goBack(); } diff --git a/app/code/Magento/Checkout/Controller/Cart/Addgroup.php b/app/code/Magento/Checkout/Controller/Cart/Addgroup.php index fba1b85caf7b9..c205f3c16072f 100644 --- a/app/code/Magento/Checkout/Controller/Cart/Addgroup.php +++ b/app/code/Magento/Checkout/Controller/Cart/Addgroup.php @@ -60,12 +60,12 @@ public function execute() $this->addOrderItem($item); } catch (\Magento\Framework\Exception\LocalizedException $e) { if ($this->_checkoutSession->getUseNotice(true)) { - $this->messageManager->addNotice($e->getMessage()); + $this->messageManager->addNoticeMessage($e->getMessage()); } else { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } } catch (\Exception $e) { - $this->messageManager->addException( + $this->messageManager->addExceptionMessage( $e, __('We can\'t add this item to your shopping cart right now.') ); diff --git a/app/code/Magento/Checkout/Controller/Cart/Configure.php b/app/code/Magento/Checkout/Controller/Cart/Configure.php index 6d409144ff66d..19b2d2db345a1 100644 --- a/app/code/Magento/Checkout/Controller/Cart/Configure.php +++ b/app/code/Magento/Checkout/Controller/Cart/Configure.php @@ -64,7 +64,9 @@ public function execute() try { if (!$quoteItem || $productId != $quoteItem->getProduct()->getId()) { - $this->messageManager->addError(__("The quote item isn't found. Verify the item and try again.")); + $this->messageManager->addErrorMessage( + __("The quote item isn't found. Verify the item and try again.") + ); return $this->resultFactory->create(ResultFactory::TYPE_REDIRECT)->setPath('checkout/cart'); } @@ -83,7 +85,7 @@ public function execute() ); return $resultPage; } catch (\Exception $e) { - $this->messageManager->addError(__('We cannot configure the product.')); + $this->messageManager->addErrorMessage(__('We cannot configure the product.')); $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); return $this->_goBack(); } diff --git a/app/code/Magento/Checkout/Controller/Cart/CouponPost.php b/app/code/Magento/Checkout/Controller/Cart/CouponPost.php index 56215814d2cf6..5f68335181174 100644 --- a/app/code/Magento/Checkout/Controller/Cart/CouponPost.php +++ b/app/code/Magento/Checkout/Controller/Cart/CouponPost.php @@ -95,14 +95,14 @@ public function execute() if (!$itemsCount) { if ($isCodeLengthValid && $coupon->getId()) { $this->_checkoutSession->getQuote()->setCouponCode($couponCode)->save(); - $this->messageManager->addSuccess( + $this->messageManager->addSuccessMessage( __( 'You used coupon code "%1".', $escaper->escapeHtml($couponCode) ) ); } else { - $this->messageManager->addError( + $this->messageManager->addErrorMessage( __( 'The coupon code "%1" is not valid.', $escaper->escapeHtml($couponCode) @@ -111,14 +111,14 @@ public function execute() } } else { if ($isCodeLengthValid && $coupon->getId() && $couponCode == $cartQuote->getCouponCode()) { - $this->messageManager->addSuccess( + $this->messageManager->addSuccessMessage( __( 'You used coupon code "%1".', $escaper->escapeHtml($couponCode) ) ); } else { - $this->messageManager->addError( + $this->messageManager->addErrorMessage( __( 'The coupon code "%1" is not valid.', $escaper->escapeHtml($couponCode) @@ -127,12 +127,12 @@ public function execute() } } } else { - $this->messageManager->addSuccess(__('You canceled the coupon code.')); + $this->messageManager->addSuccessMessage(__('You canceled the coupon code.')); } } catch (\Magento\Framework\Exception\LocalizedException $e) { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { - $this->messageManager->addError(__('We cannot apply the coupon code.')); + $this->messageManager->addErrorMessage(__('We cannot apply the coupon code.')); $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); } diff --git a/app/code/Magento/Checkout/Controller/Cart/Delete.php b/app/code/Magento/Checkout/Controller/Cart/Delete.php index 5687e0cad0710..4a6174e83fd02 100644 --- a/app/code/Magento/Checkout/Controller/Cart/Delete.php +++ b/app/code/Magento/Checkout/Controller/Cart/Delete.php @@ -24,7 +24,7 @@ public function execute() try { $this->cart->removeItem($id)->save(); } catch (\Exception $e) { - $this->messageManager->addError(__('We can\'t remove the item.')); + $this->messageManager->addErrorMessage(__('We can\'t remove the item.')); $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); } } diff --git a/app/code/Magento/Checkout/Controller/Cart/UpdateItemOptions.php b/app/code/Magento/Checkout/Controller/Cart/UpdateItemOptions.php index d7cb94f3da673..59bd6489bf926 100644 --- a/app/code/Magento/Checkout/Controller/Cart/UpdateItemOptions.php +++ b/app/code/Magento/Checkout/Controller/Cart/UpdateItemOptions.php @@ -67,17 +67,17 @@ public function execute() $this->_objectManager->get(\Magento\Framework\Escaper::class) ->escapeHtml($item->getProduct()->getName()) ); - $this->messageManager->addSuccess($message); + $this->messageManager->addSuccessMessage($message); } return $this->_goBack($this->_url->getUrl('checkout/cart')); } } catch (\Magento\Framework\Exception\LocalizedException $e) { if ($this->_checkoutSession->getUseNotice(true)) { - $this->messageManager->addNotice($e->getMessage()); + $this->messageManager->addNoticeMessage($e->getMessage()); } else { $messages = array_unique(explode("\n", $e->getMessage())); foreach ($messages as $message) { - $this->messageManager->addError($message); + $this->messageManager->addErrorMessage($message); } } @@ -89,7 +89,7 @@ public function execute() return $this->resultRedirectFactory->create()->setUrl($this->_redirect->getRedirectUrl($cartUrl)); } } catch (\Exception $e) { - $this->messageManager->addException($e, __('We can\'t update the item right now.')); + $this->messageManager->addExceptionMessage($e, __('We can\'t update the item right now.')); $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); return $this->_goBack(); } diff --git a/app/code/Magento/Checkout/Controller/Cart/UpdatePost.php b/app/code/Magento/Checkout/Controller/Cart/UpdatePost.php index 09fa1bd64f8c6..174cb38b0e9a9 100644 --- a/app/code/Magento/Checkout/Controller/Cart/UpdatePost.php +++ b/app/code/Magento/Checkout/Controller/Cart/UpdatePost.php @@ -18,9 +18,9 @@ protected function _emptyShoppingCart() try { $this->cart->truncate()->save(); } catch (\Magento\Framework\Exception\LocalizedException $exception) { - $this->messageManager->addError($exception->getMessage()); + $this->messageManager->addErrorMessage($exception->getMessage()); } catch (\Exception $exception) { - $this->messageManager->addException($exception, __('We can\'t update the shopping cart.')); + $this->messageManager->addExceptionMessage($exception, __('We can\'t update the shopping cart.')); } } @@ -52,11 +52,11 @@ protected function _updateShoppingCart() $this->cart->updateItems($cartData)->save(); } } catch (\Magento\Framework\Exception\LocalizedException $e) { - $this->messageManager->addError( + $this->messageManager->addErrorMessage( $this->_objectManager->get(\Magento\Framework\Escaper::class)->escapeHtml($e->getMessage()) ); } catch (\Exception $e) { - $this->messageManager->addException($e, __('We can\'t update the shopping cart.')); + $this->messageManager->addExceptionMessage($e, __('We can\'t update the shopping cart.')); $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); } } diff --git a/app/code/Magento/Checkout/Controller/Index/Index.php b/app/code/Magento/Checkout/Controller/Index/Index.php index 0a5b7f190e3d3..785c1f1473be6 100644 --- a/app/code/Magento/Checkout/Controller/Index/Index.php +++ b/app/code/Magento/Checkout/Controller/Index/Index.php @@ -4,6 +4,9 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Checkout\Controller\Index; class Index extends \Magento\Checkout\Controller\Onepage @@ -18,7 +21,7 @@ public function execute() /** @var \Magento\Checkout\Helper\Data $checkoutHelper */ $checkoutHelper = $this->_objectManager->get(\Magento\Checkout\Helper\Data::class); if (!$checkoutHelper->canOnepageCheckout()) { - $this->messageManager->addError(__('One-page checkout is turned off.')); + $this->messageManager->addErrorMessage(__('One-page checkout is turned off.')); return $this->resultRedirectFactory->create()->setPath('checkout/cart'); } @@ -28,15 +31,39 @@ public function execute() } if (!$this->_customerSession->isLoggedIn() && !$checkoutHelper->isAllowedGuestCheckout($quote)) { - $this->messageManager->addError(__('Guest checkout is disabled.')); + $this->messageManager->addErrorMessage(__('Guest checkout is disabled.')); return $this->resultRedirectFactory->create()->setPath('checkout/cart'); } - $this->_customerSession->regenerateId(); + // generate session ID only if connection is unsecure according to issues in session_regenerate_id function. + // @see http://php.net/manual/en/function.session-regenerate-id.php + if (!$this->isSecureRequest()) { + $this->_customerSession->regenerateId(); + } $this->_objectManager->get(\Magento\Checkout\Model\Session::class)->setCartWasUpdated(false); $this->getOnepage()->initCheckout(); $resultPage = $this->resultPageFactory->create(); $resultPage->getConfig()->getTitle()->set(__('Checkout')); return $resultPage; } + + /** + * Checks if current request uses SSL and referer also is secure. + * + * @return bool + */ + private function isSecureRequest(): bool + { + $request = $this->getRequest(); + + $referrer = $request->getHeader('referer'); + $secure = false; + + if ($referrer) { + $scheme = parse_url($referrer, PHP_URL_SCHEME); + $secure = $scheme === 'https'; + } + + return $secure && $request->isSecure(); + } } diff --git a/app/code/Magento/Checkout/Controller/Sidebar/UpdateItemQty.php b/app/code/Magento/Checkout/Controller/Sidebar/UpdateItemQty.php index e9a968681bf3d..0208519c33d78 100644 --- a/app/code/Magento/Checkout/Controller/Sidebar/UpdateItemQty.php +++ b/app/code/Magento/Checkout/Controller/Sidebar/UpdateItemQty.php @@ -55,7 +55,7 @@ public function __construct( public function execute() { $itemId = (int)$this->getRequest()->getParam('item_id'); - $itemQty = (int)$this->getRequest()->getParam('item_qty'); + $itemQty = $this->getRequest()->getParam('item_qty') * 1; try { $this->sidebar->checkQuoteItem($itemId); diff --git a/app/code/Magento/Checkout/CustomerData/DefaultItem.php b/app/code/Magento/Checkout/CustomerData/DefaultItem.php index 6e917366c9cd2..9351685405a60 100644 --- a/app/code/Magento/Checkout/CustomerData/DefaultItem.php +++ b/app/code/Magento/Checkout/CustomerData/DefaultItem.php @@ -6,6 +6,8 @@ namespace Magento\Checkout\CustomerData; +use Magento\Framework\App\ObjectManager; + /** * Default item */ @@ -36,12 +38,20 @@ class DefaultItem extends AbstractItem */ protected $checkoutHelper; + /** + * Escaper + * + * @var \Magento\Framework\Escaper + */ + private $escaper; + /** * @param \Magento\Catalog\Helper\Image $imageHelper * @param \Magento\Msrp\Helper\Data $msrpHelper * @param \Magento\Framework\UrlInterface $urlBuilder * @param \Magento\Catalog\Helper\Product\ConfigurationPool $configurationPool * @param \Magento\Checkout\Helper\Data $checkoutHelper + * @param \Magento\Framework\Escaper|null $escaper * @codeCoverageIgnore */ public function __construct( @@ -49,13 +59,15 @@ public function __construct( \Magento\Msrp\Helper\Data $msrpHelper, \Magento\Framework\UrlInterface $urlBuilder, \Magento\Catalog\Helper\Product\ConfigurationPool $configurationPool, - \Magento\Checkout\Helper\Data $checkoutHelper + \Magento\Checkout\Helper\Data $checkoutHelper, + \Magento\Framework\Escaper $escaper = null ) { $this->configurationPool = $configurationPool; $this->imageHelper = $imageHelper; $this->msrpHelper = $msrpHelper; $this->urlBuilder = $urlBuilder; $this->checkoutHelper = $checkoutHelper; + $this->escaper = $escaper ?: ObjectManager::getInstance()->get(\Magento\Framework\Escaper::class); } /** @@ -64,6 +76,8 @@ public function __construct( protected function doGetItemData() { $imageHelper = $this->imageHelper->init($this->getProductForThumbnail(), 'mini_cart_product_thumbnail'); + $productName = $this->escaper->escapeHtml($this->item->getProduct()->getName()); + return [ 'options' => $this->getOptionList(), 'qty' => $this->item->getQty() * 1, @@ -71,7 +85,7 @@ protected function doGetItemData() 'configure_url' => $this->getConfigureUrl(), 'is_visible_in_site_visibility' => $this->item->getProduct()->isVisibleInSiteVisibility(), 'product_id' => $this->item->getProduct()->getId(), - 'product_name' => $this->item->getProduct()->getName(), + 'product_name' => $productName, 'product_sku' => $this->item->getProduct()->getSku(), 'product_url' => $this->getProductUrl(), 'product_has_url' => $this->hasProductUrl(), diff --git a/app/code/Magento/Checkout/Model/Cart.php b/app/code/Magento/Checkout/Model/Cart.php index d1a55aee4db93..a18cba3f67c84 100644 --- a/app/code/Magento/Checkout/Model/Cart.php +++ b/app/code/Magento/Checkout/Model/Cart.php @@ -445,10 +445,10 @@ public function addProductsByIds($productIds) } if (!$allAvailable) { - $this->messageManager->addError(__("We don't have some of the products you want.")); + $this->messageManager->addErrorMessage(__("We don't have some of the products you want.")); } if (!$allAdded) { - $this->messageManager->addError(__("We don't have as many of some products as you want.")); + $this->messageManager->addErrorMessage(__("We don't have as many of some products as you want.")); } } return $this; @@ -534,7 +534,7 @@ public function updateItems($data) if (isset($itemInfo['before_suggest_qty']) && $itemInfo['before_suggest_qty'] != $qty) { $qtyRecalculatedFlag = true; - $this->messageManager->addNotice( + $this->messageManager->addNoticeMessage( __('Quantity was recalculated from %1 to %2', $itemInfo['before_suggest_qty'], $qty), 'quote_item' . $item->getId() ); @@ -543,7 +543,7 @@ public function updateItems($data) } if ($qtyRecalculatedFlag) { - $this->messageManager->addNotice( + $this->messageManager->addNoticeMessage( __('We adjusted product quantities to fit the required increments.') ); } diff --git a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php index e18940626a338..a17cf41585649 100644 --- a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php +++ b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php @@ -6,6 +6,8 @@ namespace Magento\Checkout\Model; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\ResourceConnection; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Framework\Exception\CouldNotSaveException; @@ -50,6 +52,11 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa */ private $logger; + /** + * @var ResourceConnection + */ + private $connectionPool; + /** * @param \Magento\Quote\Api\GuestBillingAddressManagementInterface $billingAddressManagement * @param \Magento\Quote\Api\GuestPaymentMethodManagementInterface $paymentMethodManagement @@ -57,6 +64,7 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa * @param \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement * @param \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory * @param CartRepositoryInterface $cartRepository + * @param ResourceConnection|null * @codeCoverageIgnore */ public function __construct( @@ -65,7 +73,8 @@ public function __construct( \Magento\Quote\Api\GuestCartManagementInterface $cartManagement, \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement, \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory, - CartRepositoryInterface $cartRepository + CartRepositoryInterface $cartRepository, + ResourceConnection $connectionPool = null ) { $this->billingAddressManagement = $billingAddressManagement; $this->paymentMethodManagement = $paymentMethodManagement; @@ -73,6 +82,7 @@ public function __construct( $this->paymentInformationManagement = $paymentInformationManagement; $this->quoteIdMaskFactory = $quoteIdMaskFactory; $this->cartRepository = $cartRepository; + $this->connectionPool = $connectionPool ?: ObjectManager::getInstance()->get(ResourceConnection::class); } /** @@ -84,21 +94,35 @@ public function savePaymentInformationAndPlaceOrder( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { - $this->savePaymentInformation($cartId, $email, $paymentMethod, $billingAddress); + $salesConnection = $this->connectionPool->getConnection('sales'); + $checkoutConnection = $this->connectionPool->getConnection('checkout'); + $salesConnection->beginTransaction(); + $checkoutConnection->beginTransaction(); + try { - $orderId = $this->cartManagement->placeOrder($cartId); - } catch (\Magento\Framework\Exception\LocalizedException $e) { - throw new CouldNotSaveException( - __($e->getMessage()), - $e - ); + $this->savePaymentInformation($cartId, $email, $paymentMethod, $billingAddress); + try { + $orderId = $this->cartManagement->placeOrder($cartId); + } catch (\Magento\Framework\Exception\LocalizedException $e) { + throw new CouldNotSaveException( + __($e->getMessage()), + $e + ); + } catch (\Exception $e) { + $this->getLogger()->critical($e); + throw new CouldNotSaveException( + __('An error occurred on the server. Please try to place the order again.'), + $e + ); + } + $salesConnection->commit(); + $checkoutConnection->commit(); } catch (\Exception $e) { - $this->getLogger()->critical($e); - throw new CouldNotSaveException( - __('A server error stopped your order from being placed. Please try to place your order again.'), - $e - ); + $salesConnection->rollBack(); + $checkoutConnection->rollBack(); + throw $e; } + return $orderId; } diff --git a/app/code/Magento/Checkout/Model/Type/Onepage.php b/app/code/Magento/Checkout/Model/Type/Onepage.php index bc6eb07b51a41..f7e55fdc84cf7 100644 --- a/app/code/Magento/Checkout/Model/Type/Onepage.php +++ b/app/code/Magento/Checkout/Model/Type/Onepage.php @@ -666,7 +666,7 @@ protected function _involveNewCustomer() $confirmationStatus = $this->accountManagement->getConfirmationStatus($customer->getId()); if ($confirmationStatus === \Magento\Customer\Model\AccountManagement::ACCOUNT_CONFIRMATION_REQUIRED) { $url = $this->_customerUrl->getEmailConfirmationUrl($customer->getEmail()); - $this->messageManager->addSuccess( + $this->messageManager->addSuccessMessage( // @codingStandardsIgnoreStart __( 'You must confirm your account. Please check your email for the confirmation link or click here for a new link.', diff --git a/app/code/Magento/Checkout/Observer/LoadCustomerQuoteObserver.php b/app/code/Magento/Checkout/Observer/LoadCustomerQuoteObserver.php index 7b0aa82be31e7..63c573f2ffe65 100644 --- a/app/code/Magento/Checkout/Observer/LoadCustomerQuoteObserver.php +++ b/app/code/Magento/Checkout/Observer/LoadCustomerQuoteObserver.php @@ -42,9 +42,9 @@ public function execute(\Magento\Framework\Event\Observer $observer) try { $this->checkoutSession->loadCustomerQuote(); } catch (\Magento\Framework\Exception\LocalizedException $e) { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { - $this->messageManager->addException($e, __('Load customer quote error')); + $this->messageManager->addExceptionMessage($e, __('Load customer quote error')); } } } diff --git a/app/code/Magento/Checkout/Setup/Patch/Data/PrepareInitialCheckoutConfiguration.php b/app/code/Magento/Checkout/Setup/Patch/Data/PrepareInitialCheckoutConfiguration.php index c7a5ddca50f48..bc38809d070b2 100644 --- a/app/code/Magento/Checkout/Setup/Patch/Data/PrepareInitialCheckoutConfiguration.php +++ b/app/code/Magento/Checkout/Setup/Patch/Data/PrepareInitialCheckoutConfiguration.php @@ -10,8 +10,8 @@ use Magento\Eav\Setup\EavSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class PrepareInitialCheckoutConfiguration diff --git a/app/code/Magento/Checkout/Test/Unit/Block/Cart/Item/RendererTest.php b/app/code/Magento/Checkout/Test/Unit/Block/Cart/Item/RendererTest.php index d963fa2d76e6b..9c9c5fd33bd07 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/Cart/Item/RendererTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/Cart/Item/RendererTest.php @@ -5,6 +5,8 @@ */ namespace Magento\Checkout\Test\Unit\Block\Cart\Item; +use Magento\Catalog\Block\Product\Image; +use Magento\Catalog\Model\Product; use Magento\Checkout\Block\Cart\Item\Renderer; use Magento\Quote\Model\Quote\Item; @@ -64,13 +66,13 @@ public function testGetProductForThumbnail() /** * Initialize product. * - * @return \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject + * @return Product|\PHPUnit_Framework_MockObject_MockObject */ protected function _initProduct() { - /** @var \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject $product */ + /** @var Product|\PHPUnit_Framework_MockObject_MockObject $product */ $product = $this->createPartialMock( - \Magento\Catalog\Model\Product::class, + Product::class, ['getName', '__wakeup', 'getIdentities'] ); $product->expects($this->any())->method('getName')->will($this->returnValue('Parent Product')); @@ -106,7 +108,7 @@ public function testGetIdentitiesFromEmptyItem() public function testGetProductPriceHtml() { $priceHtml = 'some price html'; - $product = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) + $product = $this->getMockBuilder(Product::class) ->disableOriginalConstructor() ->getMock(); @@ -193,34 +195,17 @@ public function testGetImage() { $imageId = 'test_image_id'; $attributes = []; + $product = $this->createMock(Product::class); + $imageMock = $this->createMock(Image::class); - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - - $imageMock = $this->getMockBuilder(\Magento\Catalog\Block\Product\Image::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->imageBuilder->expects($this->once()) - ->method('setProduct') - ->with($productMock) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) - ->method('setImageId') - ->with($imageId) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) - ->method('setAttributes') - ->with($attributes) - ->willReturnSelf(); - $this->imageBuilder->expects($this->once()) + $this->imageBuilder->expects(self::once()) ->method('create') + ->with($product, $imageId, $attributes) ->willReturn($imageMock); - $this->assertInstanceOf( - \Magento\Catalog\Block\Product\Image::class, - $this->_renderer->getImage($productMock, $imageId, $attributes) + static::assertInstanceOf( + Image::class, + $this->_renderer->getImage($product, $imageId, $attributes) ); } } diff --git a/app/code/Magento/Checkout/Test/Unit/Block/Cart/ShippingTest.php b/app/code/Magento/Checkout/Test/Unit/Block/Cart/ShippingTest.php index e419a1535207e..302188224b97a 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/Cart/ShippingTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/Cart/ShippingTest.php @@ -99,9 +99,6 @@ public function testGetJsLayout() ->with($this->layout) ->willReturn($layoutProcessed); - $this->serializer->expects($this->once())->method('serialize')->will( - $this->returnValue($jsonLayoutProcessed) - ); $this->assertEquals( $jsonLayoutProcessed, $this->model->getJsLayout() @@ -121,9 +118,6 @@ public function testGetSerializedCheckoutConfig() { $checkoutConfig = ['checkout', 'config']; $this->configProvider->expects($this->once())->method('getConfig')->willReturn($checkoutConfig); - $this->serializer->expects($this->once())->method('serialize')->will( - $this->returnValue(json_encode($checkoutConfig)) - ); $this->assertEquals(json_encode($checkoutConfig), $this->model->getSerializedCheckoutConfig()); } diff --git a/app/code/Magento/Checkout/Test/Unit/Block/Cart/SidebarTest.php b/app/code/Magento/Checkout/Test/Unit/Block/Cart/SidebarTest.php index 88751b899d7c9..1c5224d007ec8 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/Cart/SidebarTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/Cart/SidebarTest.php @@ -163,7 +163,6 @@ public function testGetConfig() ->willReturnMap($valueMap); $this->storeManagerMock->expects($this->exactly(2))->method('getStore')->willReturn($storeMock); $storeMock->expects($this->once())->method('getBaseUrl')->willReturn($baseUrl); - $this->imageHelper->expects($this->once())->method('getFrame')->willReturn(false); $this->scopeConfigMock->expects($this->at(0)) ->method('getValue') diff --git a/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php b/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php index e47fac06d8057..54f77c95148ac 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php @@ -93,9 +93,6 @@ public function testGetJsLayout() $processedLayout = ['layout' => ['processed' => true]]; $jsonLayout = '{"layout":{"processed":true}}'; $this->layoutProcessorMock->expects($this->once())->method('process')->with([])->willReturn($processedLayout); - $this->serializer->expects($this->once())->method('serialize')->will( - $this->returnValue(json_encode($processedLayout)) - ); $this->assertEquals($jsonLayout, $this->model->getJsLayout()); } @@ -104,9 +101,6 @@ public function testGetSerializedCheckoutConfig() { $checkoutConfig = ['checkout', 'config']; $this->configProviderMock->expects($this->once())->method('getConfig')->willReturn($checkoutConfig); - $this->serializer->expects($this->once())->method('serialize')->will( - $this->returnValue(json_encode($checkoutConfig)) - ); $this->assertEquals(json_encode($checkoutConfig), $this->model->getSerializedCheckoutConfig()); } diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/ConfigureTest.php b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/ConfigureTest.php index 05518e3ab943b..aff19b109711d 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/ConfigureTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/ConfigureTest.php @@ -198,7 +198,7 @@ public function testRedirectWithWrongProductId() $quoteItemMock->expects($this->once())->method('getProduct')->willReturn($productMock); $productMock->expects($this->once())->method('getId')->willReturn($productIdInQuota); $this->messageManagerMock->expects($this->once()) - ->method('addError') + ->method('addErrorMessage') ->willReturn(''); $this->resultRedirectMock->expects($this->once()) ->method('setPath') diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/CouponPostTest.php b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/CouponPostTest.php index b8f46feab0a48..1cf5006c20f73 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Cart/CouponPostTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Cart/CouponPostTest.php @@ -236,7 +236,7 @@ public function testExecuteWithGoodCouponAndItems() ->willReturn('CODE'); $this->messageManager->expects($this->once()) - ->method('addSuccess') + ->method('addSuccessMessage') ->willReturnSelf(); $this->objectManagerMock->expects($this->once()) @@ -290,7 +290,7 @@ public function testExecuteWithGoodCouponAndNoItems() ->willReturnSelf(); $this->messageManager->expects($this->once()) - ->method('addSuccess') + ->method('addSuccessMessage') ->willReturnSelf(); $this->objectManagerMock->expects($this->once()) @@ -344,7 +344,7 @@ public function testExecuteWithBadCouponAndItems() ->willReturnSelf(); $this->messageManager->expects($this->once()) - ->method('addSuccess') + ->method('addSuccessMessage') ->with('You canceled the coupon code.') ->willReturnSelf(); @@ -386,7 +386,7 @@ public function testExecuteWithBadCouponAndNoItems() ->willReturn($coupon); $this->messageManager->expects($this->once()) - ->method('addError') + ->method('addErrorMessage') ->willReturnSelf(); $this->objectManagerMock->expects($this->once()) diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php b/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php index 8d105f25465e4..04723c5894f8f 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Index/IndexTest.php @@ -1,14 +1,23 @@ objectManager = new ObjectManager($this); - $this->objectManagerMock = $this->basicMock(\Magento\Framework\ObjectManagerInterface::class); - $this->dataMock = $this->basicMock(\Magento\Checkout\Helper\Data::class); - $this->quoteMock = $this->createPartialMock( - \Magento\Quote\Model\Quote::class, + $this->objectManagerMock = $this->basicMock(ObjectManagerInterface::class); + $this->data = $this->basicMock(Data::class); + $this->quote = $this->createPartialMock( + Quote::class, ['getHasError', 'hasItems', 'validateMinimumAmount', 'hasError'] ); $this->contextMock = $this->basicMock(\Magento\Framework\App\Action\Context::class); - $this->sessionMock = $this->basicMock(\Magento\Customer\Model\Session::class); + $this->session = $this->basicMock(Session::class); $this->onepageMock = $this->basicMock(\Magento\Checkout\Model\Type\Onepage::class); $this->layoutMock = $this->basicMock(\Magento\Framework\View\Layout::class); - $this->requestMock = $this->basicMock(\Magento\Framework\App\RequestInterface::class); + $this->request = $this->getMockBuilder(Http::class) + ->disableOriginalConstructor() + ->setMethods(['isSecure', 'getHeader']) + ->getMock(); $this->responseMock = $this->basicMock(\Magento\Framework\App\ResponseInterface::class); $this->redirectMock = $this->basicMock(\Magento\Framework\App\Response\RedirectInterface::class); - $this->resultPageMock = $this->basicMock(\Magento\Framework\View\Result\Page::class); + $this->resultPage = $this->basicMock(Page::class); $this->pageConfigMock = $this->basicMock(\Magento\Framework\View\Page\Config::class); $this->titleMock = $this->basicMock(\Magento\Framework\View\Page\Title::class); $this->url = $this->createMock(\Magento\Framework\UrlInterface::class); @@ -130,7 +142,7 @@ protected function setUp() ->getMock(); $resultPageFactoryMock->expects($this->any()) ->method('create') - ->willReturn($this->resultPageMock); + ->willReturn($this->resultPage); $resultRedirectFactoryMock = $this->getMockBuilder(\Magento\Framework\Controller\Result\RedirectFactory::class) ->disableOriginalConstructor() @@ -141,21 +153,21 @@ protected function setUp() ->willReturn($this->resultRedirectMock); // stubs - $this->basicStub($this->onepageMock, 'getQuote')->willReturn($this->quoteMock); - $this->basicStub($this->resultPageMock, 'getLayout')->willReturn($this->layoutMock); + $this->basicStub($this->onepageMock, 'getQuote')->willReturn($this->quote); + $this->basicStub($this->resultPage, 'getLayout')->willReturn($this->layoutMock); $this->basicStub($this->layoutMock, 'getBlock') ->willReturn($this->basicMock(\Magento\Theme\Block\Html\Header::class)); - $this->basicStub($this->resultPageMock, 'getConfig')->willReturn($this->pageConfigMock); + $this->basicStub($this->resultPage, 'getConfig')->willReturn($this->pageConfigMock); $this->basicStub($this->pageConfigMock, 'getTitle')->willReturn($this->titleMock); $this->basicStub($this->titleMock, 'set')->willReturn($this->titleMock); // objectManagerMock $objectManagerReturns = [ - [\Magento\Checkout\Helper\Data::class, $this->dataMock], + [Data::class, $this->data], [\Magento\Checkout\Model\Type\Onepage::class, $this->onepageMock], [\Magento\Checkout\Model\Session::class, $this->basicMock(\Magento\Checkout\Model\Session::class)], - [\Magento\Customer\Model\Session::class, $this->basicMock(\Magento\Customer\Model\Session::class)], + [Session::class, $this->basicMock(Session::class)], ]; $this->objectManagerMock->expects($this->any()) @@ -165,7 +177,7 @@ protected function setUp() ->willReturn($this->basicMock(\Magento\Framework\UrlInterface::class)); // context stubs $this->basicStub($this->contextMock, 'getObjectManager')->willReturn($this->objectManagerMock); - $this->basicStub($this->contextMock, 'getRequest')->willReturn($this->requestMock); + $this->basicStub($this->contextMock, 'getRequest')->willReturn($this->request); $this->basicStub($this->contextMock, 'getResponse')->willReturn($this->responseMock); $this->basicStub($this->contextMock, 'getMessageManager') ->willReturn($this->basicMock(\Magento\Framework\Message\ManagerInterface::class)); @@ -175,33 +187,82 @@ protected function setUp() // SUT $this->model = $this->objectManager->getObject( - \Magento\Checkout\Controller\Index\Index::class, + Index::class, [ 'context' => $this->contextMock, - 'customerSession' => $this->sessionMock, + 'customerSession' => $this->session, 'resultPageFactory' => $resultPageFactoryMock, 'resultRedirectFactory' => $resultRedirectFactoryMock ] ); } - public function testRegenerateSessionIdOnExecute() + /** + * Checks a case when session should be or not regenerated during the request. + * + * @param bool $secure + * @param string $referer + * @param InvokedCount $expectedCall + * @dataProvider sessionRegenerationDataProvider + */ + public function testRegenerateSessionIdOnExecute(bool $secure, string $referer, InvokedCount $expectedCall) + { + $this->data->method('canOnepageCheckout') + ->willReturn(true); + $this->quote->method('hasItems') + ->willReturn(true); + $this->quote->method('getHasError') + ->willReturn(false); + $this->quote->method('validateMinimumAmount') + ->willReturn(true); + $this->session->method('isLoggedIn') + ->willReturn(true); + $this->request->method('isSecure') + ->willReturn($secure); + $this->request->method('getHeader') + ->with('referer') + ->willReturn($referer); + + $this->session->expects($expectedCall) + ->method('regenerateId'); + $this->assertSame($this->resultPage, $this->model->execute()); + } + + /** + * Gets list of variations for generating new session. + * + * @return array + */ + public function sessionRegenerationDataProvider(): array { - //Stubs to control execution flow - $this->basicStub($this->dataMock, 'canOnepageCheckout')->willReturn(true); - $this->basicStub($this->quoteMock, 'hasItems')->willReturn(true); - $this->basicStub($this->quoteMock, 'getHasError')->willReturn(false); - $this->basicStub($this->quoteMock, 'validateMinimumAmount')->willReturn(true); - $this->basicStub($this->sessionMock, 'isLoggedIn')->willReturn(true); - - //Expected outcomes - $this->sessionMock->expects($this->once())->method('regenerateId'); - $this->assertSame($this->resultPageMock, $this->model->execute()); + return [ + [ + 'secure' => false, + 'referer' => 'https://test.domain.com/', + 'expectedCall' => self::once() + ], + [ + 'secure' => true, + 'referer' => false, + 'expectedCall' => self::once() + ], + [ + 'secure' => true, + 'referer' => 'http://test.domain.com/', + 'expectedCall' => self::once() + ], + // This is the only case in which session regeneration can be skipped + [ + 'secure' => true, + 'referer' => 'https://test.domain.com/', + 'expectedCall' => self::never() + ], + ]; } public function testOnepageCheckoutNotAvailable() { - $this->basicStub($this->dataMock, 'canOnepageCheckout')->willReturn(false); + $this->basicStub($this->data, 'canOnepageCheckout')->willReturn(false); $expectedPath = 'checkout/cart'; $this->resultRedirectMock->expects($this->once()) @@ -214,7 +275,7 @@ public function testOnepageCheckoutNotAvailable() public function testInvalidQuote() { - $this->basicStub($this->quoteMock, 'hasError')->willReturn(true); + $this->basicStub($this->quote, 'hasError')->willReturn(true); $expectedPath = 'checkout/cart'; $this->resultRedirectMock->expects($this->once()) @@ -226,23 +287,22 @@ public function testInvalidQuote() } /** - * @param \PHPUnit_Framework_MockObject_MockObject $mock + * @param MockObject $mock * @param string $method * - * @return \PHPUnit\Framework\MockObject_Builder_InvocationMocker + * @return InvocationMocker */ - private function basicStub($mock, $method) + private function basicStub($mock, $method): InvocationMocker { - return $mock->expects($this->any()) - ->method($method) - ->withAnyParameters(); + return $mock->method($method) + ->withAnyParameters(); } /** * @param string $className - * @return \PHPUnit_Framework_MockObject_MockObject + * @return MockObject */ - private function basicMock($className) + private function basicMock(string $className): MockObject { return $this->getMockBuilder($className) ->disableOriginalConstructor() diff --git a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php index ba6bba6d6333d..da0de5d4f0a3d 100644 --- a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php @@ -6,6 +6,9 @@ namespace Magento\Checkout\Test\Unit\Model; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Adapter\AdapterInterface; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -46,6 +49,11 @@ class GuestPaymentInformationManagementTest extends \PHPUnit\Framework\TestCase */ private $loggerMock; + /** + * @var ResourceConnection|\PHPUnit_Framework_MockObject_MockObject + */ + private $resourceConnectionMock; + protected function setUp() { $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); @@ -63,6 +71,10 @@ protected function setUp() ['create'] ); $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + $this->resourceConnectionMock = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->model = $objectManager->getObject( \Magento\Checkout\Model\GuestPaymentInformationManagement::class, [ @@ -70,7 +82,8 @@ protected function setUp() 'paymentMethodManagement' => $this->paymentMethodManagementMock, 'cartManagement' => $this->cartManagementMock, 'cartRepository' => $this->cartRepositoryMock, - 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock + 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock, + 'connectionPool' => $this->resourceConnectionMock, ] ); $objectManager->setBackwardCompatibleProperty($this->model, 'logger', $this->loggerMock); @@ -86,6 +99,27 @@ public function testSavePaymentInformationAndPlaceOrder() $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('commit'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('commit'); + $this->billingAddressManagementMock->expects($this->once()) ->method('assign') ->with($cartId, $billingAddressMock); @@ -110,6 +144,27 @@ public function testSavePaymentInformationAndPlaceOrderException() $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('rollback'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('rollback'); + $this->billingAddressManagementMock->expects($this->once()) ->method('assign') ->with($cartId, $billingAddressMock); @@ -175,6 +230,27 @@ public function testSavePaymentInformationAndPlaceOrderWithLocalizedException() $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); + $adapterMockForSales = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $adapterMockForCheckout = $this->getMockBuilder(AdapterInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->resourceConnectionMock->expects($this->at(0)) + ->method('getConnection') + ->with('sales') + ->willReturn($adapterMockForSales); + $adapterMockForSales->expects($this->once())->method('beginTransaction'); + $adapterMockForSales->expects($this->once())->method('rollback'); + + $this->resourceConnectionMock->expects($this->at(1)) + ->method('getConnection') + ->with('checkout') + ->willReturn($adapterMockForCheckout); + $adapterMockForCheckout->expects($this->once())->method('beginTransaction'); + $adapterMockForCheckout->expects($this->once())->method('rollback'); + $this->billingAddressManagementMock->expects($this->once()) ->method('assign') ->with($cartId, $billingAddressMock); diff --git a/app/code/Magento/Checkout/Test/Unit/Observer/LoadCustomerQuoteObserverTest.php b/app/code/Magento/Checkout/Test/Unit/Observer/LoadCustomerQuoteObserverTest.php index ab207d0a67ec2..875bcda157ab3 100644 --- a/app/code/Magento/Checkout/Test/Unit/Observer/LoadCustomerQuoteObserverTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Observer/LoadCustomerQuoteObserverTest.php @@ -40,7 +40,7 @@ public function testLoadCustomerQuoteThrowingCoreException() $this->checkoutSession->expects($this->once())->method('loadCustomerQuote')->willThrowException( new \Magento\Framework\Exception\LocalizedException(__('Message')) ); - $this->messageManager->expects($this->once())->method('addError')->with('Message'); + $this->messageManager->expects($this->once())->method('addErrorMessage')->with('Message'); $observerMock = $this->getMockBuilder(\Magento\Framework\Event\Observer::class) ->disableOriginalConstructor() @@ -55,7 +55,7 @@ public function testLoadCustomerQuoteThrowingException() $this->checkoutSession->expects($this->once())->method('loadCustomerQuote')->will( $this->throwException($exception) ); - $this->messageManager->expects($this->once())->method('addException') + $this->messageManager->expects($this->once())->method('addExceptionMessage') ->with($exception, 'Load customer quote error'); $observerMock = $this->getMockBuilder(\Magento\Framework\Event\Observer::class) diff --git a/app/code/Magento/Checkout/composer.json b/app/code/Magento/Checkout/composer.json index 1fad0ab9986ea..5f695adc9f4b4 100644 --- a/app/code/Magento/Checkout/composer.json +++ b/app/code/Magento/Checkout/composer.json @@ -5,32 +5,31 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-msrp": "100.3.*", - "magento/module-page-cache": "100.3.*", - "magento/module-payment": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-sales-rule": "100.3.*", - "magento/module-shipping": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-msrp": "*", + "magento/module-page-cache": "*", + "magento/module-payment": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-sales-rule": "*", + "magento/module-shipping": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-theme": "*", + "magento/module-ui": "*" }, "suggest": { - "magento/module-cookie": "100.3.*" + "magento/module-cookie": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Checkout/etc/adminhtml/routes.xml b/app/code/Magento/Checkout/etc/adminhtml/routes.xml new file mode 100644 index 0000000000000..e537861059870 --- /dev/null +++ b/app/code/Magento/Checkout/etc/adminhtml/routes.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_index.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_index.xml index 01c687df4b775..ff4c6dbd35ff2 100644 --- a/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_index.xml +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_cart_index.xml @@ -145,21 +145,21 @@ - Magento_Checkout/js/view/summary/subtotal + Magento_Checkout/js/view/summary/subtotal Subtotal Magento_Checkout/cart/totals/subtotal - Magento_Checkout/js/view/cart/totals/shipping + Magento_Checkout/js/view/cart/totals/shipping Shipping Magento_Checkout/cart/totals/shipping - Magento_Checkout/js/view/summary/grand-total + Magento_Checkout/js/view/summary/grand-total Order Total Magento_Checkout/cart/totals/grand-total diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml index 6c562f0b9027b..d4fadedf5d7a0 100644 --- a/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml @@ -364,20 +364,20 @@ - Magento_Checkout/js/view/summary/subtotal + Magento_Checkout/js/view/summary/subtotal Cart Subtotal - Magento_Checkout/js/view/summary/shipping + Magento_Checkout/js/view/summary/shipping Shipping Not yet calculated - Magento_Checkout/js/view/summary/grand-total + Magento_Checkout/js/view/summary/grand-total Order Total diff --git a/app/code/Magento/Checkout/view/frontend/templates/cart/item/default.phtml b/app/code/Magento/Checkout/view/frontend/templates/cart/item/default.phtml index 02c969f849074..0567c61f0db60 100644 --- a/app/code/Magento/Checkout/view/frontend/templates/cart/item/default.phtml +++ b/app/code/Magento/Checkout/view/frontend/templates/cart/item/default.phtml @@ -90,7 +90,7 @@ $canApplyMsrp = $helper->isShowBeforeOrderConfirm($product) && $helper->isMinima
    getIsNeedToDisplaySideBar()): ?> -
    0); sidebarInitialized = false; this.update(updatedCart); + + if (cartData()['website_id'] !== window.checkout.websiteId) { + customerData.reload(['cart'], false); + } initSidebar(); }, this); $('[data-block="minicart"]').on('contentLoading', function () { @@ -100,10 +105,6 @@ define([ self.isLoading(true); }); - if (cartData()['website_id'] !== window.checkout.websiteId) { - customerData.reload(['cart'], false); - } - return this._super(); }, isLoading: ko.observable(false), diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js b/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js index 0cbc16ef72bc3..72cf4e3d479c3 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/progress-bar.js @@ -8,8 +8,7 @@ define([ 'underscore', 'ko', 'uiComponent', - 'Magento_Checkout/js/model/step-navigator', - 'jquery/jquery.hashchange' + 'Magento_Checkout/js/model/step-navigator' ], function ($, _, ko, Component, stepNavigator) { 'use strict'; @@ -25,7 +24,7 @@ define([ /** @inheritdoc */ initialize: function () { this._super(); - $(window).hashchange(_.bind(stepNavigator.handleHash, stepNavigator)); + window.addEventListener('hashchange', _.bind(stepNavigator.handleHash, stepNavigator)); stepNavigator.handleHash(); }, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js b/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js index c715b5c4d45ce..a7b3e18c06088 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/registration.js @@ -38,7 +38,16 @@ define([ }, /** - * Create new user account + * @return String + */ + getUrl: function () { + return this.registrationUrl; + }, + + /** + * Create new user account. + * + * @deprecated */ createAccount: function () { this.creationStarted(true); diff --git a/app/code/Magento/Checkout/view/frontend/web/template/minicart/item/default.html b/app/code/Magento/Checkout/view/frontend/web/template/minicart/item/default.html index 8d32adb75308f..41d442a76d510 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/minicart/item/default.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/minicart/item/default.html @@ -24,7 +24,7 @@
    - + @@ -45,7 +45,7 @@ - + diff --git a/app/code/Magento/Checkout/view/frontend/web/template/registration.html b/app/code/Magento/Checkout/view/frontend/web/template/registration.html index 256fc1968abfc..ea94726e5443e 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/registration.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/registration.html @@ -11,11 +11,8 @@

    :

    - - + + - - -

    - +
    diff --git a/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details.html b/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details.html index daa37cbe2dc89..94f94ec878151 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details.html @@ -35,7 +35,7 @@
    -
    +
    diff --git a/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php b/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php index ed9ecc642e16b..4a35a58a41ff9 100644 --- a/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php +++ b/app/code/Magento/CheckoutAgreements/Block/Adminhtml/Agreement/Grid.php @@ -5,27 +5,42 @@ */ namespace Magento\CheckoutAgreements\Block\Adminhtml\Agreement; +use Magento\Framework\App\ObjectManager; +use Magento\CheckoutAgreements\Model\ResourceModel\Agreement\Grid\CollectionFactory as GridCollectionFactory; + class Grid extends \Magento\Backend\Block\Widget\Grid\Extended { /** * @var \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory + * @deprecated */ protected $_collectionFactory; + /** + * @param GridCollectionFactory + */ + private $gridCollectionFactory; + /** * @param \Magento\Backend\Block\Template\Context $context * @param \Magento\Backend\Helper\Data $backendHelper * @param \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory $collectionFactory * @param array $data + * @param GridCollectionFactory $gridColFactory * @codeCoverageIgnore */ public function __construct( \Magento\Backend\Block\Template\Context $context, \Magento\Backend\Helper\Data $backendHelper, \Magento\CheckoutAgreements\Model\ResourceModel\Agreement\CollectionFactory $collectionFactory, - array $data = [] + array $data = [], + GridCollectionFactory $gridColFactory = null ) { + $this->_collectionFactory = $collectionFactory; + $this->gridCollectionFactory = $gridColFactory + ? : ObjectManager::getInstance()->get(GridCollectionFactory::class); + parent::__construct($context, $backendHelper, $data); } @@ -47,7 +62,7 @@ protected function _construct() */ protected function _prepareCollection() { - $this->setCollection($this->_collectionFactory->create()); + $this->setCollection($this->gridCollectionFactory->create()); return parent::_prepareCollection(); } diff --git a/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php b/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php new file mode 100644 index 0000000000000..70794d24a64eb --- /dev/null +++ b/app/code/Magento/CheckoutAgreements/Model/ResourceModel/Agreement/Grid/Collection.php @@ -0,0 +1,78 @@ +isLoaded()) { + return $this; + } + + parent::load($printQuery, $logQuery); + + $this->addStoresToResult(); + + return $this; + } + + /** + * @return void + */ + private function addStoresToResult() + { + $stores = $this->getStoresForAgreements(); + + if (!empty($stores)) { + $storesByAgreementId = []; + + foreach ($stores as $storeData) { + $storesByAgreementId[$storeData['agreement_id']][] = $storeData['store_id']; + } + + foreach ($this as $item) { + $agreementId = $item->getData('agreement_id'); + + if (!isset($storesByAgreementId[$agreementId])) { + continue; + } + + $item->setData('stores', $storesByAgreementId[$agreementId]); + } + } + } + + /** + * @return array + */ + private function getStoresForAgreements() + { + $agreementId = $this->getColumnValues('agreement_id'); + + if (!empty($agreementId)) { + $select = $this->getConnection()->select()->from( + ['agreement_store' => 'checkout_agreement_store'] + )->where( + 'agreement_store.agreement_id IN (?)', + $agreementId + ); + + return $this->getConnection()->fetchAll($select); + } + + return []; + } +} diff --git a/app/code/Magento/CheckoutAgreements/composer.json b/app/code/Magento/CheckoutAgreements/composer.json index 74df969951219..7408bf5cab3fe 100644 --- a/app/code/Magento/CheckoutAgreements/composer.json +++ b/app/code/Magento/CheckoutAgreements/composer.json @@ -5,15 +5,14 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-checkout": "*", + "magento/module-quote": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml b/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml index 1249ea44b991e..5a708f49a7034 100644 --- a/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml +++ b/app/code/Magento/CheckoutAgreements/etc/adminhtml/routes.xml @@ -7,7 +7,7 @@ --> - + diff --git a/app/code/Magento/CheckoutAgreements/etc/db_schema.xml b/app/code/Magento/CheckoutAgreements/etc/db_schema.xml index 41bd8dffb4b34..31b3111df98eb 100644 --- a/app/code/Magento/CheckoutAgreements/etc/db_schema.xml +++ b/app/code/Magento/CheckoutAgreements/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/ResetButton.php b/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/ResetButton.php deleted file mode 100644 index acbfd3e1a7aa1..0000000000000 --- a/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/ResetButton.php +++ /dev/null @@ -1,27 +0,0 @@ - __('Reset'), - 'class' => 'reset', - 'on_click' => 'location.reload();', - 'sort_order' => 30 - ]; - } -} diff --git a/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/SaveAndContinueButton.php b/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/SaveAndContinueButton.php deleted file mode 100644 index eeae1ee18663e..0000000000000 --- a/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/SaveAndContinueButton.php +++ /dev/null @@ -1,31 +0,0 @@ - __('Save and Continue Edit'), - 'class' => 'save', - 'data_attribute' => [ - 'mage-init' => [ - 'button' => ['event' => 'saveAndContinueEdit'], - ], - ], - 'sort_order' => 80, - ]; - } -} diff --git a/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/SaveButton.php b/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/SaveButton.php index f64f54ef5918c..ac2b9652c9f78 100644 --- a/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/SaveButton.php +++ b/app/code/Magento/Cms/Block/Adminhtml/Block/Edit/SaveButton.php @@ -6,6 +6,7 @@ namespace Magento\Cms\Block\Adminhtml\Block\Edit; use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; +use Magento\Ui\Component\Control\Container; /** * Class SaveButton @@ -19,13 +20,85 @@ class SaveButton extends GenericButton implements ButtonProviderInterface public function getButtonData() { return [ - 'label' => __('Save Block'), + 'label' => __('Save'), 'class' => 'save primary', 'data_attribute' => [ - 'mage-init' => ['button' => ['event' => 'save']], - 'form-role' => 'save', + 'mage-init' => [ + 'buttonAdapter' => [ + 'actions' => [ + [ + 'targetName' => 'cms_block_form.cms_block_form', + 'actionName' => 'save', + 'params' => [ + true, + [ + 'back' => 'continue' + ] + ] + ] + ] + ] + ] ], - 'sort_order' => 90, + 'class_name' => Container::SPLIT_BUTTON, + 'options' => $this->getOptions(), ]; } + + /** + * Retrieve options + * + * @return array + */ + private function getOptions() + { + $options = [ + [ + 'label' => __('Save & Duplicate'), + 'id_hard' => 'save_and_duplicate', + 'data_attribute' => [ + 'mage-init' => [ + 'buttonAdapter' => [ + 'actions' => [ + [ + 'targetName' => 'cms_block_form.cms_block_form', + 'actionName' => 'save', + 'params' => [ + true, + [ + 'back' => 'duplicate' + ] + ] + ] + ] + ] + ] + ] + ], + [ + 'id_hard' => 'save_and_close', + 'label' => __('Save & Close'), + 'data_attribute' => [ + 'mage-init' => [ + 'buttonAdapter' => [ + 'actions' => [ + [ + 'targetName' => 'cms_block_form.cms_block_form', + 'actionName' => 'save', + 'params' => [ + true, + [ + 'back' => 'close' + ] + ] + ] + ] + ] + ] + ] + ] + ]; + + return $options; + } } diff --git a/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/ResetButton.php b/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/ResetButton.php deleted file mode 100644 index 5be536e2da39b..0000000000000 --- a/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/ResetButton.php +++ /dev/null @@ -1,27 +0,0 @@ - __('Reset'), - 'class' => 'reset', - 'on_click' => 'location.reload();', - 'sort_order' => 30 - ]; - } -} diff --git a/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/SaveAndContinueButton.php b/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/SaveAndContinueButton.php deleted file mode 100644 index b767b299c41d3..0000000000000 --- a/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/SaveAndContinueButton.php +++ /dev/null @@ -1,31 +0,0 @@ - __('Save and Continue Edit'), - 'class' => 'save', - 'data_attribute' => [ - 'mage-init' => [ - 'button' => ['event' => 'saveAndContinueEdit'], - ], - ], - 'sort_order' => 80, - ]; - } -} diff --git a/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/SaveButton.php b/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/SaveButton.php index 5631e6c838fb2..676cdd3918470 100644 --- a/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/SaveButton.php +++ b/app/code/Magento/Cms/Block/Adminhtml/Page/Edit/SaveButton.php @@ -6,6 +6,7 @@ namespace Magento\Cms\Block\Adminhtml\Page\Edit; use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; +use Magento\Ui\Component\Control\Container; /** * Class SaveButton @@ -19,13 +20,80 @@ class SaveButton extends GenericButton implements ButtonProviderInterface public function getButtonData() { return [ - 'label' => __('Save Page'), + 'label' => __('Save'), 'class' => 'save primary', 'data_attribute' => [ - 'mage-init' => ['button' => ['event' => 'save']], - 'form-role' => 'save', + 'mage-init' => [ + 'buttonAdapter' => [ + 'actions' => [ + [ + 'targetName' => 'cms_page_form.cms_page_form', + 'actionName' => 'save', + 'params' => [ + false + ] + ] + ] + ] + ] ], + 'class_name' => Container::SPLIT_BUTTON, + 'options' => $this->getOptions(), 'sort_order' => 90, ]; } + + /** + * Retrieve options + * + * @return array + */ + private function getOptions() + { + $options = [ + [ + 'label' => __('Save & Duplicate'), + 'id_hard' => 'save_and_duplicate', + 'data_attribute' => [ + 'mage-init' => [ + 'buttonAdapter' => [ + 'actions' => [ + [ + 'targetName' => 'cms_page_form.cms_page_form', + 'actionName' => 'save', + 'params' => [ + true, + [ + 'back' => 'duplicate' + ] + ] + ] + ] + ] + ] + ], + ], + [ + 'id_hard' => 'save_and_close', + 'label' => __('Save & Close'), + 'data_attribute' => [ + 'mage-init' => [ + 'buttonAdapter' => [ + 'actions' => [ + [ + 'targetName' => 'cms_page_form.cms_page_form', + 'actionName' => 'save', + 'params' => [ + true + ] + ] + ] + ] + ] + ], + ] + ]; + + return $options; + } } diff --git a/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Content.php b/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Content.php index 44c2f3ca2f8c4..89f6be0525663 100644 --- a/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Content.php +++ b/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Content.php @@ -94,7 +94,6 @@ public function getContentsUrl() { return $this->getUrl('cms/*/contents', [ 'type' => $this->getRequest()->getParam('type'), - 'use_storage_root' => (int) $this->getRequest()->getParam('use_storage_root'), ]); } @@ -143,9 +142,7 @@ public function getNewfolderUrl() */ protected function getDeletefolderUrl() { - return $this->getUrl('cms/*/deleteFolder', [ - 'use_storage_root' => (int) $this->getRequest()->getParam('use_storage_root'), - ]); + return $this->getUrl('cms/*/deleteFolder'); } /** @@ -165,9 +162,7 @@ public function getDeleteFilesUrl() */ public function getOnInsertUrl() { - return $this->getUrl('cms/*/onInsert', [ - 'use_storage_root' => (int) $this->getRequest()->getParam('use_storage_root'), - ]); + return $this->getUrl('cms/*/onInsert'); } /** diff --git a/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Tree.php b/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Tree.php index 7bed7cee308f5..41e9358e160cf 100644 --- a/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Tree.php +++ b/app/code/Magento/Cms/Block/Adminhtml/Wysiwyg/Images/Tree.php @@ -95,9 +95,17 @@ public function getTreeJson() */ public function getTreeLoaderUrl() { + $params = []; + + $currentTreePath = $this->getRequest()->getParam('current_tree_path'); + + if (strlen($currentTreePath)) { + $params['current_tree_path'] = $currentTreePath; + } + return $this->getUrl( 'cms/*/treeJson', - ['use_storage_root' => (int) $this->getRequest()->getParam('use_storage_root')] + $params ); } @@ -119,7 +127,14 @@ public function getRootNodeName() public function getTreeCurrentPath() { $treePath = ['root']; - if ($path = $this->_coreRegistry->registry('storage')->getSession()->getCurrentPath()) { + + if ($idEncodedPath = $this->getRequest()->getParam('current_tree_path')) { + $path = $this->_cmsWysiwygImages->idDecode($idEncodedPath); + } else { + $path = $this->_coreRegistry->registry('storage')->getSession()->getCurrentPath(); + } + + if (strlen($path)) { $path = str_replace($this->_cmsWysiwygImages->getStorageRoot(), '', $path); $relative = []; foreach (explode('/', $path) as $dirName) { @@ -129,6 +144,7 @@ public function getTreeCurrentPath() } } } + return $treePath; } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Block/Save.php b/app/code/Magento/Cms/Controller/Adminhtml/Block/Save.php index 3eb790c83ad69..40974b7a4b5c1 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Block/Save.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Block/Save.php @@ -7,10 +7,12 @@ namespace Magento\Cms\Controller\Adminhtml\Block; use Magento\Backend\App\Action\Context; +use Magento\Cms\Api\BlockRepositoryInterface; use Magento\Cms\Model\Block; +use Magento\Cms\Model\BlockFactory; use Magento\Framework\App\Request\DataPersistorInterface; use Magento\Framework\Exception\LocalizedException; -use Magento\TestFramework\Inspection\Exception; +use Magento\Framework\Registry; class Save extends \Magento\Cms\Controller\Adminhtml\Block { @@ -19,17 +21,35 @@ class Save extends \Magento\Cms\Controller\Adminhtml\Block */ protected $dataPersistor; + /** + * @var BlockFactory + */ + private $blockFactory; + + /** + * @var BlockRepositoryInterface + */ + private $blockRepository; + /** * @param Context $context - * @param \Magento\Framework\Registry $coreRegistry + * @param Registry $coreRegistry * @param DataPersistorInterface $dataPersistor + * @param BlockFactory|null $blockFactory + * @param BlockRepositoryInterface|null $blockRepository */ public function __construct( Context $context, - \Magento\Framework\Registry $coreRegistry, - DataPersistorInterface $dataPersistor + Registry $coreRegistry, + DataPersistorInterface $dataPersistor, + BlockFactory $blockFactory = null, + BlockRepositoryInterface $blockRepository = null ) { $this->dataPersistor = $dataPersistor; + $this->blockFactory = $blockFactory + ?: \Magento\Framework\App\ObjectManager::getInstance()->get(BlockFactory::class); + $this->blockRepository = $blockRepository + ?: \Magento\Framework\App\ObjectManager::getInstance()->get(BlockRepositoryInterface::class); parent::__construct($context, $coreRegistry); } @@ -45,8 +65,6 @@ public function execute() $resultRedirect = $this->resultRedirectFactory->create(); $data = $this->getRequest()->getPostValue(); if ($data) { - $id = $this->getRequest()->getParam('block_id'); - if (isset($data['is_active']) && $data['is_active'] === 'true') { $data['is_active'] = Block::STATUS_ENABLED; } @@ -55,32 +73,64 @@ public function execute() } /** @var \Magento\Cms\Model\Block $model */ - $model = $this->_objectManager->create(\Magento\Cms\Model\Block::class)->load($id); - if (!$model->getId() && $id) { - $this->messageManager->addError(__('This block no longer exists.')); - return $resultRedirect->setPath('*/*/'); + $model = $this->blockFactory->create(); + + $id = $this->getRequest()->getParam('block_id'); + if ($id) { + try { + $model = $this->blockRepository->getById($id); + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage(__('This block no longer exists.')); + return $resultRedirect->setPath('*/*/'); + } } $model->setData($data); try { - $model->save(); - $this->messageManager->addSuccess(__('You saved the block.')); + $this->blockRepository->save($model); + $this->messageManager->addSuccessMessage(__('You saved the block.')); $this->dataPersistor->clear('cms_block'); - - if ($this->getRequest()->getParam('back')) { - return $resultRedirect->setPath('*/*/edit', ['block_id' => $model->getId()]); - } - return $resultRedirect->setPath('*/*/'); + return $this->processBlockReturn($model, $data, $resultRedirect); } catch (LocalizedException $e) { - $this->messageManager->addError($e->getMessage()); + $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { - $this->messageManager->addException($e, __('Something went wrong while saving the block.')); + $this->messageManager->addExceptionMessage($e, __('Something went wrong while saving the block.')); } $this->dataPersistor->set('cms_block', $data); - return $resultRedirect->setPath('*/*/edit', ['block_id' => $this->getRequest()->getParam('block_id')]); + return $resultRedirect->setPath('*/*/edit', ['block_id' => $id]); } return $resultRedirect->setPath('*/*/'); } + + /** + * Process and set the block return + * + * @param \Magento\Cms\Model\Block $model + * @param array $data + * @param \Magento\Framework\Controller\ResultInterface $resultRedirect + * @return \Magento\Framework\Controller\ResultInterface + */ + private function processBlockReturn($model, $data, $resultRedirect) + { + $redirect = $data['back'] ?? 'close'; + + if ($redirect ==='continue') { + $resultRedirect->setPath('*/*/edit', ['block_id' => $model->getId()]); + } else if ($redirect === 'close') { + $resultRedirect->setPath('*/*/'); + } else if ($redirect === 'duplicate') { + $duplicateModel = $this->blockFactory->create(['data' => $data]); + $duplicateModel->setId(null); + $duplicateModel->setIdentifier($data['identifier'] . '-' . uniqid()); + $duplicateModel->setIsActive(Block::STATUS_DISABLED); + $this->blockRepository->save($duplicateModel); + $id = $duplicateModel->getId(); + $this->messageManager->addSuccessMessage(__('You duplicated the block.')); + $this->dataPersistor->set('cms_block', $data); + $resultRedirect->setPath('*/*/edit', ['block_id' => $id]); + } + return $resultRedirect; + } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Page/Save.php b/app/code/Magento/Cms/Controller/Adminhtml/Page/Save.php index 5644e25dd4c4a..ef4fda60c0f81 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Page/Save.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Page/Save.php @@ -44,9 +44,8 @@ class Save extends \Magento\Backend\App\Action * @param Action\Context $context * @param PostDataProcessor $dataProcessor * @param DataPersistorInterface $dataPersistor - * @param \Magento\Cms\Model\PageFactory $pageFactory - * @param \Magento\Cms\Api\PageRepositoryInterface $pageRepository - * + * @param \Magento\Cms\Model\PageFactory|null $pageFactory + * @param \Magento\Cms\Api\PageRepositoryInterface|null $pageRepository */ public function __construct( Action\Context $context, @@ -90,11 +89,10 @@ public function execute() $id = $this->getRequest()->getParam('page_id'); if ($id) { - $model->load($id); - if (!$model->getId()) { + try { + $model = $this->pageRepository->getById($id); + } catch (LocalizedException $e) { $this->messageManager->addErrorMessage(__('This page no longer exists.')); - /** \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ - $resultRedirect = $this->resultRedirectFactory->create(); return $resultRedirect->setPath('*/*/'); } } @@ -113,13 +111,9 @@ public function execute() try { $this->pageRepository->save($model); $this->messageManager->addSuccessMessage(__('You saved the page.')); - $this->dataPersistor->clear('cms_page'); - if ($this->getRequest()->getParam('back')) { - return $resultRedirect->setPath('*/*/edit', ['page_id' => $model->getId(), '_current' => true]); - } - return $resultRedirect->setPath('*/*/'); + return $this->processResultRedirect($model, $resultRedirect, $data); } catch (LocalizedException $e) { - $this->messageManager->addExceptionMessage($e->getPrevious() ?:$e); + $this->messageManager->addExceptionMessage($e->getPrevious() ?: $e); } catch (\Exception $e) { $this->messageManager->addExceptionMessage($e, __('Something went wrong while saving the page.')); } @@ -129,4 +123,38 @@ public function execute() } return $resultRedirect->setPath('*/*/'); } + + /** + * Process result redirect + * + * @param \Magento\Cms\Api\Data\PageInterface $model + * @param \Magento\Backend\Model\View\Result\Redirect $resultRedirect + * @param array $data + * @return \Magento\Backend\Model\View\Result\Redirect + * @throws LocalizedException + */ + private function processResultRedirect($model, $resultRedirect, $data) + { + if ($this->getRequest()->getParam('back', false) === 'duplicate') { + $newPage = $this->pageFactory->create(['data' => $data]); + $newPage->setId(null); + $identifier = $model->getIdentifier() . '-' . uniqid(); + $newPage->setIdentifier($identifier); + $newPage->setIsActive(false); + $this->pageRepository->save($newPage); + $this->messageManager->addSuccessMessage(__('You duplicated the page.')); + return $resultRedirect->setPath( + '*/*/edit', + [ + 'page_id' => $newPage->getId(), + '_current' => true + ] + ); + } + $this->dataPersistor->clear('cms_page'); + if ($this->getRequest()->getParam('back')) { + return $resultRedirect->setPath('*/*/edit', ['page_id' => $model->getId(), '_current' => true]); + } + return $resultRedirect->setPath('*/*/'); + } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php index 19dc989620b89..890c9bf5eae52 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php @@ -7,6 +7,9 @@ use Magento\Framework\App\Filesystem\DirectoryList; +/** + * Delete image files. + */ class DeleteFiles extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images { /** @@ -19,6 +22,11 @@ class DeleteFiles extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images */ protected $resultRawFactory; + /** + * @var \Magento\Framework\App\Filesystem\DirectoryResolver + */ + private $directoryResolver; + /** * Constructor * @@ -26,22 +34,28 @@ class DeleteFiles extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory * @param \Magento\Framework\Controller\Result\RawFactory $resultRawFactory + * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, - \Magento\Framework\Controller\Result\RawFactory $resultRawFactory + \Magento\Framework\Controller\Result\RawFactory $resultRawFactory, + \Magento\Framework\App\Filesystem\DirectoryResolver $directoryResolver = null ) { + parent::__construct($context, $coreRegistry); + $this->resultRawFactory = $resultRawFactory; $this->resultJsonFactory = $resultJsonFactory; - parent::__construct($context, $coreRegistry); + $this->directoryResolver = $directoryResolver + ?: $this->_objectManager->get(\Magento\Framework\App\Filesystem\DirectoryResolver::class); } /** - * Delete file from media storage + * Delete file from media storage. * * @return \Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { @@ -54,6 +68,11 @@ public function execute() /** @var $helper \Magento\Cms\Helper\Wysiwyg\Images */ $helper = $this->_objectManager->get(\Magento\Cms\Helper\Wysiwyg\Images::class); $path = $this->getStorage()->getSession()->getCurrentPath(); + if (!$this->directoryResolver->validatePath($path, DirectoryList::MEDIA)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Directory %1 is not under storage root path.', $path) + ); + } foreach ($files as $file) { $file = $helper->idDecode($file); /** @var \Magento\Framework\Filesystem $filesystem */ @@ -64,11 +83,13 @@ public function execute() $this->getStorage()->deleteFile($filePath); } } + return $this->resultRawFactory->create(); } catch (\Exception $e) { $result = ['error' => true, 'message' => $e->getMessage()]; /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFolder.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFolder.php index 8a89de87a6f85..a1de11c3c462e 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFolder.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFolder.php @@ -6,6 +6,11 @@ */ namespace Magento\Cms\Controller\Adminhtml\Wysiwyg\Images; +use Magento\Framework\App\Filesystem\DirectoryList; + +/** + * Delete image folder. + */ class DeleteFolder extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images { /** @@ -18,38 +23,55 @@ class DeleteFolder extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images */ protected $resultRawFactory; + /** + * @var \Magento\Framework\App\Filesystem\DirectoryResolver + */ + private $directoryResolver; + /** * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory * @param \Magento\Framework\Controller\Result\RawFactory $resultRawFactory + * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, - \Magento\Framework\Controller\Result\RawFactory $resultRawFactory + \Magento\Framework\Controller\Result\RawFactory $resultRawFactory, + \Magento\Framework\App\Filesystem\DirectoryResolver $directoryResolver = null ) { + parent::__construct($context, $coreRegistry); $this->resultRawFactory = $resultRawFactory; $this->resultJsonFactory = $resultJsonFactory; - parent::__construct($context, $coreRegistry); + $this->directoryResolver = $directoryResolver + ?: $this->_objectManager->get(\Magento\Framework\App\Filesystem\DirectoryResolver::class); } /** - * Delete folder action + * Delete folder action. * * @return \Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { try { $path = $this->getStorage()->getCmsWysiwygImages()->getCurrentPath(); + if (!$this->directoryResolver->validatePath($path, DirectoryList::MEDIA)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Directory %1 is not under storage root path.', $path) + ); + } $this->getStorage()->deleteDirectory($path); + return $this->resultRawFactory->create(); } catch (\Exception $e) { $result = ['error' => true, 'message' => $e->getMessage()]; /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/NewFolder.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/NewFolder.php index 2124bdabe6009..a7f49e8a431a4 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/NewFolder.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/NewFolder.php @@ -6,6 +6,11 @@ */ namespace Magento\Cms\Controller\Adminhtml\Wysiwyg\Images; +use Magento\Framework\App\Filesystem\DirectoryList; + +/** + * Creates new folder. + */ class NewFolder extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images { /** @@ -13,24 +18,34 @@ class NewFolder extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images */ protected $resultJsonFactory; + /** + * @var \Magento\Framework\App\Filesystem\DirectoryResolver + */ + private $directoryResolver; + /** * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, - \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, + \Magento\Framework\App\Filesystem\DirectoryResolver $directoryResolver = null ) { - $this->resultJsonFactory = $resultJsonFactory; parent::__construct($context, $coreRegistry); + $this->resultJsonFactory = $resultJsonFactory; + $this->directoryResolver = $directoryResolver + ?: $this->_objectManager->get(\Magento\Framework\App\Filesystem\DirectoryResolver::class); } /** - * New folder action + * New folder action. * * @return \Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { @@ -38,12 +53,18 @@ public function execute() $this->_initAction(); $name = $this->getRequest()->getPost('name'); $path = $this->getStorage()->getSession()->getCurrentPath(); + if (!$this->directoryResolver->validatePath($path, DirectoryList::MEDIA)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Directory %1 is not under storage root path.', $path) + ); + } $result = $this->getStorage()->createDirectory($name, $path); } catch (\Exception $e) { $result = ['error' => true, 'message' => $e->getMessage()]; } /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/Upload.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/Upload.php index 7a94c4ab6aa12..5c9aa2243bc6d 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/Upload.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/Upload.php @@ -6,6 +6,11 @@ */ namespace Magento\Cms\Controller\Adminhtml\Wysiwyg\Images; +use Magento\Framework\App\Filesystem\DirectoryList; + +/** + * Upload image. + */ class Upload extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images { /** @@ -13,36 +18,52 @@ class Upload extends \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images */ protected $resultJsonFactory; + /** + * @var \Magento\Framework\App\Filesystem\DirectoryResolver + */ + private $directoryResolver; + /** * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Framework\Registry $coreRegistry * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\Registry $coreRegistry, - \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, + \Magento\Framework\App\Filesystem\DirectoryResolver $directoryResolver = null ) { - $this->resultJsonFactory = $resultJsonFactory; parent::__construct($context, $coreRegistry); + $this->resultJsonFactory = $resultJsonFactory; + $this->directoryResolver = $directoryResolver + ?: $this->_objectManager->get(\Magento\Framework\App\Filesystem\DirectoryResolver::class); } /** - * Files upload processing + * Files upload processing. * * @return \Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { try { $this->_initAction(); - $targetPath = $this->getStorage()->getSession()->getCurrentPath(); - $result = $this->getStorage()->uploadFile($targetPath, $this->getRequest()->getParam('type')); + $path = $this->getStorage()->getSession()->getCurrentPath(); + if (!$this->directoryResolver->validatePath($path, DirectoryList::MEDIA)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Directory %1 is not under storage root path.', $path) + ); + } + $result = $this->getStorage()->uploadFile($path, $this->getRequest()->getParam('type')); } catch (\Exception $e) { $result = ['error' => $e->getMessage(), 'errorcode' => $e->getCode()]; } /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Cms/Helper/Wysiwyg/Images.php b/app/code/Magento/Cms/Helper/Wysiwyg/Images.php index c8bab43fe8a5d..cd3473c6bab87 100644 --- a/app/code/Magento/Cms/Helper/Wysiwyg/Images.php +++ b/app/code/Magento/Cms/Helper/Wysiwyg/Images.php @@ -8,7 +8,7 @@ use Magento\Framework\App\Filesystem\DirectoryList; /** - * Wysiwyg Images Helper + * Wysiwyg Images Helper. */ class Images extends \Magento\Framework\App\Helper\AbstractHelper { @@ -118,9 +118,7 @@ public function getStorageRoot() */ public function getStorageRootSubpath() { - return $this->_getRequest()->getParam('use_storage_root') - ? '' - : \Magento\Cms\Model\Wysiwyg\Config::IMAGE_DIRECTORY; + return ''; } /** @@ -156,17 +154,23 @@ public function convertPathToId($path) } /** - * Decode HTML element id + * Decode HTML element id. * * @param string $id * @return string + * @throws \InvalidArgumentException When path contains restricted symbols. */ public function convertIdToPath($id) { if ($id === \Magento\Theme\Helper\Storage::NODE_ROOT) { return $this->getStorageRoot(); } else { - return $this->getStorageRoot() . $this->idDecode($id); + $path = $this->getStorageRoot() . $this->idDecode($id); + if (preg_match('/\.\.(\\\|\/)/', $path)) { + throw new \InvalidArgumentException('Path is invalid'); + } + + return $path; } } @@ -207,7 +211,13 @@ public function getImageHtmlDeclaration($filename, $renderAsTag = false) $html = $fileUrl; } else { $directive = $this->urlEncoder->encode($directive); - $html = $this->_backendData->getUrl('cms/wysiwyg/directive', ['___directive' => $directive]); + $html = $this->_backendData->getUrl( + 'cms/wysiwyg/directive', + [ + '___directive' => $directive, + '_escape_params' => false, + ] + ); } } return $html; diff --git a/app/code/Magento/Cms/Model/PageRepository.php b/app/code/Magento/Cms/Model/PageRepository.php index 65b23bce7e94c..5578ae49f586d 100644 --- a/app/code/Magento/Cms/Model/PageRepository.php +++ b/app/code/Magento/Cms/Model/PageRepository.php @@ -111,7 +111,7 @@ public function __construct( */ public function save(\Magento\Cms\Api\Data\PageInterface $page) { - if (empty($page->getStoreId())) { + if ($page->getStoreId() === null) { $storeId = $this->storeManager->getStore()->getId(); $page->setStoreId($storeId); } diff --git a/app/code/Magento/Cms/Model/ResourceModel/Block/Grid/Collection.php b/app/code/Magento/Cms/Model/ResourceModel/Block/Grid/Collection.php index f45d9ee223106..60e87afc61884 100644 --- a/app/code/Magento/Cms/Model/ResourceModel/Block/Grid/Collection.php +++ b/app/code/Magento/Cms/Model/ResourceModel/Block/Grid/Collection.php @@ -6,7 +6,7 @@ namespace Magento\Cms\Model\ResourceModel\Block\Grid; use Magento\Framework\Api\Search\SearchResultInterface; -use Magento\Framework\Search\AggregationInterface; +use Magento\Framework\Api\Search\AggregationInterface; use Magento\Cms\Model\ResourceModel\Block\Collection as BlockCollection; /** @@ -82,6 +82,7 @@ public function getAggregations() public function setAggregations($aggregations) { $this->aggregations = $aggregations; + return $this; } /** diff --git a/app/code/Magento/Cms/Model/ResourceModel/Page/Grid/Collection.php b/app/code/Magento/Cms/Model/ResourceModel/Page/Grid/Collection.php index c5c43c3120dcc..19f945e5b4637 100644 --- a/app/code/Magento/Cms/Model/ResourceModel/Page/Grid/Collection.php +++ b/app/code/Magento/Cms/Model/ResourceModel/Page/Grid/Collection.php @@ -83,6 +83,7 @@ public function getAggregations() public function setAggregations($aggregations) { $this->aggregations = $aggregations; + return $this; } /** diff --git a/app/code/Magento/Cms/Model/Wysiwyg/ConfigProviderFactory.php b/app/code/Magento/Cms/Model/Wysiwyg/ConfigProviderFactory.php index 01faec48e2a78..d07f90f43adad 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/ConfigProviderFactory.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/ConfigProviderFactory.php @@ -3,8 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Cms\Model\Wysiwyg; +use Magento\Framework\Data\Wysiwyg\ConfigProviderInterface as WysiwygConfigInterface; + /** * Class ConfigProviderFactory to create config provider object by class name */ @@ -15,7 +20,7 @@ class ConfigProviderFactory * * @var \Magento\Framework\ObjectManagerInterface */ - protected $objectManager; + private $objectManager; /** * @param \Magento\Framework\ObjectManagerInterface $objectManager @@ -31,22 +36,9 @@ public function __construct(\Magento\Framework\ObjectManagerInterface $objectMan * @param string $instance * @param array $arguments * @return \Magento\Framework\Data\Wysiwyg\ConfigProviderInterface - * @throws \InvalidArgumentException */ - public function create($instance, array $arguments = []) + public function create(string $instance, array $arguments = []): WysiwygConfigInterface { - if (!is_subclass_of( - $instance, - \Magento\Framework\Data\Wysiwyg\ConfigProviderInterface::class - ) - ) { - throw new \InvalidArgumentException( - $instance . - ' does not implement ' . - \Magento\Framework\Data\Wysiwyg\ConfigProviderInterface::class - ); - } - return $this->objectManager->create($instance, $arguments); } } diff --git a/app/code/Magento/Cms/Model/Wysiwyg/DefaultConfigProvider.php b/app/code/Magento/Cms/Model/Wysiwyg/DefaultConfigProvider.php index dee37c8e901ec..2ff2aa3f82ba8 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/DefaultConfigProvider.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/DefaultConfigProvider.php @@ -4,6 +4,8 @@ * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Cms\Model\Wysiwyg; /** @@ -27,7 +29,7 @@ public function __construct(\Magento\Framework\View\Asset\Repository $assetRepo) /** * {@inheritdoc} */ - public function getConfig($config) + public function getConfig(\Magento\Framework\DataObject $config) : \Magento\Framework\DataObject { $config->addData([ 'tinymce4' => [ diff --git a/app/code/Magento/Cms/Model/Wysiwyg/Gallery/DefaultConfigProvider.php b/app/code/Magento/Cms/Model/Wysiwyg/Gallery/DefaultConfigProvider.php index 20d73c0889981..822f9ce2b1cb5 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/Gallery/DefaultConfigProvider.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/Gallery/DefaultConfigProvider.php @@ -4,6 +4,8 @@ * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Cms\Model\Wysiwyg\Gallery; class DefaultConfigProvider implements \Magento\Framework\Data\Wysiwyg\ConfigProviderInterface @@ -13,25 +15,43 @@ class DefaultConfigProvider implements \Magento\Framework\Data\Wysiwyg\ConfigPro */ private $backendUrl; + /** + * @var \Magento\Cms\Helper\Wysiwyg\Images + */ + private $imagesHelper; + /** * @var array */ private $windowSize; + /** + * @var string|null + */ + private $currentTreePath; + /** * @param \Magento\Backend\Model\UrlInterface $backendUrl + * @param \Magento\Cms\Helper\Wysiwyg\Images $imagesHelper * @param array $windowSize + * @param string|null $currentTreePath */ - public function __construct(\Magento\Backend\Model\UrlInterface $backendUrl, array $windowSize = []) - { + public function __construct( + \Magento\Backend\Model\UrlInterface $backendUrl, + \Magento\Cms\Helper\Wysiwyg\Images $imagesHelper, + array $windowSize = [], + $currentTreePath = null + ) { $this->backendUrl = $backendUrl; + $this->imagesHelper = $imagesHelper; $this->windowSize = $windowSize; + $this->currentTreePath = $currentTreePath; } /** * {@inheritdoc} */ - public function getConfig($config) + public function getConfig(\Magento\Framework\DataObject $config) : \Magento\Framework\DataObject { $pluginData = (array) $config->getData('plugins'); $imageData = [ @@ -39,10 +59,22 @@ public function getConfig($config) 'name' => 'image', ] ]; + + $fileBrowserUrlParams = []; + + if (is_string($this->currentTreePath)) { + $fileBrowserUrlParams = [ + 'current_tree_path' => $this->imagesHelper->idEncode($this->currentTreePath), + ]; + } + return $config->addData( [ 'add_images' => true, - 'files_browser_window_url' => $this->backendUrl->getUrl('cms/wysiwyg_images/index'), + 'files_browser_window_url' => $this->backendUrl->getUrl( + 'cms/wysiwyg_images/index', + $fileBrowserUrlParams + ), 'files_browser_window_width' => $this->windowSize['width'], 'files_browser_window_height' => $this->windowSize['height'], 'plugins' => array_merge($pluginData, $imageData) diff --git a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php index 0c8ff7d0b2b78..4b7cd239a66f5 100644 --- a/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php +++ b/app/code/Magento/Cms/Model/Wysiwyg/Images/Storage.php @@ -739,7 +739,7 @@ protected function _validatePath($path) */ protected function _sanitizePath($path) { - return rtrim(preg_replace('~[/\\\]+~', '/', $this->_directory->getDriver()->getRealPath($path)), '/'); + return rtrim(preg_replace('~[/\\\]+~', '/', $this->_directory->getDriver()->getRealPathSafety($path)), '/'); } /** diff --git a/app/code/Magento/Cms/Model/WysiwygDefaultConfig.php b/app/code/Magento/Cms/Model/WysiwygDefaultConfig.php index c03629188798b..b0f7260d209ea 100644 --- a/app/code/Magento/Cms/Model/WysiwygDefaultConfig.php +++ b/app/code/Magento/Cms/Model/WysiwygDefaultConfig.php @@ -3,6 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Cms\Model; class WysiwygDefaultConfig implements \Magento\Framework\Data\Wysiwyg\ConfigProviderInterface @@ -10,7 +12,7 @@ class WysiwygDefaultConfig implements \Magento\Framework\Data\Wysiwyg\ConfigProv /** * {@inheritdoc} */ - public function getConfig($config) + public function getConfig(\Magento\Framework\DataObject $config) : \Magento\Framework\DataObject { return $config; } diff --git a/app/code/Magento/Cms/Setup/Patch/Data/ConvertWidgetConditionsToJson.php b/app/code/Magento/Cms/Setup/Patch/Data/ConvertWidgetConditionsToJson.php index d36623fea6052..61f26c9bd0710 100644 --- a/app/code/Magento/Cms/Setup/Patch/Data/ConvertWidgetConditionsToJson.php +++ b/app/code/Magento/Cms/Setup/Patch/Data/ConvertWidgetConditionsToJson.php @@ -10,8 +10,8 @@ use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Select\QueryModifierFactory; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Framework\DB\AggregatedFieldDataConverter; use Magento\Framework\DB\FieldToConvert; use Magento\Framework\EntityManager\MetadataPool; diff --git a/app/code/Magento/Cms/Setup/Patch/Data/CreateDefaultPages.php b/app/code/Magento/Cms/Setup/Patch/Data/CreateDefaultPages.php index d097ed5c81741..615cdb144fd1f 100644 --- a/app/code/Magento/Cms/Setup/Patch/Data/CreateDefaultPages.php +++ b/app/code/Magento/Cms/Setup/Patch/Data/CreateDefaultPages.php @@ -6,8 +6,8 @@ namespace Magento\Cms\Setup\Patch\Data; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Framework\Module\Setup\Migration; use Magento\Framework\Setup\ModuleDataSetupInterface; diff --git a/app/code/Magento/Cms/Setup/Patch/Data/UpdatePrivacyPolicyPage.php b/app/code/Magento/Cms/Setup/Patch/Data/UpdatePrivacyPolicyPage.php index ced4d9d7ffc4e..dff931bfd6359 100644 --- a/app/code/Magento/Cms/Setup/Patch/Data/UpdatePrivacyPolicyPage.php +++ b/app/code/Magento/Cms/Setup/Patch/Data/UpdatePrivacyPolicyPage.php @@ -7,8 +7,8 @@ namespace Magento\Cms\Setup\Patch\Data; use Magento\Cms\Model\PageFactory; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdatePrivacyPolicyPage diff --git a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Block/SaveTest.php b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Block/SaveTest.php index f6b709a5c96c9..0d44f66048ba3 100644 --- a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Block/SaveTest.php +++ b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Block/SaveTest.php @@ -65,6 +65,16 @@ class SaveTest extends \PHPUnit\Framework\TestCase */ protected $saveController; + /** + * @var \Magento\Cms\Model\BlockFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $blockFactory; + + /** + * @var \Magento\Cms\Api\BlockRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $blockRepository; + /** * @var int */ @@ -129,11 +139,22 @@ protected function setUp() ->method('getResultRedirectFactory') ->willReturn($this->resultRedirectFactory); + $this->blockFactory = $this->getMockBuilder(\Magento\Cms\Model\BlockFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + + $this->blockRepository = $this->getMockBuilder(\Magento\Cms\Api\BlockRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->saveController = $this->objectManager->getObject( \Magento\Cms\Controller\Adminhtml\Block\Save::class, [ 'context' => $this->contextMock, 'dataPersistor' => $this->dataPersistorMock, + 'blockFactory' => $this->blockFactory, + 'blockRepository' => $this->blockRepository, ] ); } @@ -141,11 +162,12 @@ protected function setUp() public function testSaveAction() { $postData = [ - 'title' => '">;', - 'identifier' => 'unique_title_123', - 'stores' => ['0'], - 'is_active' => true, - 'content' => '">' + 'title' => '">;', + 'identifier' => 'unique_title_123', + 'stores' => ['0'], + 'is_active' => true, + 'content' => '">', + 'back' => 'continue' ]; $this->requestMock->expects($this->any())->method('getPostValue')->willReturn($postData); @@ -154,33 +176,31 @@ public function testSaveAction() ->willReturnMap( [ ['block_id', null, 1], - ['back', null, false], + ['back', null, 'continue'], ] ); - $this->objectManagerMock->expects($this->atLeastOnce()) + $this->blockFactory->expects($this->atLeastOnce()) ->method('create') - ->with($this->equalTo(\Magento\Cms\Model\Block::class)) ->willReturn($this->blockMock); - $this->blockMock->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $this->blockMock->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->blockRepository->expects($this->once()) + ->method('getById') + ->with($this->blockId) + ->willReturn($this->blockMock); + $this->blockMock->expects($this->once())->method('setData'); - $this->blockMock->expects($this->once())->method('save'); + $this->blockRepository->expects($this->once())->method('save')->with($this->blockMock); $this->dataPersistorMock->expects($this->any()) ->method('clear') ->with('cms_block'); $this->messageManagerMock->expects($this->once()) - ->method('addSuccess') + ->method('addSuccessMessage') ->with(__('You saved the block.')); - $this->resultRedirect->expects($this->atLeastOnce())->method('setPath')->with('*/*/') ->willReturnSelf(); + $this->resultRedirect->expects($this->atLeastOnce())->method('setPath')->with('*/*/edit') ->willReturnSelf(); $this->assertSame($this->resultRedirect, $this->saveController->execute()); } @@ -194,7 +214,12 @@ public function testSaveActionWithoutData() public function testSaveActionNoId() { - $this->requestMock->expects($this->any())->method('getPostValue')->willReturn(['block_id' => 1]); + $postData = [ + 'block_id' => 1, + 'back' => 'continue' + ]; + + $this->requestMock->expects($this->any())->method('getPostValue')->willReturn($postData); $this->requestMock->expects($this->atLeastOnce()) ->method('getParam') ->willReturnMap( @@ -204,20 +229,17 @@ public function testSaveActionNoId() ] ); - $this->objectManagerMock->expects($this->atLeastOnce()) + $this->blockFactory->expects($this->atLeastOnce()) ->method('create') - ->with($this->equalTo(\Magento\Cms\Model\Block::class)) ->willReturn($this->blockMock); - $this->blockMock->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $this->blockMock->expects($this->any()) - ->method('getId') - ->willReturn(false); + $this->blockRepository->expects($this->once()) + ->method('getById') + ->with($this->blockId) + ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Error message'))); $this->messageManagerMock->expects($this->once()) - ->method('addError') + ->method('addErrorMessage') ->with(__('This block no longer exists.')); $this->resultRedirect->expects($this->atLeastOnce())->method('setPath')->with('*/*/') ->willReturnSelf(); @@ -225,9 +247,18 @@ public function testSaveActionNoId() $this->assertSame($this->resultRedirect, $this->saveController->execute()); } - public function testSaveAndContinue() + public function testSaveAndDuplicate() { - $this->requestMock->expects($this->any())->method('getPostValue')->willReturn(['block_id' => 1]); + $postData = [ + 'title' => 'unique_title_123', + 'identifier' => 'unique_title_123', + 'stores' => ['0'], + 'is_active' => true, + 'content' => '">', + 'back' => 'duplicate' + ]; + + $this->requestMock->expects($this->any())->method('getPostValue')->willReturn($postData); $this->requestMock->expects($this->atLeastOnce()) ->method('getParam') ->willReturnMap( @@ -237,24 +268,53 @@ public function testSaveAndContinue() ] ); - $this->objectManagerMock->expects($this->atLeastOnce()) + $this->blockFactory->expects($this->at(0)) ->method('create') - ->with($this->equalTo(\Magento\Cms\Model\Block::class)) ->willReturn($this->blockMock); - $this->blockMock->expects($this->any()) - ->method('load') + $duplicateBlockMock = $this->getMockBuilder( + \Magento\Cms\Model\Block::class + )->disableOriginalConstructor()->getMock(); + + $this->blockFactory->expects($this->at(1)) + ->method('create') + ->willReturn($duplicateBlockMock); + + $duplicateBlockMock->expects($this->atLeastOnce()) + ->method('setId') + ->with(null) + ->willReturnSelf(); + + $duplicateBlockMock->expects($this->atLeastOnce()) + ->method('setIdentifier') + ->willReturnSelf(); + + $duplicateBlockMock->expects($this->atLeastOnce()) + ->method('setIsActive') + ->with(0) ->willReturnSelf(); - $this->blockMock->expects($this->any()) + + $duplicateBlockMock->expects($this->atLeastOnce()) ->method('getId') - ->willReturn(true); - $this->blockMock->expects($this->once())->method('setData'); - $this->blockMock->expects($this->once())->method('save'); + ->willReturn(1); - $this->messageManagerMock->expects($this->once()) - ->method('addSuccess') + $this->blockRepository->expects($this->once()) + ->method('getById') + ->with($this->blockId) + ->willReturn($this->blockMock); + + $this->blockMock->expects($this->any())->method('setData'); + $this->blockRepository->expects($this->at(1))->method('save')->with($this->blockMock); + $this->blockRepository->expects($this->at(2))->method('save')->with($duplicateBlockMock); + + $this->messageManagerMock->expects($this->at(0)) + ->method('addSuccessMessage') ->with(__('You saved the block.')); + $this->messageManagerMock->expects($this->at(1)) + ->method('addSuccessMessage') + ->with(__('You duplicated the block.')); + $this->dataPersistorMock->expects($this->any()) ->method('clear') ->with('cms_block'); @@ -267,9 +327,64 @@ public function testSaveAndContinue() $this->assertSame($this->resultRedirect, $this->saveController->execute()); } + public function testSaveAndClose() + { + $postData = [ + 'title' => '">;', + 'identifier' => 'unique_title_123', + 'stores' => ['0'], + 'is_active' => true, + 'content' => '">', + 'back' => 'close' + ]; + + $this->requestMock->expects($this->any())->method('getPostValue')->willReturn($postData); + $this->requestMock->expects($this->atLeastOnce()) + ->method('getParam') + ->willReturnMap( + [ + ['block_id', null, 1], + ['back', null, 'close'], + ] + ); + + $this->blockFactory->expects($this->atLeastOnce()) + ->method('create') + ->willReturn($this->blockMock); + + $this->blockRepository->expects($this->atLeastOnce()) + ->method('getById') + ->with($this->blockId) + ->willReturn($this->blockMock); + + $this->blockMock->expects($this->atLeastOnce())->method('setData'); + $this->blockRepository->expects($this->once())->method('save')->with($this->blockMock); + + $this->dataPersistorMock->expects($this->any()) + ->method('clear') + ->with('cms_block'); + + $this->messageManagerMock->expects($this->atLeastOnce()) + ->method('addSuccessMessage') + ->with(__('You saved the block.')); + + $this->resultRedirect->expects($this->atLeastOnce())->method('setPath')->with('*/*/')->willReturnSelf(); + + $this->assertSame($this->resultRedirect, $this->saveController->execute()); + } + public function testSaveActionThrowsException() { - $this->requestMock->expects($this->any())->method('getPostValue')->willReturn(['block_id' => 1]); + $postData = [ + 'title' => '">;', + 'identifier' => 'unique_title_123', + 'stores' => ['0'], + 'is_active' => true, + 'content' => '">', + 'back' => 'continue' + ]; + + $this->requestMock->expects($this->any())->method('getPostValue')->willReturn($postData); $this->requestMock->expects($this->atLeastOnce()) ->method('getParam') ->willReturnMap( @@ -279,28 +394,28 @@ public function testSaveActionThrowsException() ] ); - $this->objectManagerMock->expects($this->atLeastOnce()) + $this->blockFactory->expects($this->atLeastOnce()) ->method('create') - ->with($this->equalTo(\Magento\Cms\Model\Block::class)) ->willReturn($this->blockMock); - $this->blockMock->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $this->blockMock->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->blockRepository->expects($this->once()) + ->method('getById') + ->with($this->blockId) + ->willReturn($this->blockMock); + $this->blockMock->expects($this->once())->method('setData'); - $this->blockMock->expects($this->once())->method('save')->willThrowException(new \Exception('Error message.')); + $this->blockRepository->expects($this->once())->method('save') + ->with($this->blockMock) + ->willThrowException(new \Exception('Error message.')); $this->messageManagerMock->expects($this->never()) - ->method('addSuccess'); + ->method('addSuccessMessage'); $this->messageManagerMock->expects($this->once()) - ->method('addException'); + ->method('addExceptionMessage'); $this->dataPersistorMock->expects($this->any()) ->method('set') - ->with('cms_block', ['block_id' => 1]); + ->with('cms_block', array_merge($postData, ['block_id' => null])); $this->resultRedirect->expects($this->atLeastOnce()) ->method('setPath') diff --git a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Page/SaveTest.php b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Page/SaveTest.php index 03a8fc0969064..26b4055923107 100644 --- a/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Page/SaveTest.php +++ b/app/code/Magento/Cms/Test/Unit/Controller/Adminhtml/Page/SaveTest.php @@ -117,11 +117,12 @@ protected function setUp() public function testSaveAction() { $postData = [ - 'title' => '">;', - 'identifier' => 'unique_title_123', - 'stores' => ['0'], - 'is_active' => true, - 'content' => '">' + 'title' => '">;', + 'identifier' => 'unique_title_123', + 'stores' => ['0'], + 'is_active' => true, + 'content' => '">', + 'back' => 'close' ]; $filteredPostData = [ @@ -129,7 +130,8 @@ public function testSaveAction() 'identifier' => 'unique_title_123', 'stores' => ['0'], 'is_active' => true, - 'content' => '"><script>alert("cookie: "+document.cookie)</script>' + 'content' => '"><script>alert("cookie: "+document.cookie)</script>', + 'back' => 'close' ]; $this->dataProcessorMock->expects($this->any()) @@ -153,12 +155,7 @@ public function testSaveAction() ->method('create') ->willReturn($page); - $page->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $page->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->pageRepository->expects($this->once())->method('getById')->with($this->pageId)->willReturn($page); $page->expects($this->once())->method('setData'); $this->pageRepository->expects($this->once())->method('save')->with($page); @@ -182,15 +179,53 @@ public function testSaveActionWithoutData() $this->assertSame($this->resultRedirect, $this->saveController->execute()); } + public function testSaveActionNoId() + { + $this->requestMock->expects($this->any())->method('getPostValue')->willReturn(['page_id' => 1]); + $this->requestMock->expects($this->atLeastOnce()) + ->method('getParam') + ->willReturnMap( + [ + ['page_id', null, 1], + ['back', null, 'close'], + ] + ); + + $page = $this->getMockBuilder(\Magento\Cms\Model\Page::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->pageFactory->expects($this->atLeastOnce()) + ->method('create') + ->willReturn($page); + $this->pageRepository->expects($this->once()) + ->method('getById') + ->with($this->pageId) + ->willThrowException(new \Magento\Framework\Exception\NoSuchEntityException(__('Error message'))); + $this->messageManagerMock->expects($this->once()) + ->method('addErrorMessage') + ->with(__('This page no longer exists.')); + $this->resultRedirect->expects($this->atLeastOnce())->method('setPath')->with('*/*/') ->willReturnSelf(); + $this->assertSame($this->resultRedirect, $this->saveController->execute()); + } + public function testSaveAndContinue() { - $this->requestMock->expects($this->any())->method('getPostValue')->willReturn(['page_id' => $this->pageId]); + $postData = [ + 'title' => '">;', + 'identifier' => 'unique_title_123', + 'stores' => ['0'], + 'is_active' => true, + 'content' => '">', + 'back' => 'continue' + ]; + $this->requestMock->expects($this->any())->method('getPostValue')->willReturn($postData); $this->requestMock->expects($this->atLeastOnce()) ->method('getParam') ->willReturnMap( [ ['page_id', null, $this->pageId], - ['back', null, true], + ['back', null, 'continue'], ] ); @@ -204,12 +239,7 @@ public function testSaveAndContinue() ->method('create') ->willReturn($page); - $page->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $page->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->pageRepository->expects($this->once())->method('getById')->with($this->pageId)->willReturn($page); $page->expects($this->once())->method('setData'); $this->pageRepository->expects($this->once())->method('save')->with($page); @@ -251,12 +281,7 @@ public function testSaveActionThrowsException() ->method('create') ->willReturn($page); - $page->expects($this->any()) - ->method('load') - ->willReturnSelf(); - $page->expects($this->any()) - ->method('getId') - ->willReturn(true); + $this->pageRepository->expects($this->once())->method('getById')->with($this->pageId)->willReturn($page); $page->expects($this->once())->method('setData'); $this->pageRepository->expects($this->once())->method('save')->with($page) ->willThrowException(new \Exception('Error message.')); diff --git a/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php b/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php index add8c7ce79cad..0c2c62ac62191 100644 --- a/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php +++ b/app/code/Magento/Cms/Test/Unit/Helper/Wysiwyg/ImagesTest.php @@ -79,7 +79,7 @@ class ImagesTest extends \PHPUnit\Framework\TestCase protected function setUp() { - $this->path = 'PATH/'; + $this->path = 'PATH'; $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->eventManagerMock = $this->createMock(\Magento\Framework\Event\ManagerInterface::class); @@ -110,7 +110,8 @@ protected function setUp() ->willReturnMap( [ [WysiwygConfig::IMAGE_DIRECTORY, null, $this->getAbsolutePath(WysiwygConfig::IMAGE_DIRECTORY)], - [null, null, $this->getAbsolutePath(null)] + [null, null, $this->getAbsolutePath(null)], + ['', null, $this->getAbsolutePath('')], ] ); @@ -124,7 +125,7 @@ protected function setUp() [ 'clearWebsiteCache', 'getDefaultStoreView', 'getGroup', 'getGroups', 'getStore', 'getStores', 'getWebsite', 'getWebsites', 'hasSingleStore', - 'isSingleStoreMode', 'reinitStores', 'setCurrentStore', 'setIsSingleStoreModeAllowed' + 'isSingleStoreMode', 'reinitStores', 'setCurrentStore', 'setIsSingleStoreModeAllowed', ] ) ->disableOriginalConstructor() @@ -179,7 +180,7 @@ public function testSetStoreId() public function testGetStorageRoot() { $this->assertEquals( - $this->getAbsolutePath(WysiwygConfig::IMAGE_DIRECTORY), + $this->getAbsolutePath(''), $this->imagesHelper->getStorageRoot() ); } @@ -203,7 +204,7 @@ public function testGetTreeNodeName() public function testConvertPathToId() { $pathOne = '/test_path'; - $pathTwo = $this->getAbsolutePath(WysiwygConfig::IMAGE_DIRECTORY) . '/test_path'; + $pathTwo = $this->getAbsolutePath('') . '/test_path'; $this->assertEquals( $this->imagesHelper->convertPathToId($pathOne), $this->imagesHelper->convertPathToId($pathTwo) @@ -229,7 +230,7 @@ public function providerConvertIdToPath() { return [ ['', ''], - ['/test_path', 'L3Rlc3RfcGF0aA--'] + ['/test_path', 'L3Rlc3RfcGF0aA--'], ]; } @@ -239,6 +240,15 @@ public function testConvertIdToPathNodeRoot() $this->assertEquals($this->imagesHelper->getStorageRoot(), $this->imagesHelper->convertIdToPath($pathId)); } + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Path is invalid + */ + public function testConvertIdToPathInvalid() + { + $this->imagesHelper->convertIdToPath('Ly4uLy4uLy4uLy4uLy4uL3dvcms-'); + } + /** * @param string $fileName * @param int $maxLength @@ -258,7 +268,7 @@ public function providerShortFilename() return [ ['test', 3, 'tes...'], ['test', 4, 'test'], - ['test', 20, 'test'] + ['test', 20, 'test'], ]; } @@ -280,7 +290,7 @@ public function providerShortFilenameDefaultMaxLength() return [ ['Mini text', 'Mini text'], ['20 symbols are here', '20 symbols are here'], - ['Some text for this unit test', 'Some text for this u...'] + ['Some text for this unit test', 'Some text for this u...'], ]; } @@ -319,7 +329,7 @@ public function providerIsUsingStaticUrlsAllowed() { return [ [true], - [false] + [false], ]; } @@ -336,7 +346,6 @@ public function testGetCurrentPath($pathId, $expectedPath, $isExist) ->willReturnMap( [ ['node', null, $pathId], - ['use_storage_root', null, false], ] ); @@ -344,18 +353,18 @@ public function testGetCurrentPath($pathId, $expectedPath, $isExist) ->method('isDirectory') ->willReturnMap( [ - ['/../wysiwyg/test_path', true], - ['/../wysiwyg/my.jpg', false], - ['/../wysiwyg', true] + ['/../test_path', true], + ['/../my.jpg', false], + ['.', true], ] ); $this->directoryWriteMock->expects($this->any()) ->method('getRelativePath') ->willReturnMap( [ - ['PATH/wysiwyg/test_path', '/../wysiwyg/test_path'], - ['PATH/wysiwyg/my.jpg', '/../wysiwyg/my.jpg'], - ['PATH/wysiwyg', '/../wysiwyg'], + ['PATH/test_path', '/../test_path'], + ['PATH/my.jpg', '/../my.jpg'], + ['PATH', '.'], ] ); $this->directoryWriteMock->expects($this->once()) @@ -370,10 +379,8 @@ public function testGetCurrentPath($pathId, $expectedPath, $isExist) public function testGetCurrentPathThrowException() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - 'The directory PATH/wysiwyg is not writable by server.' - ); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage('The directory PATH is not writable by server.'); $this->directoryWriteMock->expects($this->once()) ->method('isExist') @@ -392,12 +399,12 @@ public function testGetCurrentPathThrowException() public function providerGetCurrentPath() { return [ - ['L3Rlc3RfcGF0aA--', 'PATH/wysiwyg/test_path', true], - ['L215LmpwZw--', 'PATH/wysiwyg', true], - [null, 'PATH/wysiwyg', true], - ['L3Rlc3RfcGF0aA--', 'PATH/wysiwyg/test_path', false], - ['L215LmpwZw--', 'PATH/wysiwyg', false], - [null, 'PATH/wysiwyg', false] + ['L3Rlc3RfcGF0aA--', 'PATH/test_path', true], + ['L215LmpwZw--', 'PATH', true], + [null, 'PATH', true], + ['L3Rlc3RfcGF0aA--', 'PATH/test_path', false], + ['L215LmpwZw--', 'PATH', false], + [null, 'PATH', false], ]; } @@ -450,15 +457,15 @@ public function providerGetImageHtmlDeclarationRenderingAsTag() 'test.png', true, null, - '' + '', ], [ 'http://localhost', 'test.png', false, '{{media url="/test.png"}}', - '' - ] + '', + ], ]; } @@ -482,7 +489,7 @@ public function testGetImageHtmlDeclaration($baseUrl, $fileName, $isUsingStaticU $this->backendDataMock->expects($this->any()) ->method('getUrl') - ->with('cms/wysiwyg/directive', ['___directive' => $directive]) + ->with('cms/wysiwyg/directive', ['___directive' => $directive, '_escape_params' => false]) ->willReturn($directive); $this->assertEquals($expectedHtml, $this->imagesHelper->getImageHtmlDeclaration($fileName)); @@ -492,7 +499,7 @@ public function providerGetImageHtmlDeclaration() { return [ ['http://localhost', 'test.png', true, 'http://localhost/test.png'], - ['http://localhost', 'test.png', false, '{{media url="/test.png"}}'] + ['http://localhost', 'test.png', false, '{{media url="/test.png"}}'], ]; } diff --git a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php index a2178489e1298..25134451d5a56 100644 --- a/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php +++ b/app/code/Magento/Cms/Test/Unit/Model/Wysiwyg/Images/StorageTest.php @@ -114,20 +114,13 @@ class StorageTest extends \PHPUnit\Framework\TestCase protected function setUp() { $this->filesystemMock = $this->createMock(\Magento\Framework\Filesystem::class); - $this->driverMock = $this->getMockForAbstractClass( - \Magento\Framework\Filesystem\DriverInterface::class, - [], - '', - false, - false, - true, - ['getRealPath'] - ); - $this->driverMock->expects($this->any())->method('getRealPath')->will($this->returnArgument(0)); + $this->driverMock = $this->getMockBuilder(\Magento\Framework\Filesystem\DriverInterface::class) + ->setMethods(['getRealPathSafety']) + ->getMockForAbstractClass(); $this->directoryMock = $this->createPartialMock( \Magento\Framework\Filesystem\Directory\Write::class, - ['delete', 'getDriver', 'create'] + ['delete', 'getDriver', 'create', 'getRelativePath', 'isExist'] ); $this->directoryMock->expects( $this->any() @@ -151,7 +144,7 @@ protected function setUp() $this->adapterFactoryMock = $this->createMock(\Magento\Framework\Image\AdapterFactory::class); $this->imageHelperMock = $this->createPartialMock( \Magento\Cms\Helper\Wysiwyg\Images::class, - ['getStorageRoot'] + ['getStorageRoot', 'getCurrentPath'] ); $this->imageHelperMock->expects( $this->any() @@ -182,7 +175,10 @@ protected function setUp() $this->uploaderFactoryMock = $this->getMockBuilder(\Magento\MediaStorage\Model\File\UploaderFactory::class) ->disableOriginalConstructor() ->getMock(); - $this->sessionMock = $this->createMock(\Magento\Backend\Model\Session::class); + $this->sessionMock = $this->getMockBuilder(\Magento\Backend\Model\Session::class) + ->setMethods(['getCurrentPath']) + ->disableOriginalConstructor() + ->getMock(); $this->backendUrlMock = $this->createMock(\Magento\Backend\Model\Url::class); $this->coreFileStorageMock = $this->getMockBuilder(\Magento\MediaStorage\Helper\File\Storage\Database::class) @@ -236,10 +232,11 @@ public function testGetResizeHeight() */ public function testDeleteDirectoryOverRoot() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage( sprintf('Directory %s is not under storage root path.', self::INVALID_DIRECTORY_OVER_ROOT) ); + $this->driverMock->expects($this->atLeastOnce())->method('getRealPathSafety')->will($this->returnArgument(0)); $this->imagesStorage->deleteDirectory(self::INVALID_DIRECTORY_OVER_ROOT); } @@ -248,10 +245,9 @@ public function testDeleteDirectoryOverRoot() */ public function testDeleteRootDirectory() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - sprintf('We can\'t delete root directory %s right now.', self::STORAGE_ROOT_DIR) - ); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage(sprintf('We can\'t delete root directory %s right now.', self::STORAGE_ROOT_DIR)); + $this->driverMock->expects($this->atLeastOnce())->method('getRealPathSafety')->will($this->returnArgument(0)); $this->imagesStorage->deleteDirectory(self::STORAGE_ROOT_DIR); } diff --git a/app/code/Magento/Cms/composer.json b/app/code/Magento/Cms/composer.json index d6842ccc67169..f051271c05051 100644 --- a/app/code/Magento/Cms/composer.json +++ b/app/code/Magento/Cms/composer.json @@ -5,23 +5,22 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-email": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-variable": "100.3.*", - "magento/module-widget": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-email": "*", + "magento/module-media-storage": "*", + "magento/module-store": "*", + "magento/module-theme": "*", + "magento/module-ui": "*", + "magento/module-variable": "*", + "magento/module-widget": "*" }, "suggest": { - "magento/module-cms-sample-data": "Sample Data version:100.3.*" + "magento/module-cms-sample-data": "*" }, "type": "magento2-module", - "version": "101.2.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Cms/etc/adminhtml/di.xml b/app/code/Magento/Cms/etc/adminhtml/di.xml index 40482e3b75e7e..98a8ff6e9ec91 100644 --- a/app/code/Magento/Cms/etc/adminhtml/di.xml +++ b/app/code/Magento/Cms/etc/adminhtml/di.xml @@ -40,10 +40,16 @@ + \Magento\Cms\Model\Wysiwyg\Config::IMAGE_DIRECTORY 600 1000 + + + web/default_layouts/default_cms_layout + + diff --git a/app/code/Magento/Cms/etc/adminhtml/system.xml b/app/code/Magento/Cms/etc/adminhtml/system.xml index c3cfb6077ac4a..20d543440565b 100644 --- a/app/code/Magento/Cms/etc/adminhtml/system.xml +++ b/app/code/Magento/Cms/etc/adminhtml/system.xml @@ -41,6 +41,12 @@ Magento\Config\Model\Config\Source\Yesno + + + + Magento\Cms\Model\Page\Source\PageLayout + +
    diff --git a/app/code/Magento/Cms/etc/config.xml b/app/code/Magento/Cms/etc/config.xml index 60e54c8f223b7..7090bb7a1fd25 100644 --- a/app/code/Magento/Cms/etc/config.xml +++ b/app/code/Magento/Cms/etc/config.xml @@ -16,6 +16,9 @@ cms/noroute/index 1 + + 1column + diff --git a/app/code/Magento/Cms/etc/db_schema.xml b/app/code/Magento/Cms/etc/db_schema.xml index b108dc1b63c32..2b825544f56f1 100644 --- a/app/code/Magento/Cms/etc/db_schema.xml +++ b/app/code/Magento/Cms/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Cms/i18n/en_US.csv b/app/code/Magento/Cms/i18n/en_US.csv index e4989777593f8..b34793c35d659 100644 --- a/app/code/Magento/Cms/i18n/en_US.csv +++ b/app/code/Magento/Cms/i18n/en_US.csv @@ -45,6 +45,7 @@ Blocks,Blocks "Please correct the data sent.","Please correct the data sent." "A total of %1 record(s) have been deleted.","A total of %1 record(s) have been deleted." "You saved the block.","You saved the block." +"You duplicated the block.","You duplicated the block." "Something went wrong while saving the block.","Something went wrong while saving the block." "The page has been deleted.","The page has been deleted." "We can't find a page to delete.","We can't find a page to delete." @@ -58,6 +59,7 @@ Pages,Pages "Page Title","Page Title" "To apply changes you should fill in hidden required ""%1"" field","To apply changes you should fill in hidden required ""%1"" field" "You saved the page.","You saved the page." +"You duplicated the page.","You duplicated the page." "The directory %1 is not writable by server.","The directory %1 is not writable by server." "Make sure that static block content does not reference the block itself.","Make sure that static block content does not reference the block itself." "CMS Block with id ""%1"" does not exist.","CMS Block with id ""%1"" does not exist." @@ -146,6 +148,7 @@ To,To "New Theme","New Theme" "-- Please Select --","-- Please Select --" "New Layout","New Layout" +"Default Page Layout","Default Page Layout" Disable,Disable Enable,Enable "Custom design from","Custom design from" diff --git a/app/code/Magento/Cms/view/adminhtml/ui_component/cms_block_form.xml b/app/code/Magento/Cms/view/adminhtml/ui_component/cms_block_form.xml index 4864f32534b9c..4b4a1a9bfe4db 100644 --- a/app/code/Magento/Cms/view/adminhtml/ui_component/cms_block_form.xml +++ b/app/code/Magento/Cms/view/adminhtml/ui_component/cms_block_form.xml @@ -15,9 +15,7 @@ -
    diff --git a/app/code/Magento/Config/etc/di.xml b/app/code/Magento/Config/etc/di.xml index bcddd8ceaf27a..a5dd18097fb47 100644 --- a/app/code/Magento/Config/etc/di.xml +++ b/app/code/Magento/Config/etc/di.xml @@ -296,10 +296,21 @@ Magento\Config\Console\Command\ConfigSet\DefaultProcessor - Magento\Config\Console\Command\ConfigSet\LockProcessor + Magento\Config\Console\Command\ConfigSet\VirtualLockEnvProcessor + Magento\Config\Console\Command\ConfigSet\VirtualLockConfigProcessor + + + app_env + + + + + app_config + + diff --git a/app/code/Magento/Config/etc/module.xml b/app/code/Magento/Config/etc/module.xml index 19051a96a434c..2fd4255a2bc0c 100644 --- a/app/code/Magento/Config/etc/module.xml +++ b/app/code/Magento/Config/etc/module.xml @@ -6,5 +6,9 @@ */ --> - + + + + + diff --git a/app/code/Magento/Config/view/adminhtml/templates/system/config/edit.phtml b/app/code/Magento/Config/view/adminhtml/templates/system/config/edit.phtml index 8264c7d7b396a..a1e26b7805d4b 100644 --- a/app/code/Magento/Config/view/adminhtml/templates/system/config/edit.phtml +++ b/app/code/Magento/Config/view/adminhtml/templates/system/config/edit.phtml @@ -32,7 +32,6 @@ require([ "jquery", "uiRegistry", - "jquery/jquery.hashchange", "mage/mage", "prototype", "mage/adminhtml/form", @@ -379,7 +378,7 @@ require([ return false; }; - jQuery(window).hashchange(handleHash); + window.addEventListener('hashchange', handleHash); handleHash(); registry.set('adminSystemConfig', adminSystemConfig); diff --git a/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml b/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml index 93af2cfa653f8..cf235d368b9bc 100644 --- a/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml +++ b/app/code/Magento/Config/view/adminhtml/templates/system/config/form/field/array.phtml @@ -21,7 +21,7 @@ $_colspan = $block->isAddAfter() ? 2 : 1; getColumns() as $columnName => $column): ?> - + diff --git a/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php b/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php index 3dc47b42c1e40..64a0c23139c01 100644 --- a/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableImportExport/Model/Import/Product/Type/Configurable.php @@ -11,6 +11,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\CatalogImportExport\Model\Import\Product as ImportProduct; use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Exception\LocalizedException; /** * Importing configurable products @@ -32,6 +33,8 @@ class Configurable extends \Magento\CatalogImportExport\Model\Import\Product\Typ const ERROR_DUPLICATED_VARIATIONS = 'duplicatedVariations'; + const ERROR_UNIDENTIFIABLE_VARIATION = 'unidentifiableVariation'; + /** * Validation failure message template definitions * @@ -42,6 +45,7 @@ class Configurable extends \Magento\CatalogImportExport\Model\Import\Product\Typ self::ERROR_INVALID_OPTION_VALUE => 'Invalid option value for attribute "%s"', self::ERROR_INVALID_WEBSITE => 'Invalid website code for super attribute', self::ERROR_DUPLICATED_VARIATIONS => 'SKU %s contains duplicated variations', + self::ERROR_UNIDENTIFIABLE_VARIATION => 'Configurable variation "%s" is unidentifiable', ]; /** @@ -247,9 +251,8 @@ protected function _getSuperAttributeId($productId, $attributeId) { if (isset($this->_productSuperAttrs["{$productId}_{$attributeId}"])) { return $this->_productSuperAttrs["{$productId}_{$attributeId}"]; - } else { - return null; } + return null; } /** @@ -470,14 +473,22 @@ protected function _processSuperData() * @param array $rowData * * @return array + * @throws LocalizedException * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function _parseVariations($rowData) { $additionalRows = []; - if (!isset($rowData['configurable_variations'])) { + if (empty($rowData['configurable_variations'])) { return $additionalRows; + } elseif (!empty($rowData['store_view_code'])) { + throw new LocalizedException( + __( + 'Product with assigned super attributes should not have specified "%1" value', + 'store_view_code' + ) + ); } $variations = explode(ImportProduct::PSEUDO_MULTI_LINE_SEPARATOR, $rowData['configurable_variations']); foreach ($variations as $variation) { @@ -489,7 +500,8 @@ protected function _parseVariations($rowData) $nameAndValue = explode(ImportProduct::PAIR_NAME_VALUE_SEPARATOR, $nameAndValue); if (!empty($nameAndValue)) { $value = isset($nameAndValue[1]) ? trim($nameAndValue[1]) : ''; - $fieldName = trim($nameAndValue[0]); + // Ignoring field names' case. + $fieldName = strtolower(trim($nameAndValue[0])); if ($fieldName) { $fieldAndValuePairs[$fieldName] = $value; } @@ -510,8 +522,18 @@ protected function _parseVariations($rowData) $additionalRow = []; $position += 1; } + } else { + throw new LocalizedException( + __( + sprintf( + $this->_messageTemplates[self::ERROR_UNIDENTIFIABLE_VARIATION], + $variation + ) + ) + ); } } + return $additionalRows; } @@ -822,7 +844,14 @@ protected function configurableInBunch($bunch) public function isRowValid(array $rowData, $rowNum, $isNewProduct = true) { $error = false; - $dataWithExtraVirtualRows = $this->_parseVariations($rowData); + try { + $dataWithExtraVirtualRows = $this->_parseVariations($rowData); + } catch (LocalizedException $exception) { + $this->_entityModel->addRowError($exception->getMessage(), $rowNum); + + return false; + } + $skus = []; $rowData['price'] = isset($rowData['price']) && $rowData['price'] ? $rowData['price'] : '0.00'; if (!empty($dataWithExtraVirtualRows)) { @@ -846,6 +875,7 @@ public function isRowValid(array $rowData, $rowNum, $isNewProduct = true) } $error |= !parent::isRowValid($option, $rowNum, $isNewProduct); } + return !$error; } diff --git a/app/code/Magento/ConfigurableImportExport/Test/Unit/Model/Import/Product/Type/ConfigurableTest.php b/app/code/Magento/ConfigurableImportExport/Test/Unit/Model/Import/Product/Type/ConfigurableTest.php index f6912fe8b6d6c..8cdb5531a3cab 100644 --- a/app/code/Magento/ConfigurableImportExport/Test/Unit/Model/Import/Product/Type/ConfigurableTest.php +++ b/app/code/Magento/ConfigurableImportExport/Test/Unit/Model/Import/Product/Type/ConfigurableTest.php @@ -560,15 +560,56 @@ public function testIsRowValid() '_type' => 'configurable', '_product_websites' => 'website_1', ]; + // Checking that variations' field names are case-insensitive with this + // product. + $caseInsensitiveSKU = 'configurableskuI22CaseInsensitive'; + $caseInsensitiveProduct = [ + 'sku' => $caseInsensitiveSKU, + 'store_view_code' => null, + 'attribute_set_code' => 'Default', + 'product_type' => 'configurable', + 'name' => 'Configurable Product 21', + 'product_websites' => 'website_1', + 'configurable_variation_labels' => 'testattr2=Select Color, testattr3=Select Size', + 'configurable_variations' => 'SKU=testconf2-attr2val1-testattr3v1,' + . 'testattr2=attr2val1,' + . 'testattr3=testattr3v1,' + . 'display=1|sku=testconf2-attr2val1-testattr3v2,' + . 'testattr2=attr2val1,' + . 'testattr3=testattr3v2,' + . 'display=0', + '_store' => null, + '_attribute_set' => 'Default', + '_type' => 'configurable', + '_product_websites' => 'website_1', + ]; $bunch[] = $badProduct; + $bunch[] = $caseInsensitiveProduct; // Set _attributes to avoid error in Magento\CatalogImportExport\Model\Import\Product\Type\AbstractType. $this->setPropertyValue($this->configurable, '_attributes', [ $badProduct[\Magento\CatalogImportExport\Model\Import\Product::COL_ATTR_SET] => [], ]); + // Avoiding errors about attributes not being super + $this->setPropertyValue( + $this->configurable, + '_superAttributes', + [ + 'testattr2' => ['options' => ['attr2val1' => 1]], + 'testattr3' => [ + 'options' => [ + 'testattr3v2' => 1, + 'testattr3v1' => 1, + ], + ], + ] + ); foreach ($bunch as $rowData) { $result = $this->configurable->isRowValid($rowData, 0, !isset($this->_oldSku[$rowData['sku']])); $this->assertNotNull($result); + if ($rowData['sku'] === $caseInsensitiveSKU) { + $this->assertTrue($result); + } } } diff --git a/app/code/Magento/ConfigurableImportExport/composer.json b/app/code/Magento/ConfigurableImportExport/composer.json index 1de5024f61981..c1aab3e7a148f 100644 --- a/app/code/Magento/ConfigurableImportExport/composer.json +++ b/app/code/Magento/ConfigurableImportExport/composer.json @@ -5,16 +5,15 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-import-export": "100.3.*", - "magento/module-configurable-product": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-import-export": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-catalog-import-export": "*", + "magento/module-configurable-product": "*", + "magento/module-eav": "*", + "magento/module-import-export": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php b/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php index b5d02f64e6eb5..2502b79921e99 100644 --- a/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Block/Product/View/Type/Configurable.php @@ -74,6 +74,11 @@ class Configurable extends \Magento\Catalog\Block\Product\View\AbstractView */ private $customerSession; + /** + * @var \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices + */ + private $variationPrices; + /** * @param \Magento\Catalog\Block\Product\Context $context * @param \Magento\Framework\Stdlib\ArrayUtils $arrayUtils @@ -86,6 +91,7 @@ class Configurable extends \Magento\Catalog\Block\Product\View\AbstractView * @param array $data * @param Format|null $localeFormat * @param Session|null $customerSession + * @param \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices|null $variationPrices * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -99,7 +105,8 @@ public function __construct( ConfigurableAttributeData $configurableAttributeData, array $data = [], Format $localeFormat = null, - Session $customerSession = null + Session $customerSession = null, + \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices $variationPrices = null ) { $this->priceCurrency = $priceCurrency; $this->helper = $helper; @@ -109,6 +116,9 @@ public function __construct( $this->configurableAttributeData = $configurableAttributeData; $this->localeFormat = $localeFormat ?: ObjectManager::getInstance()->get(Format::class); $this->customerSession = $customerSession ?: ObjectManager::getInstance()->get(Session::class); + $this->variationPrices = $variationPrices ?: ObjectManager::getInstance()->get( + \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices::class + ); parent::__construct( $context, @@ -126,7 +136,7 @@ public function __construct( public function getCacheKeyInfo() { $parentData = parent::getCacheKeyInfo(); - $parentData[] = $this->priceCurrency->getCurrencySymbol(); + $parentData[] = $this->priceCurrency->getCurrency()->getCode(); $parentData[] = $this->customerSession->getCustomerGroupId(); return $parentData; } @@ -211,9 +221,6 @@ public function getJsonConfig() $store = $this->getCurrentStore(); $currentProduct = $this->getProduct(); - $regularPrice = $currentProduct->getPriceInfo()->getPrice('regular_price'); - $finalPrice = $currentProduct->getPriceInfo()->getPrice('final_price'); - $options = $this->helper->getOptions($currentProduct, $this->getAllowProducts()); $attributesData = $this->configurableAttributeData->getAttributesData($currentProduct, $options); @@ -223,17 +230,7 @@ public function getJsonConfig() 'currencyFormat' => $store->getCurrentCurrency()->getOutputFormat(), 'optionPrices' => $this->getOptionPrices(), 'priceFormat' => $this->localeFormat->getPriceFormat(), - 'prices' => [ - 'oldPrice' => [ - 'amount' => $this->localeFormat->getNumber($regularPrice->getAmount()->getValue()), - ], - 'basePrice' => [ - 'amount' => $this->localeFormat->getNumber($finalPrice->getAmount()->getBaseAmount()), - ], - 'finalPrice' => [ - 'amount' => $this->localeFormat->getNumber($finalPrice->getAmount()->getValue()), - ], - ], + 'prices' => $this->variationPrices->getFormattedPrices($this->getProduct()->getPriceInfo()), 'productId' => $currentProduct->getId(), 'chooseText' => __('Choose an Option...'), 'images' => $this->getOptionImages(), diff --git a/app/code/Magento/ConfigurableProduct/CustomerData/ConfigurableItem.php b/app/code/Magento/ConfigurableProduct/CustomerData/ConfigurableItem.php index 0a4fc20578ed9..3a9ed653305c5 100644 --- a/app/code/Magento/ConfigurableProduct/CustomerData/ConfigurableItem.php +++ b/app/code/Magento/ConfigurableProduct/CustomerData/ConfigurableItem.php @@ -26,6 +26,7 @@ class ConfigurableItem extends DefaultItem * @param \Magento\Catalog\Helper\Product\ConfigurationPool $configurationPool * @param \Magento\Checkout\Helper\Data $checkoutHelper * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig + * @param \Magento\Framework\Escaper|null $escaper */ public function __construct( \Magento\Catalog\Helper\Image $imageHelper, @@ -33,14 +34,16 @@ public function __construct( \Magento\Framework\UrlInterface $urlBuilder, \Magento\Catalog\Helper\Product\ConfigurationPool $configurationPool, \Magento\Checkout\Helper\Data $checkoutHelper, - \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig + \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, + \Magento\Framework\Escaper $escaper = null ) { parent::__construct( $imageHelper, $msrpHelper, $urlBuilder, $configurationPool, - $checkoutHelper + $checkoutHelper, + $escaper ); $this->_scopeConfig = $scopeConfig; } diff --git a/app/code/Magento/ConfigurableProduct/Helper/Data.php b/app/code/Magento/ConfigurableProduct/Helper/Data.php index 1de82eaad3196..674bd3703fa82 100644 --- a/app/code/Magento/ConfigurableProduct/Helper/Data.php +++ b/app/code/Magento/ConfigurableProduct/Helper/Data.php @@ -6,7 +6,11 @@ namespace Magento\ConfigurableProduct\Helper; -use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Image\UrlBuilder; +use Magento\Framework\App\ObjectManager; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product\Image; /** * Class Data @@ -17,50 +21,48 @@ class Data { /** - * Catalog Image Helper - * - * @var \Magento\Catalog\Helper\Image + * @var ImageHelper */ protected $imageHelper; /** - * @param \Magento\Catalog\Helper\Image $imageHelper + * @var UrlBuilder + */ + private $imageUrlBuilder; + + /** + * @param ImageHelper $imageHelper + * @param UrlBuilder $urlBuilder */ - public function __construct(\Magento\Catalog\Helper\Image $imageHelper) + public function __construct(ImageHelper $imageHelper, UrlBuilder $urlBuilder = null) { $this->imageHelper = $imageHelper; + $this->imageUrlBuilder = $urlBuilder ?? ObjectManager::getInstance()->get(UrlBuilder::class); } /** * Retrieve collection of gallery images * - * @param \Magento\Catalog\Api\Data\ProductInterface $product - * @return \Magento\Catalog\Model\Product\Image[]|null + * @param ProductInterface $product + * @return Image[]|null */ - public function getGalleryImages(\Magento\Catalog\Api\Data\ProductInterface $product) + public function getGalleryImages(ProductInterface $product) { $images = $product->getMediaGalleryImages(); if ($images instanceof \Magento\Framework\Data\Collection) { + /** @var $image Image */ foreach ($images as $image) { - /** @var $image \Magento\Catalog\Model\Product\Image */ - $image->setData( - 'small_image_url', - $this->imageHelper->init($product, 'product_page_image_small') - ->setImageFile($image->getFile()) - ->getUrl() - ); - $image->setData( - 'medium_image_url', - $this->imageHelper->init($product, 'product_page_image_medium_no_frame') - ->setImageFile($image->getFile()) - ->getUrl() - ); - $image->setData( - 'large_image_url', - $this->imageHelper->init($product, 'product_page_image_large_no_frame') - ->setImageFile($image->getFile()) - ->getUrl() - ); + $smallImageUrl = $this->imageUrlBuilder + ->getUrl($image->getFile(), 'product_page_image_small'); + $image->setData('small_image_url', $smallImageUrl); + + $mediumImageUrl = $this->imageUrlBuilder + ->getUrl($image->getFile(), 'product_page_image_medium'); + $image->setData('medium_image_url', $mediumImageUrl); + + $largeImageUrl = $this->imageUrlBuilder + ->getUrl($image->getFile(), 'product_page_image_large'); + $image->setData('large_image_url', $largeImageUrl); } } diff --git a/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php b/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php new file mode 100644 index 0000000000000..0c3fc6fba6005 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/Plugin/ProductIdentitiesExtender.php @@ -0,0 +1,56 @@ +configurableType = $configurableType; + $this->productRepository = $productRepository; + } + + /** + * Add parent identities to product identities + * + * @param Product $subject + * @param array $identities + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetIdentities(Product $subject, array $identities): array + { + foreach ($this->configurableType->getParentIdsByChild($subject->getId()) as $parentId) { + $parentProduct = $this->productRepository->getById($parentId); + $identities = array_merge($identities, $parentProduct->getIdentities()); + } + + return array_unique($identities); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php deleted file mode 100644 index ac42e320f3ad9..0000000000000 --- a/app/code/Magento/ConfigurableProduct/Model/Product/Cache/Tag/Configurable.php +++ /dev/null @@ -1,51 +0,0 @@ -catalogProductTypeConfigurable = $catalogProductTypeConfigurable; - } - - /** - * {@inheritdoc} - */ - public function getTags($object) - { - if (!is_object($object)) { - throw new \InvalidArgumentException('Provided argument is not an object'); - } - - if (!($object instanceof \Magento\Catalog\Model\Product)) { - throw new \InvalidArgumentException('Provided argument must be a product'); - } - - $result = $object->getIdentities(); - - foreach ($this->catalogProductTypeConfigurable->getParentIdsByChild($object->getId()) as $parentId) { - $result[] = \Magento\Catalog\Model\Product::CACHE_TAG . '_' . $parentId; - } - return $result; - } -} diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php b/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php index d42d4ccafdd01..1c470808824a8 100644 --- a/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php +++ b/app/code/Magento/ConfigurableProduct/Model/Product/SaveHandler.php @@ -10,6 +10,8 @@ use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable as ResourceModelConfigurable; use Magento\Framework\EntityManager\Operation\ExtensionInterface; +use Magento\ConfigurableProduct\Api\Data\OptionInterface; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable\Attribute; /** * Class SaveHandler @@ -76,35 +78,66 @@ public function execute($entity, $arguments = []) } /** - * Save attributes for configurable product + * Save only newly created attributes for configurable product. * * @param ProductInterface $product * @param array $attributes * @return array */ - private function saveConfigurableProductAttributes(ProductInterface $product, array $attributes) + private function saveConfigurableProductAttributes(ProductInterface $product, array $attributes): array { $ids = []; + $existingAttributeIds = []; + foreach ($this->optionRepository->getList($product->getSku()) as $option) { + $existingAttributeIds[$option->getAttributeId()] = $option; + } /** @var \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Attribute $attribute */ foreach ($attributes as $attribute) { - $attribute->setId(null); - $ids[] = $this->optionRepository->save($product->getSku(), $attribute); + if (!in_array($attribute->getAttributeId(), array_keys($existingAttributeIds)) + || $this->isOptionChanged($existingAttributeIds[$attribute->getAttributeId()], $attribute) + ) { + $attribute->setId(null); + $ids[] = $this->optionRepository->save($product->getSku(), $attribute); + } } return $ids; } /** - * Remove product attributes + * Remove product attributes which no longer used. * * @param ProductInterface $product * @return void */ - private function deleteConfigurableProductAttributes(ProductInterface $product) + private function deleteConfigurableProductAttributes(ProductInterface $product): void + { + $newAttributeIds = []; + foreach ($product->getExtensionAttributes()->getConfigurableProductOptions() as $option) { + $newAttributeIds[$option->getAttributeId()] = $option; + } + foreach ($this->optionRepository->getList($product->getSku()) as $option) { + if (!in_array($option->getAttributeId(), array_keys($newAttributeIds)) + || $this->isOptionChanged($option, $newAttributeIds[$option->getAttributeId()]) + ) { + $this->optionRepository->deleteById($product->getSku(), $option->getId()); + } + } + } + + /** + * Check if existing option is changed. + * + * @param OptionInterface $option + * @param Attribute $attribute + * @return bool + */ + private function isOptionChanged(OptionInterface $option, Attribute $attribute): bool { - $list = $this->optionRepository->getList($product->getSku()); - foreach ($list as $item) { - $this->optionRepository->deleteById($product->getSku(), $item->getId()); + if ($option->getLabel() == $attribute->getLabel() && $option->getPosition() == $attribute->getPosition()) { + return false; } + + return true; } } diff --git a/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Variations/Prices.php b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Variations/Prices.php new file mode 100644 index 0000000000000..b8a948d55f11a --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/Product/Type/Configurable/Variations/Prices.php @@ -0,0 +1,53 @@ +localeFormat = $localeFormat; + } + + /** + * Get product prices for configurable variations + * + * @param \Magento\Framework\Pricing\PriceInfo\Base $priceInfo + * @return array + */ + public function getFormattedPrices(\Magento\Framework\Pricing\PriceInfo\Base $priceInfo) + { + $regularPrice = $priceInfo->getPrice('regular_price'); + $finalPrice = $priceInfo->getPrice('final_price'); + + return [ + 'oldPrice' => [ + 'amount' => $this->localeFormat->getNumber($regularPrice->getAmount()->getValue()), + ], + 'basePrice' => [ + 'amount' => $this->localeFormat->getNumber($finalPrice->getAmount()->getBaseAmount()), + ], + 'finalPrice' => [ + 'amount' => $this->localeFormat->getNumber($finalPrice->getAmount()->getValue()), + ], + ]; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php index 487ab19de2063..326310cc3c802 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php @@ -8,10 +8,7 @@ namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price; use Magento\Catalog\Api\Data\ProductInterface; -use Magento\Catalog\Model\Product\Attribute\Source\Status as ProductStatus; -use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\Store\Api\StoreResolverInterface; -use Magento\Store\Model\Store; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -58,7 +55,7 @@ protected function reindex($entityIds = null) if ($this->hasEntity() || !empty($entityIds)) { $this->prepareFinalPriceDataForType($entityIds, $this->getTypeId()); $this->_applyCustomOption(); - $this->_applyConfigurableOption(); + $this->_applyConfigurableOption($entityIds); $this->_movePriceDataToIndexTable($entityIds); } return $this; @@ -110,10 +107,11 @@ protected function _prepareConfigurableOptionPriceTable() * Calculate minimal and maximal prices for configurable product options * and apply it to final price * + * @param array|null $entityIds * @return \Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\Configurable * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function _applyConfigurableOption() + protected function _applyConfigurableOption($entityIds = null) { $metadata = $this->getMetadataPool()->getMetadata(ProductInterface::class); $connection = $this->getConnection(); @@ -135,6 +133,10 @@ protected function _applyConfigurableOption() ['parent_id' => 'entity_id'] ); + if ($entityIds !== null) { + $subSelect->where('le.entity_id IN (?)', $entityIds); + } + $select = $connection->select(); $select ->from(['sub' => new \Zend_Db_Expr('(' . (string)$subSelect . ')')], '') @@ -174,6 +176,7 @@ protected function _applyConfigurableOption() ' AND i.website_id = io.website_id', [] ); + // adds price of custom option, that was applied in DefaultPrice::_applyCustomOption $select->columns( [ 'min_price' => new \Zend_Db_Expr('i.min_price - i.orig_price + io.min_price'), diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/LinkedProductSelectBuilder.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/LinkedProductSelectBuilder.php new file mode 100644 index 0000000000000..3f1c22548c8d8 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/LinkedProductSelectBuilder.php @@ -0,0 +1,54 @@ +baseSelectProcessor = $baseSelectProcessor; + $this->linkedProductSelectBuilder = $linkedProductSelectBuilder; + } + + /** + * {@inheritdoc} + */ + public function build($productId) + { + $selects = $this->linkedProductSelectBuilder->build($productId); + + foreach ($selects as $select) { + $this->baseSelectProcessor->process($select); + } + + return $selects; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/StockStatusBaseSelectProcessor.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/StockStatusBaseSelectProcessor.php new file mode 100644 index 0000000000000..bc118043adc8e --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/StockStatusBaseSelectProcessor.php @@ -0,0 +1,64 @@ +stockConfig = $stockConfig; + $this->stockStatusResource = $stockStatusResource; + } + + /** + * {@inheritdoc} + */ + public function process(Select $select) + { + if ($this->stockConfig->isShowOutOfStock()) { + $select->joinInner( + ['stock' => $this->stockStatusResource->getMainTable()], + sprintf( + 'stock.product_id = %s.entity_id', + BaseSelectProcessorInterface::PRODUCT_TABLE_ALIAS + ), + [] + )->where( + 'stock.stock_status = ?', + StockStatus::STATUS_IN_STOCK + ); + } + + return $select; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php index 95afba984d57d..ccff85dd9717f 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Type/Configurable.php @@ -189,7 +189,6 @@ public function getChildrenIds($parentId, $required = true) */ public function getParentIdsByChild($childId) { - $parentIds = []; $select = $this->getConnection() ->select() ->from(['l' => $this->getMainTable()], []) @@ -198,10 +197,7 @@ public function getParentIdsByChild($childId) 'e.' . $this->optionProvider->getProductEntityLinkField() . ' = l.parent_id', ['e.entity_id'] )->where('l.product_id IN(?)', $childId); - - foreach ($this->getConnection()->fetchAll($select) as $row) { - $parentIds[] = $row['entity_id']; - } + $parentIds = $this->getConnection()->fetchCol($select); return $parentIds; } diff --git a/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php b/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php new file mode 100644 index 0000000000000..efddb278df36c --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php @@ -0,0 +1,54 @@ +lowestPriceOptionsProvider = $lowestPriceOptionsProvider; + } + + /** + * Performs an additional check whether given configurable product has + * at least one configuration in-stock. + * + * @param \Magento\Catalog\Model\Product\Pricing\Renderer\SalableResolver $subject + * @param bool $result + * @param \Magento\Framework\Pricing\SaleableInterface $salableItem + * + * @return bool + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterIsSalable( + \Magento\Catalog\Model\Product\Pricing\Renderer\SalableResolver $subject, + $result, + \Magento\Framework\Pricing\SaleableInterface $salableItem + ) { + if ($salableItem->getTypeId() == 'configurable' && $result) { + if (!$this->lowestPriceOptionsProvider->getProducts($salableItem)) { + $result = false; + } + } + + return $result; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Pricing/Price/LowestPriceOptionsProvider.php b/app/code/Magento/ConfigurableProduct/Pricing/Price/LowestPriceOptionsProvider.php index 66bc3db7ee89d..d3ce508b31e0d 100644 --- a/app/code/Magento/ConfigurableProduct/Pricing/Price/LowestPriceOptionsProvider.php +++ b/app/code/Magento/ConfigurableProduct/Pricing/Price/LowestPriceOptionsProvider.php @@ -9,6 +9,7 @@ use Magento\Catalog\Model\ResourceModel\Product\LinkedProductSelectBuilderInterface; use Magento\Framework\App\ResourceConnection; use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\Store\Model\StoreManagerInterface; /** * Retrieve list of products where each product contains lower price than others at least for one possible price type @@ -31,7 +32,12 @@ class LowestPriceOptionsProvider implements LowestPriceOptionsProviderInterface private $collectionFactory; /** - * Key is product id. Value is array of prepared linked products + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * Key is product id and store id. Value is array of prepared linked products * * @var array */ @@ -41,15 +47,18 @@ class LowestPriceOptionsProvider implements LowestPriceOptionsProviderInterface * @param ResourceConnection $resourceConnection * @param LinkedProductSelectBuilderInterface $linkedProductSelectBuilder * @param CollectionFactory $collectionFactory + * @param StoreManagerInterface $storeManager */ public function __construct( ResourceConnection $resourceConnection, LinkedProductSelectBuilderInterface $linkedProductSelectBuilder, - CollectionFactory $collectionFactory + CollectionFactory $collectionFactory, + StoreManagerInterface $storeManager ) { $this->resource = $resourceConnection; $this->linkedProductSelectBuilder = $linkedProductSelectBuilder; $this->collectionFactory = $collectionFactory; + $this->storeManager = $storeManager; } /** @@ -57,18 +66,19 @@ public function __construct( */ public function getProducts(ProductInterface $product) { - if (!isset($this->linkedProductMap[$product->getId()])) { + $key = $this->storeManager->getStore()->getId() . '-' . $product->getId(); + if (!isset($this->linkedProductMap[$key])) { $productIds = $this->resource->getConnection()->fetchCol( '(' . implode(') UNION (', $this->linkedProductSelectBuilder->build($product->getId())) . ')' ); - $this->linkedProductMap[$product->getId()] = $this->collectionFactory->create() + $this->linkedProductMap[$key] = $this->collectionFactory->create() ->addAttributeToSelect( ['price', 'special_price', 'special_from_date', 'special_to_date', 'tax_class_id'] ) ->addIdFilter($productIds) ->getItems(); } - return $this->linkedProductMap[$product->getId()]; + return $this->linkedProductMap[$key]; } } diff --git a/app/code/Magento/ConfigurableProduct/Setup/Patch/Data/InstallInitialConfigurableAttributes.php b/app/code/Magento/ConfigurableProduct/Setup/Patch/Data/InstallInitialConfigurableAttributes.php index d9f11c6f910f9..c9fea3e74d3d2 100644 --- a/app/code/Magento/ConfigurableProduct/Setup/Patch/Data/InstallInitialConfigurableAttributes.php +++ b/app/code/Magento/ConfigurableProduct/Setup/Patch/Data/InstallInitialConfigurableAttributes.php @@ -10,8 +10,8 @@ use Magento\Eav\Setup\EavSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\ConfigurableProduct\Model\Product\Type\Configurable; /** diff --git a/app/code/Magento/ConfigurableProduct/Setup/Patch/Data/UpdateTierPriceAttribute.php b/app/code/Magento/ConfigurableProduct/Setup/Patch/Data/UpdateTierPriceAttribute.php index 4d04fb4db5bbc..325fb447d225f 100644 --- a/app/code/Magento/ConfigurableProduct/Setup/Patch/Data/UpdateTierPriceAttribute.php +++ b/app/code/Magento/ConfigurableProduct/Setup/Patch/Data/UpdateTierPriceAttribute.php @@ -10,8 +10,8 @@ use Magento\Eav\Setup\EavSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\ConfigurableProduct\Model\Product\Type\Configurable; /** diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Product/View/Type/ConfigurableTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Product/View/Type/ConfigurableTest.php index 1908d897be6da..b45306d670bff 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Product/View/Type/ConfigurableTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Product/View/Type/ConfigurableTest.php @@ -48,6 +48,11 @@ class ConfigurableTest extends \PHPUnit\Framework\TestCase */ private $priceCurrency; + /** + * @var \Magento\Directory\Model\Currency|\PHPUnit_Framework_MockObject_MockObject + */ + private $currency; + /** * @var \Magento\ConfigurableProduct\Model\ConfigurableAttributeData|\PHPUnit_Framework_MockObject_MockObject */ @@ -73,6 +78,11 @@ class ConfigurableTest extends \PHPUnit\Framework\TestCase */ private $customerSession; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $variationPricesMock; + protected function setUp() { $this->mockContextObject(); @@ -122,6 +132,9 @@ protected function setUp() $this->context->expects($this->once()) ->method('getResolver') ->willReturn($fileResolverMock); + $this->currency = $this->getMockBuilder(\Magento\Directory\Model\Currency::class) + ->disableOriginalConstructor() + ->getMock(); $this->configurableAttributeData = $this->getMockBuilder( \Magento\ConfigurableProduct\Model\ConfigurableAttributeData::class ) @@ -136,6 +149,10 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->variationPricesMock = $this->createMock( + \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices::class + ); + $this->block = new \Magento\ConfigurableProduct\Block\Product\View\Type\Configurable( $this->context, $this->arrayUtils, @@ -147,7 +164,8 @@ protected function setUp() $this->configurableAttributeData, [], $this->localeFormat, - $this->customerSession + $this->customerSession, + $this->variationPricesMock ); } @@ -192,10 +210,10 @@ public function cacheKeyProvider() : array 2 => null, 'base_url' => null, 'template' => null, - 3 => '$', + 3 => 'USD', 4 => null, ], - '$', + 'USD', null, ] ]; @@ -223,7 +241,10 @@ public function testGetCacheKeyInfo(array $expected, string $priceCurrency = nul ->method('getStore') ->willReturn($storeMock); $this->priceCurrency->expects($this->once()) - ->method('getCurrencySymbol') + ->method('getCurrency') + ->willReturn($this->currency); + $this->currency->expects($this->once()) + ->method('getCode') ->willReturn($priceCurrency); $this->customerSession->expects($this->once()) ->method('getCustomerGroupId') @@ -249,12 +270,8 @@ public function testGetJsonConfig() 'getAmount', ]) ->getMockForAbstractClass(); - $priceMock->expects($this->any()) - ->method('getAmount') - ->willReturn($amountMock); - + $priceMock->expects($this->any())->method('getAmount')->willReturn($amountMock); $tierPriceMock = $this->getTierPriceMock($amountMock, $priceQty, $percentage); - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) ->disableOriginalConstructor() ->getMock(); @@ -272,27 +289,16 @@ public function testGetJsonConfig() ['tier_price', $tierPriceMock], ]); - $productMock->expects($this->any()) - ->method('getTypeInstance') - ->willReturn($productTypeMock); - $productMock->expects($this->any()) - ->method('getPriceInfo') - ->willReturn($priceInfoMock); - $productMock->expects($this->any()) - ->method('isSaleable') - ->willReturn(true); - $productMock->expects($this->any()) - ->method('getId') - ->willReturn($productId); + $productMock->expects($this->any())->method('getTypeInstance')->willReturn($productTypeMock); + $productMock->expects($this->any())->method('getPriceInfo')->willReturn($priceInfoMock); + $productMock->expects($this->any())->method('isSaleable')->willReturn(true); + $productMock->expects($this->any())->method('getId')->willReturn($productId); $this->helper->expects($this->any()) ->method('getOptions') ->with($productMock, [$productMock]) ->willReturn([]); - - $this->product->expects($this->any()) - ->method('getSkipSaleableCheck') - ->willReturn(true); + $this->product->expects($this->any())->method('getSkipSaleableCheck')->willReturn(true); $attributesData = [ 'attributes' => [], @@ -304,9 +310,7 @@ public function testGetJsonConfig() ->with($productMock, []) ->willReturn($attributesData); - $this->localeFormat->expects($this->any()) - ->method('getPriceFormat') - ->willReturn([]); + $this->localeFormat->expects($this->atLeastOnce())->method('getPriceFormat')->willReturn([]); $this->localeFormat->expects($this->any()) ->method('getNumber') ->willReturnMap([ @@ -315,16 +319,29 @@ public function testGetJsonConfig() [$percentage, $percentage], ]); + $this->variationPricesMock->expects($this->once()) + ->method('getFormattedPrices') + ->with($priceInfoMock) + ->willReturn( + [ + 'oldPrice' => [ + 'amount' => $amount, + ], + 'basePrice' => [ + 'amount' => $amount, + ], + 'finalPrice' => [ + 'amount' => $amount, + ], + ] + ); + $expectedArray = $this->getExpectedArray($productId, $amount, $priceQty, $percentage); $expectedJson = json_encode($expectedArray); - $this->jsonEncoder->expects($this->once()) - ->method('encode') - ->with($expectedArray) - ->willReturn($expectedJson); + $this->jsonEncoder->expects($this->once())->method('encode')->with($expectedArray)->willReturn($expectedJson); $this->block->setData('product', $productMock); - $result = $this->block->getJsonConfig(); $this->assertEquals($expectedJson, $result); } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php index 92b7cace509bf..cd9fb419981a1 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Helper/DataTest.php @@ -6,6 +6,9 @@ namespace Magento\ConfigurableProduct\Test\Unit\Helper; +use Magento\Catalog\Model\Product\Image\UrlBuilder; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; + class DataTest extends \PHPUnit\Framework\TestCase { /** @@ -23,12 +26,27 @@ class DataTest extends \PHPUnit\Framework\TestCase */ protected $_productMock; + /** + * @var UrlBuilder|\PHPUnit_Framework_MockObject_MockObject + */ + protected $imageUrlBuilder; + protected function setUp() { + $objectManager = new ObjectManager($this); + $this->imageUrlBuilder = $this->getMockBuilder(UrlBuilder::class) + ->disableOriginalConstructor() + ->getMock(); $this->_imageHelperMock = $this->createMock(\Magento\Catalog\Helper\Image::class); $this->_productMock = $this->createMock(\Magento\Catalog\Model\Product::class); - $this->_model = new \Magento\ConfigurableProduct\Helper\Data($this->_imageHelperMock); + $this->_model = $objectManager->getObject( + \Magento\ConfigurableProduct\Helper\Data::class, + [ + '_imageHelper' => $this->_imageHelperMock + ] + ); + $objectManager->setBackwardCompatibleProperty($this->_model, 'imageUrlBuilder', $this->imageUrlBuilder); } public function testGetAllowAttributes() @@ -196,25 +214,38 @@ public function testGetGalleryImages() ->method('getMediaGalleryImages') ->willReturn($this->getImagesCollection()); - $this->_imageHelperMock->expects($this->exactly(3)) - ->method('init') - ->willReturnMap([ - [$productMock, 'product_page_image_small', [], $this->_imageHelperMock], - [$productMock, 'product_page_image_medium_no_frame', [], $this->_imageHelperMock], - [$productMock, 'product_page_image_large_no_frame', [], $this->_imageHelperMock], - ]) - ->willReturnSelf(); - $this->_imageHelperMock->expects($this->exactly(3)) + $this->imageUrlBuilder->expects($this->exactly(3)) + ->method('getUrl') + ->withConsecutive( + [ + self::identicalTo('test_file'), + self::identicalTo('product_page_image_small') + ], + [ + self::identicalTo('test_file'), + self::identicalTo('product_page_image_medium') + ], + [ + self::identicalTo('test_file'), + self::identicalTo('product_page_image_large') + ] + ) + ->will(self::onConsecutiveCalls( + 'testSmallImageUrl', + 'testMediumImageUrl', + 'testLargeImageUrl' + )); + $this->_imageHelperMock->expects(self::never()) ->method('setImageFile') ->with('test_file') ->willReturnSelf(); - $this->_imageHelperMock->expects($this->at(0)) + $this->_imageHelperMock->expects(self::never()) ->method('getUrl') ->willReturn('product_page_image_small_url'); - $this->_imageHelperMock->expects($this->at(1)) + $this->_imageHelperMock->expects(self::never()) ->method('getUrl') ->willReturn('product_page_image_medium_url'); - $this->_imageHelperMock->expects($this->at(2)) + $this->_imageHelperMock->expects(self::never()) ->method('getUrl') ->willReturn('product_page_image_large_url'); diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/OptionRepositoryTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/OptionRepositoryTest.php index 8f3f3979b8669..7ea55c51a5bb3 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/OptionRepositoryTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/OptionRepositoryTest.php @@ -357,7 +357,8 @@ public function testGetListNotConfigurableProduct() */ public function testValidateNewOptionData($attributeId, $label, $optionValues, $msg) { - $this->expectException(\Magento\Framework\Exception\InputException::class, $msg); + $this->expectException(\Magento\Framework\Exception\InputException::class); + $this->expectExceptionMessage($msg); $optionValueMock = $this->getMockBuilder(\Magento\ConfigurableProduct\Api\Data\OptionValueInterface::class) ->setMethods(['getValueIndex', 'getPricingValue', 'getIsPercent']) ->getMockForAbstractClass(); diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php new file mode 100644 index 0000000000000..d29f163ee1129 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Plugin/ProductIdentitiesExtenderTest.php @@ -0,0 +1,77 @@ +configurableTypeMock = $this->getMockBuilder(Configurable::class) + ->disableOriginalConstructor() + ->getMock(); + $this->productRepositoryMock = $this->getMockBuilder(ProductRepositoryInterface::class) + ->getMock(); + + $this->plugin = new ProductIdentitiesExtender($this->configurableTypeMock, $this->productRepositoryMock); + } + + public function testAfterGetIdentities() + { + $productId = 1; + $productIdentity = 'cache_tag_1'; + $productMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + $parentProductId = 2; + $parentProductIdentity = 'cache_tag_2'; + $parentProductMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->getMock(); + + $productMock->expects($this->once()) + ->method('getId') + ->willReturn($productId); + $this->configurableTypeMock->expects($this->once()) + ->method('getParentIdsByChild') + ->with($productId) + ->willReturn([$parentProductId]); + $this->productRepositoryMock->expects($this->once()) + ->method('getById') + ->with($parentProductId) + ->willReturn($parentProductMock); + $parentProductMock->expects($this->once()) + ->method('getIdentities') + ->willReturn([$parentProductIdentity]); + + $productIdentities = $this->plugin->afterGetIdentities($productMock, [$productIdentity]); + $this->assertEquals([$productIdentity, $parentProductIdentity], $productIdentities); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php deleted file mode 100644 index 5b4a8d5b8a975..0000000000000 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Cache/Tag/ConfigurableTest.php +++ /dev/null @@ -1,64 +0,0 @@ -typeResource = $this->createMock( - \Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable::class - ); - - $this->model = new Configurable($this->typeResource); - } - - public function testGetWithScalar() - { - $this->expectException(\InvalidArgumentException::class, 'Provided argument is not an object'); - $this->model->getTags('scalar'); - } - - public function testGetTagsWithObject() - { - $this->expectException(\InvalidArgumentException::class, 'Provided argument must be a product'); - $this->model->getTags(new \stdClass()); - } - - public function testGetTagsWithVariation() - { - $product = $this->createMock(\Magento\Catalog\Model\Product::class); - - $identities = ['id1', 'id2']; - - $product->expects($this->once()) - ->method('getIdentities') - ->willReturn($identities); - - $parentId = 4; - $this->typeResource->expects($this->once()) - ->method('getParentIdsByChild') - ->willReturn([$parentId]); - - $expected = array_merge($identities, [\Magento\Catalog\Model\Product::CACHE_TAG . '_' . $parentId]); - - $this->assertEquals($expected, $this->model->getTags($product)); - } -} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php index 6fda5b867ccef..851595422f596 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/SaveHandlerTest.php @@ -88,6 +88,7 @@ public function testExecuteWithInvalidProductType() public function testExecuteWithEmptyExtensionAttributes() { $sku = 'test'; + $configurableProductLinks = [1, 2, 3]; $product = $this->getMockBuilder(Product::class) ->disableOriginalConstructor() ->setMethods(['getTypeId', 'getExtensionAttributes', 'getSku']) @@ -105,16 +106,16 @@ public function testExecuteWithEmptyExtensionAttributes() ->disableOriginalConstructor() ->getMockForAbstractClass(); - $product->expects(static::once()) + $product->expects(static::atLeastOnce()) ->method('getExtensionAttributes') ->willReturn($extensionAttributes); - $extensionAttributes->expects(static::exactly(2)) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductOptions') ->willReturn([]); - $extensionAttributes->expects(static::once()) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductLinks') - ->willReturn([]); + ->willReturn($configurableProductLinks); $this->optionRepository->expects(static::once()) ->method('getList') @@ -133,7 +134,10 @@ public function testExecuteWithEmptyExtensionAttributes() public function testExecute() { $sku = 'config-1'; - $id = 25; + $idOld = 25; + $idNew = 26; + $attributeIdOld = 11; + $attributeIdNew = 22; $configurableProductLinks = [1, 2, 3]; $product = $this->getMockBuilder(Product::class) @@ -143,7 +147,7 @@ public function testExecute() $product->expects(static::once()) ->method('getTypeId') ->willReturn(ConfigurableModel::TYPE_CODE); - $product->expects(static::exactly(3)) + $product->expects(static::exactly(4)) ->method('getSku') ->willReturn($sku); @@ -156,30 +160,36 @@ public function testExecute() ->method('getExtensionAttributes') ->willReturn($extensionAttributes); - $attribute = $this->getMockBuilder(Attribute::class) + $attributeNew = $this->getMockBuilder(Attribute::class) ->disableOriginalConstructor() ->setMethods(['getAttributeId', 'loadByProductAndAttribute', 'setId', 'getId']) ->getMock(); - $this->processSaveOptions($attribute, $sku, $id); - - $option = $this->getMockForAbstractClass(OptionInterface::class); - $option->expects(static::once()) + $attributeNew->expects(static::atLeastOnce()) + ->method('getAttributeId') + ->willReturn($attributeIdNew); + $this->processSaveOptions($attributeNew, $sku, $idNew); + + $optionOld = $this->getMockForAbstractClass(OptionInterface::class); + $optionOld->expects(static::atLeastOnce()) + ->method('getAttributeId') + ->willReturn($attributeIdOld); + $optionOld->expects(static::atLeastOnce()) ->method('getId') - ->willReturn($id); + ->willReturn($idOld); - $list = [$option]; - $this->optionRepository->expects(static::once()) + $list = [$optionOld]; + $this->optionRepository->expects(static::atLeastOnce()) ->method('getList') ->with($sku) ->willReturn($list); $this->optionRepository->expects(static::once()) ->method('deleteById') - ->with($sku, $id); + ->with($sku, $idOld); $configurableAttributes = [ - $attribute + $attributeNew ]; - $extensionAttributes->expects(static::exactly(2)) + $extensionAttributes->expects(static::atLeastOnce()) ->method('getConfigurableProductOptions') ->willReturn($configurableAttributes); diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/Configurable/Variations/PricesTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/Configurable/Variations/PricesTest.php new file mode 100644 index 0000000000000..b4e7689498fe6 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/Product/Type/Configurable/Variations/PricesTest.php @@ -0,0 +1,62 @@ +localeFormatMock = $this->createMock(\Magento\Framework\Locale\Format::class); + $this->model = new \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Variations\Prices( + $this->localeFormatMock + ); + } + + public function testGetFormattedPrices() + { + $expected = [ + 'oldPrice' => [ + 'amount' => 500 + ], + 'basePrice' => [ + 'amount' => 1000 + ], + 'finalPrice' => [ + 'amount' => 500 + ] + ]; + $priceInfoMock = $this->createMock(\Magento\Framework\Pricing\PriceInfo\Base::class); + $priceMock = $this->createMock(\Magento\Framework\Pricing\Price\PriceInterface::class); + $priceInfoMock->expects($this->atLeastOnce())->method('getPrice')->willReturn($priceMock); + + $amountMock = $this->createMock(\Magento\Framework\Pricing\Amount\AmountInterface::class); + $amountMock->expects($this->atLeastOnce())->method('getValue')->willReturn(500); + $amountMock->expects($this->atLeastOnce())->method('getBaseAmount')->willReturn(1000); + $priceMock->expects($this->atLeastOnce())->method('getAmount')->willReturn($amountMock); + + $this->localeFormatMock->expects($this->atLeastOnce()) + ->method('getNumber') + ->withConsecutive([500], [1000], [500]) + ->will($this->onConsecutiveCalls(500, 1000, 500)); + + $this->assertEquals($expected, $this->model->getFormattedPrices($priceInfoMock)); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/LinkedProductSelectBuilderTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/LinkedProductSelectBuilderTest.php new file mode 100644 index 0000000000000..3ef03f32ae05d --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/LinkedProductSelectBuilderTest.php @@ -0,0 +1,72 @@ +baseSelectProcessorMock = $this->getMockBuilder(BaseSelectProcessorInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->linkedProductSelectBuilderMock = $this->getMockBuilder(LinkedProductSelectBuilderInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->subject = (new ObjectManager($this))->getObject( + LinkedProductSelectBuilder::class, + [ + 'baseSelectProcessor' => $this->baseSelectProcessorMock, + 'linkedProductSelectBuilder' => $this->linkedProductSelectBuilderMock, + ] + ); + } + + public function testBuild() + { + $productId = 42; + + /** @var Select|\PHPUnit_Framework_MockObject_MockObject $selectMock */ + $selectMock = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->getMock(); + + $expectedResult = [$selectMock]; + + $this->linkedProductSelectBuilderMock->expects($this->any()) + ->method('build') + ->with($productId) + ->willReturn($expectedResult); + + $this->baseSelectProcessorMock->expects($this->once()) + ->method('process') + ->with($selectMock); + + $this->assertEquals($expectedResult, $this->subject->build($productId)); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/StockStatusBaseSelectProcessorTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/StockStatusBaseSelectProcessorTest.php new file mode 100644 index 0000000000000..5717e995f9f96 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Model/ResourceModel/Product/StockStatusBaseSelectProcessorTest.php @@ -0,0 +1,113 @@ +stockConfigMock = $this->getMockBuilder(StockConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->stockStatusResourceMock = $this->getMockBuilder(StockStatusResource::class) + ->disableOriginalConstructor() + ->getMock(); + $this->stockStatusResourceMock->expects($this->any()) + ->method('getMainTable') + ->willReturn($this->stockStatusTable); + + $this->subject = (new ObjectManager($this))->getObject( + StockStatusBaseSelectProcessor::class, + [ + 'stockConfig' => $this->stockConfigMock, + 'stockStatusResource' => $this->stockStatusResourceMock, + ] + ); + } + + /** + * @param bool $isShowOutOfStock + * + * @dataProvider processDataProvider + */ + public function testProcess($isShowOutOfStock) + { + $this->stockConfigMock->expects($this->any()) + ->method('isShowOutOfStock') + ->willReturn($isShowOutOfStock); + + /** @var Select|\PHPUnit_Framework_MockObject_MockObject $selectMock */ + $selectMock = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->getMock(); + + if ($isShowOutOfStock) { + $selectMock->expects($this->once()) + ->method('joinInner') + ->with( + ['stock' => $this->stockStatusTable], + sprintf( + 'stock.product_id = %s.entity_id', + BaseSelectProcessorInterface::PRODUCT_TABLE_ALIAS + ), + [] + ) + ->willReturnSelf(); + $selectMock->expects($this->once()) + ->method('where') + ->with( + 'stock.stock_status = ?', + StockStatus::STATUS_IN_STOCK + ) + ->willReturnSelf(); + } else { + $selectMock->expects($this->never()) + ->method($this->anything()); + } + + $this->assertEquals($selectMock, $this->subject->process($selectMock)); + } + + /** + * @return array + */ + public function processDataProvider() + { + return [ + 'Out of stock products are being displayed' => [true], + 'Out of stock products are NOT being displayed' => [false], + ]; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Pricing/Price/LowestPriceOptionsProviderTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Pricing/Price/LowestPriceOptionsProviderTest.php index ceeb242a750a2..7c83645a9fda3 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Pricing/Price/LowestPriceOptionsProviderTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Pricing/Price/LowestPriceOptionsProviderTest.php @@ -9,6 +9,9 @@ use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\ResourceModel\Product\LinkedProductSelectBuilderInterface; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\Store; class LowestPriceOptionsProviderTest extends \PHPUnit\Framework\TestCase { @@ -42,6 +45,16 @@ class LowestPriceOptionsProviderTest extends \PHPUnit\Framework\TestCase */ private $productCollection; + /** + * @var StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $storeManagerMock; + + /** + * @var StoreInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $storeMock; + protected function setUp() { $this->connection = $this @@ -68,6 +81,11 @@ protected function setUp() ->setMethods(['create']) ->getMock(); $this->collectionFactory->expects($this->once())->method('create')->willReturn($this->productCollection); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); + $this->storeMock = $this->getMockBuilder(StoreInterface::class) + ->setMethods(['getId']) + ->getMockForAbstractClass(); $objectManager = new ObjectManager($this); $this->model = $objectManager->getObject( @@ -76,6 +94,7 @@ protected function setUp() 'resourceConnection' => $this->resourceConnection, 'linkedProductSelectBuilder' => $this->linkedProductSelectBuilder, 'collectionFactory' => $this->collectionFactory, + 'storeManager' => $this->storeManagerMock, ] ); } @@ -94,6 +113,13 @@ public function testGetProducts() ->willReturnSelf(); $this->productCollection->expects($this->once())->method('addIdFilter')->willReturnSelf(); $this->productCollection->expects($this->once())->method('getItems')->willReturn($linkedProducts); + $this->storeManagerMock->expects($this->any()) + ->method('getStore') + ->with(Store::DEFAULT_STORE_ID) + ->willReturn($this->storeMock); + $this->storeMock->expects($this->any()) + ->method('getId') + ->willReturn(Store::DEFAULT_STORE_ID); $this->assertEquals($linkedProducts, $this->model->getProducts($product)); } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CompositeTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CompositeTest.php index 67f758af22bec..c3f2008c8a687 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CompositeTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CompositeTest.php @@ -164,15 +164,21 @@ public function testDisallowModifyMeta() { $meta = ['some meta']; $modifiers = ['modifier1', 'modifier2']; - $this->productMock->expects(static::any()) + $this->productMock->expects(self::any()) ->method('getTypeId') ->willReturn(ConfigurableType::TYPE_CODE); - $this->allowedProductTypesMock->expects(static::once()) + $this->allowedProductTypesMock->expects(self::once()) ->method('isAllowedProductType') ->with($this->productMock) ->willReturn(false); - $this->objectManagerMock->expects(static::never()) - ->method('get'); + $this->objectManagerMock->expects(self::exactly(2)) + ->method('get') + ->willReturnMap( + [ + ['modifier1', $this->createModifierMock($meta, ['modifier1_meta'])], + ['modifier2', $this->createModifierMock(['modifier1_meta'], $meta)], + ] + ); $this->assertSame($meta, $this->createCompositeModifier($modifiers)->modifyMeta($meta)); } diff --git a/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Composite.php b/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Composite.php index c9c3a5659aa5e..2d727b81143de 100644 --- a/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Composite.php +++ b/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Composite.php @@ -44,6 +44,11 @@ class Composite extends AbstractModifier */ protected $allowedProductTypes; + /** + * @var ModifierInterface[] + */ + private $modifiersObjects = []; + /** * @param LocatorInterface $locator * @param ObjectManagerInterface $objectManager @@ -63,6 +68,19 @@ public function __construct( $this->associatedProducts = $associatedProducts; $this->allowedProductTypes = $allowedProductTypes; $this->modifiers = $modifiers; + + foreach ($this->modifiers as $modifierClass) { + /** @var ModifierInterface $bundleModifier */ + $modifier = $this->objectManager->get($modifierClass); + if (!$modifier instanceof ModifierInterface) { + throw new \InvalidArgumentException(__( + 'Type %1 is not an instance of %2', + $modifierClass, + ModifierInterface::class + )); + } + $this->modifiersObjects[] = $modifier; + } } /** @@ -86,6 +104,10 @@ public function modifyData(array $data) } } + foreach ($this->modifiersObjects as $modifier) { + $data = $modifier->modifyData($data); + } + return $data; } @@ -95,19 +117,11 @@ public function modifyData(array $data) public function modifyMeta(array $meta) { if ($this->allowedProductTypes->isAllowedProductType($this->locator->getProduct())) { - foreach ($this->modifiers as $modifierClass) { - /** @var ModifierInterface $bundleModifier */ - $modifier = $this->objectManager->get($modifierClass); - if (!$modifier instanceof ModifierInterface) { - throw new \InvalidArgumentException(__( - 'Type %1 is not an instance of %2', - $modifierClass, - ModifierInterface::class - )); - } + foreach ($this->modifiersObjects as $modifier) { $meta = $modifier->modifyMeta($meta); } } + return $meta; } } diff --git a/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/ConfigurablePanel.php b/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/ConfigurablePanel.php index 9fd225e8acaab..fbab25ff1bea6 100644 --- a/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/ConfigurablePanel.php +++ b/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/ConfigurablePanel.php @@ -475,7 +475,10 @@ protected function getRows() [ 'required-entry' => true, 'max_text_length' => Sku::SKU_MAX_LENGTH, - ] + ], + ], + [ + 'elementTmpl' => 'Magento_ConfigurableProduct/components/cell-sku', ] ), 'price_container' => $this->getColumn( diff --git a/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Data/AssociatedProducts.php b/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Data/AssociatedProducts.php index 9150215e2c41e..c474acbec5094 100644 --- a/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Data/AssociatedProducts.php +++ b/app/code/Magento/ConfigurableProduct/Ui/DataProvider/Product/Form/Modifier/Data/AssociatedProducts.php @@ -17,6 +17,8 @@ use Magento\Framework\Json\Helper\Data as JsonHelper; use Magento\Framework\Locale\CurrencyInterface; use Magento\Framework\UrlInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Escaper; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -83,6 +85,11 @@ class AssociatedProducts */ protected $imageHelper; + /** + * @var Escaper + */ + private $escaper; + /** * @param LocatorInterface $locator * @param UrlInterface $urlBuilder @@ -93,6 +100,8 @@ class AssociatedProducts * @param CurrencyInterface $localeCurrency * @param JsonHelper $jsonHelper * @param ImageHelper $imageHelper + * @param Escaper|null $escaper + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( LocatorInterface $locator, @@ -103,7 +112,8 @@ public function __construct( VariationMatrix $variationMatrix, CurrencyInterface $localeCurrency, JsonHelper $jsonHelper, - ImageHelper $imageHelper + ImageHelper $imageHelper, + Escaper $escaper = null ) { $this->locator = $locator; $this->urlBuilder = $urlBuilder; @@ -114,6 +124,7 @@ public function __construct( $this->localeCurrency = $localeCurrency; $this->jsonHelper = $jsonHelper; $this->imageHelper = $imageHelper; + $this->escaper = $escaper ?: ObjectManager::getInstance()->get(Escaper::class); } /** @@ -280,9 +291,9 @@ protected function prepareVariations() 'product_link' => '' . $product->getName() . '', + ) . '" target="_blank">' . $this->escaper->escapeHtml($product->getName()) . '', 'sku' => $product->getSku(), - 'name' => $product->getName(), + 'name' => $this->escaper->escapeHtml($product->getName()), 'qty' => $this->getProductStockQty($product), 'price' => $price, 'price_string' => $currency->toCurrency(sprintf("%f", $price)), diff --git a/app/code/Magento/ConfigurableProduct/composer.json b/app/code/Magento/ConfigurableProduct/composer.json index f06afe07d7b7f..959c036981878 100644 --- a/app/code/Magento/ConfigurableProduct/composer.json +++ b/app/code/Magento/ConfigurableProduct/composer.json @@ -5,29 +5,28 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-msrp": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-ui": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-checkout": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-media-storage": "*", + "magento/module-msrp": "*", + "magento/module-quote": "*", + "magento/module-store": "*", + "magento/module-ui": "*" }, "suggest": { - "magento/module-webapi": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-product-video": "100.3.*", - "magento/module-configurable-sample-data": "Sample Data version:100.3.*", - "magento/module-product-links-sample-data": "Sample Data version:100.3.*" + "magento/module-webapi": "*", + "magento/module-sales": "*", + "magento/module-product-video": "*", + "magento/module-configurable-sample-data": "*", + "magento/module-product-links-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ConfigurableProduct/etc/db_schema.xml b/app/code/Magento/ConfigurableProduct/etc/db_schema.xml index d45c06bea1c3e..7c6661a5f399a 100644 --- a/app/code/Magento/ConfigurableProduct/etc/db_schema.xml +++ b/app/code/Magento/ConfigurableProduct/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    Action
    - - - - \Magento\ConfigurableProduct\Model\Product\Cache\Tag\Configurable - - - @@ -196,4 +189,20 @@ Magento\Catalog\Model\Indexer\Product\Full + + + Magento\ConfigurableProduct\Model\ResourceModel\Product\LinkedProductSelectBuilder + + + + + Magento\ConfigurableProduct\Model\ResourceModel\Product\StockStatusBaseSelectProcessor + + + + + + + + diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/product/configurable/affected-attribute-set-selector/js.phtml b/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/product/configurable/affected-attribute-set-selector/js.phtml index d70576d975ac3..0e0eb3464eaa6 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/product/configurable/affected-attribute-set-selector/js.phtml +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/product/configurable/affected-attribute-set-selector/js.phtml @@ -89,7 +89,7 @@ showLoader: true, context: $form }) - .success(function (data) { + .done(function (data) { if (!data.error) { setAttributeSetId(data.id); closeDialogAndProcessForm($form); diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/components/dynamic-rows-configurable.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/components/dynamic-rows-configurable.js index 01abce7696014..94a24779450ae 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/components/dynamic-rows-configurable.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/components/dynamic-rows-configurable.js @@ -349,8 +349,6 @@ define([ ); _.each(data, function (row) { - var attributesText; - if (row.productId) { index = _.indexOf(productIdsToDelete, row.productId); @@ -364,36 +362,8 @@ define([ ); } } + product = this.getProductData(row); - attributesText = ''; - _.each(row.options, function (attribute) { - if (attributesText) { - attributesText += ', '; - } - attributesText += attribute['attribute_label'] + ': ' + attribute.label; - }, this); - - product = { - 'id': row.productId, - 'product_link': row.productUrl, - 'name': row.name, - 'sku': row.sku, - 'status': row.status, - 'price': row.price, - 'price_currency': row.priceCurrency, - 'price_string': row.priceCurrency + row.price, - 'weight': row.weight, - 'qty': row.quantity, - 'variationKey': row.variationKey, - 'configurable_attribute': row.attribute, - 'thumbnail_image': row.images.preview, - 'media_gallery': row['media_gallery'], - 'swatch_image': row['swatch_image'], - 'small_image': row['small_image'], - image: row.image, - 'thumbnail': row.thumbnail, - 'attributes': attributesText - }; product[this.changedFlag] = true; product[this.canEditField] = row.editable; product[this.newProductField] = row.newProduct; @@ -412,6 +382,47 @@ define([ this.unionInsertData(tmpArray); }, + /** + * + * @param {Object} row + * @returns {Object} + */ + getProductData: function (row) { + var product, + attributesText = ''; + + _.each(row.options, function (attribute) { + if (attributesText) { + attributesText += ', '; + } + attributesText += attribute['attribute_label'] + ': ' + attribute.label; + }, this); + + product = { + 'id': row.productId, + 'product_link': row.productUrl, + 'name': row.name, + 'sku': row.sku, + 'status': row.status, + 'price': row.price, + 'price_currency': row.priceCurrency, + 'price_string': row.priceCurrency + row.price, + 'weight': row.weight, + 'qty': row.quantity, + 'variationKey': row.variationKey, + 'configurable_attribute': row.attribute, + 'thumbnail_image': row.images.preview, + 'media_gallery': row['media_gallery'], + 'swatch_image': row['swatch_image'], + 'small_image': row['small_image'], + image: row.image, + 'thumbnail': row.thumbnail, + 'attributes': attributesText + }; + + return product; + }, + /** * Remove array items matching condition. * diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/product-grid.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/product-grid.js index d8f902f0dbfc6..63361baab809f 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/product-grid.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/product-grid.js @@ -95,7 +95,7 @@ define([ type: 'GET', url: this._buildGridUrl(filterData), context: $('body') - }).success(function (data) { + }).done(function (data) { bootstrap(JSON.parse(data)); }); }, diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/bulk.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/bulk.js index 8ae6804908cb0..00bf1feff7fb5 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/bulk.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/bulk.js @@ -78,10 +78,12 @@ define([ * Make options sections. */ this.makeOptionSections = function () { - this.images = new self.makeImages(null); - this.price = self.price; - this.quantity = self.quantity; - }; + return { + images: new this.makeImages(null), + price: this.price, + quantity: this.quantity + }; + }.bind(this); /** * @param {Object} images @@ -152,7 +154,7 @@ define([ //fill option section data this.attributes.each(function (attribute) { attribute.chosen.each(function (option) { - option.sections = ko.observable(new this.makeOptionSections()); + option.sections = ko.observable(this.makeOptionSections()); }, this); }, this); //reset section.attribute diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/summary.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/summary.js index dc83a58899981..ac952ca531a34 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/summary.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/summary.js @@ -53,7 +53,8 @@ define([ attributes: [], attributesName: [$.mage.__('Images'), $.mage.__('SKU'), $.mage.__('Quantity'), $.mage.__('Price')], sections: [], - gridTemplate: 'Magento_ConfigurableProduct/variations/steps/summary-grid' + gridTemplate: 'Magento_ConfigurableProduct/variations/steps/summary-grid', + quantityFieldName: 'quantity' }, /** @inheritdoc */ @@ -112,12 +113,12 @@ define([ return memo + '-' + option.label; }, ''); name = productName + _.reduce(options, function (memo, option) { - return memo + '-' + option.label; - }, ''); - quantity = getSectionValue('quantity', options); + return memo + '-' + option.label; + }, ''); + quantity = getSectionValue(this.quantityFieldName, options); if (!quantity && productId) { - quantity = product.quantity; + quantity = product[this.quantityFieldName]; } price = getSectionValue('price', options); @@ -133,12 +134,12 @@ define([ images: images, sku: sku, name: name, - quantity: quantity, price: price, productId: productId, weight: productWeight, editable: true }; + variation[this.quantityFieldName] = quantity; if (productId) { variation.sku = product.sku; @@ -163,7 +164,6 @@ define([ this.variationsExisting = gridExisting; this.variationsNew = gridNew; this.variationsDeleted = gridDeleted; - }, /** @@ -195,7 +195,7 @@ define([ images: [] }, variation.images)); row.push(variation.sku); - row.push(variation.quantity); + row.push(variation[this.quantityFieldName]); _.each(variation.options, function (option) { row.push(option.label); }); diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js index 537482994fd38..9aa67beb3a51d 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js @@ -492,7 +492,7 @@ define([ dataType: 'json', showLoader: true, context: this - }).success(function (data) { + }).done(function (data) { if (!data.error) { this.set( 'skeletonAttributeSet', @@ -507,7 +507,7 @@ define([ } return false; - }).error(function (xhr) { + }).fail(function (xhr) { if (xhr.statusText === 'abort') { return; } diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/template/components/cell-sku.html b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/template/components/cell-sku.html new file mode 100644 index 0000000000000..fcd4e3b74442b --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/template/components/cell-sku.html @@ -0,0 +1,13 @@ + +
    + + +
    diff --git a/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/final_price.phtml b/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/final_price.phtml index 98abb906f69d6..f020e4c2c9495 100644 --- a/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/final_price.phtml +++ b/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/final_price.phtml @@ -19,16 +19,20 @@ $finalPriceModel = $block->getPriceType('final_price'); $idSuffix = $block->getIdSuffix() ? $block->getIdSuffix() : ''; $schema = ($block->getZone() == 'item_view') ? true : false; ?> + + __('As low as'), + 'price_id' => $block->getPriceId('product-price-' . $idSuffix), + 'price_type' => 'finalPrice', + 'include_container' => true, + 'schema' => $schema, + ]; + /* @noEscape */ echo $block->renderAmount($finalPriceModel->getAmount(), $arguments); + ?> + + isProductList() && $block->hasSpecialPrice()): ?> - - renderAmount($finalPriceModel->getAmount(), [ - 'display_label' => __('Special Price'), - 'price_id' => $block->getPriceId('product-price-' . $idSuffix), - 'price_type' => 'finalPrice', - 'include_container' => true, - 'schema' => $schema - ]); ?> - renderAmount($priceModel->getAmount(), [ 'display_label' => __('Regular Price'), @@ -38,13 +42,6 @@ $schema = ($block->getZone() == 'item_view') ? true : false; 'skip_adjustments' => true ]); ?> - - renderAmount($finalPriceModel->getAmount(), [ - 'price_id' => $block->getPriceId('product-price-' . $idSuffix), - 'price_type' => 'finalPrice', - 'include_container' => true, - 'schema' => $schema - ]); ?> showMinimalPrice()): ?> diff --git a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js index 545887d04c965..8cabe71c17504 100644 --- a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js +++ b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js @@ -32,6 +32,7 @@ define([ mediaGallerySelector: '[data-gallery-role=gallery-placeholder]', mediaGalleryInitial: null, slyOldPriceSelector: '.sly-old-price', + normalPriceLabelSelector: '.normal-price .price-label', /** * Defines the mechanism of how images of a gallery should be @@ -269,6 +270,7 @@ define([ this._reloadPrice(); this._displayRegularPriceBlock(this.simpleProduct); this._displayTierPriceBlock(this.simpleProduct); + this._displayNormalPriceLabel(); this._changeProductImage(); }, @@ -527,8 +529,16 @@ define([ * @private */ _displayRegularPriceBlock: function (optionId) { - if (typeof optionId != 'undefined' && - this.options.spConfig.optionPrices[optionId].oldPrice.amount != //eslint-disable-line eqeqeq + var shouldBeShown = true; + + _.each(this.options.settings, function (element) { + if (element.value === '') { + shouldBeShown = false; + } + }); + + if (shouldBeShown && + this.options.spConfig.optionPrices[optionId].oldPrice.amount !== this.options.spConfig.optionPrices[optionId].finalPrice.amount ) { $(this.options.slyOldPriceSelector).show(); @@ -537,6 +547,27 @@ define([ } }, + /** + * Show or hide normal price label + * + * @private + */ + _displayNormalPriceLabel: function () { + var shouldBeShown = false; + + _.each(this.options.settings, function (element) { + if (element.value === '') { + shouldBeShown = true; + } + }); + + if (shouldBeShown) { + $(this.options.normalPriceLabelSelector).show(); + } else { + $(this.options.normalPriceLabelSelector).hide(); + } + }, + /** * Callback which fired after gallery gets initialized. * diff --git a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js index 64aefc27dc080..37b7c7c41b216 100644 --- a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js +++ b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/options-updater.js @@ -1,15 +1,18 @@ define([ 'jquery', + 'underscore', 'Magento_Customer/js/customer-data' -], function ($, customerData) { +], function ($, _, customerData) { 'use strict'; var selectors = { formSelector: '#product_addtocart_form', - productIdSelector: '#product_addtocart_form [name="product"]' + productIdSelector: '#product_addtocart_form [name="product"]', + itemIdSelector: '#product_addtocart_form [name="item"]' }, cartData = customerData.get('cart'), productId = $(selectors.productIdSelector).val(), + itemId = $(selectors.itemIdSelector).val(), /** * set productOptions according to cart data from customer-data @@ -24,7 +27,9 @@ define([ return false; } changedProductOptions = data.items.find(function (item) { - return item['product_id'] === productId; + if (item['item_id'] === itemId) { + return item['product_id'] === productId; + } }); changedProductOptions = changedProductOptions && changedProductOptions.options && changedProductOptions.options.reduce(function (obj, val) { diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/ConfigurableProductTypeResolver.php b/app/code/Magento/ConfigurableProductGraphQl/Model/ConfigurableProductTypeResolver.php index 191f802187d56..aae39800cdd30 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/ConfigurableProductTypeResolver.php +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/ConfigurableProductTypeResolver.php @@ -3,10 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\ConfigurableProductGraphQl\Model; -use Magento\Framework\GraphQl\Config\Data\TypeResolverInterface; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; /** * {@inheritdoc} @@ -16,10 +17,11 @@ class ConfigurableProductTypeResolver implements TypeResolverInterface /** * {@inheritdoc} */ - public function resolveType(array $data) + public function resolveType(array $data) : string { if (isset($data['type_id']) && $data['type_id'] == 'configurable') { return 'ConfigurableProduct'; } + return ''; } } diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Options/Collection.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/Collection.php new file mode 100644 index 0000000000000..90ed5cf54892d --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Options/Collection.php @@ -0,0 +1,131 @@ +attributeCollectionFactory = $attributeCollectionFactory; + $this->productFactory = $productFactory; + $this->metadataPool = $metadataPool; + } + + /** + * Add product id to attribute collection filter. + * + * @param int $productId + */ + public function addProductId(int $productId) : void + { + if (!in_array($productId, $this->productIds)) { + $this->productIds[] = $productId; + } + } + + /** + * Retrieve attributes for given product id or empty array + * + * @param int $productId + * @return array + */ + public function getAttributesByProductId(int $productId) : array + { + $attributes = $this->fetch(); + + if (!isset($attributes[$productId])) { + return []; + } + + return $attributes[$productId]; + } + + /** + * Fetch attribute data + * + * @return array + */ + private function fetch() : array + { + if (empty($this->productIds) || !empty($this->attributeMap)) { + return $this->attributeMap; + } + + $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); + /** @var AttributeCollection $attributeCollection */ + $attributeCollection = $this->attributeCollectionFactory->create(); + foreach ($this->productIds as $id) { + /** @var Product $product */ + $product = $this->productFactory->create(); + $product->setData($linkField, $id); + $attributeCollection->setProductFilter($product); + } + + /** @var Attribute $attribute */ + foreach ($attributeCollection->getItems() as $attribute) { + $productId = (int)$attribute->getProductId(); + if (!isset($this->attributeMap[$productId])) { + $this->attributeMap[$productId] = []; + } + + $attributeData = $attribute->getData(); + $this->attributeMap[$productId][$attribute->getId()] = $attribute->getData(); + $this->attributeMap[$productId][$attribute->getId()]['id'] = $attribute->getId(); + $this->attributeMap[$productId][$attribute->getId()]['attribute_code'] + = $attribute->getProductAttribute()->getAttributeCode(); + $this->attributeMap[$productId][$attribute->getId()]['values'] = $attributeData['options']; + } + + return $this->attributeMap; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Plugin/Model/Resolver/Products/DataProvider/ProductPlugin.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Plugin/Model/Resolver/Products/DataProvider/ProductPlugin.php deleted file mode 100644 index fdf5bbc34f85a..0000000000000 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Plugin/Model/Resolver/Products/DataProvider/ProductPlugin.php +++ /dev/null @@ -1,149 +0,0 @@ -configurable = $configurable; - $this->attributeCollection = $attributeCollection; - $this->productCollection = $productCollection; - $this->productRepository = $productRepository; - $this->searchCriteriaBuilder = $searchCriteriaBuilder; - $this->metadataPool = $metadataPool; - } - - /** - * Intercept GraphQLCatalog getList, and add any necessary configurable fields - * - * @param Product $subject - * @param SearchResultsInterface $result - * @return SearchResultsInterface - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function afterGetList(Product $subject, SearchResultsInterface $result) - { - $processConfigurableData = false; - /** @var ProductInterface $product */ - foreach ($result->getItems() as $product) { - if ($product->getTypeId() === Configurable::TYPE_CODE) { - $this->productCollection->setProductFilter($product); - $this->attributeCollection->setProductFilter($product); - $processConfigurableData = true; - } - } - - if ($processConfigurableData) { - /** @var \Magento\Catalog\Model\Product[] $children */ - $children = $this->productCollection->getItems(); - /** @var Attribute[] $attributes */ - $attributes = $this->attributeCollection->getItems(); - $result = $this->addConfigurableData($result, $children, $attributes); - } - - return $result; - } - - /** - * Add configurable data to any configurable products in result set - * - * @param SearchResultsInterface $result - * @param DataObject[] $children - * @param DataObject[] $attributes - * @return SearchResultsInterface - */ - private function addConfigurableData($result, $children, $attributes) - { - $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); - foreach ($result->getItems() as $product) { - if ($product->getTypeId() === Configurable::TYPE_CODE) { - $extensionAttributes = $product->getExtensionAttributes(); - $childrenIds = []; - foreach ($children as $child) { - if ($child->getParentId() === $product->getData($linkField)) { - $childrenIds[] = $child->getId(); - } - } - $productAttributes = []; - foreach ($attributes as $attribute) { - if ($attribute->getProductId() === $product->getId()) { - $productAttributes[] = $attribute; - } - } - $extensionAttributes->setConfigurableProductLinks($childrenIds); - $extensionAttributes->setConfigurableProductOptions($productAttributes); - $product->setExtensionAttributes($extensionAttributes); - } - } - - return $result; - } -} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/ConfigurableVariant.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/ConfigurableVariant.php new file mode 100644 index 0000000000000..e63c75d500327 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/ConfigurableVariant.php @@ -0,0 +1,131 @@ +variantCollection = $variantCollection; + $this->optionCollection = $optionCollection; + $this->valueFactory = $valueFactory; + $this->attributeCollection = $attributeCollection; + $this->metadataPool = $metadataPool; + } + + /** + * Fetch and format configurable variants. + * + * {@inheritDoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); + if ($value['type_id'] !== Type::TYPE_CODE || !isset($value[$linkField])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + $this->variantCollection->addParentId((int)$value[$linkField]); + $fields = $this->getProductFields($info); + $matchedFields = $this->attributeCollection->getRequestAttributes($fields); + $this->variantCollection->addEavAttributes($matchedFields); + $this->optionCollection->addProductId((int)$value[$linkField]); + + $result = function () use ($value, $linkField) { + $children = $this->variantCollection->getChildProductsByParentId((int)$value[$linkField]); + $options = $this->optionCollection->getAttributesByProductId((int)$value[$linkField]); + $variants = []; + /** @var Product $child */ + foreach ($children as $key => $child) { + $variants[$key] = ['sku' => $child['sku'], 'product' => $child, 'options' => $options]; + } + + return $variants; + }; + + return $this->valueFactory->create($result); + } + + /** + * Return field names for all requested product fields. + * + * @param ResolveInfo $info + * @return string[] + */ + private function getProductFields(ResolveInfo $info) + { + $fieldNames = []; + foreach ($info->fieldNodes as $node) { + if ($node->name->value !== 'product') { + continue; + } + + foreach ($node->selectionSet->selections as $selectionNode) { + $fieldNames[] = $selectionNode->name->value; + } + } + + return $fieldNames; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Options.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Options.php new file mode 100644 index 0000000000000..53912f7029e55 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Options.php @@ -0,0 +1,78 @@ +optionCollection = $optionCollection; + $this->valueFactory = $valueFactory; + $this->metadataPool = $metadataPool; + } + + /** + * Fetch and format configurable variants. + * + * {@inheritDoc} + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) : Value + { + $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); + if ($value['type_id'] !== Type::TYPE_CODE || !isset($value[$linkField])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + $this->optionCollection->addProductId((int)$value[$linkField]); + + $result = function () use ($value, $linkField) { + return $this->optionCollection->getAttributesByProductId((int)$value[$linkField]); + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/ConfigurableOptions.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/ConfigurableOptions.php deleted file mode 100644 index 9924e755c774b..0000000000000 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Products/DataProvider/Product/Formatter/ConfigurableOptions.php +++ /dev/null @@ -1,51 +0,0 @@ -configurableData = $configurableData; - } - - /** - * Add configurable links and options to configurable types - * - * {@inheritdoc} - */ - public function format(Product $product, array $productData = []) - { - if ($product->getTypeId() === Configurable::TYPE_CODE) { - $extensionAttributes = $product->getExtensionAttributes(); - $productData['configurable_product_options'] = $extensionAttributes->getConfigurableProductOptions(); - $productData['configurable_product_links'] = $extensionAttributes->getConfigurableProductLinks(); - /** @var \Magento\ConfigurableProduct\Model\Product\Type\Configurable\Attribute $option */ - foreach ($productData['configurable_product_options'] as $optionKey => $option) { - $productData['configurable_product_options'][$optionKey]['attribute_code'] - = $option->getProductAttribute()->getAttributeCode(); - } - } - - return $productData; - } -} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Products/Query/ConfigurableProductPostProcessor.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Products/Query/ConfigurableProductPostProcessor.php deleted file mode 100644 index 0c329aaf4f0fa..0000000000000 --- a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Products/Query/ConfigurableProductPostProcessor.php +++ /dev/null @@ -1,115 +0,0 @@ -searchCriteriaBuilder = $searchCriteriaBuilder; - $this->productDataProvider = $productDataProvider; - $this->productResource = $productResource; - $this->formatter = $formatter; - } - - /** - * Process all configurable product data, including adding simple product data and formatting relevant attributes. - * - * @param array $resultData - * @return array - */ - public function process(array $resultData) - { - $childrenIds = []; - foreach ($resultData as $key => $product) { - if (isset($product['type_id']) && $product['type_id'] === Configurable::TYPE_CODE) { - $formattedChildIds = []; - if (isset($product['configurable_product_links'])) { - foreach ($product['configurable_product_links'] as $childId) { - $childrenIds[] = (int)$childId; - $formattedChildIds[$childId] = null; - } - } - $resultData[$key]['configurable_product_links'] = $formattedChildIds; - } - } - - $this->searchCriteriaBuilder->addFilter('entity_id', $childrenIds, 'in'); - $childProducts = $this->productDataProvider->getList($this->searchCriteriaBuilder->create()); - $resultData = $this->addChildData($childProducts->getItems(), $resultData); - - return $resultData; - } - - /** - * Format and add configurable child data to their matching products result items. - * - * @param \Magento\Catalog\Model\Product[] $childProducts - * @param array $resultData - * @return array - */ - private function addChildData(array $childProducts, array $resultData) - { - /** @var \Magento\Catalog\Model\Product $childProduct */ - foreach ($childProducts as $childProduct) { - $childData = $this->formatter->format($childProduct); - $childId = (int)$childProduct->getId(); - foreach ($resultData as $key => $item) { - if (isset($item['configurable_product_links']) - && array_key_exists($childId, $item['configurable_product_links']) - ) { - $resultData[$key]['configurable_product_links'][$childId] = $childData; - $categoryLinks = $this->productResource->getCategoryIds($childProduct); - foreach ($categoryLinks as $position => $link) { - $resultData[$key]['configurable_product_links'][$childId]['category_links'][] = - ['position' => $position, 'category_id' => $link]; - } - } - } - } - return $resultData; - } -} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Variant/Attributes.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Variant/Attributes.php new file mode 100644 index 0000000000000..9c275de3f0962 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Resolver/Variant/Attributes.php @@ -0,0 +1,79 @@ +valueFactory = $valueFactory; + } + + /** + * Format product's option data to conform to GraphQL schema + * + * {@inheritdoc} + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ): Value { + if (!isset($value['options']) || !isset($value['product'])) { + $result = function () { + return null; + }; + return $this->valueFactory->create($result); + } + + $result = function () use ($value) { + $data = []; + foreach ($value['options'] as $option) { + $code = $option['attribute_code']; + if (!isset($value['product'][$code])) { + continue; + } + + foreach ($option['values'] as $optionValue) { + if ($optionValue['value_index'] != $value['product'][$code]) { + continue; + } + $data[] = [ + 'label' => $optionValue['label'], + 'code' => $code, + 'use_default_value' => $optionValue['use_default_value'], + 'value_index' => $optionValue['value_index'] + ]; + } + } + + return $data; + }; + + return $this->valueFactory->create($result); + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/Model/Variant/Collection.php b/app/code/Magento/ConfigurableProductGraphQl/Model/Variant/Collection.php new file mode 100644 index 0000000000000..0d86e16574395 --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/Model/Variant/Collection.php @@ -0,0 +1,163 @@ +childCollectionFactory = $childCollectionFactory; + $this->productFactory = $productFactory; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->productDataProvider = $productDataProvider; + $this->metadataPool = $metadataPool; + } + + /** + * Add parent Id to collection filter + * + * @param int $id + * @return void + */ + public function addParentId(int $id) : void + { + if (!in_array($id, $this->parentIds) && !empty($this->childrenMap)) { + $this->childrenMap = []; + $this->parentIds[] = $id; + } elseif (!in_array($id, $this->parentIds)) { + $this->parentIds[] = $id; + } + } + + /** + * Add attributes to collection filter + * + * @param array $attributeCodes + * @return void + */ + public function addEavAttributes(array $attributeCodes) : void + { + $this->attributeCodes = array_replace($this->attributeCodes, $attributeCodes); + } + + /** + * Retrieve child products from for passed in parent id. + * + * @param int $id + * @return array + */ + public function getChildProductsByParentId(int $id) : array + { + $childrenMap = $this->fetch(); + + if (!isset($childrenMap[$id])) { + return []; + } + + return $childrenMap[$id]; + } + + /** + * Fetch all children products from parent id's. + * + * @return array + */ + private function fetch() : array + { + if (empty($this->parentIds) || !empty($this->childrenMap)) { + return $this->childrenMap; + } + + $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField(); + foreach ($this->parentIds as $id) { + /** @var ChildCollection $childCollection */ + $childCollection = $this->childCollectionFactory->create(); + /** @var Product $product */ + $product = $this->productFactory->create(); + $product->setData($linkField, $id); + $childCollection->setProductFilter($product); + + /** @var Product $childProduct */ + foreach ($childCollection->getItems() as $childProduct) { + $formattedChild = ['model' => $childProduct, 'sku' => $childProduct->getSku()]; + $parentId = (int)$childProduct->getParentId(); + if (!isset($this->childrenMap[$parentId])) { + $this->childrenMap[$parentId] = []; + } + + $this->childrenMap[$parentId][] = $formattedChild; + } + } + + return $this->childrenMap; + } +} diff --git a/app/code/Magento/ConfigurableProductGraphQl/composer.json b/app/code/Magento/ConfigurableProductGraphQl/composer.json index 58881fedd63ea..a22df27734b76 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/composer.json +++ b/app/code/Magento/ConfigurableProductGraphQl/composer.json @@ -2,13 +2,12 @@ "name": "magento/module-configurable-product-graph-ql", "description": "N/A", "type": "magento2-module", - "version": "100.0.0-dev", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/module-catalog": "101.2.*", - "magento/module-configurable-product": "100.3.*", - "magento/module-catalog-graph-ql": "100.0.*", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/module-catalog": "*", + "magento/module-configurable-product": "*", + "magento/module-catalog-graph-ql": "*", + "magento/framework": "*" }, "license": [ "OSL-3.0", diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/di.xml b/app/code/Magento/ConfigurableProductGraphQl/etc/di.xml deleted file mode 100644 index 1dae6f2a3d9ba..0000000000000 --- a/app/code/Magento/ConfigurableProductGraphQl/etc/di.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - Magento\ConfigurableProductGraphQl\Model\Resolver\Products\Query\ConfigurableProductPostProcessor - - - - diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/graphql.xml b/app/code/Magento/ConfigurableProductGraphQl/etc/graphql.xml deleted file mode 100644 index 748462cf94772..0000000000000 --- a/app/code/Magento/ConfigurableProductGraphQl/etc/graphql.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/graphql/di.xml b/app/code/Magento/ConfigurableProductGraphQl/etc/graphql/di.xml index dbbe79930efa2..f9d3c8b769795 100644 --- a/app/code/Magento/ConfigurableProductGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/ConfigurableProductGraphQl/etc/graphql/di.xml @@ -6,13 +6,6 @@ */ --> - - - - Magento\ConfigurableProductGraphQl\Model\Resolver\Products\DataProvider\Product\Formatter\ConfigurableOptions - - - @@ -20,7 +13,7 @@ - + diff --git a/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls b/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls new file mode 100644 index 0000000000000..267a94a1d434e --- /dev/null +++ b/app/code/Magento/ConfigurableProductGraphQl/etc/schema.graphqls @@ -0,0 +1,37 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +type ConfigurableProduct implements ProductInterface, PhysicalProductInterface, CustomizableProductInterface @doc(description: "ConfigurableProduct defines basic features of a configurable product and its simple product variants") { + variants: [ConfigurableVariant] @doc(description: "An array of variants of products") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\ConfigurableVariant") + configurable_options: [ConfigurableProductOptions] @doc(description: "An array of linked simple product items") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\Options") +} + +type ConfigurableVariant @doc(description: "An array containing all the simple product variants of a configurable product") { + attributes: [ConfigurableAttributeOption] @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\Variant\\Attributes") @doc(description: "") + product: SimpleProduct @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product") +} + +type ConfigurableAttributeOption @doc(description: "ConfigurableAttributeOption contains the value_index (and other related information) assigned to a configurable product option") { + label: String @doc(description: "A string that describes the configurable attribute option") + code: String @doc(description: "The ID assigned to the attribute") + value_index: Int @doc(description: "A unique index number assigned to the configurable product option") +} + +type ConfigurableProductOptions @doc(description: "ConfigurableProductOptions defines configurable attributes for the specified product") { + id: Int @doc(description: "The configurable option ID number assigned by the system") + attribute_id: String @doc(description: "The ID assigned to the attribute") + attribute_code: String @doc(description: "A string that identifies the attribute") + label: String @doc(description: "A string that describes the configurable product option, which is displayed on the UI") + position: Int @doc(description: "A number that indicates the order in which the attribute is displayed") + use_default: Boolean @doc(description: "Indicates whether the option is the default") + values: [ConfigurableProductOptionsValues] @doc(description: "An array that defines the value_index codes assigned to the configurable product") + product_id: Int @doc(description: "This is the same as a product's id field") +} + +type ConfigurableProductOptionsValues @doc(description: "ConfigurableProductOptionsValues contains the index number assigned to a configurable product option") { + value_index: Int @doc(description: "A unique index number assigned to the configurable product option") + label: String @doc(description: "The label of the product") + default_label: String @doc(description: "The label of the product on the default store") + store_label: String @doc(description: "The label of the product on the current store") + use_default_value: Boolean @doc(description: "Indicates whether to use the default_label") +} diff --git a/app/code/Magento/ConfigurableProductSales/composer.json b/app/code/Magento/ConfigurableProductSales/composer.json index 4a1d4732b9967..2a106b3abc75a 100644 --- a/app/code/Magento/ConfigurableProductSales/composer.json +++ b/app/code/Magento/ConfigurableProductSales/composer.json @@ -5,17 +5,16 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-sales": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-configurable-product": "100.3.*" + "magento/module-configurable-product": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Contact/composer.json b/app/code/Magento/Contact/composer.json index 688089adaae1e..b45132d0a360b 100644 --- a/app/code/Magento/Contact/composer.json +++ b/app/code/Magento/Contact/composer.json @@ -5,15 +5,14 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-cms": "101.2.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-cms": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Cookie/composer.json b/app/code/Magento/Cookie/composer.json index 7b1c04461766a..58f28ad472120 100644 --- a/app/code/Magento/Cookie/composer.json +++ b/app/code/Magento/Cookie/composer.json @@ -5,15 +5,14 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-backend": "100.3.*" + "magento/module-backend": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Cron/Model/ResourceModel/Schedule.php b/app/code/Magento/Cron/Model/ResourceModel/Schedule.php index a47227bb60598..25dd02c207f4e 100644 --- a/app/code/Magento/Cron/Model/ResourceModel/Schedule.php +++ b/app/code/Magento/Cron/Model/ResourceModel/Schedule.php @@ -66,7 +66,14 @@ public function trySetJobUniqueStatusAtomic($scheduleId, $newStatus, $currentSta { $connection = $this->getConnection(); - $match = $connection->quoteInto('existing.job_code = current.job_code AND existing.status = ?', $newStatus); + // this condition added to avoid cron jobs locking after incorrect termination of running job + $match = $connection->quoteInto( + 'existing.job_code = current.job_code ' . + 'AND (existing.executed_at > UTC_TIMESTAMP() - INTERVAL 1 DAY OR existing.executed_at IS NULL) ' . + 'AND existing.status = ?', + $newStatus + ); + $selectIfUnlocked = $connection->select() ->joinLeft( ['existing' => $this->getTable('cron_schedule')], diff --git a/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php b/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php index b98a456a511f1..ed5e46d7a60f7 100644 --- a/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php +++ b/app/code/Magento/Cron/Observer/ProcessCronQueueObserver.php @@ -290,8 +290,15 @@ protected function _runJob($scheduledTime, $currentTime, $jobConfig, $schedule, try { call_user_func_array($callback, [$schedule]); - } catch (\Exception $e) { + } catch (\Throwable $e) { $schedule->setStatus(Schedule::STATUS_ERROR); + if (!$e instanceof \Exception) { + $e = new \RuntimeException( + 'Error when running a cron job', + 0, + $e + ); + } throw $e; } diff --git a/app/code/Magento/Cron/Setup/Recurring.php b/app/code/Magento/Cron/Setup/Recurring.php new file mode 100644 index 0000000000000..e45524c174afe --- /dev/null +++ b/app/code/Magento/Cron/Setup/Recurring.php @@ -0,0 +1,49 @@ +schedule = $schedule; + } + + /** + * {@inheritdoc} + */ + public function install(SchemaSetupInterface $setup, ModuleContextInterface $context) + { + $connection = $this->schedule->getConnection(); + $connection->update( + $this->schedule->getMainTable(), + [ + 'status' => \Magento\Cron\Model\Schedule::STATUS_ERROR, + 'messages' => 'The job is terminated due to system upgrade' + ], + $connection->quoteInto('status = ?', \Magento\Cron\Model\Schedule::STATUS_RUNNING) + ); + } +} diff --git a/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php b/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php index c50afa0e6f0d1..6954fe49fdc43 100644 --- a/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php +++ b/app/code/Magento/Cron/Test/Unit/Model/CronJobException.php @@ -12,8 +12,27 @@ class CronJobException { + /** + * @var \Throwable|null + */ + private $exception; + + /** + * @param \Throwable|null $exception + */ + public function __construct(\Throwable $exception = null) + { + $this->exception = $exception; + } + + /** + * @throws \Throwable + */ public function execute() { - throw new \Exception('Test exception'); + if (!$this->exception) { + $this->exception = new \Exception('Test exception'); + } + throw $this->exception; } } diff --git a/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php b/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php index 0db6a598fb56f..d8cb79af52138 100644 --- a/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php +++ b/app/code/Magento/Cron/Test/Unit/Observer/ProcessCronQueueObserverTest.php @@ -468,6 +468,8 @@ public function testDispatchExceptionInCallback( */ public function dispatchExceptionInCallbackDataProvider() { + $throwable = new \TypeError(); + return [ 'non-callable callback' => [ 'Not_Existed_Class', @@ -483,6 +485,19 @@ public function dispatchExceptionInCallbackDataProvider() 2, new \Exception(__('Test exception')) ], + 'throwable in execution' => [ + 'CronJobException', + new \Magento\Cron\Test\Unit\Model\CronJobException( + $throwable + ), + 'Error when running a cron job', + 2, + new \RuntimeException( + 'Error when running a cron job', + 0, + $throwable + ), + ], ]; } diff --git a/app/code/Magento/Cron/composer.json b/app/code/Magento/Cron/composer.json index a3b87649f3c77..5595bf1cb55f5 100644 --- a/app/code/Magento/Cron/composer.json +++ b/app/code/Magento/Cron/composer.json @@ -5,15 +5,14 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-config": "100.3.*" + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Cron/etc/db_schema.xml b/app/code/Magento/Cron/etc/db_schema.xml index 9f019d108b257..deff05d3eec96 100644 --- a/app/code/Magento/Cron/etc/db_schema.xml +++ b/app/code/Magento/Cron/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/CurrencySymbol/Setup/Patch/Data/ConvertSerializedCustomCurrencySymbolToJson.php b/app/code/Magento/CurrencySymbol/Setup/Patch/Data/ConvertSerializedCustomCurrencySymbolToJson.php index dd5e59c4d9dcd..be2f2c5147124 100644 --- a/app/code/Magento/CurrencySymbol/Setup/Patch/Data/ConvertSerializedCustomCurrencySymbolToJson.php +++ b/app/code/Magento/CurrencySymbol/Setup/Patch/Data/ConvertSerializedCustomCurrencySymbolToJson.php @@ -12,8 +12,8 @@ use Magento\Framework\DB\Select\QueryModifierFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ConvertSerializedCustomCurrencySymbolToJson diff --git a/app/code/Magento/CurrencySymbol/composer.json b/app/code/Magento/CurrencySymbol/composer.json index deb971933f9fa..009cb62488916 100644 --- a/app/code/Magento/CurrencySymbol/composer.json +++ b/app/code/Magento/CurrencySymbol/composer.json @@ -5,16 +5,15 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-page-cache": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-config": "*", + "magento/module-directory": "*", + "magento/module-page-cache": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Customer/Api/AccountDelegationInterface.php b/app/code/Magento/Customer/Api/AccountDelegationInterface.php new file mode 100644 index 0000000000000..e3a738530c49d --- /dev/null +++ b/app/code/Magento/Customer/Api/AccountDelegationInterface.php @@ -0,0 +1,31 @@ +getSortOrder() < $secondLink->getSortOrder()); + if ($firstLink->getSortOrder() == $secondLink->getSortOrder()) { + return 0; + } + + return ($firstLink->getSortOrder() < $secondLink->getSortOrder()) ? 1 : -1; } } diff --git a/app/code/Magento/Customer/Block/Address/Edit.php b/app/code/Magento/Customer/Block/Address/Edit.php index 6362f28a4f96d..6a42e9670ccc6 100644 --- a/app/code/Magento/Customer/Block/Address/Edit.php +++ b/app/code/Magento/Customer/Block/Address/Edit.php @@ -129,7 +129,7 @@ protected function _prepareLayout() if ($postedData = $this->_customerSession->getAddressFormData(true)) { $postedData['region'] = [ - 'region_id' => $postedData['region_id'], + 'region_id' => isset($postedData['region_id']) ? $postedData['region_id'] : null, 'region' => $postedData['region'], ]; $this->dataObjectHelper->populateWithArray( diff --git a/app/code/Magento/Customer/Controller/Account/Index.php b/app/code/Magento/Customer/Controller/Account/Index.php index f734660fc3a77..2ecf79d35b11f 100644 --- a/app/code/Magento/Customer/Controller/Account/Index.php +++ b/app/code/Magento/Customer/Controller/Account/Index.php @@ -35,9 +35,6 @@ public function __construct( */ public function execute() { - /** @var \Magento\Framework\View\Result\Page $resultPage */ - $resultPage = $this->resultPageFactory->create(); - $resultPage->getConfig()->getTitle()->set(__('My Account')); - return $resultPage; + return $this->resultPageFactory->create(); } } diff --git a/app/code/Magento/Customer/Controller/Adminhtml/Index/Viewfile.php b/app/code/Magento/Customer/Controller/Adminhtml/Index/Viewfile.php index 711fab9e608bf..20d330354bce4 100644 --- a/app/code/Magento/Customer/Controller/Adminhtml/Index/Viewfile.php +++ b/app/code/Magento/Customer/Controller/Adminhtml/Index/Viewfile.php @@ -132,30 +132,15 @@ public function __construct( */ public function execute() { - $file = null; - $plain = false; - if ($this->getRequest()->getParam('file')) { - // download file - $file = $this->urlDecoder->decode( - $this->getRequest()->getParam('file') - ); - } elseif ($this->getRequest()->getParam('image')) { - // show plain image - $file = $this->urlDecoder->decode( - $this->getRequest()->getParam('image') - ); - $plain = true; - } else { - throw new NotFoundException(__('Page not found.')); - } + list($file, $plain) = $this->getFileParams(); /** @var \Magento\Framework\Filesystem $filesystem */ $filesystem = $this->_objectManager->get(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryRead(DirectoryList::MEDIA); $fileName = CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER . '/' . ltrim($file, '/'); $path = $directory->getAbsolutePath($fileName); - if (!$directory->isFile($fileName) - && !$this->_objectManager->get(\Magento\MediaStorage\Helper\File\Storage::class)->processStorageFile($path) + if (mb_strpos($path, '..') !== false || (!$directory->isFile($fileName) + && !$this->_objectManager->get(\Magento\MediaStorage\Helper\File\Storage::class)->processStorageFile($path)) ) { throw new NotFoundException(__('Page not found.')); } @@ -198,4 +183,32 @@ public function execute() ); } } + + /** + * Get parameters from request. + * + * @return array + * @throws NotFoundException + */ + private function getFileParams() + { + $file = null; + $plain = false; + if ($this->getRequest()->getParam('file')) { + // download file + $file = $this->urlDecoder->decode( + $this->getRequest()->getParam('file') + ); + } elseif ($this->getRequest()->getParam('image')) { + // show plain image + $file = $this->urlDecoder->decode( + $this->getRequest()->getParam('image') + ); + $plain = true; + } else { + throw new NotFoundException(__('Page not found.')); + } + + return [$file, $plain]; + } } diff --git a/app/code/Magento/Customer/Controller/Section/Load.php b/app/code/Magento/Customer/Controller/Section/Load.php index 71775ff8f8ce1..7a2345c91750c 100644 --- a/app/code/Magento/Customer/Controller/Section/Load.php +++ b/app/code/Magento/Customer/Controller/Section/Load.php @@ -64,6 +64,8 @@ public function execute() { /** @var \Magento\Framework\Controller\Result\Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); + $resultJson->setHeader('Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store', true); + $resultJson->setHeader('Pragma', 'no-cache', true); try { $sectionNames = $this->getRequest()->getParam('sections'); $sectionNames = $sectionNames ? array_unique(\explode(',', $sectionNames)) : null; diff --git a/app/code/Magento/Customer/Model/Account/Redirect.php b/app/code/Magento/Customer/Model/Account/Redirect.php index 2e8d596474e96..2ccaaea45680c 100644 --- a/app/code/Magento/Customer/Model/Account/Redirect.php +++ b/app/code/Magento/Customer/Model/Account/Redirect.php @@ -74,6 +74,11 @@ class Redirect */ private $hostChecker; + /** + * @var Session + */ + private $session; + /** * @param RequestInterface $request * @param Session $customerSession @@ -206,6 +211,10 @@ protected function processLoggedCustomer() $referer = $this->request->getParam(CustomerUrl::REFERER_QUERY_PARAM_NAME); if ($referer) { $referer = $this->urlDecoder->decode($referer); + preg_match('/logoutSuccess\//', $referer, $matches, PREG_OFFSET_CAPTURE); + if (!empty($matches)) { + $referer = str_replace('logoutSuccess/', '', $referer); + } if ($this->hostChecker->isOwnOrigin($referer)) { $this->applyRedirect($referer); } diff --git a/app/code/Magento/Customer/Model/AccountManagement.php b/app/code/Magento/Customer/Model/AccountManagement.php index cd5fef7316999..7d0b271b9b137 100644 --- a/app/code/Magento/Customer/Model/AccountManagement.php +++ b/app/code/Magento/Customer/Model/AccountManagement.php @@ -48,6 +48,9 @@ use Magento\Store\Model\ScopeInterface; use Magento\Store\Model\StoreManagerInterface; use Psr\Log\LoggerInterface as PsrLogger; +use Magento\Framework\Session\SessionManagerInterface; +use Magento\Framework\Session\SaveHandlerInterface; +use Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory; /** * Handle various customer account actions @@ -243,6 +246,21 @@ class AccountManagement implements AccountManagementInterface */ private $transportBuilder; + /** + * @var SessionManagerInterface + */ + private $sessionManager; + + /** + * @var SaveHandlerInterface + */ + private $saveHandler; + + /** + * @var CollectionFactory + */ + private $visitorCollectionFactory; + /** * @var DataObjectProcessor */ @@ -335,6 +353,9 @@ class AccountManagement implements AccountManagementInterface * @param CredentialsValidator|null $credentialsValidator * @param DateTimeFactory|null $dateTimeFactory * @param AccountConfirmation|null $accountConfirmation + * @param SessionManagerInterface|null $sessionManager + * @param SaveHandlerInterface|null $saveHandler + * @param CollectionFactory|null $visitorCollectionFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -363,7 +384,10 @@ public function __construct( ExtensibleDataObjectConverter $extensibleDataObjectConverter, CredentialsValidator $credentialsValidator = null, DateTimeFactory $dateTimeFactory = null, - AccountConfirmation $accountConfirmation = null + AccountConfirmation $accountConfirmation = null, + SessionManagerInterface $sessionManager = null, + SaveHandlerInterface $saveHandler = null, + CollectionFactory $visitorCollectionFactory = null ) { $this->customerFactory = $customerFactory; $this->eventManager = $eventManager; @@ -393,6 +417,12 @@ public function __construct( $this->dateTimeFactory = $dateTimeFactory ?: ObjectManager::getInstance()->get(DateTimeFactory::class); $this->accountConfirmation = $accountConfirmation ?: ObjectManager::getInstance() ->get(AccountConfirmation::class); + $this->sessionManager = $sessionManager + ?: ObjectManager::getInstance()->get(SessionManagerInterface::class); + $this->saveHandler = $saveHandler + ?: ObjectManager::getInstance()->get(SaveHandlerInterface::class); + $this->visitorCollectionFactory = $visitorCollectionFactory + ?: ObjectManager::getInstance()->get(CollectionFactory::class); } /** @@ -594,7 +624,10 @@ public function resetPassword($email, $resetToken, $newPassword) $customerSecure->setRpToken(null); $customerSecure->setRpTokenCreatedAt(null); $customerSecure->setPasswordHash($this->createPasswordHash($newPassword)); + $this->sessionManager->destroy(); + $this->destroyCustomerSessions($customer->getId()); $this->customerRepository->save($customer); + return true; } @@ -896,7 +929,9 @@ private function changePasswordForCustomer($customer, $currentPassword, $newPass $customerSecure->setRpTokenCreatedAt(null); $this->checkPasswordStrength($newPassword); $customerSecure->setPasswordHash($this->createPasswordHash($newPassword)); + $this->destroyCustomerSessions($customer->getId()); $this->customerRepository->save($customer); + return true; } @@ -1399,4 +1434,35 @@ private function getEmailNotification() return $this->emailNotification; } } + + /** + * Destroy all active customer sessions by customer id (current session will not be destroyed). + * Customer sessions which should be deleted are collecting from the "customer_visitor" table considering + * configured session lifetime. + * + * @param string|int $customerId + * @return void + */ + private function destroyCustomerSessions($customerId) + { + $sessionLifetime = $this->scopeConfig->getValue( + \Magento\Framework\Session\Config::XML_PATH_COOKIE_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + $dateTime = $this->dateTimeFactory->create(); + $activeSessionsTime = $dateTime->setTimestamp($dateTime->getTimestamp() - $sessionLifetime) + ->format(DateTime::DATETIME_PHP_FORMAT); + /** @var \Magento\Customer\Model\ResourceModel\Visitor\Collection $visitorCollection */ + $visitorCollection = $this->visitorCollectionFactory->create(); + $visitorCollection->addFieldToFilter('customer_id', $customerId); + $visitorCollection->addFieldToFilter('last_visit_at', ['from' => $activeSessionsTime]); + $visitorCollection->addFieldToFilter('session_id', ['neq' => $this->sessionManager->getSessionId()]); + /** @var \Magento\Customer\Model\Visitor $visitor */ + foreach ($visitorCollection->getItems() as $visitor) { + $sessionId = $visitor->getSessionId(); + $this->sessionManager->start(); + $this->saveHandler->destroy($sessionId); + $this->sessionManager->writeClose(); + } + } } diff --git a/app/code/Magento/Customer/Model/Address/AbstractAddress.php b/app/code/Magento/Customer/Model/Address/AbstractAddress.php index aab9a811168f9..6408276630c3f 100644 --- a/app/code/Magento/Customer/Model/Address/AbstractAddress.php +++ b/app/code/Magento/Customer/Model/Address/AbstractAddress.php @@ -12,6 +12,7 @@ use Magento\Customer\Api\Data\RegionInterface; use Magento\Customer\Api\Data\RegionInterfaceFactory; use Magento\Customer\Model\Data\Address as AddressData; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Model\AbstractExtensibleModel; /** @@ -118,6 +119,9 @@ class AbstractAddress extends AbstractExtensibleModel implements AddressModelInt */ protected $dataObjectHelper; + /** @var CompositeValidator */ + private $compositeValidator; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -135,6 +139,8 @@ class AbstractAddress extends AbstractExtensibleModel implements AddressModelInt * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data + * @param CompositeValidator $compositeValidator + * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -153,7 +159,8 @@ public function __construct( \Magento\Framework\Api\DataObjectHelper $dataObjectHelper, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + CompositeValidator $compositeValidator = null ) { $this->_directoryData = $directoryData; $data = $this->_implodeArrayField($data); @@ -165,6 +172,8 @@ public function __construct( $this->addressDataFactory = $addressDataFactory; $this->regionDataFactory = $regionDataFactory; $this->dataObjectHelper = $dataObjectHelper; + $this->compositeValidator = $compositeValidator ?: ObjectManager::getInstance() + ->get(CompositeValidator::class); parent::__construct( $context, $registry, @@ -562,84 +571,22 @@ public function getDataModel($defaultBillingAddressId = null, $defaultShippingAd } /** - * Validate address attribute values - * - * + * Validate address attribute values. * - * @return bool|array - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) + * @return array|bool */ public function validate() { if ($this->getShouldIgnoreValidation()) { return true; } - - $errors = []; - if (!\Zend_Validate::is($this->getFirstname(), 'NotEmpty')) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'firstname']); - } - - if (!\Zend_Validate::is($this->getLastname(), 'NotEmpty')) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'lastname']); - } - - if (!\Zend_Validate::is($this->getStreetLine(1), 'NotEmpty')) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'street']); - } - if (!\Zend_Validate::is($this->getCity(), 'NotEmpty')) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'city']); - } - - if ($this->isTelephoneRequired()) { - if (!\Zend_Validate::is($this->getTelephone(), 'NotEmpty')) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'telephone']); - } - } - - if ($this->isFaxRequired()) { - if (!\Zend_Validate::is($this->getFax(), 'NotEmpty')) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'fax']); - } - } - - if ($this->isCompanyRequired()) { - if (!\Zend_Validate::is($this->getCompany(), 'NotEmpty')) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'company']); - } - } - - $_havingOptionalZip = $this->_directoryData->getCountriesWithOptionalZip(); - if (!in_array( - $this->getCountryId(), - $_havingOptionalZip - ) && !\Zend_Validate::is( - $this->getPostcode(), - 'NotEmpty' - ) - ) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'postcode']); - } - - if (!\Zend_Validate::is($this->getCountryId(), 'NotEmpty')) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'countryId']); - } - - if ($this->getCountryModel()->getRegionCollection()->getSize() && !\Zend_Validate::is( - $this->getRegionId(), - 'NotEmpty' - ) && $this->_directoryData->isRegionRequired( - $this->getCountryId() - ) - ) { - $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'regionId']); - } + $errors = $this->compositeValidator->validate($this); if (empty($errors)) { return true; } + return $errors; } diff --git a/app/code/Magento/Customer/Model/Address/CompositeValidator.php b/app/code/Magento/Customer/Model/Address/CompositeValidator.php new file mode 100644 index 0000000000000..1d16a929532f5 --- /dev/null +++ b/app/code/Magento/Customer/Model/Address/CompositeValidator.php @@ -0,0 +1,40 @@ +validators = $validators; + } + + /** + * @inheritdoc + */ + public function validate(AbstractAddress $address) + { + $errors = []; + foreach ($this->validators as $validator) { + $errors = array_merge($errors, $validator->validate($address)); + } + + return $errors; + } +} diff --git a/app/code/Magento/Customer/Model/Address/Validator/Country.php b/app/code/Magento/Customer/Model/Address/Validator/Country.php new file mode 100644 index 0000000000000..0ba8a21ff8cd9 --- /dev/null +++ b/app/code/Magento/Customer/Model/Address/Validator/Country.php @@ -0,0 +1,100 @@ +directoryData = $directoryData; + } + + /** + * @inheritdoc + */ + public function validate(AbstractAddress $address) + { + $errors = $this->validateCountry($address); + if (empty($errors)) { + $errors = $this->validateRegion($address); + } + + return $errors; + } + + /** + * Validate country existence. + * + * @param AbstractAddress $address + * @return array + */ + private function validateCountry(AbstractAddress $address) + { + $countryId = $address->getCountryId(); + $errors = []; + if (!\Zend_Validate::is($countryId, 'NotEmpty')) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'countryId']); + } elseif (!in_array($countryId, $this->directoryData->getCountryCollection()->getAllIds(), true)) { + //Checking if such country exists. + $errors[] = __( + 'Invalid value of "%value" provided for the %fieldName field.', + ['fieldName' => 'countryId', 'value' => htmlspecialchars($countryId)] + ); + } + + return $errors; + } + + /** + * Validate region existence. + * + * @param AbstractAddress $address + * @return array + */ + private function validateRegion(AbstractAddress $address) + { + $errors = []; + $countryId = $address->getCountryId(); + $countryModel = $address->getCountryModel(); + $regionCollection = $countryModel->getRegionCollection(); + $region = $address->getRegion(); + $regionId = (string)$address->getRegionId(); + $allowedRegions = $regionCollection->getAllIds(); + $isRegionRequired = $this->directoryData->isRegionRequired($countryId); + if ($isRegionRequired && empty($allowedRegions) && !\Zend_Validate::is($region, 'NotEmpty')) { + //If region is required for country and country doesn't provide regions list + //region must be provided. + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'region']); + } elseif ($allowedRegions && !\Zend_Validate::is($regionId, 'NotEmpty') && $isRegionRequired) { + //If country actually has regions and requires you to + //select one then it must be selected. + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'regionId']); + } elseif ($regionId && !in_array($regionId, $allowedRegions, true)) { + //If a region is selected then checking if it exists. + $errors[] = __( + 'Invalid value of "%value" provided for the %fieldName field.', + ['fieldName' => 'regionId', 'value' => htmlspecialchars($regionId)] + ); + } + + return $errors; + } +} diff --git a/app/code/Magento/Customer/Model/Address/Validator/General.php b/app/code/Magento/Customer/Model/Address/Validator/General.php new file mode 100644 index 0000000000000..679f288712b4b --- /dev/null +++ b/app/code/Magento/Customer/Model/Address/Validator/General.php @@ -0,0 +1,151 @@ +eavConfig = $eavConfig; + $this->directoryData = $directoryData; + } + + /** + * @inheritdoc + */ + public function validate(AbstractAddress $address) + { + $errors = array_merge( + $this->checkRequredFields($address), + $this->checkOptionalFields($address) + ); + + return $errors; + } + + /** + * Check fields that are generally required. + * + * @param AbstractAddress $address + * @return array + * @throws \Zend_Validate_Exception + */ + private function checkRequredFields(AbstractAddress $address) + { + $errors = []; + if (!\Zend_Validate::is($address->getFirstname(), 'NotEmpty')) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'firstname']); + } + + if (!\Zend_Validate::is($address->getLastname(), 'NotEmpty')) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'lastname']); + } + + if (!\Zend_Validate::is($address->getStreetLine(1), 'NotEmpty')) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'street']); + } + + if (!\Zend_Validate::is($address->getCity(), 'NotEmpty')) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'city']); + } + + return $errors; + } + + /** + * Check fields that are conditionally required. + * + * @param AbstractAddress $address + * @return array + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Zend_Validate_Exception + */ + private function checkOptionalFields(AbstractAddress $address) + { + $errors = []; + if ($this->isTelephoneRequired() + && !\Zend_Validate::is($address->getTelephone(), 'NotEmpty') + ) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'telephone']); + } + + if ($this->isFaxRequired() + && !\Zend_Validate::is($address->getFax(), 'NotEmpty') + ) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'fax']); + } + + if ($this->isCompanyRequired() + && !\Zend_Validate::is($address->getCompany(), 'NotEmpty') + ) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'company']); + } + + $havingOptionalZip = $this->directoryData->getCountriesWithOptionalZip(); + if (!in_array($address->getCountryId(), $havingOptionalZip) + && !\Zend_Validate::is($address->getPostcode(), 'NotEmpty') + ) { + $errors[] = __('"%fieldName" is required. Enter and try again.', ['fieldName' => 'postcode']); + } + + return $errors; + } + + /** + * Check if company field required in configuration. + * + * @return bool + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function isCompanyRequired() + { + return $this->eavConfig->getAttribute('customer_address', 'company')->getIsRequired(); + } + + /** + * Check if telephone field required in configuration. + * + * @return bool + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function isTelephoneRequired() + { + return $this->eavConfig->getAttribute('customer_address', 'telephone')->getIsRequired(); + } + + /** + * Check if fax field required in configuration. + * + * @return bool + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function isFaxRequired() + { + return $this->eavConfig->getAttribute('customer_address', 'fax')->getIsRequired(); + } +} diff --git a/app/code/Magento/Customer/Model/Address/ValidatorInterface.php b/app/code/Magento/Customer/Model/Address/ValidatorInterface.php new file mode 100644 index 0000000000000..8468f28e70e70 --- /dev/null +++ b/app/code/Magento/Customer/Model/Address/ValidatorInterface.php @@ -0,0 +1,24 @@ +cache = $cache; + $this->serializer = $serializer ?: ObjectManager::getInstance()->get(SerializerInterface::class); } /** @@ -45,7 +46,7 @@ public function __construct(FrontendInterface $cache) public function add($notificationType, $customerId) { $this->cache->save( - $this->getSerializer()->serialize([ + $this->serializer->serialize([ 'customer_id' => $customerId, 'notification_type' => $notificationType ]), @@ -88,19 +89,4 @@ private function getCacheKey($notificationType, $customerId) { return 'notification_' . $notificationType . '_' . $customerId; } - - /** - * Get serializer - * - * @return SerializerInterface - * @deprecated 100.2.0 - */ - private function getSerializer() - { - if ($this->serializer === null) { - $this->serializer = \Magento\Framework\App\ObjectManager::getInstance() - ->get(SerializerInterface::class); - } - return $this->serializer; - } } diff --git a/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php b/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php new file mode 100644 index 0000000000000..85c67213c4613 --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/AccountDelegation.php @@ -0,0 +1,53 @@ +redirectFactory = $redirectFactory; + $this->storage = $storage; + } + + /** + * {@inheritdoc} + */ + public function createRedirectForNew( + CustomerInterface $customer, + array $mixedData = null + ): Redirect { + $this->storage->storeNewOperation($customer, $mixedData); + + return $this->redirectFactory->create()->setPath('customer/account/create'); + } +} diff --git a/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php b/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php new file mode 100644 index 0000000000000..5dcefc2326794 --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/Data/NewOperation.php @@ -0,0 +1,54 @@ +customer = $customer; + $this->additionalData = $additionalData; + } + + /** + * @return CustomerInterface + */ + public function getCustomer(): CustomerInterface + { + return $this->customer; + } + + /** + * @return array + */ + public function getAdditionalData(): array + { + return $this->additionalData; + } +} diff --git a/app/code/Magento/Customer/Model/Delegation/Storage.php b/app/code/Magento/Customer/Model/Delegation/Storage.php new file mode 100644 index 0000000000000..71a61d59057cb --- /dev/null +++ b/app/code/Magento/Customer/Model/Delegation/Storage.php @@ -0,0 +1,151 @@ +newFactory = $newFactory; + $this->customerFactory = $customerFactory; + $this->addressFactory = $addressFactory; + $this->regionFactory = $regionFactory; + $this->logger = $logger; + $this->session = $session; + } + + /** + * Store data for new account operation. + * + * @param CustomerInterface $customer + * @param array $delegatedData + * + * @return void + */ + public function storeNewOperation(CustomerInterface $customer, array $delegatedData): void + { + /** @var Customer $customer */ + $customerData = $customer->__toArray(); + $addressesData = []; + if ($customer->getAddresses()) { + /** @var Address $address */ + foreach ($customer->getAddresses() as $address) { + $addressesData[] = $address->__toArray(); + } + } + $this->session->setCustomerFormData($customerData); + $this->session->setDelegatedNewCustomerData([ + 'customer' => $customerData, + 'addresses' => $addressesData, + 'delegated_data' => $delegatedData, + ]); + } + + /** + * Retrieve delegated new operation data and mark it as used. + * + * @return NewOperation|null + */ + public function consumeNewOperation() + { + try { + $serialized = $this->session->getDelegatedNewCustomerData(true); + } catch (\Throwable $exception) { + $this->logger->error($exception); + $serialized = null; + } + if ($serialized === null) { + return null; + } + + /** @var AddressInterface[] $addresses */ + $addresses = []; + foreach ($serialized['addresses'] as $addressData) { + if (isset($addressData['region'])) { + /** @var RegionInterface $region */ + $region = $this->regionFactory->create( + ['data' => $addressData['region']] + ); + $addressData['region'] = $region; + } + $addresses[] = $this->addressFactory->create( + ['data' => $addressData] + ); + } + $customerData = $serialized['customer']; + $customerData['addresses'] = $addresses; + + return $this->newFactory->create([ + 'customer' => $this->customerFactory->create( + ['data' => $customerData] + ), + 'additionalData' => $serialized['delegated_data'], + ]); + } +} diff --git a/app/code/Magento/Customer/Model/EmailNotification.php b/app/code/Magento/Customer/Model/EmailNotification.php index 53c16a4b37093..4b65dcca0973f 100644 --- a/app/code/Magento/Customer/Model/EmailNotification.php +++ b/app/code/Magento/Customer/Model/EmailNotification.php @@ -7,6 +7,8 @@ namespace Magento\Customer\Model; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Mail\Template\SenderResolverInterface; use Magento\Store\Model\StoreManagerInterface; use Magento\Framework\Mail\Template\TransportBuilder; use Magento\Customer\Helper\View as CustomerViewHelper; @@ -91,6 +93,11 @@ class EmailNotification implements EmailNotificationInterface */ private $scopeConfig; + /** + * @var SenderResolverInterface + */ + private $senderResolver; + /** * @param CustomerRegistry $customerRegistry * @param StoreManagerInterface $storeManager @@ -98,6 +105,7 @@ class EmailNotification implements EmailNotificationInterface * @param CustomerViewHelper $customerViewHelper * @param DataObjectProcessor $dataProcessor * @param ScopeConfigInterface $scopeConfig + * @param SenderResolverInterface|null $senderResolver */ public function __construct( CustomerRegistry $customerRegistry, @@ -105,7 +113,8 @@ public function __construct( TransportBuilder $transportBuilder, CustomerViewHelper $customerViewHelper, DataObjectProcessor $dataProcessor, - ScopeConfigInterface $scopeConfig + ScopeConfigInterface $scopeConfig, + SenderResolverInterface $senderResolver = null ) { $this->customerRegistry = $customerRegistry; $this->storeManager = $storeManager; @@ -113,6 +122,7 @@ public function __construct( $this->customerViewHelper = $customerViewHelper; $this->dataProcessor = $dataProcessor; $this->scopeConfig = $scopeConfig; + $this->senderResolver = $senderResolver ?: ObjectManager::getInstance()->get(SenderResolverInterface::class); } /** @@ -231,6 +241,7 @@ private function passwordReset(CustomerInterface $customer) * @param int|null $storeId * @param string $email * @return void + * @throws \Magento\Framework\Exception\MailException */ private function sendEmailTemplate( $customer, @@ -244,10 +255,17 @@ private function sendEmailTemplate( if ($email === null) { $email = $customer->getEmail(); } + + /** @var array $from */ + $from = $this->senderResolver->resolve( + $this->scopeConfig->getValue($sender, 'store', $storeId), + $storeId + ); + $transport = $this->transportBuilder->setTemplateIdentifier($templateId) ->setTemplateOptions(['area' => 'frontend', 'store' => $storeId]) ->setTemplateVars($templateParams) - ->setFrom($this->scopeConfig->getValue($sender, 'store', $storeId)) + ->setFrom($from) ->addTo($email, $this->customerViewHelper->getCustomerName($customer)) ->getTransport(); @@ -296,9 +314,9 @@ private function getWebsiteStoreId($customer, $defaultStoreId = null) */ public function passwordReminder(CustomerInterface $customer) { - $storeId = $this->getWebsiteStoreId($customer); + $storeId = $customer->getStoreId(); if (!$storeId) { - $storeId = $this->storeManager->getStore()->getId(); + $storeId = $this->getWebsiteStoreId($customer); } $customerEmailData = $this->getFullCustomerObject($customer); diff --git a/app/code/Magento/Customer/Model/Plugin/CustomerNotification.php b/app/code/Magento/Customer/Model/Plugin/CustomerNotification.php index 040ffb8f3baba..517aef5690ee6 100644 --- a/app/code/Magento/Customer/Model/Plugin/CustomerNotification.php +++ b/app/code/Magento/Customer/Model/Plugin/CustomerNotification.php @@ -12,6 +12,8 @@ use Magento\Framework\App\Area; use Magento\Framework\App\RequestInterface; use Magento\Framework\App\State; +use Magento\Framework\Exception\NoSuchEntityException; +use Psr\Log\LoggerInterface; class CustomerNotification { @@ -35,6 +37,11 @@ class CustomerNotification */ private $state; + /** + * @var LoggerInterface + */ + private $logger; + /** * Initialize dependencies. * @@ -42,17 +49,20 @@ class CustomerNotification * @param NotificationStorage $notificationStorage * @param State $state * @param CustomerRepositoryInterface $customerRepository + * @param LoggerInterface $logger */ public function __construct( Session $session, NotificationStorage $notificationStorage, State $state, - CustomerRepositoryInterface $customerRepository + CustomerRepositoryInterface $customerRepository, + LoggerInterface $logger ) { $this->session = $session; $this->notificationStorage = $notificationStorage; $this->state = $state; $this->customerRepository = $customerRepository; + $this->logger = $logger; } /** @@ -63,17 +73,23 @@ public function __construct( */ public function beforeDispatch(AbstractAction $subject, RequestInterface $request) { + $customerId = $this->session->getCustomerId(); + if ($this->state->getAreaCode() == Area::AREA_FRONTEND && $request->isPost() && $this->notificationStorage->isExists( NotificationStorage::UPDATE_CUSTOMER_SESSION, - $this->session->getCustomerId() + $customerId ) ) { - $customer = $this->customerRepository->getById($this->session->getCustomerId()); - $this->session->setCustomerData($customer); - $this->session->setCustomerGroupId($customer->getGroupId()); - $this->session->regenerateId(); - $this->notificationStorage->remove(NotificationStorage::UPDATE_CUSTOMER_SESSION, $customer->getId()); + try { + $customer = $this->customerRepository->getById($customerId); + $this->session->setCustomerData($customer); + $this->session->setCustomerGroupId($customer->getGroupId()); + $this->session->regenerateId(); + $this->notificationStorage->remove(NotificationStorage::UPDATE_CUSTOMER_SESSION, $customer->getId()); + } catch (NoSuchEntityException $e) { + $this->logger->error($e); + } } } } diff --git a/app/code/Magento/Customer/Model/ResourceModel/AddressRepository.php b/app/code/Magento/Customer/Model/ResourceModel/AddressRepository.php index 2c7b778f5f485..7f69ab3c02bcf 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/AddressRepository.php +++ b/app/code/Magento/Customer/Model/ResourceModel/AddressRepository.php @@ -92,7 +92,7 @@ public function __construct( $this->addressFactory = $addressFactory; $this->addressRegistry = $addressRegistry; $this->customerRegistry = $customerRegistry; - $this->addressResource = $addressResourceModel; + $this->addressResourceModel = $addressResourceModel; $this->directoryData = $directoryData; $this->addressSearchResultsFactory = $addressSearchResultsFactory; $this->addressCollectionFactory = $addressCollectionFactory; @@ -236,7 +236,7 @@ public function delete(\Magento\Customer\Api\Data\AddressInterface $address) $address = $this->addressRegistry->retrieve($addressId); $customerModel = $this->customerRegistry->retrieve($address->getCustomerId()); $customerModel->getAddressesCollection()->clear(); - $this->addressResource->delete($address); + $this->addressResourceModel->delete($address); $this->addressRegistry->remove($addressId); return true; } @@ -254,7 +254,7 @@ public function deleteById($addressId) $address = $this->addressRegistry->retrieve($addressId); $customerModel = $this->customerRegistry->retrieve($address->getCustomerId()); $customerModel->getAddressesCollection()->removeItemByKey($addressId); - $this->addressResource->delete($address); + $this->addressResourceModel->delete($address); $this->addressRegistry->remove($addressId); return true; } diff --git a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php index 1a15b55a1e7e3..29e35c721a3be 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php +++ b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php @@ -7,14 +7,20 @@ namespace Magento\Customer\Model\ResourceModel; use Magento\Customer\Api\CustomerMetadataInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\Delegation\Data\NewOperation; +use Magento\Customer\Model\Customer\NotificationStorage; use Magento\Framework\Api\DataObjectHelper; use Magento\Framework\Api\ImageProcessorInterface; use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Customer\Model\Delegation\Storage as DelegatedStorage; +use Magento\Framework\App\ObjectManager; /** * Customer repository. * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyFields) */ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInterface { @@ -88,6 +94,16 @@ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInte */ private $collectionProcessor; + /** + * @var NotificationStorage + */ + private $notificationStorage; + + /** + * @var DelegatedStorage + */ + private $delegatedStorage; + /** * @param \Magento\Customer\Model\CustomerFactory $customerFactory * @param \Magento\Customer\Model\Data\CustomerSecureFactory $customerSecureFactory @@ -103,6 +119,8 @@ class CustomerRepository implements \Magento\Customer\Api\CustomerRepositoryInte * @param ImageProcessorInterface $imageProcessor * @param \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor * @param CollectionProcessorInterface $collectionProcessor + * @param NotificationStorage $notificationStorage + * @param DelegatedStorage|null $delegatedStorage * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -119,7 +137,9 @@ public function __construct( DataObjectHelper $dataObjectHelper, ImageProcessorInterface $imageProcessor, \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $extensionAttributesJoinProcessor, - CollectionProcessorInterface $collectionProcessor = null + CollectionProcessorInterface $collectionProcessor, + NotificationStorage $notificationStorage, + DelegatedStorage $delegatedStorage = null ) { $this->customerFactory = $customerFactory; $this->customerSecureFactory = $customerSecureFactory; @@ -134,7 +154,10 @@ public function __construct( $this->dataObjectHelper = $dataObjectHelper; $this->imageProcessor = $imageProcessor; $this->extensionAttributesJoinProcessor = $extensionAttributesJoinProcessor; - $this->collectionProcessor = $collectionProcessor ?: $this->getCollectionProcessor(); + $this->collectionProcessor = $collectionProcessor; + $this->notificationStorage = $notificationStorage; + $this->delegatedStorage = $delegatedStorage + ?? ObjectManager::getInstance()->get(DelegatedStorage::class); } /** @@ -142,8 +165,10 @@ public function __construct( * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ - public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $passwordHash = null) + public function save(CustomerInterface $customer, $passwordHash = null) { + /** @var NewOperation|null $delegatedNewOperation */ + $delegatedNewOperation = !$customer->getId() ? $this->delegatedStorage->consumeNewOperation() : null; $prevCustomerData = null; $prevCustomerDataArr = null; if ($customer->getId()) { @@ -157,56 +182,49 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER, $prevCustomerData ); - $origAddresses = $customer->getAddresses(); $customer->setAddresses([]); - $customerData = $this->extensibleDataObjectConverter->toNestedArray( - $customer, - [], - \Magento\Customer\Api\Data\CustomerInterface::class - ); - + $customerData = $this->extensibleDataObjectConverter->toNestedArray($customer, [], CustomerInterface::class); $customer->setAddresses($origAddresses); + /** @var Customer $customerModel */ $customerModel = $this->customerFactory->create(['data' => $customerData]); + //Model's actual ID field maybe different than "id" so "id" field from $customerData may be ignored. + $customerModel->setId($customer->getId()); $storeId = $customerModel->getStoreId(); if ($storeId === null) { $customerModel->setStoreId($this->storeManager->getStore()->getId()); } - $customerModel->setId($customer->getId()); - // Need to use attribute set or future updates can cause data loss if (!$customerModel->getAttributeSetId()) { - $customerModel->setAttributeSetId( - \Magento\Customer\Api\CustomerMetadataInterface::ATTRIBUTE_SET_ID_CUSTOMER - ); + $customerModel->setAttributeSetId(CustomerMetadataInterface::ATTRIBUTE_SET_ID_CUSTOMER); } $this->populateCustomerWithSecureData($customerModel, $passwordHash); - // If customer email was changed, reset RpToken info - if ($prevCustomerData - && $prevCustomerData->getEmail() !== $customerModel->getEmail() - ) { + if ($prevCustomerData && $prevCustomerData->getEmail() !== $customerModel->getEmail()) { $customerModel->setRpToken(null); $customerModel->setRpTokenCreatedAt(null); } - if (!array_key_exists('default_billing', $customerArr) && - null !== $prevCustomerDataArr && - array_key_exists('default_billing', $prevCustomerDataArr) + if (!array_key_exists('default_billing', $customerArr) + && null !== $prevCustomerDataArr + && array_key_exists('default_billing', $prevCustomerDataArr) ) { $customerModel->setDefaultBilling($prevCustomerDataArr['default_billing']); } - - if (!array_key_exists('default_shipping', $customerArr) && - null !== $prevCustomerDataArr && - array_key_exists('default_shipping', $prevCustomerDataArr) + if (!array_key_exists('default_shipping', $customerArr) + && null !== $prevCustomerDataArr + && array_key_exists('default_shipping', $prevCustomerDataArr) ) { $customerModel->setDefaultShipping($prevCustomerDataArr['default_shipping']); } - $customerModel->save(); $this->customerRegistry->push($customerModel); $customerId = $customerModel->getId(); - + if (!$customer->getAddresses() + && $delegatedNewOperation + && $delegatedNewOperation->getCustomer()->getAddresses() + ) { + $customer->setAddresses($delegatedNewOperation->getCustomer()->getAddresses()); + } if ($customer->getAddresses() !== null) { if ($customer->getId()) { $existingAddresses = $this->getById($customer->getId())->getAddresses(); @@ -217,7 +235,6 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa } else { $existingAddressIds = []; } - $savedAddressIds = []; foreach ($customer->getAddresses() as $address) { $address->setCustomerId($customerId) @@ -227,7 +244,6 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa $savedAddressIds[] = $address->getId(); } } - $addressIdsToDelete = array_diff($existingAddressIds, $savedAddressIds); foreach ($addressIdsToDelete as $addressId) { $this->addressRepository->deleteById($addressId); @@ -237,8 +253,13 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa $savedCustomer = $this->get($customer->getEmail(), $customer->getWebsiteId()); $this->eventManager->dispatch( 'customer_save_after_data_object', - ['customer_data_object' => $savedCustomer, 'orig_customer_data_object' => $prevCustomerData] + [ + 'customer_data_object' => $savedCustomer, + 'orig_customer_data_object' => $prevCustomerData, + 'delegate_data' => $delegatedNewOperation ? $delegatedNewOperation->getAdditionalData() : [], + ] ); + return $savedCustomer; } @@ -300,7 +321,7 @@ public function getList(SearchCriteriaInterface $searchCriteria) $collection = $this->customerFactory->create()->getCollection(); $this->extensionAttributesJoinProcessor->process( $collection, - \Magento\Customer\Api\Data\CustomerInterface::class + CustomerInterface::class ); // This is needed to make sure all the attributes are properly loaded foreach ($this->customerMetadata->getAllAttributesMetadata() as $metadata) { @@ -332,7 +353,7 @@ public function getList(SearchCriteriaInterface $searchCriteria) /** * {@inheritdoc} */ - public function delete(\Magento\Customer\Api\Data\CustomerInterface $customer) + public function delete(CustomerInterface $customer) { return $this->deleteById($customer->getId()); } @@ -345,6 +366,8 @@ public function deleteById($customerId) $customerModel = $this->customerRegistry->retrieve($customerId); $customerModel->delete(); $this->customerRegistry->remove($customerId); + $this->notificationStorage->remove(NotificationStorage::UPDATE_CUSTOMER_SESSION, $customerId); + return true; } @@ -370,20 +393,4 @@ protected function addFilterGroupToCollection( $collection->addFieldToFilter($fields); } } - - /** - * Retrieve collection processor - * - * @deprecated 100.2.0 - * @return CollectionProcessorInterface - */ - private function getCollectionProcessor() - { - if (!$this->collectionProcessor) { - $this->collectionProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get( - 'Magento\Eav\Model\Api\SearchCriteria\CollectionProcessor' - ); - } - return $this->collectionProcessor; - } } diff --git a/app/code/Magento/Customer/Model/Visitor.php b/app/code/Magento/Customer/Model/Visitor.php index b4bad240bc825..4624dd8b6bcf5 100644 --- a/app/code/Magento/Customer/Model/Visitor.php +++ b/app/code/Magento/Customer/Model/Visitor.php @@ -151,6 +151,9 @@ public function initByRequest($observer) if ($this->session->getVisitorData()) { $this->setData($this->session->getVisitorData()); + if ($this->getSessionId() != $this->session->getSessionId()) { + $this->setSessionId($this->session->getSessionId()); + } } $this->setLastVisitAt((new \DateTime())->format(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT)); diff --git a/app/code/Magento/Customer/Setup/Patch/Data/AddCustomerUpdatedAtAttribute.php b/app/code/Magento/Customer/Setup/Patch/Data/AddCustomerUpdatedAtAttribute.php index d9103959e0227..bad5735bc3e3a 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/AddCustomerUpdatedAtAttribute.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/AddCustomerUpdatedAtAttribute.php @@ -10,8 +10,8 @@ use Magento\Customer\Setup\CustomerSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class AddCustomerUpdatedAtAttribute diff --git a/app/code/Magento/Customer/Setup/Patch/Data/AddNonSpecifiedGenderAttributeOption.php b/app/code/Magento/Customer/Setup/Patch/Data/AddNonSpecifiedGenderAttributeOption.php index e5c80e0854205..ba50f6e17dd87 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/AddNonSpecifiedGenderAttributeOption.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/AddNonSpecifiedGenderAttributeOption.php @@ -21,8 +21,8 @@ use Magento\Framework\DB\FieldDataConverterFactory; use Magento\Framework\DB\DataConverter\SerializedToJson; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class AddNonSpecifiedGenderAttributeOption diff --git a/app/code/Magento/Customer/Setup/Patch/Data/AddSecurityTrackingAttributes.php b/app/code/Magento/Customer/Setup/Patch/Data/AddSecurityTrackingAttributes.php index afc8b570ca3d2..b066d14a3c63e 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/AddSecurityTrackingAttributes.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/AddSecurityTrackingAttributes.php @@ -10,8 +10,8 @@ use Magento\Customer\Setup\CustomerSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class AddSecurityTrackingAttributes diff --git a/app/code/Magento/Customer/Setup/Patch/Data/ConvertValidationRulesFromSerializedToJson.php b/app/code/Magento/Customer/Setup/Patch/Data/ConvertValidationRulesFromSerializedToJson.php index a6c5087a86bdd..83c5fe7ae6d1e 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/ConvertValidationRulesFromSerializedToJson.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/ConvertValidationRulesFromSerializedToJson.php @@ -10,8 +10,8 @@ use Magento\Framework\DB\DataConverter\SerializedToJson; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ConvertValidationRulesFromSerializedToJson diff --git a/app/code/Magento/Customer/Setup/Patch/Data/DefaultCustomerGroupsAndAttributes.php b/app/code/Magento/Customer/Setup/Patch/Data/DefaultCustomerGroupsAndAttributes.php index d37acd2fd7e7e..6e61b66f3c9db 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/DefaultCustomerGroupsAndAttributes.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/DefaultCustomerGroupsAndAttributes.php @@ -11,8 +11,8 @@ use Magento\Framework\Module\Setup\Migration; use Magento\Framework\Setup\ModuleDataSetupInterface; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class DefaultCustomerGroupsAndAttributes diff --git a/app/code/Magento/Customer/Setup/Patch/Data/MigrateStoresAllowedCountriesToWebsite.php b/app/code/Magento/Customer/Setup/Patch/Data/MigrateStoresAllowedCountriesToWebsite.php index e9dc08912f368..7488f3fd4a920 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/MigrateStoresAllowedCountriesToWebsite.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/MigrateStoresAllowedCountriesToWebsite.php @@ -11,8 +11,8 @@ use Magento\Directory\Model\AllowedCountriesFactory; use Magento\Store\Model\ScopeInterface; use Magento\Store\Model\StoreManagerInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; class MigrateStoresAllowedCountriesToWebsite implements DataPatchInterface, PatchVersionInterface { diff --git a/app/code/Magento/Customer/Setup/Patch/Data/RemoveCheckoutRegisterAndUpdateAttributes.php b/app/code/Magento/Customer/Setup/Patch/Data/RemoveCheckoutRegisterAndUpdateAttributes.php index 1895f1f6c473a..51f54dc4a432c 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/RemoveCheckoutRegisterAndUpdateAttributes.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/RemoveCheckoutRegisterAndUpdateAttributes.php @@ -21,8 +21,8 @@ use Magento\Framework\DB\FieldDataConverterFactory; use Magento\Framework\DB\DataConverter\SerializedToJson; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class RemoveCheckoutRegisterAndUpdateAttributes diff --git a/app/code/Magento/Customer/Setup/Patch/Data/UpdateAutocompleteOnStorefrontConfigPath.php b/app/code/Magento/Customer/Setup/Patch/Data/UpdateAutocompleteOnStorefrontConfigPath.php index bbd0d2b66e68b..30435ace54d46 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/UpdateAutocompleteOnStorefrontConfigPath.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/UpdateAutocompleteOnStorefrontConfigPath.php @@ -8,8 +8,8 @@ use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateAutocompleteOnStorefrontCOnfigPath diff --git a/app/code/Magento/Customer/Setup/Patch/Data/UpdateCustomerAttributeInputFilters.php b/app/code/Magento/Customer/Setup/Patch/Data/UpdateCustomerAttributeInputFilters.php index 9df78ff182ecd..938cd3cd52e73 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/UpdateCustomerAttributeInputFilters.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/UpdateCustomerAttributeInputFilters.php @@ -9,8 +9,8 @@ use Magento\Customer\Setup\CustomerSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateCustomerAttributeInputFilters diff --git a/app/code/Magento/Customer/Setup/Patch/Data/UpdateCustomerAttributesMetadata.php b/app/code/Magento/Customer/Setup/Patch/Data/UpdateCustomerAttributesMetadata.php index 5863803f79ff8..5f4d9952590ba 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/UpdateCustomerAttributesMetadata.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/UpdateCustomerAttributesMetadata.php @@ -9,8 +9,8 @@ use Magento\Customer\Setup\CustomerSetup; use Magento\Customer\Setup\CustomerSetupFactory; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateCustomerAttributesMetadata diff --git a/app/code/Magento/Customer/Setup/Patch/Data/UpdateIdentifierCustomerAttributesVisibility.php b/app/code/Magento/Customer/Setup/Patch/Data/UpdateIdentifierCustomerAttributesVisibility.php index e0b92b342716c..7d0cad768d6b0 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/UpdateIdentifierCustomerAttributesVisibility.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/UpdateIdentifierCustomerAttributesVisibility.php @@ -9,8 +9,8 @@ use Magento\Customer\Setup\CustomerSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpdateIdentifierCustomerAttributesVisibility diff --git a/app/code/Magento/Customer/Setup/Patch/Data/UpdateVATNumber.php b/app/code/Magento/Customer/Setup/Patch/Data/UpdateVATNumber.php index e61d204bcac0c..d31301eedf4b1 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/UpdateVATNumber.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/UpdateVATNumber.php @@ -21,8 +21,8 @@ use Magento\Framework\DB\FieldDataConverterFactory; use Magento\Framework\DB\DataConverter\SerializedToJson; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; class UpdateVATNumber implements DataPatchInterface, PatchVersionInterface { diff --git a/app/code/Magento/Customer/Setup/Patch/Data/UpgradePasswordHashAndAddress.php b/app/code/Magento/Customer/Setup/Patch/Data/UpgradePasswordHashAndAddress.php index 16c74405e786b..3b8f96a037343 100644 --- a/app/code/Magento/Customer/Setup/Patch/Data/UpgradePasswordHashAndAddress.php +++ b/app/code/Magento/Customer/Setup/Patch/Data/UpgradePasswordHashAndAddress.php @@ -10,8 +10,8 @@ use Magento\Framework\Encryption\Encryptor; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class UpgradePasswordHashAndAddress diff --git a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/ViewfileTest.php b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/ViewfileTest.php index 59c940bb85297..8e1aeaa87a10e 100644 --- a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/ViewfileTest.php +++ b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/ViewfileTest.php @@ -206,4 +206,53 @@ public function testExecuteGetParamImage() ); $this->assertSame($this->resultRawMock, $controller->execute()); } + + /** + * @expectedException \Magento\Framework\Exception\NotFoundException + * @expectedExceptionMessage Page not found. + */ + public function testExecuteInvalidFile() + { + $file = '../../../app/etc/env.php'; + $decodedFile = base64_encode($file); + $fileName = 'customer/' . $file; + $path = 'path'; + + $this->requestMock->expects($this->atLeastOnce())->method('getParam')->with('file')->willReturn($decodedFile); + + $this->directoryMock->expects($this->once())->method('getAbsolutePath')->with($fileName)->willReturn($path); + + $this->fileSystemMock->expects($this->once())->method('getDirectoryRead') + ->with(\Magento\Framework\App\Filesystem\DirectoryList::MEDIA) + ->willReturn($this->directoryMock); + + $this->storage->expects($this->once())->method('processStorageFile')->with($path)->willReturn(false); + + $this->objectManagerMock->expects($this->any())->method('get') + ->willReturnMap( + [ + [\Magento\Framework\Filesystem::class, $this->fileSystemMock], + [\Magento\MediaStorage\Helper\File\Storage::class, $this->storage], + ] + ); + + $this->urlDecoderMock->expects($this->once())->method('decode')->with($decodedFile)->willReturn($file); + $fileFactoryMock = $this->createMock( + \Magento\Framework\App\Response\Http\FileFactory::class, + [], + [], + '', + false + ); + + $controller = $this->objectManager->getObject( + \Magento\Customer\Controller\Adminhtml\Index\Viewfile::class, + [ + 'context' => $this->contextMock, + 'urlDecoder' => $this->urlDecoderMock, + 'fileFactory' => $fileFactoryMock, + ] + ); + $controller->execute(); + } } diff --git a/app/code/Magento/Customer/Test/Unit/Controller/Section/LoadTest.php b/app/code/Magento/Customer/Test/Unit/Controller/Section/LoadTest.php new file mode 100644 index 0000000000000..3e9089c473212 --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Controller/Section/LoadTest.php @@ -0,0 +1,183 @@ +contextMock = $this->createMock(Context::class); + $this->resultJsonFactoryMock = $this->createMock(JsonFactory::class); + $this->sectionIdentifierMock = $this->createMock(Identifier::class); + $this->sectionPoolMock = $this->getMockForAbstractClass(SectionPoolInterface::class); + $this->escaperMock = $this->createMock(Escaper::class); + $this->httpRequestMock = $this->createMock(HttpRequest::class); + $this->resultJsonMock = $this->createMock(Json::class); + + $this->contextMock->expects($this->once()) + ->method('getRequest') + ->willReturn($this->httpRequestMock); + + $this->loadAction = new Load( + $this->contextMock, + $this->resultJsonFactoryMock, + $this->sectionIdentifierMock, + $this->sectionPoolMock, + $this->escaperMock + ); + } + + /** + * @param $sectionNames + * @param $updateSectionID + * @param $sectionNamesAsArray + * @param $updateIds + * @dataProvider executeDataProvider + */ + public function testExecute($sectionNames, $updateSectionID, $sectionNamesAsArray, $updateIds) + { + $this->resultJsonFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->resultJsonMock); + $this->resultJsonMock->expects($this->exactly(2)) + ->method('setHeader') + ->withConsecutive( + ['Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store'], + ['Pragma', 'no-cache'] + ); + + $this->httpRequestMock->expects($this->exactly(2)) + ->method('getParam') + ->withConsecutive(['sections'], ['update_section_id']) + ->willReturnOnConsecutiveCalls($sectionNames, $updateSectionID); + + $this->sectionPoolMock->expects($this->once()) + ->method('getSectionsData') + ->with($sectionNamesAsArray, $updateIds) + ->willReturn([ + 'message' => 'some message', + 'someKey' => 'someValue' + ]); + + $this->resultJsonMock->expects($this->once()) + ->method('setData') + ->with([ + 'message' => 'some message', + 'someKey' => 'someValue' + ]) + ->willReturn($this->resultJsonMock); + + $this->loadAction->execute(); + } + + public function executeDataProvider() + { + return [ + [ + 'sectionNames' => 'sectionName1,sectionName2,sectionName3', + 'updateSectionID' => 'updateSectionID', + 'sectionNamesAsArray' => ['sectionName1', 'sectionName2', 'sectionName3'], + 'updateIds' => true + ], + [ + 'sectionNames' => null, + 'updateSectionID' => null, + 'sectionNamesAsArray' => null, + 'updateIds' => false + ], + ]; + } + + public function testExecuteWithThrowException() + { + $this->resultJsonFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->resultJsonMock); + $this->resultJsonMock->expects($this->exactly(2)) + ->method('setHeader') + ->withConsecutive( + ['Cache-Control', 'max-age=0, must-revalidate, no-cache, no-store'], + ['Pragma', 'no-cache'] + ); + + $this->httpRequestMock->expects($this->once()) + ->method('getParam') + ->with('sections') + ->willThrowException(new \Exception('Some Message')); + + $this->resultJsonMock->expects($this->once()) + ->method('setStatusHeader') + ->with( + \Zend\Http\Response::STATUS_CODE_400, + \Zend\Http\AbstractMessage::VERSION_11, + 'Bad Request' + ); + + $this->escaperMock->expects($this->once()) + ->method('escapeHtml') + ->with('Some Message') + ->willReturn('Some Message'); + + $this->resultJsonMock->expects($this->once()) + ->method('setData') + ->with(['message' => 'Some Message']) + ->willReturn($this->resultJsonMock); + + $this->loadAction->execute(); + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Model/Account/RedirectTest.php b/app/code/Magento/Customer/Test/Unit/Model/Account/RedirectTest.php index e63ce964c15b0..0138c6c709b7c 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Account/RedirectTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Account/RedirectTest.php @@ -11,9 +11,9 @@ use Magento\Customer\Model\Account\Redirect; use Magento\Customer\Model\Url as CustomerUrl; use Magento\Framework\Controller\ResultFactory; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\Url\HostChecker; use Magento\Store\Model\ScopeInterface; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -147,15 +147,15 @@ protected function setUp() $this->model = $objectManager->getObject( \Magento\Customer\Model\Account\Redirect::class, [ - 'request' => $this->request, - 'customerSession' => $this->customerSession, - 'scopeConfig' => $this->scopeConfig, - 'storeManager' => $this->storeManager, - 'url' => $this->url, - 'urlDecoder' => $this->urlDecoder, - 'customerUrl' => $this->customerUrl, - 'resultFactory' => $this->resultFactory, - 'hostChecker' => $this->hostChecker + 'request' => $this->request, + 'customerSession' => $this->customerSession, + 'scopeConfig' => $this->scopeConfig, + 'storeManager' => $this->storeManager, + 'url' => $this->url, + 'urlDecoder' => $this->urlDecoder, + 'customerUrl' => $this->customerUrl, + 'resultFactory' => $this->resultFactory, + 'hostChecker' => $this->hostChecker, ] ); } @@ -191,57 +191,31 @@ public function testGetRedirect( $redirectToDashboard ) { // Preparations for method updateLastCustomerId() - $this->customerSession->expects($this->once()) - ->method('getLastCustomerId') - ->willReturn($customerId); - $this->customerSession->expects($this->any()) - ->method('isLoggedIn') - ->willReturn($customerLoggedIn); - $this->customerSession->expects($this->any()) - ->method('getId') - ->willReturn($lastCustomerId); - $this->customerSession->expects($this->any()) - ->method('unsBeforeAuthUrl') - ->willReturnSelf(); + $this->customerSession->expects($this->once())->method('getLastCustomerId')->willReturn($customerId); + $this->customerSession->expects($this->any())->method('isLoggedIn')->willReturn($customerLoggedIn); + $this->customerSession->expects($this->any())->method('getId')->willReturn($lastCustomerId); + $this->customerSession->expects($this->any())->method('unsBeforeAuthUrl')->willReturnSelf(); $this->customerSession->expects($this->any()) ->method('setLastCustomerId') ->with($lastCustomerId) ->willReturnSelf(); // Preparations for method prepareRedirectUrl() - $this->store->expects($this->once()) - ->method('getBaseUrl') - ->willReturn($baseUrl); + $this->store->expects($this->once())->method('getBaseUrl')->willReturn($baseUrl); - $this->customerSession->expects($this->any()) - ->method('getBeforeAuthUrl') - ->willReturn($beforeAuthUrl); - $this->customerSession->expects($this->any()) - ->method('setBeforeAuthUrl') - ->willReturnSelf(); - $this->customerSession->expects($this->any()) - ->method('getAfterAuthUrl') - ->willReturn($afterAuthUrl); + $this->customerSession->expects($this->any())->method('getBeforeAuthUrl')->willReturn($beforeAuthUrl); + $this->customerSession->expects($this->any())->method('setBeforeAuthUrl')->willReturnSelf(); + $this->customerSession->expects($this->any())->method('getAfterAuthUrl')->willReturn($afterAuthUrl); $this->customerSession->expects($this->any()) ->method('setAfterAuthUrl') ->with($beforeAuthUrl) ->willReturnSelf(); - $this->customerSession->expects($this->any()) - ->method('getBeforeRequestParams') - ->willReturn(false); - - $this->customerUrl->expects($this->any()) - ->method('getAccountUrl') - ->willReturn($accountUrl); - $this->customerUrl->expects($this->any()) - ->method('getLoginUrl') - ->willReturn($loginUrl); - $this->customerUrl->expects($this->any()) - ->method('getLogoutUrl') - ->willReturn($logoutUrl); - $this->customerUrl->expects($this->any()) - ->method('getDashboardUrl') - ->willReturn($dashboardUrl); + $this->customerSession->expects($this->any())->method('getBeforeRequestParams')->willReturn(false); + + $this->customerUrl->expects($this->any())->method('getAccountUrl')->willReturn($accountUrl); + $this->customerUrl->expects($this->any())->method('getLoginUrl')->willReturn($loginUrl); + $this->customerUrl->expects($this->any())->method('getLogoutUrl')->willReturn($logoutUrl); + $this->customerUrl->expects($this->any())->method('getDashboardUrl')->willReturn($dashboardUrl); $this->scopeConfig->expects($this->any()) ->method('isSetFlag') @@ -253,25 +227,19 @@ public function testGetRedirect( ->with(CustomerUrl::REFERER_QUERY_PARAM_NAME) ->willReturn($referer); - $this->urlDecoder->expects($this->any()) - ->method('decode') - ->with($referer) - ->willReturn($referer); + $this->urlDecoder->expects($this->any())->method('decode')->with($referer)->willReturn($referer); - $this->url->expects($this->any()) - ->method('isOwnOriginUrl') - ->willReturn(true); + $this->url->expects($this->any())->method('isOwnOriginUrl')->willReturn(true); - $this->resultRedirect->expects($this->once()) - ->method('setUrl') - ->with($beforeAuthUrl) - ->willReturnSelf(); + $this->resultRedirect->expects($this->once())->method('setUrl')->with($beforeAuthUrl)->willReturnSelf(); $this->resultFactory->expects($this->once()) ->method('create') ->with(ResultFactory::TYPE_REDIRECT) ->willReturn($this->resultRedirect); + $this->hostChecker->expects($this->any())->method('isOwnOrigin')->willReturn(true); + $this->model->getRedirect(); } @@ -306,6 +274,21 @@ public function getRedirectDataProvider() [1, 2, 'referer', 'base', 'logout', '', 'account', 'login', 'logout', 'dashboard', false, true], // Default redirect [1, 2, 'referer', 'base', 'defined', '', 'account', 'login', 'logout', 'dashboard', true, true], + // Logout, Without Redirect to Dashboard + [ + 'customer_id' => 1, + 'last_customer_id' => 2, + 'referer' => 'http://base.com/customer/account/logoutSuccess/', + 'base_url' => 'http://base.com/', + 'before_auth_url' => 'http://base.com/', + 'after_auth_url' => 'http://base.com/customer/account/', + 'account_url' => 'account', + 'login_url' => 'login', + 'logout_url' => 'logout', + 'dashboard_url' => 'dashboard', + 'is_customer_logged_id_flag' => true, + 'redirect_to_dashboard_flag' => false, + ], ]; } diff --git a/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php b/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php index 0ca79833e8a13..cfd1729e4e06e 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php @@ -127,6 +127,21 @@ class AccountManagementTest extends \PHPUnit\Framework\TestCase */ private $accountConfirmation; + /** + * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Framework\Session\SessionManagerInterface + */ + private $sessionManager; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory + */ + private $visitorCollectionFactory; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|\Magento\Framework\Session\SaveHandlerInterface + */ + private $saveHandler; + /** * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -179,6 +194,19 @@ protected function setUp() $this->dateTimeFactory = $this->createMock(DateTimeFactory::class); $this->accountConfirmation = $this->createMock(AccountConfirmation::class); + $this->visitorCollectionFactory = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + ) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->sessionManager = $this->getMockBuilder(\Magento\Framework\Session\SessionManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->saveHandler = $this->getMockBuilder(\Magento\Framework\Session\SaveHandlerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->objectManagerHelper = new ObjectManagerHelper($this); $this->accountManagement = $this->objectManagerHelper->getObject( \Magento\Customer\Model\AccountManagement::class, @@ -207,16 +235,22 @@ protected function setUp() 'objectFactory' => $this->objectFactory, 'extensibleDataObjectConverter' => $this->extensibleDataObjectConverter, 'dateTimeFactory' => $this->dateTimeFactory, - 'accountConfirmation' => $this->accountConfirmation + 'accountConfirmation' => $this->accountConfirmation, + 'sessionManager' => $this->sessionManager, + 'saveHandler' => $this->saveHandler, + 'visitorCollectionFactory' => $this->visitorCollectionFactory, ] ); - $reflection = new \ReflectionClass(get_class($this->accountManagement)); - $reflectionProperty = $reflection->getProperty('authentication'); - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($this->accountManagement, $this->authenticationMock); - $reflectionProperty = $reflection->getProperty('emailNotification'); - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($this->accountManagement, $this->emailNotificationMock); + $this->objectManagerHelper->setBackwardCompatibleProperty( + $this->accountManagement, + 'authentication', + $this->authenticationMock + ); + $this->objectManagerHelper->setBackwardCompatibleProperty( + $this->accountManagement, + 'emailNotification', + $this->emailNotificationMock + ); } /** @@ -672,14 +706,14 @@ public function dataProviderCheckPasswordStrength() 'testNumber' => 1, 'password' => 'qwer', 'minPasswordLength' => 5, - 'minCharacterSetsNum' => 1 + 'minCharacterSetsNum' => 1, ], [ 'testNumber' => 2, 'password' => 'wrfewqedf1', 'minPasswordLength' => 5, - 'minCharacterSetsNum' => 3 - ] + 'minCharacterSetsNum' => 3, + ], ]; } @@ -711,7 +745,8 @@ public function testCreateAccountWithPasswordInputException( AccountManagement::XML_PATH_REQUIRED_CHARACTER_CLASSES_NUMBER, 'default', null, - $minCharacterSetsNum], + $minCharacterSetsNum, + ], ] ) ); @@ -722,19 +757,15 @@ public function testCreateAccountWithPasswordInputException( ->willReturn(iconv_strlen($password, 'UTF-8')); if ($testNumber == 1) { - $this->expectException( - \Magento\Framework\Exception\InputException::class, - 'The password needs at least ' . $minPasswordLength . ' characters. ' - . 'Create a new password and try again.' - ); + $this->expectException(\Magento\Framework\Exception\InputException::class); + $this->expectExceptionMessage('The password needs at least ' . $minPasswordLength . ' characters. ' + . 'Create a new password and try again.'); } if ($testNumber == 2) { - $this->expectException( - \Magento\Framework\Exception\InputException::class, - 'Minimum of different classes of characters in password is ' . $minCharacterSetsNum . - '. Classes of characters: Lower Case, Upper Case, Digits, Special Characters.' - ); + $this->expectException(\Magento\Framework\Exception\InputException::class); + $this->expectExceptionMessage('Minimum of different classes of characters in password is ' . + $minCharacterSetsNum . '. Classes of characters: Lower Case, Upper Case, Digits, Special Characters.'); } $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class)->getMock(); @@ -752,10 +783,8 @@ public function testCreateAccountInputExceptionExtraLongPassword() ->with($password) ->willReturn(iconv_strlen($password, 'UTF-8')); - $this->expectException( - \Magento\Framework\Exception\InputException::class, - 'Please enter a password with at most 256 characters.' - ); + $this->expectException(\Magento\Framework\Exception\InputException::class); + $this->expectExceptionMessage('Please enter a password with at most 256 characters.'); $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class)->getMock(); $this->accountManagement->createAccount($customer, $password); @@ -795,7 +824,8 @@ public function testCreateAccountWithPassword() AccountManagement::XML_PATH_REQUIRED_CHARACTER_CLASSES_NUMBER, 'default', null, - $minCharacterSetsNum], + $minCharacterSetsNum, + ], [ AccountManagement::XML_PATH_REGISTER_EMAIL_TEMPLATE, ScopeInterface::SCOPE_STORE, @@ -806,8 +836,8 @@ public function testCreateAccountWithPassword() AccountManagement::XML_PATH_REGISTER_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, 1, - $sender - ] + $sender, + ], ] ); $this->string->expects($this->any()) @@ -1276,27 +1306,67 @@ private function reInitModel() { $this->customerSecure = $this->getMockBuilder(\Magento\Customer\Model\Data\CustomerSecure::class) ->disableOriginalConstructor() - ->setMethods(['getRpToken', 'getRpTokenCreatedAt']) + ->setMethods( + [ + 'getRpToken', + 'getRpTokenCreatedAt', + 'getPasswordHash', + 'setPasswordHash', + 'setRpToken', + 'setRpTokenCreatedAt', + ] + ) ->getMock(); - - $this->customerSecure - ->expects($this->any()) + $this->customerSecure->expects($this->any()) ->method('getRpToken') ->willReturn('newStringToken'); - $pastDateTime = '2016-10-25 00:00:00'; - - $this->customerSecure - ->expects($this->any()) + $this->customerSecure->expects($this->any()) ->method('getRpTokenCreatedAt') ->willReturn($pastDateTime); - $this->customer = $this->getMockBuilder(\Magento\Customer\Model\Customer::class) ->disableOriginalConstructor() ->setMethods(['getResetPasswordLinkExpirationPeriod']) ->getMock(); $this->prepareDateTimeFactory(); + $this->sessionManager = $this->getMockBuilder(\Magento\Framework\Session\SessionManagerInterface::class) + ->disableOriginalConstructor() + ->setMethods(['destroy', 'start', 'writeClose']) + ->getMockForAbstractClass(); + $this->visitorCollectionFactory = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + ) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->saveHandler = $this->getMockBuilder(\Magento\Framework\Session\SaveHandlerInterface::class) + ->disableOriginalConstructor() + ->setMethods(['destroy']) + ->getMockForAbstractClass(); + + $dateTime = '2017-10-25 18:57:08'; + $timestamp = '1508983028'; + $dateTimeMock = $this->getMockBuilder(\DateTime::class) + ->disableOriginalConstructor() + ->setMethods(['format', 'getTimestamp', 'setTimestamp']) + ->getMock(); + + $dateTimeMock->expects($this->any()) + ->method('format') + ->with(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT) + ->willReturn($dateTime); + $dateTimeMock->expects($this->any()) + ->method('getTimestamp') + ->willReturn($timestamp); + $dateTimeMock->expects($this->any()) + ->method('setTimestamp') + ->willReturnSelf(); + $dateTimeFactory = $this->getMockBuilder(DateTimeFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $dateTimeFactory->expects($this->any())->method('create')->willReturn($dateTimeMock); $this->objectManagerHelper = new ObjectManagerHelper($this); $this->accountManagement = $this->objectManagerHelper->getObject( @@ -1306,13 +1376,23 @@ private function reInitModel() 'customerRegistry' => $this->customerRegistry, 'customerRepository' => $this->customerRepository, 'customerModel' => $this->customer, - 'dateTimeFactory' => $this->dateTimeFactory, + 'dateTimeFactory' => $dateTimeFactory, + 'stringHelper' => $this->string, + 'scopeConfig' => $this->scopeConfig, + 'sessionManager' => $this->sessionManager, + 'visitorCollectionFactory' => $this->visitorCollectionFactory, + 'saveHandler' => $this->saveHandler, + 'encryptor' => $this->encryptor, + 'dataProcessor' => $this->dataObjectProcessor, + 'storeManager' => $this->storeManager, + 'transportBuilder' => $this->transportBuilder, ] ); - $reflection = new \ReflectionClass(get_class($this->accountManagement)); - $reflectionProperty = $reflection->getProperty('authentication'); - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($this->accountManagement, $this->authenticationMock); + $this->objectManagerHelper->setBackwardCompatibleProperty( + $this->accountManagement, + 'authentication', + $this->authenticationMock + ); } /** @@ -1326,6 +1406,7 @@ public function testChangePassword() $newPassword = 'abcdefg'; $passwordHash = '1a2b3f4c'; + $this->reInitModel(); $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class) ->getMock(); $customer->expects($this->any()) @@ -1341,24 +1422,20 @@ public function testChangePassword() $this->authenticationMock->expects($this->once()) ->method('authenticate'); - $customerSecure = $this->getMockBuilder(\Magento\Customer\Model\Data\CustomerSecure::class) - ->setMethods(['setRpToken', 'setRpTokenCreatedAt', 'getPasswordHash']) - ->disableOriginalConstructor() - ->getMock(); - $customerSecure->expects($this->once()) + $this->customerSecure->expects($this->once()) ->method('setRpToken') ->with(null); - $customerSecure->expects($this->once()) + $this->customerSecure->expects($this->once()) ->method('setRpTokenCreatedAt') ->willReturnSelf(); - $customerSecure->expects($this->any()) + $this->customerSecure->expects($this->any()) ->method('getPasswordHash') ->willReturn($passwordHash); $this->customerRegistry->expects($this->any()) ->method('retrieveSecureData') ->with($customerId) - ->willReturn($customerSecure); + ->willReturn($this->customerSecure); $this->scopeConfig->expects($this->any()) ->method('getValue') @@ -1374,7 +1451,7 @@ public function testChangePassword() AccountManagement::XML_PATH_REQUIRED_CHARACTER_CLASSES_NUMBER, 'default', null, - 1 + 1, ], ] ); @@ -1388,9 +1465,85 @@ public function testChangePassword() ->method('save') ->with($customer); + $this->sessionManager->expects($this->atLeastOnce())->method('start'); + $this->sessionManager->expects($this->atLeastOnce())->method('writeClose'); + $this->sessionManager->expects($this->atLeastOnce())->method('getSessionId'); + + $visitor = $this->getMockBuilder(\Magento\Customer\Model\Visitor::class) + ->disableOriginalConstructor() + ->setMethods(['getSessionId']) + ->getMock(); + $visitor->expects($this->atLeastOnce())->method('getSessionId') + ->willReturnOnConsecutiveCalls('session_id_1', 'session_id_2'); + $visitorCollection = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + ) + ->disableOriginalConstructor()->setMethods(['addFieldToFilter', 'getItems'])->getMock(); + $visitorCollection->expects($this->atLeastOnce())->method('addFieldToFilter')->willReturnSelf(); + $visitorCollection->expects($this->atLeastOnce())->method('getItems')->willReturn([$visitor, $visitor]); + $this->visitorCollectionFactory->expects($this->atLeastOnce())->method('create') + ->willReturn($visitorCollection); + $this->saveHandler->expects($this->atLeastOnce())->method('destroy') + ->withConsecutive( + ['session_id_1'], + ['session_id_2'] + ); + $this->assertTrue($this->accountManagement->changePassword($email, $currentPassword, $newPassword)); } + public function testResetPassword() + { + $customerEmail = 'customer@example.com'; + $customerId = '1'; + $resetToken = 'newStringToken'; + $newPassword = 'new_password'; + + $this->reInitModel(); + $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class)->getMock(); + $customer->expects($this->any())->method('getId')->willReturn($customerId); + $this->customerRepository->expects($this->atLeastOnce())->method('get')->with($customerEmail) + ->willReturn($customer); + $this->customer->expects($this->atLeastOnce())->method('getResetPasswordLinkExpirationPeriod') + ->willReturn(100000); + $this->string->expects($this->any())->method('strlen')->willReturnCallback( + function ($string) { + return strlen($string); + } + ); + $this->customerRegistry->expects($this->atLeastOnce())->method('retrieveSecureData') + ->willReturn($this->customerSecure); + + $this->customerSecure->expects($this->once())->method('setRpToken')->with(null); + $this->customerSecure->expects($this->once())->method('setRpTokenCreatedAt')->with(null); + $this->customerSecure->expects($this->any())->method('setPasswordHash')->willReturn(null); + + $this->sessionManager->expects($this->atLeastOnce())->method('destroy'); + $this->sessionManager->expects($this->atLeastOnce())->method('start'); + $this->sessionManager->expects($this->atLeastOnce())->method('writeClose'); + $this->sessionManager->expects($this->atLeastOnce())->method('getSessionId'); + $visitor = $this->getMockBuilder(\Magento\Customer\Model\Visitor::class) + ->disableOriginalConstructor() + ->setMethods(['getSessionId']) + ->getMock(); + $visitor->expects($this->atLeastOnce())->method('getSessionId') + ->willReturnOnConsecutiveCalls('session_id_1', 'session_id_2'); + $visitorCollection = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + ) + ->disableOriginalConstructor()->setMethods(['addFieldToFilter', 'getItems'])->getMock(); + $visitorCollection->expects($this->atLeastOnce())->method('addFieldToFilter')->willReturnSelf(); + $visitorCollection->expects($this->atLeastOnce())->method('getItems')->willReturn([$visitor, $visitor]); + $this->visitorCollectionFactory->expects($this->atLeastOnce())->method('create') + ->willReturn($visitorCollection); + $this->saveHandler->expects($this->atLeastOnce())->method('destroy') + ->withConsecutive( + ['session_id_1'], + ['session_id_2'] + ); + $this->assertTrue($this->accountManagement->resetPassword($customerEmail, $resetToken, $newPassword)); + } + /** * @return void */ @@ -1409,10 +1562,8 @@ public function testChangePasswordException() ->with($email) ->willThrowException($exception); - $this->expectException( - \Magento\Framework\Exception\InvalidEmailOrPasswordException::class, - 'Invalid login or password.' - ); + $this->expectException(\Magento\Framework\Exception\InvalidEmailOrPasswordException::class); + $this->expectExceptionMessage('Invalid login or password.'); $this->accountManagement->changePassword($email, $currentPassword, $newPassword); } @@ -1466,10 +1617,10 @@ public function testAuthenticate() ->withConsecutive( [ 'customer_customer_authenticated', - ['model' => $customerModel, 'password' => $password] + ['model' => $customerModel, 'password' => $password], ], [ - 'customer_data_object_login', ['customer' => $customerData] + 'customer_data_object_login', ['customer' => $customerData], ] ); diff --git a/app/code/Magento/Customer/Test/Unit/Model/Address/AbstractAddressTest.php b/app/code/Magento/Customer/Test/Unit/Model/Address/AbstractAddressTest.php index a94b7e39bc0eb..0fc3d01673c47 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Address/AbstractAddressTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Address/AbstractAddressTest.php @@ -6,6 +6,8 @@ namespace Magento\Customer\Test\Unit\Model\Address; +use Magento\Customer\Model\Address\CompositeValidator; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -41,6 +43,12 @@ class AbstractAddressTest extends \PHPUnit\Framework\TestCase /** @var \Magento\Customer\Model\Address\AbstractAddress */ protected $model; + /** @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager */ + private $objectManager; + + /** @var \Magento\Customer\Model\Address\CompositeValidator|\PHPUnit_Framework_MockObject_MockObject */ + private $compositeValidatorMock; + protected function setUp() { $this->contextMock = $this->createMock(\Magento\Framework\Model\Context::class); @@ -69,8 +77,9 @@ protected function setUp() $this->resourceCollectionMock = $this->getMockBuilder(\Magento\Framework\Data\Collection\AbstractDb::class) ->disableOriginalConstructor() ->getMockForAbstractClass(); - $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->model = $objectManager->getObject( + $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->compositeValidatorMock = $this->createMock(CompositeValidator::class); + $this->model = $this->objectManager->getObject( \Magento\Customer\Model\Address\AbstractAddress::class, [ 'context' => $this->contextMock, @@ -81,7 +90,8 @@ protected function setUp() 'regionFactory' => $this->regionFactoryMock, 'countryFactory' => $this->countryFactoryMock, 'resource' => $this->resourceMock, - 'resourceCollection' => $this->resourceCollectionMock + 'resourceCollection' => $this->resourceCollectionMock, + 'compositeValidator' => $this->compositeValidatorMock, ] ); } @@ -275,28 +285,15 @@ public function testSetDataWithObject() } /** - * @param $data - * @param $expected + * @param array $data + * @param array|bool $expected + * @return void * * @dataProvider validateDataProvider */ - public function testValidate($data, $expected) + public function testValidate(array $data, $expected) { - $attributeMock = $this->createMock(\Magento\Eav\Model\Entity\Attribute::class); - $attributeMock->expects($this->any()) - ->method('getIsRequired') - ->willReturn(true); - - $this->eavConfigMock->expects($this->any()) - ->method('getAttribute') - ->will($this->returnValue($attributeMock)); - - $this->directoryDataMock->expects($this->once()) - ->method('getCountriesWithOptionalZip') - ->will($this->returnValue([])); - - $this->directoryDataMock->expects($this->never()) - ->method('isRegionRequired'); + $this->compositeValidatorMock->method('validate')->with($this->model)->willReturn($expected); foreach ($data as $key => $value) { $this->model->setData($key, $value); @@ -349,6 +346,10 @@ public function validateDataProvider() array_merge(array_diff_key($data, ['postcode' => '']), ['country_id' => $countryId++]), ['"postcode" is required. Enter and try again.'], ], + 'region_id' => [ + array_merge($data, ['country_id' => $countryId++, 'region_id' => 2]), + ['Invalid value of "2" provided for the regionId field.'], + ], 'country_id' => [ array_diff_key($data, ['country_id' => '']), ['"countryId" is required. Enter and try again.'], @@ -388,4 +389,13 @@ public function getStreetFullDataProvider() ['single line', 'single line'], ]; } + + protected function tearDown() + { + $this->objectManager->setBackwardCompatibleProperty( + $this->model, + '_countryModels', + [] + ); + } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/CountryTest.php b/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/CountryTest.php new file mode 100644 index 0000000000000..e70f93edab12c --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/CountryTest.php @@ -0,0 +1,160 @@ +directoryDataMock = $this->createMock(\Magento\Directory\Helper\Data::class); + $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->model = $this->objectManager->getObject( + \Magento\Customer\Model\Address\Validator\Country::class, + [ + 'directoryData' => $this->directoryDataMock, + ] + ); + } + + /** + * @param array $data + * @param array $countryIds + * @param array $allowedRegions + * @param array $expected + * @return void + * + * @dataProvider validateDataProvider + */ + public function testValidate(array $data, array $countryIds, array $allowedRegions, array $expected) + { + $addressMock = $this + ->getMockBuilder(\Magento\Customer\Model\Address\AbstractAddress::class) + ->disableOriginalConstructor() + ->setMethods( + [ + 'getCountryId', + 'getRegion', + 'getRegionId', + 'getCountryModel', + ] + )->getMock(); + + $this->directoryDataMock->expects($this->any()) + ->method('isRegionRequired') + ->willReturn($data['regionRequired']); + + $countryCollectionMock = $this->getMockBuilder(\Magento\Directory\Model\ResourceModel\Country\Collection::class) + ->disableOriginalConstructor() + ->setMethods(['getAllIds']) + ->getMock(); + + $this->directoryDataMock->expects($this->any()) + ->method('getCountryCollection') + ->willReturn($countryCollectionMock); + + $countryCollectionMock->expects($this->any())->method('getAllIds')->willReturn($countryIds); + + $addressMock->method('getCountryId')->willReturn($data['country_id']); + + $countryModelMock = $this->getMockBuilder(\Magento\Directory\Model\Country::class) + ->disableOriginalConstructor() + ->setMethods(['getRegionCollection']) + ->getMock(); + + $addressMock->method('getCountryModel')->willReturn($countryModelMock); + + $regionCollectionMock = $this->getMockBuilder(\Magento\Directory\Model\ResourceModel\Region\Collection::class) + ->disableOriginalConstructor() + ->setMethods(['getAllIds']) + ->getMock(); + $countryModelMock + ->expects($this->any()) + ->method('getRegionCollection') + ->willReturn($regionCollectionMock); + $regionCollectionMock->expects($this->any())->method('getAllIds')->willReturn($allowedRegions); + + $addressMock->method('getRegionId')->willReturn($data['region_id']); + $addressMock->method('getRegion')->willReturn(null); + + $actual = $this->model->validate($addressMock); + $this->assertEquals($expected, $actual); + } + + /** + * @return array + */ + public function validateDataProvider() + { + $countryId = 1; + $data = [ + 'firstname' => 'First Name', + 'lastname' => 'Last Name', + 'street' => "Street 1\nStreet 2", + 'city' => 'Odessa', + 'telephone' => '555-55-55', + 'country_id' => $countryId, + 'postcode' => 07201, + 'region_id' => 1, + 'region' => '', + 'regionRequired' => false, + 'company' => 'Magento', + 'fax' => '222-22-22', + ]; + $result = [ + 'country_id1' => [ + array_merge($data, ['country_id' => null]), + [], + [1], + ['"countryId" is required. Enter and try again.'], + ], + 'country_id2' => [ + $data, + [], + [1], + ['Invalid value of "' . $countryId . '" provided for the countryId field.'], + ], + 'region' => [ + array_merge($data, ['country_id' => $countryId, 'regionRequired' => true]), + [$countryId++], + [], + ['"region" is required. Enter and try again.'], + ], + 'region_id1' => [ + array_merge($data, ['country_id' => $countryId, 'regionRequired' => true, 'region_id' => '']), + [$countryId++], + [1], + ['"regionId" is required. Enter and try again.'], + ], + 'region_id2' => [ + array_merge($data, ['country_id' => $countryId, 'region_id' => 2]), + [$countryId++], + [1], + ['Invalid value of "2" provided for the regionId field.'], + ], + 'validated' => [ + array_merge($data, ['country_id' => $countryId]), + [$countryId], + ['1'], + [], + ], + ]; + + return $result; + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/GeneralTest.php b/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/GeneralTest.php new file mode 100644 index 0000000000000..058a69e86b43a --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Model/Address/Validator/GeneralTest.php @@ -0,0 +1,140 @@ +directoryDataMock = $this->createMock(\Magento\Directory\Helper\Data::class); + $this->eavConfigMock = $this->createMock(\Magento\Eav\Model\Config::class); + $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->model = $this->objectManager->getObject( + \Magento\Customer\Model\Address\Validator\General::class, + [ + 'eavConfig' => $this->eavConfigMock, + 'directoryData' => $this->directoryDataMock, + ] + ); + } + + /** + * @param array $data + * @param array $expected + * @return void + * + * @dataProvider validateDataProvider + */ + public function testValidate(array $data, array $expected) + { + $addressMock = $this + ->getMockBuilder(\Magento\Customer\Model\Address\AbstractAddress::class) + ->disableOriginalConstructor() + ->setMethods( + [ + 'getFirstname', + 'getLastname', + 'getStreetLine', + 'getCity', + 'getTelephone', + 'getFax', + 'getCompany', + 'getPostcode', + 'getCountryId', + ] + )->getMock(); + + $attributeMock = $this->createMock(\Magento\Eav\Model\Entity\Attribute::class); + $attributeMock->expects($this->any()) + ->method('getIsRequired') + ->willReturn(true); + + $this->eavConfigMock->expects($this->any()) + ->method('getAttribute') + ->will($this->returnValue($attributeMock)); + + $this->directoryDataMock->expects($this->once()) + ->method('getCountriesWithOptionalZip') + ->will($this->returnValue([])); + + $addressMock->method('getFirstName')->willReturn($data['firstname']); + $addressMock->method('getLastname')->willReturn($data['lastname']); + $addressMock->method('getStreetLine')->with(1)->willReturn($data['street']); + $addressMock->method('getCity')->willReturn($data['city']); + $addressMock->method('getTelephone')->willReturn($data['telephone']); + $addressMock->method('getFax')->willReturn($data['fax']); + $addressMock->method('getCompany')->willReturn($data['company']); + $addressMock->method('getPostcode')->willReturn($data['postcode']); + $addressMock->method('getCountryId')->willReturn($data['country_id']); + + $actual = $this->model->validate($addressMock); + $this->assertEquals($expected, $actual); + } + + /** + * @return array + */ + public function validateDataProvider() + { + $countryId = 1; + $data = [ + 'firstname' => 'First Name', + 'lastname' => 'Last Name', + 'street' => "Street 1\nStreet 2", + 'city' => 'Odessa', + 'telephone' => '555-55-55', + 'country_id' => $countryId, + 'postcode' => 07201, + 'company' => 'Magento', + 'fax' => '222-22-22', + ]; + $result = [ + 'firstname' => [ + array_merge(array_merge($data, ['firstname' => '']), ['country_id' => $countryId++]), + ['"firstname" is required. Enter and try again.'], + ], + 'lastname' => [ + array_merge(array_merge($data, ['lastname' => '']), ['country_id' => $countryId++]), + ['"lastname" is required. Enter and try again.'], + ], + 'street' => [ + array_merge(array_merge($data, ['street' => '']), ['country_id' => $countryId++]), + ['"street" is required. Enter and try again.'], + ], + 'city' => [ + array_merge(array_merge($data, ['city' => '']), ['country_id' => $countryId++]), + ['"city" is required. Enter and try again.'], + ], + 'telephone' => [ + array_merge(array_merge($data, ['telephone' => '']), ['country_id' => $countryId++]), + ['"telephone" is required. Enter and try again.'], + ], + 'postcode' => [ + array_merge(array_merge($data, ['postcode' => '']), ['country_id' => $countryId++]), + ['"postcode" is required. Enter and try again.'], + ], + 'validated' => [array_merge($data, ['country_id' => $countryId++]), []], + ]; + + return $result; + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php b/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php index 0240b7ab29ab7..318023d8068c5 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php @@ -3,10 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Customer\Test\Unit\Model; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\EmailNotification; use Magento\Framework\App\Area; +use Magento\Framework\Mail\Template\SenderResolverInterface; use Magento\Store\Model\ScopeInterface; /** @@ -47,7 +50,7 @@ class EmailNotificationTest extends \PHPUnit\Framework\TestCase private $customerSecureMock; /** - * @var \Magento\Framework\App\Config\ScopeConfigInterface + * @var \Magento\Framework\App\Config\ScopeConfigInterface|\PHPUnit_Framework_MockObject_MockObject */ private $scopeConfigMock; @@ -61,6 +64,11 @@ class EmailNotificationTest extends \PHPUnit\Framework\TestCase */ private $model; + /** + * @var SenderResolverInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $senderResolverMock; + public function setUp() { $this->customerRegistryMock = $this->createMock(\Magento\Customer\Model\CustomerRegistry::class); @@ -88,17 +96,23 @@ public function setUp() $this->storeMock = $this->createMock(\Magento\Store\Model\Store::class); + $this->senderResolverMock = $this->getMockBuilder(SenderResolverInterface::class) + ->setMethods(['resolve']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->model = $objectManager->getObject( EmailNotification::class, [ - 'customerRegistry' => $this->customerRegistryMock, + 'customerRegistry' => $this->customerRegistryMock, 'storeManager' => $this->storeManagerMock, 'transportBuilder' => $this->transportBuilderMock, 'customerViewHelper' => $this->customerViewHelperMock, 'dataProcessor' => $this->dataProcessorMock, - 'scopeConfig' => $this->scopeConfigMock + 'scopeConfig' => $this->scopeConfigMock, + 'senderResolver' => $this->senderResolverMock, ] ); } @@ -121,7 +135,10 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + $expects = $this->once(); + $xmlPathTemplate = EmailNotification::XML_PATH_RESET_PASSWORD_TEMPLATE; switch ($testNumber) { case 1: $xmlPathTemplate = EmailNotification::XML_PATH_RESET_PASSWORD_TEMPLATE; @@ -137,7 +154,14 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas break; } - $origCustomer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $this->senderResolverMock + ->expects($expects) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); + + /** @var \PHPUnit_Framework_MockObject_MockObject $origCustomer */ + $origCustomer = $this->createMock(CustomerInterface::class); $origCustomer->expects($this->any()) ->method('getStoreId') ->willReturn(0); @@ -175,7 +199,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas $this->dataProcessorMock->expects(clone $expects) ->method('buildOutputDataArray') - ->with($origCustomer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($origCustomer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -192,6 +216,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas ->with('name', $customerName) ->willReturnSelf(); + /** @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject $savedCustomer */ $savedCustomer = clone $origCustomer; $origCustomer->expects($this->any()) @@ -234,7 +259,7 @@ public function testCredentialsChanged($testNumber, $oldEmail, $newEmail, $isPas ->willReturnSelf(); $this->transportBuilderMock->expects(clone $expects) ->method('setFrom') - ->with($sender) + ->with($senderValues) ->willReturnSelf(); $this->transportBuilderMock->expects(clone $expects) @@ -287,14 +312,27 @@ public function sendNotificationEmailsDataProvider() public function testPasswordReminder() { $customerId = 1; + $customerWebsiteId = 1; $customerStoreId = 2; $customerEmail = 'email@email.com'; $customerData = ['key' => 'value']; $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + $storeIds = [1, 2]; + + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + /** @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); + $customer->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($customerWebsiteId); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -313,10 +351,15 @@ public function testPasswordReminder() ->method('getStore') ->willReturn($this->storeMock); - $this->storeManagerMock->expects($this->at(1)) - ->method('getStore') - ->with($customerStoreId) - ->willReturn($this->storeMock); + $websiteMock = $this->createPartialMock(\Magento\Store\Model\Website::class, ['getStoreIds']); + $websiteMock->expects($this->any()) + ->method('getStoreIds') + ->willReturn($storeIds); + + $this->storeManagerMock->expects($this->any()) + ->method('getWebsite') + ->with($customerWebsiteId) + ->willReturn($websiteMock); $this->customerRegistryMock->expects($this->once()) ->method('retrieveSecureData') @@ -325,7 +368,7 @@ public function testPasswordReminder() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -351,35 +394,108 @@ public function testPasswordReminder() ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); - $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateIdentifier') - ->with($templateIdentifier) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateOptions') - ->with(['area' => Area::AREA_FRONTEND, 'store' => $customerStoreId]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'store' => $this->storeMock]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setFrom') - ->with($sender) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('addTo') - ->with($customerEmail, $customerName) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('getTransport') - ->willReturn($transport); + $this->model->passwordReminder($customer); + } - $transport->expects($this->once()) - ->method('sendMessage'); + /** + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testPasswordReminderCustomerWithoutStoreId() + { + $customerId = 1; + $customerWebsiteId = 1; + $customerStoreId = null; + $customerEmail = 'email@email.com'; + $customerData = ['key' => 'value']; + $customerName = 'Customer Name'; + $templateIdentifier = 'Template Identifier'; + $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + $storeIds = [1, 2]; + $defaultStoreId = reset($storeIds); + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $defaultStoreId) + ->willReturn($senderValues); + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); + $customer->expects($this->any()) + ->method('getWebsiteId') + ->willReturn($customerWebsiteId); + $customer->expects($this->any()) + ->method('getStoreId') + ->willReturn($customerStoreId); + $customer->expects($this->any()) + ->method('getId') + ->willReturn($customerId); + $customer->expects($this->any()) + ->method('getEmail') + ->willReturn($customerEmail); + $this->storeMock->expects($this->any()) + ->method('getId') + ->willReturn($defaultStoreId); + $this->storeManagerMock->expects($this->at(0)) + ->method('getStore') + ->willReturn($this->storeMock); + $this->storeManagerMock->expects($this->at(1)) + ->method('getStore') + ->with($defaultStoreId) + ->willReturn($this->storeMock); + $websiteMock = $this->createPartialMock(\Magento\Store\Model\Website::class, ['getStoreIds']); + $websiteMock->expects($this->any()) + ->method('getStoreIds') + ->willReturn($storeIds); + $this->storeManagerMock->expects($this->any()) + ->method('getWebsite') + ->with($customerWebsiteId) + ->willReturn($websiteMock); + $this->customerRegistryMock->expects($this->once()) + ->method('retrieveSecureData') + ->with($customerId) + ->willReturn($this->customerSecureMock); + $this->dataProcessorMock->expects($this->once()) + ->method('buildOutputDataArray') + ->with($customer, CustomerInterface::class) + ->willReturn($customerData); + $this->customerViewHelperMock->expects($this->any()) + ->method('getCustomerName') + ->with($customer) + ->willReturn($customerName); + $this->customerSecureMock->expects($this->once()) + ->method('addData') + ->with($customerData) + ->willReturnSelf(); + $this->customerSecureMock->expects($this->once()) + ->method('setData') + ->with('name', $customerName) + ->willReturnSelf(); + $this->scopeConfigMock->expects($this->at(0)) + ->method('getValue') + ->with(EmailNotification::XML_PATH_REMIND_EMAIL_TEMPLATE, ScopeInterface::SCOPE_STORE, $defaultStoreId) + ->willReturn($templateIdentifier); + $this->scopeConfigMock->expects($this->at(1)) + ->method('getValue') + ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $defaultStoreId) + ->willReturn($sender); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $defaultStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); $this->model->passwordReminder($customer); } @@ -395,8 +511,16 @@ public function testPasswordResetConfirmation() $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); + + /** @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -427,7 +551,7 @@ public function testPasswordResetConfirmation() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -453,34 +577,14 @@ public function testPasswordResetConfirmation() ->with(EmailNotification::XML_PATH_FORGOT_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); - $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); - - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateIdentifier') - ->with($templateIdentifier) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateOptions') - ->with(['area' => Area::AREA_FRONTEND, 'store' => $customerStoreId]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'store' => $this->storeMock]) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('setFrom') - ->with($sender) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('addTo') - ->with($customerEmail, $customerName) - ->willReturnSelf(); - $this->transportBuilderMock->expects($this->once()) - ->method('getTransport') - ->willReturn($transport); - - $transport->expects($this->once()) - ->method('sendMessage'); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'store' => $this->storeMock] + ); $this->model->passwordResetConfirmation($customer); } @@ -497,8 +601,16 @@ public function testNewAccount() $customerName = 'Customer Name'; $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; + $senderValues = ['name' => $sender, 'email' => $sender]; + + $this->senderResolverMock + ->expects($this->once()) + ->method('resolve') + ->with($sender, $customerStoreId) + ->willReturn($senderValues); - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + /** @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); $customer->expects($this->any()) ->method('getStoreId') ->willReturn($customerStoreId); @@ -525,7 +637,7 @@ public function testNewAccount() $this->dataProcessorMock->expects($this->once()) ->method('buildOutputDataArray') - ->with($customer, \Magento\Customer\Api\Data\CustomerInterface::class) + ->with($customer, CustomerInterface::class) ->willReturn($customerData); $this->customerViewHelperMock->expects($this->any()) @@ -551,6 +663,38 @@ public function testNewAccount() ->with(EmailNotification::XML_PATH_REGISTER_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); + $this->mockDefaultTransportBuilder( + $templateIdentifier, + $customerStoreId, + $senderValues, + $customerEmail, + $customerName, + ['customer' => $this->customerSecureMock, 'back_url' => '', 'store' => $this->storeMock] + ); + + $this->model->newAccount($customer, EmailNotification::NEW_ACCOUNT_EMAIL_REGISTERED, '', $customerStoreId); + } + + /** + * Create default mock for $this->transportBuilderMock. + * + * @param string $templateIdentifier + * @param int $customerStoreId + * @param array $senderValues + * @param string $customerEmail + * @param string $customerName + * @param array $templateVars + * + * @return void + */ + private function mockDefaultTransportBuilder( + string $templateIdentifier, + int $customerStoreId, + array $senderValues, + string $customerEmail, + string $customerName, + array $templateVars = [] + ): void { $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); $this->transportBuilderMock->expects($this->once()) @@ -563,11 +707,11 @@ public function testNewAccount() ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('setTemplateVars') - ->with(['customer' => $this->customerSecureMock, 'back_url' => '', 'store' => $this->storeMock]) + ->with($templateVars) ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('setFrom') - ->with($sender) + ->with($senderValues) ->willReturnSelf(); $this->transportBuilderMock->expects($this->once()) ->method('addTo') @@ -579,7 +723,5 @@ public function testNewAccount() $transport->expects($this->once()) ->method('sendMessage'); - - $this->model->newAccount($customer, EmailNotification::NEW_ACCOUNT_EMAIL_REGISTERED, '', $customerStoreId); } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/LoggerTest.php b/app/code/Magento/Customer/Test/Unit/Model/LoggerTest.php index 4cea7ee22837d..408389182ae49 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/LoggerTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/LoggerTest.php @@ -71,7 +71,8 @@ public function testLog($customerId, $data) $data = array_filter($data); if (!$data) { - $this->expectException('\InvalidArgumentException', 'Log data is empty'); + $this->expectException('\InvalidArgumentException'); + $this->expectExceptionMessage('Log data is empty'); $this->logger->log($customerId, $data); return; } diff --git a/app/code/Magento/Customer/Test/Unit/Model/Plugin/CustomerNotificationTest.php b/app/code/Magento/Customer/Test/Unit/Model/Plugin/CustomerNotificationTest.php index 14a95dda76f61..c3c853bca1469 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Plugin/CustomerNotificationTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Plugin/CustomerNotificationTest.php @@ -5,82 +5,119 @@ */ namespace Magento\Customer\Test\Unit\Model\Plugin; +use Magento\Backend\App\AbstractAction; +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\Customer\NotificationStorage; use Magento\Customer\Model\Plugin\CustomerNotification; +use Magento\Customer\Model\Session; +use Magento\Framework\App\Area; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\App\State; +use Magento\Framework\Exception\NoSuchEntityException; +use Psr\Log\LoggerInterface; class CustomerNotificationTest extends \PHPUnit\Framework\TestCase { - /** @var \Magento\Customer\Model\Session|\PHPUnit_Framework_MockObject_MockObject */ - protected $session; + /** @var Session|\PHPUnit_Framework_MockObject_MockObject */ + private $sessionMock; - /** @var \Magento\Customer\Model\Customer\NotificationStorage|\PHPUnit_Framework_MockObject_MockObject */ - protected $notificationStorage; + /** @var NotificationStorage|\PHPUnit_Framework_MockObject_MockObject */ + private $notificationStorageMock; - /** @var \Magento\Customer\Api\CustomerRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $customerRepository; + /** @var CustomerRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $customerRepositoryMock; - /** @var \Magento\Framework\App\State|\PHPUnit_Framework_MockObject_MockObject */ - protected $appState; + /** @var State|\PHPUnit_Framework_MockObject_MockObject */ + private $appStateMock; - /** @var \Magento\Framework\App\RequestInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $request; + /** @var RequestInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $requestMock; - /** @var \Magento\Backend\App\AbstractAction|\PHPUnit_Framework_MockObject_MockObject */ - protected $abstractAction; + /** @var AbstractAction|\PHPUnit_Framework_MockObject_MockObject */ + private $abstractActionMock; + + /** @var LoggerInterface */ + private $loggerMock; /** @var CustomerNotification */ - protected $plugin; + private $plugin; + + /** @var int */ + private static $customerId = 1; protected function setUp() { - $this->session = $this->getMockBuilder(\Magento\Customer\Model\Session::class) + $this->sessionMock = $this->getMockBuilder(Session::class) ->disableOriginalConstructor() + ->setMethods(['getCustomerId', 'setCustomerData', 'setCustomerGroupId', 'regenerateId']) ->getMock(); - $this->notificationStorage = $this->getMockBuilder(\Magento\Customer\Model\Customer\NotificationStorage::class) + $this->notificationStorageMock = $this->getMockBuilder(NotificationStorage::class) ->disableOriginalConstructor() + ->setMethods(['isExists', 'remove']) ->getMock(); - $this->customerRepository = $this->getMockBuilder(\Magento\Customer\Api\CustomerRepositoryInterface::class) + $this->customerRepositoryMock = $this->getMockBuilder(CustomerRepositoryInterface::class) ->getMockForAbstractClass(); - $this->abstractAction = $this->getMockBuilder(\Magento\Backend\App\AbstractAction::class) + $this->abstractActionMock = $this->getMockBuilder(AbstractAction::class) ->disableOriginalConstructor() ->getMockForAbstractClass(); - $this->request = $this->getMockBuilder(\Magento\Framework\App\RequestInterface::class) + $this->requestMock = $this->getMockBuilder(RequestInterface::class) ->setMethods(['isPost']) ->getMockForAbstractClass(); - $this->appState = $this->getMockBuilder(\Magento\Framework\App\State::class) - ->disableOriginalConstructor()->getMock(); + $this->appStateMock = $this->getMockBuilder(State::class) + ->disableOriginalConstructor() + ->setMethods(['getAreaCode']) + ->getMock(); + + $this->loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); + $this->appStateMock->method('getAreaCode')->willReturn(Area::AREA_FRONTEND); + $this->requestMock->method('isPost')->willReturn(true); + $this->sessionMock->method('getCustomerId')->willReturn(self::$customerId); + $this->notificationStorageMock->expects($this->any()) + ->method('isExists') + ->with(NotificationStorage::UPDATE_CUSTOMER_SESSION, self::$customerId) + ->willReturn(true); + $this->plugin = new CustomerNotification( - $this->session, - $this->notificationStorage, - $this->appState, - $this->customerRepository + $this->sessionMock, + $this->notificationStorageMock, + $this->appStateMock, + $this->customerRepositoryMock, + $this->loggerMock ); } public function testBeforeDispatch() { - $customerId = 1; $customerGroupId =1; - $this->appState->expects($this->any()) - ->method('getAreaCode') - ->willReturn(\Magento\Framework\App\Area::AREA_FRONTEND); - $this->request->expects($this->any())->method('isPost')->willReturn(true); - $customerMock = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class) - ->getMockForAbstractClass(); - $customerMock->expects($this->any())->method('getGroupId')->willReturn($customerGroupId); - $this->customerRepository->expects($this->any()) + + $customerMock = $this->getMockForAbstractClass(CustomerInterface::class); + $customerMock->method('getGroupId')->willReturn($customerGroupId); + $customerMock->method('getId')->willReturn(self::$customerId); + + $this->customerRepositoryMock->expects($this->once()) ->method('getById') - ->with($customerId) + ->with(self::$customerId) ->willReturn($customerMock); - $this->session->expects($this->any())->method('getCustomerId')->willReturn($customerId); - $this->session->expects($this->any())->method('setCustomerData')->with($customerMock); - $this->session->expects($this->any())->method('setCustomerGroupId')->with($customerGroupId); - $this->session->expects($this->once())->method('regenerateId'); - $this->notificationStorage->expects($this->any()) - ->method('isExists') - ->with(NotificationStorage::UPDATE_CUSTOMER_SESSION, $customerId) - ->willReturn(true); + $this->notificationStorageMock->expects($this->once()) + ->method('remove') + ->with(NotificationStorage::UPDATE_CUSTOMER_SESSION, self::$customerId); + + $this->sessionMock->expects($this->once())->method('setCustomerData')->with($customerMock); + $this->sessionMock->expects($this->once())->method('setCustomerGroupId')->with($customerGroupId); + $this->sessionMock->expects($this->once())->method('regenerateId'); + + $this->plugin->beforeDispatch($this->abstractActionMock, $this->requestMock); + } + + public function testBeforeDispatchWithNoCustomerFound() + { + $this->customerRepositoryMock->method('getById') + ->with(self::$customerId) + ->willThrowException(new NoSuchEntityException()); + $this->loggerMock->expects($this->once()) + ->method('error'); - $this->plugin->beforeDispatch($this->abstractAction, $this->request); + $this->plugin->beforeDispatch($this->abstractActionMock, $this->requestMock); } } diff --git a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php index a05e9e23ef94f..bd1dc774b5319 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php @@ -7,6 +7,7 @@ namespace Magento\Customer\Test\Unit\Model\ResourceModel; use Magento\Customer\Api\CustomerMetadataInterface; +use Magento\Customer\Model\Customer\NotificationStorage; use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; /** @@ -18,82 +19,87 @@ class CustomerRepositoryTest extends \PHPUnit\Framework\TestCase /** * @var \Magento\Customer\Model\CustomerFactory|\PHPUnit_Framework_MockObject_MockObject */ - protected $customerFactory; + private $customerFactory; /** * @var \Magento\Customer\Model\Data\CustomerSecureFactory|\PHPUnit_Framework_MockObject_MockObject */ - protected $customerSecureFactory; + private $customerSecureFactory; /** * @var \Magento\Customer\Model\CustomerRegistry|\PHPUnit_Framework_MockObject_MockObject */ - protected $customerRegistry; + private $customerRegistry; /** * @var \Magento\Customer\Model\ResourceModel\AddressRepository|\PHPUnit_Framework_MockObject_MockObject */ - protected $addressRepository; + private $addressRepository; /** * @var \Magento\Customer\Model\ResourceModel\Customer|\PHPUnit_Framework_MockObject_MockObject */ - protected $customerResourceModel; + private $customerResourceModel; /** * @var \Magento\Customer\Api\CustomerMetadataInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $customerMetadata; + private $customerMetadata; /** * @var \Magento\Customer\Api\Data\CustomerSearchResultsInterfaceFactory|\PHPUnit_Framework_MockObject_MockObject */ - protected $searchResultsFactory; + private $searchResultsFactory; /** * @var \Magento\Framework\Event\ManagerInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $eventManager; + private $eventManager; /** * @var \Magento\Store\Model\StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $storeManager; + private $storeManager; /** * @var \Magento\Framework\Api\ExtensibleDataObjectConverter|\PHPUnit_Framework_MockObject_MockObject */ - protected $extensibleDataObjectConverter; + private $extensibleDataObjectConverter; /** * @var \Magento\Framework\Api\DataObjectHelper|\PHPUnit_Framework_MockObject_MockObject */ - protected $dataObjectHelper; + private $dataObjectHelper; /** * @var \Magento\Framework\Api\ImageProcessorInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $imageProcessor; + private $imageProcessor; /** * @var \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $extensionAttributesJoinProcessor; + private $extensionAttributesJoinProcessor; /** * @var \Magento\Customer\Api\Data\CustomerInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $customer; + private $customer; /** * @var CollectionProcessorInterface|\PHPUnit_Framework_MockObject_MockObject */ private $collectionProcessorMock; + /** + * @var NotificationStorage|\PHPUnit_Framework_MockObject_MockObject + */ + private $notificationStorage; + /** * @var \Magento\Customer\Model\ResourceModel\CustomerRepository */ - protected $model; + private $model; protected function setUp() { @@ -158,6 +164,10 @@ protected function setUp() ); $this->collectionProcessorMock = $this->getMockBuilder(CollectionProcessorInterface::class) ->getMock(); + $this->notificationStorage = $this->getMockBuilder(NotificationStorage::class) + ->disableOriginalConstructor() + ->getMock(); + $this->model = new \Magento\Customer\Model\ResourceModel\CustomerRepository( $this->customerFactory, $this->customerSecureFactory, @@ -172,7 +182,8 @@ protected function setUp() $this->dataObjectHelper, $this->imageProcessor, $this->extensionAttributesJoinProcessor, - $this->collectionProcessorMock + $this->collectionProcessorMock, + $this->notificationStorage ); } @@ -408,7 +419,11 @@ public function testSave() ->method('dispatch') ->with( 'customer_save_after_data_object', - ['customer_data_object' => $this->customer, 'orig_customer_data_object' => $origCustomer] + [ + 'customer_data_object' => $this->customer, + 'orig_customer_data_object' => $origCustomer, + 'delegate_data' => [], + ] ); $this->model->save($this->customer); @@ -635,7 +650,11 @@ public function testSaveWithPasswordHash() ->method('dispatch') ->with( 'customer_save_after_data_object', - ['customer_data_object' => $this->customer, 'orig_customer_data_object' => $origCustomer] + [ + 'customer_data_object' => $this->customer, + 'orig_customer_data_object' => $origCustomer, + 'delegate_data' => [], + ] ); $this->model->save($this->customer, $passwordHash); @@ -793,6 +812,9 @@ public function testDelete() $this->customerRegistry->expects($this->atLeastOnce()) ->method('remove') ->with($customerId); + $this->notificationStorage->expects($this->atLeastOnce()) + ->method('remove') + ->with(NotificationStorage::UPDATE_CUSTOMER_SESSION, $customerId); $this->assertTrue($this->model->delete($this->customer)); } diff --git a/app/code/Magento/Customer/Test/Unit/Model/VisitorTest.php b/app/code/Magento/Customer/Test/Unit/Model/VisitorTest.php index ca6b8708f695c..ec787b9d3c873 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/VisitorTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/VisitorTest.php @@ -60,15 +60,15 @@ protected function setUp() 'commit', 'clean', ])->disableOriginalConstructor()->getMock(); - $this->resource->expects($this->any())->method('getIdFieldName')->will($this->returnValue('visitor_id')); - $this->resource->expects($this->any())->method('addCommitCallback')->will($this->returnSelf()); + $this->resource->expects($this->any())->method('getIdFieldName')->willReturn('visitor_id'); + $this->resource->expects($this->any())->method('addCommitCallback')->willReturnSelf(); $arguments = $this->objectManagerHelper->getConstructArguments( \Magento\Customer\Model\Visitor::class, [ 'registry' => $this->registry, 'session' => $this->session, - 'resource' => $this->resource + 'resource' => $this->resource, ] ); @@ -77,14 +77,13 @@ protected function setUp() public function testInitByRequest() { - $this->session->expects($this->once())->method('getSessionId') - ->will($this->returnValue('asdfhasdfjhkj2198sadf8sdf897')); - $this->visitor->initByRequest(null); - $this->assertEquals('asdfhasdfjhkj2198sadf8sdf897', $this->visitor->getSessionId()); - - $this->visitor->setData(['visitor_id' => 1]); + $oldSessionId = 'asdfhasdfjhkj2198sadf8sdf897'; + $newSessionId = 'bsdfhasdfjhkj2198sadf8sdf897'; + $this->session->expects($this->any())->method('getSessionId')->willReturn($newSessionId); + $this->session->expects($this->atLeastOnce())->method('getVisitorData') + ->willReturn(['session_id' => $oldSessionId]); $this->visitor->initByRequest(null); - $this->assertNull($this->visitor->getSessionId()); + $this->assertEquals($newSessionId, $this->visitor->getSessionId()); } public function testSaveByRequest() @@ -101,7 +100,7 @@ public function testIsModuleIgnored() 'registry' => $this->registry, 'session' => $this->session, 'resource' => $this->resource, - 'ignores' => ['test_route_name' => true] + 'ignores' => ['test_route_name' => true], ] ); $request = new \Magento\Framework\DataObject(['route_name' => 'test_route_name']); @@ -164,7 +163,7 @@ public function testBindQuoteDestroy() public function testClean() { - $this->resource->expects($this->once())->method('clean')->with($this->visitor)->will($this->returnSelf()); + $this->resource->expects($this->once())->method('clean')->with($this->visitor)->willReturnSelf(); $this->visitor->clean(); } } diff --git a/app/code/Magento/Customer/composer.json b/app/code/Magento/Customer/composer.json index 9dbac36146be1..b9a7aca73fe34 100644 --- a/app/code/Magento/Customer/composer.json +++ b/app/code/Magento/Customer/composer.json @@ -5,34 +5,33 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-authorization": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-checkout": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-integration": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-newsletter": "100.3.*", - "magento/module-page-cache": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-review": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-wishlist": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-authorization": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-checkout": "*", + "magento/module-config": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-integration": "*", + "magento/module-media-storage": "*", + "magento/module-newsletter": "*", + "magento/module-page-cache": "*", + "magento/module-quote": "*", + "magento/module-review": "*", + "magento/module-sales": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-theme": "*", + "magento/module-ui": "*", + "magento/module-wishlist": "*" }, "suggest": { - "magento/module-cookie": "100.3.*", - "magento/module-customer-sample-data": "Sample Data version:100.3.*" + "magento/module-cookie": "*", + "magento/module-customer-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Customer/etc/db_schema.xml b/app/code/Magento/Customer/etc/db_schema.xml index 490211e4307ab..368ca417432fd 100644 --- a/app/code/Magento/Customer/etc/db_schema.xml +++ b/app/code/Magento/Customer/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Customer/etc/di.xml b/app/code/Magento/Customer/etc/di.xml index 6df04c7f89656..0d99c1145e81b 100644 --- a/app/code/Magento/Customer/etc/di.xml +++ b/app/code/Magento/Customer/etc/di.xml @@ -420,4 +420,20 @@ + + + Magento\Framework\Session\SessionManagerInterface\Proxy + + + + + + Magento\Customer\Model\Address\Validator\General + Magento\Customer\Model\Address\Validator\Country + + + + diff --git a/app/code/Magento/Customer/view/frontend/layout/customer_account.xml b/app/code/Magento/Customer/view/frontend/layout/customer_account.xml index ac03fa7d293a4..4224e84972f88 100644 --- a/app/code/Magento/Customer/view/frontend/layout/customer_account.xml +++ b/app/code/Magento/Customer/view/frontend/layout/customer_account.xml @@ -6,6 +6,9 @@ */ --> + + My Account + diff --git a/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml b/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml index 7881b7e857fd9..ac8e1298b29b9 100644 --- a/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/account/dashboard/info.phtml @@ -20,6 +20,7 @@ escapeHtml($block->getName()) ?>
    escapeHtml($block->getCustomer()->getEmail()) ?>

    + getChildHtml('customer.account.dashboard.info.extra'); ?>
    escapeHtml(__('Edit')) ?> diff --git a/app/code/Magento/Customer/view/frontend/web/js/action/login.js b/app/code/Magento/Customer/view/frontend/web/js/action/login.js index 8bf96cd76b1a8..d75b8f70c5346 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/action/login.js +++ b/app/code/Magento/Customer/view/frontend/web/js/action/login.js @@ -7,8 +7,9 @@ define([ 'jquery', 'mage/storage', 'Magento_Ui/js/model/messageList', - 'Magento_Customer/js/customer-data' -], function ($, storage, globalMessageList, customerData) { + 'Magento_Customer/js/customer-data', + 'mage/translate' +], function ($, storage, globalMessageList, customerData, $t) { 'use strict'; var callbacks = [], @@ -48,7 +49,7 @@ define([ } }).fail(function () { messageContainer.addErrorMessage({ - 'message': 'Could not authenticate. Please try again later' + 'message': $t('Could not authenticate. Please try again later') }); callbacks.forEach(function (callback) { callback(loginData); diff --git a/app/code/Magento/Customer/view/frontend/web/js/customer-data.js b/app/code/Magento/Customer/view/frontend/web/js/customer-data.js index c4672c48e1f4a..15df80f360bf7 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/customer-data.js +++ b/app/code/Magento/Customer/view/frontend/web/js/customer-data.js @@ -232,9 +232,11 @@ define([ if (!_.isEmpty(privateContent)) { countryData = this.get('directory-data'); - if (_.isEmpty(countryData())) { - customerData.reload(['directory-data'], false); - } + countryData.subscribe(function () { + if (_.isEmpty(countryData())) { + customerData.reload(['directory-data'], false); + } + }, this); } }, diff --git a/app/code/Magento/Customer/view/frontend/web/template/authentication-popup.html b/app/code/Magento/Customer/view/frontend/web/template/authentication-popup.html index ad3d62f6c1c27..6b3a232cd3e39 100644 --- a/app/code/Magento/Customer/view/frontend/web/template/authentication-popup.html +++ b/app/code/Magento/Customer/view/frontend/web/template/authentication-popup.html @@ -54,10 +54,10 @@ id="login-form">
    diff --git a/app/code/Magento/Directory/etc/zip_codes.xml b/app/code/Magento/Directory/etc/zip_codes.xml index d70dee8abc15b..d9041d1ff50a7 100644 --- a/app/code/Magento/Directory/etc/zip_codes.xml +++ b/app/code/Magento/Directory/etc/zip_codes.xml @@ -81,6 +81,7 @@ ^[a-zA-z]{1}[0-9]{1}[a-zA-z]{1}\s[0-9]{1}[a-zA-z]{1}[0-9]{1}$ + ^[a-zA-z]{1}[0-9]{1}[a-zA-z]{1}[0-9]{1}[a-zA-z]{1}[0-9]{1}$ @@ -206,7 +207,7 @@ ^[0-9]{3}-[0-9]{4}$ - ^[0-9]{3}$ + ^[0-9]{7}$ diff --git a/app/code/Magento/Downloadable/Controller/Adminhtml/Downloadable/Product/Edit/Link.php b/app/code/Magento/Downloadable/Controller/Adminhtml/Downloadable/Product/Edit/Link.php index 8ee93c37a7775..8d5f64e02be47 100644 --- a/app/code/Magento/Downloadable/Controller/Adminhtml/Downloadable/Product/Edit/Link.php +++ b/app/code/Magento/Downloadable/Controller/Adminhtml/Downloadable/Product/Edit/Link.php @@ -7,6 +7,7 @@ namespace Magento\Downloadable\Controller\Adminhtml\Downloadable\Product\Edit; use Magento\Downloadable\Helper\Download as DownloadHelper; +use Magento\Framework\App\Response\Http as HttpResponse; class Link extends \Magento\Catalog\Controller\Adminhtml\Product\Edit { @@ -42,7 +43,9 @@ protected function _processDownload($resource, $resourceType) $fileName = $helper->getFilename(); $contentType = $helper->getContentType(); - $this->getResponse()->setHttpResponseCode( + /** @var HttpResponse $response */ + $response = $this->getResponse(); + $response->setHttpResponseCode( 200 )->setHeader( 'Pragma', @@ -59,16 +62,22 @@ protected function _processDownload($resource, $resourceType) ); if ($fileSize = $helper->getFileSize()) { - $this->getResponse()->setHeader('Content-Length', $fileSize); + $response->setHeader('Content-Length', $fileSize); } - - if ($contentDisposition = $helper->getContentDisposition()) { - $this->getResponse() - ->setHeader('Content-Disposition', $contentDisposition . '; filename=' . $fileName); + //Setting disposition as state in the config or forcing it for HTML. + /** @var string|null $contentDisposition */ + $contentDisposition = $helper->getContentDisposition(); + if (!$contentDisposition || $contentType === 'text/html') { + $contentDisposition = 'attachment'; } - - $this->getResponse()->clearBody(); - $this->getResponse()->sendHeaders(); + $response->setHeader( + 'Content-Disposition', + $contentDisposition . '; filename=' . $fileName + ); + //Rendering + $response->clearBody(); + $response->sendHeaders(); + $helper->output(); } diff --git a/app/code/Magento/Downloadable/Controller/Download.php b/app/code/Magento/Downloadable/Controller/Download.php index b2cf89c1af980..583b5a33c6b9c 100644 --- a/app/code/Magento/Downloadable/Controller/Download.php +++ b/app/code/Magento/Downloadable/Controller/Download.php @@ -6,6 +6,7 @@ namespace Magento\Downloadable\Controller; use Magento\Downloadable\Helper\Download as DownloadHelper; +use Magento\Framework\App\Response\Http as HttpResponse; /** * Download controller @@ -14,6 +15,13 @@ */ abstract class Download extends \Magento\Framework\App\Action\Action { + /** + * @var array + */ + private $disallowedContentTypes = [ + 'text/html', + ]; + /** * Prepare response to output resource contents * @@ -28,9 +36,12 @@ protected function _processDownload($path, $resourceType) $helper->setResource($path, $resourceType); $fileName = $helper->getFilename(); + $contentType = $helper->getContentType(); - $this->getResponse()->setHttpResponseCode( + /** @var HttpResponse $response */ + $response = $this->getResponse(); + $response->setHttpResponseCode( 200 )->setHeader( 'Pragma', @@ -47,15 +58,19 @@ protected function _processDownload($path, $resourceType) ); if ($fileSize = $helper->getFileSize()) { - $this->getResponse()->setHeader('Content-Length', $fileSize); + $response->setHeader('Content-Length', $fileSize); } - if ($contentDisposition = $helper->getContentDisposition()) { - $this->getResponse()->setHeader('Content-Disposition', $contentDisposition . '; filename=' . $fileName); + $contentDisposition = $helper->getContentDisposition(); + if (!$contentDisposition || in_array($contentType, $this->disallowedContentTypes)) { + // For security reasons we force browsers to download the file instead of opening it. + $contentDisposition = \Magento\Framework\HTTP\Mime::DISPOSITION_ATTACHMENT; } - $this->getResponse()->clearBody(); - $this->getResponse()->sendHeaders(); + $response->setHeader('Content-Disposition', $contentDisposition . '; filename=' . $fileName); + //Rendering + $response->clearBody(); + $response->sendHeaders(); $helper->output(); } diff --git a/app/code/Magento/Downloadable/Setup/Patch/Data/InstallDownloadableAttributes.php b/app/code/Magento/Downloadable/Setup/Patch/Data/InstallDownloadableAttributes.php index 11dae04d6a9c1..9c101425e49ae 100644 --- a/app/code/Magento/Downloadable/Setup/Patch/Data/InstallDownloadableAttributes.php +++ b/app/code/Magento/Downloadable/Setup/Patch/Data/InstallDownloadableAttributes.php @@ -10,8 +10,8 @@ use Magento\Eav\Setup\EavSetupFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class InstallDownloadableAttributes diff --git a/app/code/Magento/Downloadable/Test/Unit/Controller/Adminhtml/Downloadable/Product/Edit/LinkTest.php b/app/code/Magento/Downloadable/Test/Unit/Controller/Adminhtml/Downloadable/Product/Edit/LinkTest.php index e125ddee9c5d8..aa8b774ab5511 100644 --- a/app/code/Magento/Downloadable/Test/Unit/Controller/Adminhtml/Downloadable/Product/Edit/LinkTest.php +++ b/app/code/Magento/Downloadable/Test/Unit/Controller/Adminhtml/Downloadable/Product/Edit/LinkTest.php @@ -22,7 +22,7 @@ class LinkTest extends \PHPUnit\Framework\TestCase protected $request; /** - * @var \Magento\Framework\App\ResponseInterface + * @var \Magento\Framework\App\ResponseInterface|\PHPUnit_Framework_MockObject_MockObject */ protected $response; @@ -109,6 +109,8 @@ protected function setUp() */ public function testExecuteFile($fileType) { + $fileSize = 58493; + $fileName = 'link.jpg'; $this->request->expects($this->at(0))->method('getParam')->with('id', 0) ->will($this->returnValue(1)); $this->request->expects($this->at(1))->method('getParam')->with('type', 0) @@ -117,7 +119,20 @@ public function testExecuteFile($fileType) ->will($this->returnSelf()); $this->response->expects($this->once())->method('clearBody') ->will($this->returnSelf()); - $this->response->expects($this->any())->method('setHeader') + $this->response + ->expects($this->any()) + ->method('setHeader') + ->withConsecutive( + ['Pragma', 'public', true], + [ + 'Cache-Control', + 'must-revalidate, post-check=0, pre-check=0', + true, + ], + ['Content-type', 'text/html'], + ['Content-Length', $fileSize], + ['Content-Disposition', 'attachment; filename=' . $fileName] + ) ->will($this->returnSelf()); $this->response->expects($this->once())->method('sendHeaders') ->will($this->returnSelf()); @@ -132,13 +147,13 @@ public function testExecuteFile($fileType) $this->downloadHelper->expects($this->once())->method('setResource') ->will($this->returnSelf()); $this->downloadHelper->expects($this->once())->method('getFilename') - ->will($this->returnValue('link.jpg')); + ->will($this->returnValue($fileName)); $this->downloadHelper->expects($this->once())->method('getContentType') - ->will($this->returnSelf('file')); + ->willReturn('text/html'); $this->downloadHelper->expects($this->once())->method('getFileSize') - ->will($this->returnValue(null)); + ->will($this->returnValue($fileSize)); $this->downloadHelper->expects($this->once())->method('getContentDisposition') - ->will($this->returnValue(null)); + ->will($this->returnValue('inline')); $this->downloadHelper->expects($this->once())->method('output') ->will($this->returnSelf()); $this->linkModel->expects($this->once())->method('load') diff --git a/app/code/Magento/Downloadable/Test/Unit/Controller/Download/LinkTest.php b/app/code/Magento/Downloadable/Test/Unit/Controller/Download/LinkTest.php index 3e1255766f1f9..7e756c1790a26 100644 --- a/app/code/Magento/Downloadable/Test/Unit/Controller/Download/LinkTest.php +++ b/app/code/Magento/Downloadable/Test/Unit/Controller/Download/LinkTest.php @@ -273,7 +273,13 @@ public function testGetLinkForWrongCustomer() $this->assertEquals($this->response, $this->link->execute()); } - public function testExceptionInUpdateLinkStatus() + /** + * @param string $mimeType + * @param string $disposition + * @dataProvider downloadTypesDataProvider + * @return void + */ + public function testExceptionInUpdateLinkStatus($mimeType, $disposition) { $this->objectManager->expects($this->at(0)) ->method('get') @@ -303,7 +309,7 @@ public function testExceptionInUpdateLinkStatus() $this->linkPurchasedItem->expects($this->once())->method('getLinkType')->willReturn('url'); $this->linkPurchasedItem->expects($this->once())->method('getLinkUrl')->willReturn('link_url'); - $this->processDownload('link_url', 'url'); + $this->processDownload('link_url', 'url', $mimeType, $disposition); $this->linkPurchasedItem->expects($this->any())->method('setNumberOfDownloadsUsed')->willReturnSelf(); $this->linkPurchasedItem->expects($this->any())->method('setStatus')->with('expired')->willReturnSelf(); @@ -317,8 +323,18 @@ public function testExceptionInUpdateLinkStatus() $this->assertEquals($this->response, $this->link->execute()); } - private function processDownload($resource, $resourceType) + /** + * @param string $resource + * @param string $resourceType + * @param string $mimeType + * @param string $disposition + * @return void + */ + private function processDownload($resource, $resourceType, $mimeType, $disposition) { + $fileSize = 58493; + $fileName = 'link.jpg'; + $this->objectManager->expects($this->at(3)) ->method('get') ->with(\Magento\Downloadable\Helper\Download::class) @@ -327,30 +343,23 @@ private function processDownload($resource, $resourceType) ->method('setResource') ->with($resource, $resourceType) ->willReturnSelf(); - $this->downloadHelper->expects($this->once())->method('getFilename')->willReturn('file_name'); - $this->downloadHelper->expects($this->once())->method('getContentType')->willReturn('content_type'); + $this->downloadHelper->expects($this->once())->method('getFilename')->willReturn($fileName); + $this->downloadHelper->expects($this->once())->method('getContentType')->willReturn($mimeType); $this->response->expects($this->once())->method('setHttpResponseCode')->with(200)->willReturnSelf(); - $this->response->expects($this->at(1))->method('setHeader')->with('Pragma', 'public', true)->willReturnSelf(); - $this->response->expects($this->at(2)) - ->method('setHeader') - ->with('Cache-Control', 'must-revalidate, post-check=0, pre-check=0', true) - ->willReturnSelf(); - $this->response->expects($this->at(3)) + $this->response + ->expects($this->any()) ->method('setHeader') - ->with('Content-type', 'content_type', true) - ->willReturnSelf(); - $this->downloadHelper->expects($this->once())->method('getFileSize')->willReturn('file_size'); - $this->response->expects($this->at(4)) - ->method('setHeader') - ->with('Content-Length', 'file_size') - ->willReturnSelf(); - $this->downloadHelper->expects($this->once()) - ->method('getContentDisposition') - ->willReturn('content_disposition'); - $this->response->expects($this->at(5)) - ->method('setHeader') - ->with('Content-Disposition', 'content_disposition; filename=file_name') + ->withConsecutive( + ['Pragma', 'public', true], + ['Cache-Control', 'must-revalidate, post-check=0, pre-check=0', true], + ['Content-type', $mimeType, true], + ['Content-Length', $fileSize], + ['Content-Disposition', $disposition . '; filename=' . $fileName] + ) ->willReturnSelf(); + + $this->downloadHelper->expects($this->once())->method('getContentDisposition')->willReturn($disposition); + $this->downloadHelper->expects($this->once())->method('getFileSize')->willReturn($fileSize); $this->response->expects($this->once())->method('clearBody')->willReturnSelf(); $this->response->expects($this->once())->method('sendHeaders')->willReturnSelf(); $this->downloadHelper->expects($this->once())->method('output'); @@ -394,6 +403,76 @@ public function testLinkNotAvailable($messageType, $status, $notice) $this->assertEquals($this->response, $this->link->execute()); } + /** + * @param string $mimeType + * @param string $disposition + * @dataProvider downloadTypesDataProvider + * @return void + */ + public function testContentDisposition($mimeType, $disposition) + { + $this->objectManager->expects($this->any()) + ->method('get') + ->willReturnMap([ + [ + \Magento\Customer\Model\Session::class, + $this->session, + ], + [ + \Magento\Downloadable\Helper\Data::class, + $this->helperData, + ], + [ + \Magento\Downloadable\Helper\Download::class, + $this->downloadHelper, + ], + ]); + + $this->request->expects($this->once())->method('getParam')->with('id', 0)->willReturn('some_id'); + $this->objectManager->expects($this->at(1)) + ->method('create') + ->with(\Magento\Downloadable\Model\Link\Purchased\Item::class) + ->willReturn($this->linkPurchasedItem); + $this->linkPurchasedItem->expects($this->once()) + ->method('load') + ->with('some_id', 'link_hash') + ->willReturnSelf(); + $this->linkPurchasedItem->expects($this->once())->method('getId')->willReturn(5); + $this->helperData->expects($this->once()) + ->method('getIsShareable') + ->with($this->linkPurchasedItem) + ->willReturn(true); + $this->linkPurchasedItem->expects($this->any())->method('getNumberOfDownloadsBought')->willReturn(10); + $this->linkPurchasedItem->expects($this->any())->method('getNumberOfDownloadsUsed')->willReturn(9); + $this->linkPurchasedItem->expects($this->once())->method('getStatus')->willReturn('available'); + $this->linkPurchasedItem->expects($this->once())->method('getLinkType')->willReturn('url'); + $this->linkPurchasedItem->expects($this->once())->method('getLinkUrl')->willReturn('link_url'); + + $fileSize = 58493; + $fileName = 'link.jpg'; + + $this->downloadHelper->expects($this->once()) + ->method('setResource') + ->with('link_url', 'url') + ->willReturnSelf(); + $this->downloadHelper->expects($this->once())->method('getFilename')->willReturn($fileName); + $this->downloadHelper->expects($this->once())->method('getContentType')->willReturn($mimeType); + $this->response->expects($this->once())->method('setHttpResponseCode')->with(200)->willReturnSelf(); + $this->response + ->expects($this->any()) + ->method('setHeader') + ->withConsecutive( + ['Pragma', 'public', true], + ['Cache-Control', 'must-revalidate, post-check=0, pre-check=0', true], + ['Content-type', $mimeType, true], + ['Content-Length', $fileSize], + ['Content-Disposition', $disposition . '; filename=' . $fileName] + ) + ->willReturnSelf(); + + $this->assertEquals($this->response, $this->link->execute()); + } + /** * @return array */ @@ -406,4 +485,15 @@ public function linkNotAvailableDataProvider() ['addError', 'wrong_status', 'Something went wrong while getting the requested content.'] ]; } + + /** + * @return array + */ + public function downloadTypesDataProvider() + { + return [ + ['mimeType' => 'text/html', 'disposition' => \Magento\Framework\HTTP\Mime::DISPOSITION_ATTACHMENT], + ['mimeType' => 'image/jpeg', 'disposition' => \Magento\Framework\HTTP\Mime::DISPOSITION_INLINE], + ]; + } } diff --git a/app/code/Magento/Downloadable/composer.json b/app/code/Magento/Downloadable/composer.json index 99aee584e103b..88658b8644cad 100644 --- a/app/code/Magento/Downloadable/composer.json +++ b/app/code/Magento/Downloadable/composer.json @@ -5,30 +5,29 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-gift-message": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-checkout": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-gift-message": "*", + "magento/module-media-storage": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-theme": "*", + "magento/module-ui": "*" }, "suggest": { - "magento/module-downloadable-sample-data": "Sample Data version:100.3.*" + "magento/module-downloadable-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Downloadable/etc/db_schema.xml b/app/code/Magento/Downloadable/etc/db_schema.xml index 675c9a5c85679..ed25628bcffd9 100644 --- a/app/code/Magento/Downloadable/etc/db_schema.xml +++ b/app/code/Magento/Downloadable/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Downloadable/view/adminhtml/templates/product/edit/downloadable/links.phtml b/app/code/Magento/Downloadable/view/adminhtml/templates/product/edit/downloadable/links.phtml index 2515e5e339366..3ec6010218fb6 100644 --- a/app/code/Magento/Downloadable/view/adminhtml/templates/product/edit/downloadable/links.phtml +++ b/app/code/Magento/Downloadable/view/adminhtml/templates/product/edit/downloadable/links.phtml @@ -21,7 +21,7 @@
    isSingleStoreMode() ? ' data-config-scope="' . __('[STORE VIEW]') . '"' : '' ?>>
    - getStoreId() && $block->getUsedDefault()) ? 'disabled="disabled"' : '' ?>> + getStoreId() && $block->getUsedDefault()) ? 'disabled="disabled"' : '' ?>> getStoreId()): ?>
    getUsedDefault() ? 'checked="checked"' : '' ?> /> @@ -158,9 +158,9 @@ require([ ''+ '
    '+ '' + - '' + + '' + ''); + $this->getResponse()->setBody(''); } } diff --git a/app/code/Magento/Integration/Model/Oauth/Consumer/Validator/KeyLength.php b/app/code/Magento/Integration/Model/Oauth/Consumer/Validator/KeyLength.php index f896f92d5768b..e697d2594bded 100644 --- a/app/code/Magento/Integration/Model/Oauth/Consumer/Validator/KeyLength.php +++ b/app/code/Magento/Integration/Model/Oauth/Consumer/Validator/KeyLength.php @@ -39,7 +39,7 @@ class KeyLength extends \Zend_Validate_StringLength * Default encoding is set to utf-8 if none provided * New option name added to allow adding key name in validation error messages * - * @param integer|array|\Zend_Config $options + * @inheritdoc */ public function __construct($options = []) { diff --git a/app/code/Magento/Integration/Setup/Patch/Data/RemoveInactiveTokens.php b/app/code/Magento/Integration/Setup/Patch/Data/RemoveInactiveTokens.php index 1f5148dd2d2ea..c6d8d58d3e65c 100644 --- a/app/code/Magento/Integration/Setup/Patch/Data/RemoveInactiveTokens.php +++ b/app/code/Magento/Integration/Setup/Patch/Data/RemoveInactiveTokens.php @@ -7,8 +7,8 @@ namespace Magento\Integration\Setup\Patch\Data; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class RemoveInactiveTokens diff --git a/app/code/Magento/Integration/Test/Unit/Model/Oauth/ConsumerTest.php b/app/code/Magento/Integration/Test/Unit/Model/Oauth/ConsumerTest.php index 73fab59176c40..c6b7ce22fc39c 100644 --- a/app/code/Magento/Integration/Test/Unit/Model/Oauth/ConsumerTest.php +++ b/app/code/Magento/Integration/Test/Unit/Model/Oauth/ConsumerTest.php @@ -6,6 +6,7 @@ namespace Magento\Integration\Test\Unit\Model\Oauth; use Magento\Framework\Url\Validator as UrlValidator; +use Zend\Validator\Uri as ZendUriValidator; use Magento\Integration\Model\Oauth\Consumer\Validator\KeyLength; /** @@ -84,7 +85,7 @@ protected function setUp() $this->keyLengthValidator = new KeyLength(); - $this->urlValidator = new UrlValidator(); + $this->urlValidator = new UrlValidator(new ZendUriValidator()); $this->oauthDataMock = $this->createPartialMock( \Magento\Integration\Helper\Oauth\Data::class, diff --git a/app/code/Magento/Integration/Test/Unit/Model/Oauth/TokenTest.php b/app/code/Magento/Integration/Test/Unit/Model/Oauth/TokenTest.php index 36b0d77d1e1ae..badb69aa19fe4 100644 --- a/app/code/Magento/Integration/Test/Unit/Model/Oauth/TokenTest.php +++ b/app/code/Magento/Integration/Test/Unit/Model/Oauth/TokenTest.php @@ -384,7 +384,8 @@ public function testValidateIfNotCallbackEstablishedAndNotValid() $this->validatorMock->expects($this->once())->method('isValid')->willReturn(false); $this->validatorMock->expects($this->once())->method('getMessages')->willReturn([$exceptionMessage]); - $this->expectException(\Magento\Framework\Oauth\Exception::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Oauth\Exception::class); + $this->expectExceptionMessage($exceptionMessage); $this->tokenModel->validate(); } @@ -402,7 +403,8 @@ public function testValidateIfSecretNotValid() $this->validatorKeyLengthMock->expects($this->once())->method('isValid')->willReturn(false); $this->validatorKeyLengthMock->expects($this->once())->method('getMessages')->willReturn([$exceptionMessage]); - $this->expectException(\Magento\Framework\Oauth\Exception::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Oauth\Exception::class); + $this->expectExceptionMessage($exceptionMessage); $this->tokenModel->validate(); } @@ -429,7 +431,8 @@ public function testValidateIfTokenNotValid() ] ); $this->validatorKeyLengthMock->expects($this->once())->method('getMessages')->willReturn([$exceptionMessage]); - $this->expectException(\Magento\Framework\Oauth\Exception::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Oauth\Exception::class); + $this->expectExceptionMessage($exceptionMessage); $this->tokenModel->validate(); } @@ -459,7 +462,8 @@ public function testValidateIfVerifierNotValid() ] ); $this->validatorKeyLengthMock->expects($this->once())->method('getMessages')->willReturn([$exceptionMessage]); - $this->expectException(\Magento\Framework\Oauth\Exception::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Oauth\Exception::class); + $this->expectExceptionMessage($exceptionMessage); $this->tokenModel->validate(); } diff --git a/app/code/Magento/Integration/Test/Unit/Oauth/OauthTest.php b/app/code/Magento/Integration/Test/Unit/Oauth/OauthTest.php index 875377776771d..966a6b293196f 100644 --- a/app/code/Magento/Integration/Test/Unit/Oauth/OauthTest.php +++ b/app/code/Magento/Integration/Test/Unit/Oauth/OauthTest.php @@ -777,11 +777,9 @@ public function testBuildAuthorizationHeader() */ public function testMissingParamForBuildAuthorizationHeader($expectedMessage, $request) { - $this->expectException( - \Magento\Framework\Oauth\OauthInputException::class, - $expectedMessage, - 0 - ); + $this->expectException(\Magento\Framework\Oauth\OauthInputException::class); + $this->expectExceptionMessage($expectedMessage); + $this->expectExceptionCode(0); $requestUrl = 'http://www.example.com/endpoint'; $this->_oauth->buildAuthorizationHeader($request, $requestUrl); diff --git a/app/code/Magento/Integration/composer.json b/app/code/Magento/Integration/composer.json index 662112431ad08..6a63854775ac3 100644 --- a/app/code/Magento/Integration/composer.json +++ b/app/code/Magento/Integration/composer.json @@ -5,17 +5,16 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-authorization": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-security": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-user": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-authorization": "*", + "magento/module-backend": "*", + "magento/module-customer": "*", + "magento/module-security": "*", + "magento/module-store": "*", + "magento/module-user": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Integration/etc/db_schema.xml b/app/code/Magento/Integration/etc/db_schema.xml index 252cdf16b9972..0e95ef54e1b99 100644 --- a/app/code/Magento/Integration/etc/db_schema.xml +++ b/app/code/Magento/Integration/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Integration/i18n/en_US.csv b/app/code/Magento/Integration/i18n/en_US.csv index 6fb5d2e071205..b225ad2766fff 100644 --- a/app/code/Magento/Integration/i18n/en_US.csv +++ b/app/code/Magento/Integration/i18n/en_US.csv @@ -93,7 +93,7 @@ Reset,Reset "A token with consumer ID 0 does not exist","A token with consumer ID 0 does not exist" "The integration you selected asks you to approve access to the following:","The integration you selected asks you to approve access to the following:" "No permissions requested","No permissions requested" -"Are you sure ?","Are you sure ?" +"Are you sure?","Are you sure?" "Are you sure you want to delete this integration? You can't undo this action.","Are you sure you want to delete this integration? You can't undo this action." "Please setup or sign in into your 3rd party account to complete setup of this integration.","Please setup or sign in into your 3rd party account to complete setup of this integration." "Available APIs","Available APIs" diff --git a/app/code/Magento/Integration/view/adminhtml/templates/integration/popup_container.phtml b/app/code/Magento/Integration/view/adminhtml/templates/integration/popup_container.phtml index 6faeee56ba692..8b7e787337e1a 100644 --- a/app/code/Magento/Integration/view/adminhtml/templates/integration/popup_container.phtml +++ b/app/code/Magento/Integration/view/adminhtml/templates/integration/popup_container.phtml @@ -33,8 +33,8 @@ $('div#integrationGrid').on('click', 'button#delete', function (e) { new Confirm({ - title: 'Are you sure?', - content: "Are you sure you want to delete this integration? You can't undo this action.", + title: '', + content: "", actions: { confirm: function () { window.location.href = $(e.target).data('url'); @@ -47,14 +47,4 @@ }); - - + \ No newline at end of file diff --git a/app/code/Magento/LayeredNavigation/composer.json b/app/code/Magento/LayeredNavigation/composer.json index 8239a80529b3e..6d322fc3ab50c 100644 --- a/app/code/Magento/LayeredNavigation/composer.json +++ b/app/code/Magento/LayeredNavigation/composer.json @@ -5,13 +5,12 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-config": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Marketplace/composer.json b/app/code/Magento/Marketplace/composer.json index bcaf453cd73b9..b52d507f825fc 100644 --- a/app/code/Magento/Marketplace/composer.json +++ b/app/code/Magento/Marketplace/composer.json @@ -5,12 +5,11 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/MediaStorage/App/Media.php b/app/code/Magento/MediaStorage/App/Media.php index bff4b0d194518..e1644ebaf5a48 100644 --- a/app/code/Magento/MediaStorage/App/Media.php +++ b/app/code/Magento/MediaStorage/App/Media.php @@ -7,6 +7,8 @@ */ namespace Magento\MediaStorage\App; +use Magento\Catalog\Model\View\Asset\PlaceholderFactory; +use Magento\Framework\App\State; use Magento\Framework\Filesystem; use Magento\MediaStorage\Model\File\Storage\ConfigFactory; use Magento\MediaStorage\Model\File\Storage\Response; @@ -14,6 +16,9 @@ use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\AppInterface; use Magento\MediaStorage\Model\File\Storage\SynchronizationFactory; +use Magento\Framework\App\Area; +use Magento\MediaStorage\Model\File\Storage\Config; +use Magento\MediaStorage\Service\ImageResize; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -68,6 +73,21 @@ class Media implements AppInterface */ private $syncFactory; + /** + * @var PlaceholderFactory + */ + private $placeholderFactory; + + /** + * @var State + */ + private $appState; + + /** + * @var ImageResize + */ + private $imageResize; + /** * @param ConfigFactory $configFactory * @param SynchronizationFactory $syncFactory @@ -77,6 +97,10 @@ class Media implements AppInterface * @param string $configCacheFile * @param string $relativeFileName * @param Filesystem $filesystem + * @param PlaceholderFactory $placeholderFactory + * @param State $state + * @param ImageResize $imageResize + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( ConfigFactory $configFactory, @@ -86,11 +110,14 @@ public function __construct( $mediaDirectory, $configCacheFile, $relativeFileName, - Filesystem $filesystem + Filesystem $filesystem, + PlaceholderFactory $placeholderFactory, + State $state, + ImageResize $imageResize ) { $this->response = $response; $this->isAllowed = $isAllowed; - $this->directory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $this->directory = $filesystem->getDirectoryWrite(DirectoryList::PUB); $mediaDirectory = trim($mediaDirectory); if (!empty($mediaDirectory)) { $this->mediaDirectoryPath = str_replace('\\', '/', realpath($mediaDirectory)); @@ -99,6 +126,9 @@ public function __construct( $this->relativeFileName = $relativeFileName; $this->configFactory = $configFactory; $this->syncFactory = $syncFactory; + $this->placeholderFactory = $placeholderFactory; + $this->appState = $state; + $this->imageResize = $imageResize; } /** @@ -109,9 +139,11 @@ public function __construct( */ public function launch() { + $this->appState->setAreaCode(Area::AREA_GLOBAL); + if ($this->mediaDirectoryPath !== $this->directory->getAbsolutePath()) { // Path to media directory changed or absent - update the config - /** @var \Magento\MediaStorage\Model\File\Storage\Config $config */ + /** @var Config $config */ $config = $this->configFactory->create(['cacheFile' => $this->configCacheFile]); $config->save(); $this->mediaDirectoryPath = $config->getMediaDirectory(); @@ -122,18 +154,40 @@ public function launch() } } - /** @var \Magento\MediaStorage\Model\File\Storage\Synchronization $sync */ - $sync = $this->syncFactory->create(['directory' => $this->directory]); - $sync->synchronize($this->relativeFileName); - - if ($this->directory->isReadable($this->relativeFileName)) { - $this->response->setFilePath($this->directory->getAbsolutePath($this->relativeFileName)); - } else { - $this->response->setHttpResponseCode(404); + try { + /** @var \Magento\MediaStorage\Model\File\Storage\Synchronization $sync */ + $sync = $this->syncFactory->create(['directory' => $this->directory]); + $sync->synchronize($this->relativeFileName); + $this->imageResize->resizeFromImageName($this->getOriginalImage($this->relativeFileName)); + if ($this->directory->isReadable($this->relativeFileName)) { + $this->response->setFilePath($this->directory->getAbsolutePath($this->relativeFileName)); + } else { + $this->setPlaceholderImage(); + } + } catch (\Exception $e) { + $this->setPlaceholderImage(); } + return $this->response; } + private function setPlaceholderImage() + { + $placeholder = $this->placeholderFactory->create(['type' => 'image']); + $this->response->setFilePath($placeholder->getPath()); + } + + /** + * Find the path to the original image of the cache path + * + * @param string $resizedImagePath + * @return string + */ + private function getOriginalImage(string $resizedImagePath): string + { + return preg_replace('|^.*((?:/[^/]+){3})$|', '$1', $resizedImagePath); + } + /** * {@inheritdoc} */ diff --git a/app/code/Magento/MediaStorage/Console/Command/ImagesResizeCommand.php b/app/code/Magento/MediaStorage/Console/Command/ImagesResizeCommand.php new file mode 100644 index 0000000000000..a4b78287df012 --- /dev/null +++ b/app/code/Magento/MediaStorage/Console/Command/ImagesResizeCommand.php @@ -0,0 +1,95 @@ +resize = $resize; + $this->appState = $appState; + $this->objectManager = $objectManager; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName('catalog:images:resize') + ->setDescription('Creates resized product images'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + try { + $this->appState->setAreaCode(Area::AREA_GLOBAL); + $generator = $this->resize->resizeFromThemes(); + + /** @var ProgressBar $progress */ + $progress = $this->objectManager->create(ProgressBar::class, [ + 'output' => $output, + 'max' => $generator->current() + ]); + $progress->setFormat( + "%current%/%max% [%bar%] %percent:3s%% %elapsed% %memory:6s% \t| %message%" + ); + + if ($output->getVerbosity() !== OutputInterface::VERBOSITY_NORMAL) { + $progress->setOverwrite(false); + } + + for (; $generator->valid(); $generator->next()) { + $progress->setMessage($generator->key()); + $progress->advance(); + } + } catch (\Exception $e) { + $output->writeln("{$e->getMessage()}"); + // we must have an exit code higher than zero to indicate something was wrong + return \Magento\Framework\Console\Cli::RETURN_FAILURE; + } + + $output->write(PHP_EOL); + $output->writeln("Product images resized successfully"); + } +} diff --git a/app/code/Magento/MediaStorage/Model/File/Storage/Synchronization.php b/app/code/Magento/MediaStorage/Model/File/Storage/Synchronization.php index d744afb0fad9f..09f269e0ebddf 100644 --- a/app/code/Magento/MediaStorage/Model/File/Storage/Synchronization.php +++ b/app/code/Magento/MediaStorage/Model/File/Storage/Synchronization.php @@ -7,7 +7,9 @@ use Magento\Framework\Exception\FileSystemException; use Magento\Framework\Filesystem\Directory\WriteInterface as DirectoryWrite; -use Magento\Framework\Filesystem\File\Write; +use Magento\Framework\Filesystem\File\WriteInterface; +use Magento\MediaStorage\Service\ImageResize; +use Magento\MediaStorage\Model\File\Storage\Database; /** * Class Synchronization @@ -17,7 +19,7 @@ class Synchronization /** * Database storage factory * - * @var \Magento\MediaStorage\Model\File\Storage\DatabaseFactory + * @var DatabaseFactory */ protected $storageFactory; @@ -29,11 +31,11 @@ class Synchronization protected $mediaDirectory; /** - * @param \Magento\MediaStorage\Model\File\Storage\DatabaseFactory $storageFactory + * @param DatabaseFactory $storageFactory * @param DirectoryWrite $directory */ public function __construct( - \Magento\MediaStorage\Model\File\Storage\DatabaseFactory $storageFactory, + DatabaseFactory $storageFactory, DirectoryWrite $directory ) { $this->storageFactory = $storageFactory; @@ -49,14 +51,14 @@ public function __construct( */ public function synchronize($relativeFileName) { - /** @var $storage \Magento\MediaStorage\Model\File\Storage\Database */ + /** @var $storage Database */ $storage = $this->storageFactory->create(); try { $storage->loadByFilename($relativeFileName); } catch (\Exception $e) { } if ($storage->getId()) { - /** @var \Magento\Framework\Filesystem\File\WriteInterface $file */ + /** @var WriteInterface $file */ $file = $this->mediaDirectory->openFile($relativeFileName, 'w'); try { $file->lock(); diff --git a/app/code/Magento/MediaStorage/Service/ImageResize.php b/app/code/Magento/MediaStorage/Service/ImageResize.php new file mode 100644 index 0000000000000..37dd6f485f1a8 --- /dev/null +++ b/app/code/Magento/MediaStorage/Service/ImageResize.php @@ -0,0 +1,265 @@ +appState = $appState; + $this->imageConfig = $imageConfig; + $this->productImage = $productImage; + $this->imageFactory = $imageFactory; + $this->paramsBuilder = $paramsBuilder; + $this->viewConfig = $viewConfig; + $this->assertImageFactory = $assertImageFactory; + $this->themeCustomizationConfig = $themeCustomizationConfig; + $this->themeCollection = $themeCollection; + $this->mediaDirectory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $this->filesystem = $filesystem; + } + + /** + * Create resized images of different sizes from an original image + * @param string $originalImageName + * @throws NotFoundException + */ + public function resizeFromImageName(string $originalImageName) + { + $originalImagePath = $this->mediaDirectory->getAbsolutePath( + $this->imageConfig->getMediaPath($originalImageName) + ); + if (!$this->mediaDirectory->isFile($originalImagePath)) { + throw new NotFoundException(__('Cannot resize image "%1" - original image not found', $originalImagePath)); + } + foreach ($this->getViewImages($this->getThemesInUse()) as $viewImage) { + $this->resize($viewImage, $originalImagePath, $originalImageName); + } + } + + /** + * Create resized images of different sizes from themes + * @param array|null $themes + * @return \Generator + * @throws NotFoundException + */ + public function resizeFromThemes(array $themes = null): \Generator + { + $count = $this->productImage->getCountAllProductImages(); + if (!$count) { + throw new NotFoundException(__('Cannot resize images - product images not found')); + } + + $productImages = $this->productImage->getAllProductImages(); + $viewImages = $this->getViewImages($themes ?? $this->getThemesInUse()); + + foreach ($productImages as $image) { + $originalImageName = $image['filepath']; + $originalImagePath = $this->mediaDirectory->getAbsolutePath( + $this->imageConfig->getMediaPath($originalImageName) + ); + foreach ($viewImages as $viewImage) { + $this->resize($viewImage, $originalImagePath, $originalImageName); + } + yield $originalImageName => $count; + } + } + + /** + * Search the current theme + * @return array + */ + private function getThemesInUse(): array + { + $themesInUse = []; + $registeredThemes = $this->themeCollection->loadRegisteredThemes(); + $storesByThemes = $this->themeCustomizationConfig->getStoresByThemes(); + $keyType = is_integer(key($storesByThemes)) ? 'getId' : 'getCode'; + foreach ($registeredThemes as $registeredTheme) { + if (array_key_exists($registeredTheme->$keyType(), $storesByThemes)) { + $themesInUse[] = $registeredTheme; + } + } + return $themesInUse; + } + + /** + * Get view images data from themes + * @param array $themes + * @return array + */ + private function getViewImages(array $themes): array + { + $viewImages = []; + /** @var \Magento\Theme\Model\Theme $theme */ + foreach ($themes as $theme) { + $config = $this->viewConfig->getViewConfig([ + 'area' => Area::AREA_FRONTEND, + 'themeModel' => $theme, + ]); + $images = $config->getMediaEntities('Magento_Catalog', ImageHelper::MEDIA_TYPE_CONFIG_NODE); + foreach ($images as $imageId => $imageData) { + $uniqIndex = $this->getUniqImageIndex($imageData); + $imageData['id'] = $imageId; + $viewImages[$uniqIndex] = $imageData; + } + } + return $viewImages; + } + + /** + * Get uniq image index + * @param array $imageData + * @return string + */ + private function getUniqImageIndex(array $imageData): string + { + ksort($imageData); + unset($imageData['type']); + return md5(json_encode($imageData)); + } + + /** + * Make image + * @param string $originalImagePath + * @param array $imageParams + * @return Image + */ + private function makeImage(string $originalImagePath, array $imageParams): Image + { + $image = $this->imageFactory->create($originalImagePath); + $image->keepAspectRatio($imageParams['keep_aspect_ratio']); + $image->keepFrame($imageParams['keep_frame']); + $image->keepTransparency($imageParams['keep_transparency']); + $image->constrainOnly($imageParams['constrain_only']); + $image->backgroundColor($imageParams['background']); + $image->quality($imageParams['quality']); + return $image; + } + + /** + * Resize image + * @param array $viewImage + * @param string $originalImagePath + * @param string $originalImageName + */ + private function resize(array $viewImage, string $originalImagePath, string $originalImageName) + { + $imageParams = $this->paramsBuilder->build($viewImage); + $image = $this->makeImage($originalImagePath, $imageParams); + $imageAsset = $this->assertImageFactory->create( + [ + 'miscParams' => $imageParams, + 'filePath' => $originalImageName, + ] + ); + + if ($imageParams['image_width'] !== null && $imageParams['image_height'] !== null) { + $image->resize($imageParams['image_width'], $imageParams['image_height']); + } + $image->save($imageAsset->getPath()); + } +} diff --git a/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php b/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php index 46becd82343e9..a94bb8ec5e489 100644 --- a/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php +++ b/app/code/Magento/MediaStorage/Test/Unit/App/MediaTest.php @@ -5,7 +5,10 @@ */ namespace Magento\MediaStorage\Test\Unit\App; +use Magento\Catalog\Model\View\Asset\Placeholder; +use Magento\Catalog\Model\View\Asset\PlaceholderFactory; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; /** * Class MediaTest @@ -91,20 +94,30 @@ protected function setUp() $this->filesystemMock->expects($this->any()) ->method('getDirectoryWrite') - ->with(DirectoryList::MEDIA) + ->with(DirectoryList::PUB) ->will($this->returnValue($this->directoryMock)); $this->responseMock = $this->createMock(\Magento\MediaStorage\Model\File\Storage\Response::class); - $this->model = new \Magento\MediaStorage\App\Media( - $this->configFactoryMock, - $this->syncFactoryMock, - $this->responseMock, - $this->closure, - self::MEDIA_DIRECTORY, - self::CACHE_FILE_PATH, - self::RELATIVE_FILE_PATH, - $this->filesystemMock + $objectManager = new ObjectManager($this); + $this->model = $objectManager->getObject( + \Magento\MediaStorage\App\Media::class, + [ + 'configFactory' => $this->configFactoryMock, + 'syncFactory' => $this->syncFactoryMock, + 'response' => $this->responseMock, + 'isAllowed' => $this->closure, + 'mediaDirectory' => false, + 'configCacheFile' => self::CACHE_FILE_PATH, + 'relativeFileName' => self::RELATIVE_FILE_PATH, + 'filesystem' => $this->filesystemMock, + 'placeholderFactory' => $this->createConfiguredMock( + PlaceholderFactory::class, + [ + 'create' => $this->createMock(Placeholder::class) + ] + ), + ] ); } @@ -115,15 +128,19 @@ protected function tearDown() public function testProcessRequestCreatesConfigFileMediaDirectoryIsNotProvided() { - $this->model = new \Magento\MediaStorage\App\Media( - $this->configFactoryMock, - $this->syncFactoryMock, - $this->responseMock, - $this->closure, - false, - self::CACHE_FILE_PATH, - self::RELATIVE_FILE_PATH, - $this->filesystemMock + $objectManager = new ObjectManager($this); + $this->model = $objectManager->getObject( + \Magento\MediaStorage\App\Media::class, + [ + 'configFactory' => $this->configFactoryMock, + 'syncFactory' => $this->syncFactoryMock, + 'response' => $this->responseMock, + 'isAllowed' => $this->closure, + 'mediaDirectory' => false, + 'configCacheFile' => self::CACHE_FILE_PATH, + 'relativeFileName' => self::RELATIVE_FILE_PATH, + 'filesystem' => $this->filesystemMock + ] ); $filePath = '/absolute/path/to/test/file.png'; $this->directoryMock->expects($this->any()) @@ -144,33 +161,6 @@ public function testProcessRequestCreatesConfigFileMediaDirectoryIsNotProvided() $this->model->launch(); } - /** - * @expectedException \LogicException - * @expectedExceptionMessage The specified path is not allowed. - */ - public function testProcessRequestReturnsNotFoundResponseIfResourceIsNotAllowed() - { - $this->closure = function () { - return false; - }; - $this->model = new \Magento\MediaStorage\App\Media( - $this->configFactoryMock, - $this->syncFactoryMock, - $this->responseMock, - $this->closure, - false, - self::CACHE_FILE_PATH, - self::RELATIVE_FILE_PATH, - $this->filesystemMock - ); - $this->directoryMock->expects($this->once()) - ->method('getAbsolutePath') - ->with() - ->will($this->returnValue(self::MEDIA_DIRECTORY)); - $this->configMock->expects($this->once())->method('getAllowedResources')->will($this->returnValue(false)); - $this->model->launch(); - } - public function testProcessRequestReturnsFileIfItsProperlySynchronized() { $filePath = '/absolute/path/to/test/file.png'; @@ -202,7 +192,6 @@ public function testProcessRequestReturnsNotFoundIfFileIsNotSynchronized() ->method('isReadable') ->with(self::RELATIVE_FILE_PATH) ->will($this->returnValue(false)); - $this->responseMock->expects($this->once())->method('setHttpResponseCode')->with(404); $this->assertSame($this->responseMock, $this->model->launch()); } diff --git a/app/code/Magento/MediaStorage/Test/Unit/Model/File/Storage/SynchronizationTest.php b/app/code/Magento/MediaStorage/Test/Unit/Model/File/Storage/SynchronizationTest.php index f58a11540dc8b..f3ebf57546950 100644 --- a/app/code/Magento/MediaStorage/Test/Unit/Model/File/Storage/SynchronizationTest.php +++ b/app/code/Magento/MediaStorage/Test/Unit/Model/File/Storage/SynchronizationTest.php @@ -5,6 +5,8 @@ */ namespace Magento\MediaStorage\Test\Unit\Model\File\Storage; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; + class SynchronizationTest extends \PHPUnit\Framework\TestCase { public function testSynchronize() @@ -40,7 +42,11 @@ public function testSynchronize() ->with($relativeFileName) ->will($this->returnValue($file)); - $model = new \Magento\MediaStorage\Model\File\Storage\Synchronization($storageFactoryMock, $directory); + $objectManager = new ObjectManager($this); + $model = $objectManager->getObject(\Magento\MediaStorage\Model\File\Storage\Synchronization::class, [ + 'storageFactory' => $storageFactoryMock, + 'directory' => $directory, + ]); $model->synchronize($relativeFileName); } } diff --git a/app/code/Magento/MediaStorage/composer.json b/app/code/Magento/MediaStorage/composer.json index 085719974ddea..7d27c88b3dcb2 100644 --- a/app/code/Magento/MediaStorage/composer.json +++ b/app/code/Magento/MediaStorage/composer.json @@ -5,14 +5,15 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-config": "*", + "magento/module-store": "*", + "magento/module-catalog": "*", + "magento/module-theme": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/MediaStorage/etc/di.xml b/app/code/Magento/MediaStorage/etc/di.xml index 8f491f5b84644..2b9317787463d 100644 --- a/app/code/Magento/MediaStorage/etc/di.xml +++ b/app/code/Magento/MediaStorage/etc/di.xml @@ -19,4 +19,11 @@ + + + + Magento\MediaStorage\Console\Command\ImagesResizeCommand + + + diff --git a/app/code/Magento/MediaStorage/etc/module.xml b/app/code/Magento/MediaStorage/etc/module.xml index 6a04d4641e66d..df4acf8904fbc 100644 --- a/app/code/Magento/MediaStorage/etc/module.xml +++ b/app/code/Magento/MediaStorage/etc/module.xml @@ -6,5 +6,10 @@ */ --> - + + + + + + diff --git a/app/code/Magento/MessageQueue/Console/ConsumerListCommand.php b/app/code/Magento/MessageQueue/Console/ConsumerListCommand.php new file mode 100644 index 0000000000000..9248d50ee78de --- /dev/null +++ b/app/code/Magento/MessageQueue/Console/ConsumerListCommand.php @@ -0,0 +1,90 @@ +getConsumers(); + $output->writeln($consumers); + return \Magento\Framework\Console\Cli::RETURN_SUCCESS; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName(self::COMMAND_QUEUE_CONSUMERS_LIST); + $this->setDescription('List of MessageQueue consumers'); + $this->setHelp( + <<getConsumerConfig()->getConsumers() as $consumer) { + $consumerNames[] = $consumer->getName(); + } + return $consumerNames; + } + + /** + * Get consumer config. + * + * @return ConsumerConfig + * + * @deprecated 100.2.0 + */ + private function getConsumerConfig() + { + if ($this->consumerConfig === null) { + $this->consumerConfig = \Magento\Framework\App\ObjectManager::getInstance()->get(ConsumerConfig::class); + } + return $this->consumerConfig; + } +} diff --git a/app/code/Magento/MessageQueue/Console/StartConsumerCommand.php b/app/code/Magento/MessageQueue/Console/StartConsumerCommand.php new file mode 100644 index 0000000000000..571b725e7335c --- /dev/null +++ b/app/code/Magento/MessageQueue/Console/StartConsumerCommand.php @@ -0,0 +1,161 @@ +appState = $appState; + $this->consumerFactory = $consumerFactory; + $this->pidConsumerManager = $pidConsumerManager ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(PidConsumerManager::class); + parent::__construct($name); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $consumerName = $input->getArgument(self::ARGUMENT_CONSUMER); + $numberOfMessages = $input->getOption(self::OPTION_NUMBER_OF_MESSAGES); + $batchSize = (int)$input->getOption(self::OPTION_BATCH_SIZE); + $areaCode = $input->getOption(self::OPTION_AREACODE); + $pidFilePath = $input->getOption(self::PID_FILE_PATH); + + if ($pidFilePath && $this->pidConsumerManager->isRun($pidFilePath)) { + $output->writeln('Consumer with the same PID is running'); + return \Magento\Framework\Console\Cli::RETURN_FAILURE; + } + + if ($pidFilePath) { + $this->pidConsumerManager->savePid($pidFilePath); + } + + if ($areaCode !== null) { + $this->appState->setAreaCode($areaCode); + } else { + $this->appState->setAreaCode('global'); + } + + $consumer = $this->consumerFactory->get($consumerName, $batchSize); + $consumer->process($numberOfMessages); + return \Magento\Framework\Console\Cli::RETURN_SUCCESS; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName(self::COMMAND_QUEUE_CONSUMERS_START); + $this->setDescription('Start MessageQueue consumer'); + $this->addArgument( + self::ARGUMENT_CONSUMER, + InputArgument::REQUIRED, + 'The name of the consumer to be started.' + ); + $this->addOption( + self::OPTION_NUMBER_OF_MESSAGES, + null, + InputOption::VALUE_REQUIRED, + 'The number of messages to be processed by the consumer before process termination. ' + . 'If not specified - terminate after processing all queued messages.' + ); + $this->addOption( + self::OPTION_BATCH_SIZE, + null, + InputOption::VALUE_REQUIRED, + 'The number of messages per batch. Applicable for the batch consumer only.' + ); + $this->addOption( + self::OPTION_AREACODE, + null, + InputOption::VALUE_REQUIRED, + 'The preferred area (global, adminhtml, etc...) ' + . 'default is global.' + ); + $this->addOption( + self::PID_FILE_PATH, + null, + InputOption::VALUE_REQUIRED, + 'The file path for saving PID' + ); + $this->setHelp( + <<%command.full_name% someConsumer + +To specify the number of messages which should be processed by consumer before its termination: + + %command.full_name% someConsumer --max-messages=50 + +To specify the number of messages per batch for the batch consumer: + + %command.full_name% someConsumer --batch-size=500 + +To specify the preferred area: + + %command.full_name% someConsumer --area-code='adminhtml' + +To save PID enter path: + + %command.full_name% someConsumer --pid-file-path='/var/someConsumer.pid' +HELP + ); + parent::configure(); + } +} diff --git a/app/code/Magento/MessageQueue/LICENSE.txt b/app/code/Magento/MessageQueue/LICENSE.txt new file mode 100644 index 0000000000000..49525fd99da9c --- /dev/null +++ b/app/code/Magento/MessageQueue/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/app/code/Magento/MessageQueue/LICENSE_AFL.txt b/app/code/Magento/MessageQueue/LICENSE_AFL.txt new file mode 100644 index 0000000000000..f39d641b18a19 --- /dev/null +++ b/app/code/Magento/MessageQueue/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/app/code/Magento/MessageQueue/Model/ConsumerRunner.php b/app/code/Magento/MessageQueue/Model/ConsumerRunner.php new file mode 100644 index 0000000000000..92a0dcd4d77d9 --- /dev/null +++ b/app/code/Magento/MessageQueue/Model/ConsumerRunner.php @@ -0,0 +1,82 @@ + + * + * + * Where consumerName should be a valid name of consumer registered in some queue.xml + * + * @api + * @since 100.0.2 + */ +class ConsumerRunner +{ + /** + * @var ConsumerFactory + */ + private $consumerFactory; + + /** + * @var MaintenanceMode + */ + private $maintenanceMode; + + /** + * @var integer + */ + private $maintenanceSleepInterval; + + /** + * Initialize dependencies. + * + * @param ConsumerFactory $consumerFactory + * @param MaintenanceMode $maintenanceMode + * @param integer $maintenanceSleepInterval + */ + public function __construct( + ConsumerFactory $consumerFactory, + MaintenanceMode $maintenanceMode = null, + $maintenanceSleepInterval = 30 + ) { + $this->consumerFactory = $consumerFactory; + $this->maintenanceMode = $maintenanceMode ?: ObjectManager::getInstance()->get(MaintenanceMode::class); + $this->maintenanceSleepInterval = $maintenanceSleepInterval; + } + + /** + * Process messages in queue using consumer, which name is equal to the current magic method name. + * + * @param string $name + * @param array $arguments + * @throws LocalizedException + * @return void + */ + public function __call($name, $arguments) + { + try { + $consumer = $this->consumerFactory->get($name); + } catch (\Exception $e) { + $errorMsg = '"%callbackMethod" callback method specified in crontab.xml ' + . 'must have corresponding consumer declared in some queue.xml.'; + throw new LocalizedException(__($errorMsg, ['callbackMethod' => $name])); + } + if (!$this->maintenanceMode->isOn()) { + $consumer->process(); + } else { + sleep($this->maintenanceSleepInterval); + } + } +} diff --git a/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner.php b/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner.php new file mode 100644 index 0000000000000..c4620862b2e10 --- /dev/null +++ b/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner.php @@ -0,0 +1,144 @@ +phpExecutableFinder = $phpExecutableFinder; + $this->consumerConfig = $consumerConfig; + $this->deploymentConfig = $deploymentConfig; + $this->shellBackground = $shellBackground; + $this->pidConsumerManager = $pidConsumerManager; + } + + /** + * Runs consumers processes + */ + public function run() + { + $runByCron = $this->deploymentConfig->get('cron_consumers_runner/cron_run', true); + + if (!$runByCron) { + return; + } + + $maxMessages = (int) $this->deploymentConfig->get('cron_consumers_runner/max_messages', 10000); + $allowedConsumers = $this->deploymentConfig->get('cron_consumers_runner/consumers', []); + $php = $this->phpExecutableFinder->find() ?: 'php'; + + foreach ($this->consumerConfig->getConsumers() as $consumer) { + $consumerName = $consumer->getName(); + + if (!$this->canBeRun($consumerName, $allowedConsumers)) { + continue; + } + + $arguments = [ + $consumerName, + '--pid-file-path=' . $this->getPidFilePath($consumerName), + ]; + + if ($maxMessages) { + $arguments[] = '--max-messages=' . $maxMessages; + } + + $command = $php . ' ' . BP . '/bin/magento queue:consumers:start %s %s' + . ($maxMessages ? ' %s' : ''); + + $this->shellBackground->execute($command, $arguments); + } + } + + /** + * Checks that the consumer can be run + * + * @param string $consumerName The consumer name + * @param array $allowedConsumers The list of allowed consumers + * If $allowedConsumers is empty it means that all consumers are allowed + * @return bool Returns true if the consumer can be run + */ + private function canBeRun($consumerName, array $allowedConsumers = []) + { + $allowed = empty($allowedConsumers) ?: in_array($consumerName, $allowedConsumers); + + return $allowed && !$this->pidConsumerManager->isRun($this->getPidFilePath($consumerName)); + } + + /** + * Returns default path to file with PID by consumers name + * + * @param string $consumerName The consumers name + * @return string The path to file with PID + */ + private function getPidFilePath($consumerName) + { + return $consumerName . static::PID_FILE_EXT; + } +} diff --git a/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner/PidConsumerManager.php b/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner/PidConsumerManager.php new file mode 100644 index 0000000000000..d5f827320ac74 --- /dev/null +++ b/app/code/Magento/MessageQueue/Model/Cron/ConsumersRunner/PidConsumerManager.php @@ -0,0 +1,127 @@ +filesystem = $filesystem; + } + + /** + * Checks if consumer process is run by pid from pidFile + * + * @param string $pidFilePath The path to file with PID + * @return bool Returns true if consumer process is run + * @throws FileSystemException + */ + public function isRun($pidFilePath) + { + $pid = $this->getPid($pidFilePath); + if ($pid) { + if (function_exists('posix_getpgid')) { + return (bool) posix_getpgid($pid); + } else { + return $this->checkIsProcessExists($pid); + } + } + + return false; + } + + /** + * Checks that process is running + * + * If php function exec is not available throws RuntimeException + * If shell command returns non-zero code and this code is not 1 throws RuntimeException + * + * @param int $pid A pid of process + * @return bool Returns true if consumer process is run + * @throws \RuntimeException + * @SuppressWarnings(PHPMD.UnusedLocalVariable) + */ + private function checkIsProcessExists($pid) + { + if (!function_exists('exec')) { + throw new \RuntimeException('Function exec is not available'); + } + + exec(escapeshellcmd('ps -p ' . $pid), $output, $code); + + $code = (int) $code; + + switch ($code) { + case 0: + return true; + break; + case 1: + return false; + break; + default: + throw new \RuntimeException('Exec returned non-zero code', $code); + break; + } + } + + /** + * Returns pid by pidFile path + * + * @param string $pidFilePath The path to file with PID + * @return int Returns pid if pid file exists for consumer else returns 0 + * @throws FileSystemException + */ + public function getPid($pidFilePath) + { + /** @var ReadInterface $directory */ + $directory = $this->filesystem->getDirectoryRead(DirectoryList::VAR_DIR); + + if ($directory->isExist($pidFilePath)) { + return (int) $directory->readFile($pidFilePath); + } + + return 0; + } + + /** + * Saves pid of current process to file + * + * @param string $pidFilePath The path to file with pid + * @throws FileSystemException + */ + public function savePid($pidFilePath) + { + /** @var WriteInterface $directory */ + $directory = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR); + $directory->writeFile($pidFilePath, function_exists('posix_getpid') ? posix_getpid() : getmypid(), 'w'); + } +} diff --git a/app/code/Magento/MessageQueue/Model/Lock.php b/app/code/Magento/MessageQueue/Model/Lock.php new file mode 100644 index 0000000000000..e63a04fd1b023 --- /dev/null +++ b/app/code/Magento/MessageQueue/Model/Lock.php @@ -0,0 +1,64 @@ +_init(\Magento\MessageQueue\Model\ResourceModel\Lock::class); + } + + /** + * Get message code + * + * @return string + */ + public function getMessageCode() + { + return $this->_getData('message_code'); + } + + /** + * Set message code + * + * @param string $value + * @return $this + */ + public function setMessageCode($value) + { + return $this->setData('message_code', $value); + } + + /** + * Get lock date + * + * @return string + */ + public function getCreatedAt() + { + return $this->_getData('created_at'); + } + + /** + * Set lock date + * + * @param string $value + * @return $this + */ + public function setCreatedAt($value) + { + return $this->setData('created_at', $value); + } +} diff --git a/app/code/Magento/MessageQueue/Model/Plugin/ResourceModel/Lock.php b/app/code/Magento/MessageQueue/Model/Plugin/ResourceModel/Lock.php new file mode 100644 index 0000000000000..30f275520febb --- /dev/null +++ b/app/code/Magento/MessageQueue/Model/Plugin/ResourceModel/Lock.php @@ -0,0 +1,42 @@ +lock = $lock; + } + + /** + * When maintenance mode is turned off, lock queue should be cleared + * + * @param \Magento\Framework\App\MaintenanceMode $subject + * @param boolean $result + * @return void + */ + public function afterSet(\Magento\Framework\App\MaintenanceMode $subject, $result) + { + if (!$subject->isOn() && $result) { + $this->lock->releaseOutdatedLocks(); + } + } +} diff --git a/app/code/Magento/MessageQueue/Model/ResourceModel/Lock.php b/app/code/Magento/MessageQueue/Model/ResourceModel/Lock.php new file mode 100644 index 0000000000000..16c02a7505664 --- /dev/null +++ b/app/code/Magento/MessageQueue/Model/ResourceModel/Lock.php @@ -0,0 +1,97 @@ +lockFactory = $lockFactory; + $this->interval = $interval; + $this->dateTime = $dateTime; + parent::__construct($context, $connectionName); + } + + /** + * {@inheritDoc} + */ + protected function _construct() + { + $this->_init(self::QUEUE_LOCK_TABLE, 'id'); + } + + /** + * {@inheritDoc} + */ + public function read(\Magento\Framework\MessageQueue\LockInterface $lock, $code) + { + $object = $this->lockFactory->create(); + $object->load($code, 'message_code'); + $lock->setId($object->getId()); + $lock->setMessageCode($object->getMessageCode() ?: $code); + $lock->setCreatedAt($object->getCreatedAt()); + } + + /** + * {@inheritDoc} + */ + public function saveLock(\Magento\Framework\MessageQueue\LockInterface $lock) + { + $object = $this->lockFactory->create(); + $object->setMessageCode($lock->getMessageCode()); + $object->setCreatedAt($this->dateTime->gmtTimestamp()); + $object->save(); + } + + /** + * {@inheritDoc} + */ + public function releaseOutdatedLocks() + { + $date = (new \DateTime())->setTimestamp($this->dateTime->gmtTimestamp()); + $date->add(new \DateInterval('PT' . $this->interval . 'S')); + $this->getConnection()->delete($this->getTable(self::QUEUE_LOCK_TABLE), ['created_at <= ?' => $date]); + } +} diff --git a/app/code/Magento/MessageQueue/README.md b/app/code/Magento/MessageQueue/README.md new file mode 100644 index 0000000000000..78bb794473529 --- /dev/null +++ b/app/code/Magento/MessageQueue/README.md @@ -0,0 +1,3 @@ +# MessageQueue + +**MessageQueue** provides support of Advanced Message Queuing Protocol diff --git a/app/code/Magento/MessageQueue/Test/Unit/Console/StartConsumerCommandTest.php b/app/code/Magento/MessageQueue/Test/Unit/Console/StartConsumerCommandTest.php new file mode 100644 index 0000000000000..922da3bfc8773 --- /dev/null +++ b/app/code/Magento/MessageQueue/Test/Unit/Console/StartConsumerCommandTest.php @@ -0,0 +1,191 @@ +pidConsumerManagerMock = $this->getMockBuilder(PidConsumerManager::class) + ->disableOriginalConstructor() + ->getMock(); + $this->consumerFactory = $this->getMockBuilder(\Magento\Framework\MessageQueue\ConsumerFactory::class) + ->disableOriginalConstructor()->getMock(); + $this->appState = $this->getMockBuilder(\Magento\Framework\App\State::class) + ->disableOriginalConstructor()->getMock(); + $this->writeFactoryMock = $this->getMockBuilder(WriteFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->command = $this->objectManager->getObject( + \Magento\MessageQueue\Console\StartConsumerCommand::class, + [ + 'consumerFactory' => $this->consumerFactory, + 'appState' => $this->appState, + 'writeFactory' => $this->writeFactoryMock, + 'pidConsumerManager' => $this->pidConsumerManagerMock, + ] + ); + parent::setUp(); + } + + /** + * Test for execute method. + * + * @param string|null $pidFilePath + * @param int $savePidExpects + * @param int $isRunExpects + * @param bool $isRun + * @param int $runProcessExpects + * @param int $expectedReturn + * @return void + * @dataProvider executeDataProvider + */ + public function testExecute( + $pidFilePath, + $savePidExpects, + $isRunExpects, + $isRun, + $runProcessExpects, + $expectedReturn + ) { + $areaCode = 'area_code'; + $numberOfMessages = 10; + $batchSize = null; + $consumerName = 'consumer_name'; + $input = $this->getMockBuilder(\Symfony\Component\Console\Input\InputInterface::class) + ->disableOriginalConstructor()->getMock(); + $output = $this->getMockBuilder(\Symfony\Component\Console\Output\OutputInterface::class) + ->disableOriginalConstructor()->getMock(); + $input->expects($this->once())->method('getArgument') + ->with(\Magento\MessageQueue\Console\StartConsumerCommand::ARGUMENT_CONSUMER) + ->willReturn($consumerName); + $input->expects($this->exactly(4))->method('getOption') + ->withConsecutive( + [\Magento\MessageQueue\Console\StartConsumerCommand::OPTION_NUMBER_OF_MESSAGES], + [\Magento\MessageQueue\Console\StartConsumerCommand::OPTION_BATCH_SIZE], + [\Magento\MessageQueue\Console\StartConsumerCommand::OPTION_AREACODE], + [\Magento\MessageQueue\Console\StartConsumerCommand::PID_FILE_PATH] + )->willReturnOnConsecutiveCalls( + $numberOfMessages, + $batchSize, + $areaCode, + $pidFilePath + ); + $this->appState->expects($this->exactly($runProcessExpects))->method('setAreaCode')->with($areaCode); + $consumer = $this->getMockBuilder(\Magento\Framework\MessageQueue\ConsumerInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->consumerFactory->expects($this->exactly($runProcessExpects)) + ->method('get')->with($consumerName, $batchSize)->willReturn($consumer); + $consumer->expects($this->exactly($runProcessExpects))->method('process')->with($numberOfMessages); + + $this->pidConsumerManagerMock->expects($this->exactly($isRunExpects)) + ->method('isRun') + ->with($pidFilePath) + ->willReturn($isRun); + + $this->pidConsumerManagerMock->expects($this->exactly($savePidExpects)) + ->method('savePid') + ->with($pidFilePath); + + $this->assertEquals( + $expectedReturn, + $this->command->run($input, $output) + ); + } + + /** + * @return array + */ + public function executeDataProvider() + { + return [ + [ + 'pidFilePath' => null, + 'savePidExpects' => 0, + 'isRunExpects' => 0, + 'isRun' => false, + 'runProcessExpects' => 1, + 'expectedReturn' => \Magento\Framework\Console\Cli::RETURN_SUCCESS, + ], + [ + 'pidFilePath' => '/var/consumer.pid', + 'savePidExpects' => 1, + 'isRunExpects' => 1, + 'isRun' => false, + 'runProcessExpects' => 1, + 'expectedReturn' => \Magento\Framework\Console\Cli::RETURN_SUCCESS, + ], + [ + 'pidFilePath' => '/var/consumer.pid', + 'savePidExpects' => 0, + 'isRunExpects' => 1, + 'isRun' => true, + 'runProcessExpects' => 0, + 'expectedReturn' => \Magento\Framework\Console\Cli::RETURN_FAILURE, + ], + ]; + } + + /** + * Test configure() method implicitly via construct invocation. + * + * @return void + */ + public function testConfigure() + { + $this->assertEquals(StartConsumerCommand::COMMAND_QUEUE_CONSUMERS_START, $this->command->getName()); + $this->assertEquals('Start MessageQueue consumer', $this->command->getDescription()); + /** Exception will be thrown if argument is not declared */ + $this->command->getDefinition()->getArgument(StartConsumerCommand::ARGUMENT_CONSUMER); + $this->command->getDefinition()->getOption(StartConsumerCommand::OPTION_NUMBER_OF_MESSAGES); + $this->command->getDefinition()->getOption(StartConsumerCommand::OPTION_AREACODE); + $this->command->getDefinition()->getOption(StartConsumerCommand::PID_FILE_PATH); + $this->assertContains('To start consumer which will process', $this->command->getHelp()); + } +} diff --git a/app/code/Magento/MessageQueue/Test/Unit/Model/ConsumerRunnerTest.php b/app/code/Magento/MessageQueue/Test/Unit/Model/ConsumerRunnerTest.php new file mode 100644 index 0000000000000..46a142bf488be --- /dev/null +++ b/app/code/Magento/MessageQueue/Test/Unit/Model/ConsumerRunnerTest.php @@ -0,0 +1,121 @@ +objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->consumerFactoryMock = $this->getMockBuilder(\Magento\Framework\MessageQueue\ConsumerFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->maintenanceModeMock = $this->getMockBuilder(\Magento\Framework\App\MaintenanceMode::class) + ->disableOriginalConstructor() + ->getMock(); + $this->consumerRunner = $this->objectManager->getObject( + \Magento\MessageQueue\Model\ConsumerRunner::class, + [ + 'consumerFactory' => $this->consumerFactoryMock, + 'maintenanceMode' => $this->maintenanceModeMock + ] + ); + parent::setUp(); + } + + /** + * Ensure that consumer, with name equal to invoked magic method name, is run. + * + * @return void + */ + public function testMagicMethod() + { + $isMaintenanceModeOn = false; + /** @var ConsumerInterface|\PHPUnit_Framework_MockObject_MockObject $consumerMock */ + $consumerMock = $this->getMockBuilder(\Magento\Framework\MessageQueue\ConsumerInterface::class)->getMock(); + $consumerMock->expects($this->once())->method('process'); + $consumerName = 'someConsumerName'; + $this->consumerFactoryMock + ->expects($this->once()) + ->method('get') + ->with($consumerName) + ->willReturn($consumerMock); + $this->maintenanceModeMock->expects($this->once())->method('isOn')->willReturn($isMaintenanceModeOn); + + $this->consumerRunner->$consumerName(); + } + + /** + * Ensure that exception will be thrown if requested magic method does not correspond to any declared consumer. + * + * @expectedException \Magento\Framework\Exception\LocalizedException + * @expectedExceptionMessage "nonDeclaredConsumer" callback method specified in crontab.xml must + * @return void + */ + public function testMagicMethodNoRelatedConsumer() + { + $consumerName = 'nonDeclaredConsumer'; + $this->consumerFactoryMock + ->expects($this->once()) + ->method('get') + ->with($consumerName) + ->willThrowException(new LocalizedException(new Phrase("Some exception"))); + + $this->consumerRunner->$consumerName(); + } + + /** + * Ensure that process method will not be invoked if maintenance mode isOn returns true + * + * @return void + */ + public function testMagicMethodMaintenanceModeIsOn() + { + $isMaintenanceModeOn = true; + /** @var ConsumerInterface|\PHPUnit_Framework_MockObject_MockObject $consumerMock */ + $consumerMock = $this->getMockBuilder(\Magento\Framework\MessageQueue\ConsumerInterface::class)->getMock(); + $consumerMock->expects($this->never())->method('process'); + $consumerName = 'someConsumerName'; + $this->consumerFactoryMock + ->expects($this->once()) + ->method('get') + ->with($consumerName) + ->willReturn($consumerMock); + $this->maintenanceModeMock->expects($this->once())->method('isOn')->willReturn($isMaintenanceModeOn); + + $this->consumerRunner->$consumerName(); + } +} diff --git a/app/code/Magento/MessageQueue/Test/Unit/Model/Cron/ConsumersRunner/PidConsumerManagerTest.php b/app/code/Magento/MessageQueue/Test/Unit/Model/Cron/ConsumersRunner/PidConsumerManagerTest.php new file mode 100644 index 0000000000000..3d48e0b19ef2c --- /dev/null +++ b/app/code/Magento/MessageQueue/Test/Unit/Model/Cron/ConsumersRunner/PidConsumerManagerTest.php @@ -0,0 +1,104 @@ +filesystemMock = $this->getMockBuilder(Filesystem::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->pidConsumerManager = new PidConsumerManager($this->filesystemMock); + } + + /** + * @param bool $fileExists + * @param int|null $pid + * @param bool $expectedResult + * @dataProvider isRunDataProvider + */ + public function testIsRun($fileExists, $pid, $expectedResult) + { + $pidFilePath = 'somepath/consumerName.pid'; + + /** @var ReadInterface|MockObject $directoryMock */ + $directoryMock = $this->getMockBuilder(ReadInterface::class) + ->getMockForAbstractClass(); + $directoryMock->expects($this->once()) + ->method('isExist') + ->willReturn($fileExists); + $directoryMock->expects($this->any()) + ->method('readFile') + ->with($pidFilePath) + ->willReturn($pid); + + $this->filesystemMock->expects($this->once()) + ->method('getDirectoryRead') + ->with(DirectoryList::VAR_DIR) + ->willReturn($directoryMock); + + $this->assertSame($expectedResult, $this->pidConsumerManager->isRun($pidFilePath)); + } + + /** + * @return array + */ + public function isRunDataProvider() + { + return [ + ['fileExists' => false, 'pid' => null, false], + ['fileExists' => false, 'pid' => 11111, false], + ['fileExists' => true, 'pid' => 11111, true], + ['fileExists' => true, 'pid' => 77777, false], + ]; + } + + public function testSavePid() + { + $pidFilePath = '/var/somePath/pidfile.pid'; + + /** @var WriteInterface|MockObject $writeMock */ + $writeMock = $this->getMockBuilder(WriteInterface::class) + ->getMockForAbstractClass(); + $this->filesystemMock->expects($this->once()) + ->method('getDirectoryWrite') + ->with(DirectoryList::VAR_DIR) + ->willReturn($writeMock); + $writeMock->expects($this->once()) + ->method('writeFile') + ->with( + $pidFilePath, + function_exists('posix_getpid') ? posix_getpid() : getmypid(), + 'w' + ); + + $this->pidConsumerManager->savePid($pidFilePath); + } +} diff --git a/app/code/Magento/MessageQueue/Test/Unit/Model/Cron/ConsumersRunnerTest.php b/app/code/Magento/MessageQueue/Test/Unit/Model/Cron/ConsumersRunnerTest.php new file mode 100644 index 0000000000000..08c8f926522de --- /dev/null +++ b/app/code/Magento/MessageQueue/Test/Unit/Model/Cron/ConsumersRunnerTest.php @@ -0,0 +1,244 @@ +phpExecutableFinderMock = $this->getMockBuilder(phpExecutableFinder::class) + ->disableOriginalConstructor() + ->getMock(); + $this->pidConsumerManagerMock = $this->getMockBuilder(PidConsumerManager::class) + ->disableOriginalConstructor() + ->getMock(); + $this->shellBackgroundMock = $this->getMockBuilder(ShellInterface::class) + ->getMockForAbstractClass(); + $this->consumerConfigMock = $this->getMockBuilder(ConsumerConfigInterface::class) + ->getMockForAbstractClass(); + $this->deploymentConfigMock = $this->getMockBuilder(DeploymentConfig::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->consumersRunner = new ConsumersRunner( + $this->phpExecutableFinderMock, + $this->consumerConfigMock, + $this->deploymentConfigMock, + $this->shellBackgroundMock, + $this->pidConsumerManagerMock + ); + } + + public function testRunDisabled() + { + $this->deploymentConfigMock->expects($this->once()) + ->method('get') + ->willReturnMap([ + ['cron_consumers_runner/cron_run', true, false], + ['cron_consumers_runner/max_messages', 10000, 10000], + ['cron_consumers_runner/consumers', [], []], + ]); + + $this->consumerConfigMock->expects($this->never()) + ->method('getConsumers'); + $this->pidConsumerManagerMock->expects($this->never()) + ->method('isRun'); + $this->shellBackgroundMock->expects($this->never()) + ->method('execute'); + + $this->consumersRunner->run(); + } + + /** + * @param int $maxMessages + * @param bool $isRun + * @param string $php + * @param string $command + * @param array $arguments + * @param array $allowedConsumers + * @param int $shellBackgroundExpects + * @param int $isRunExpects + * @dataProvider runDataProvider + */ + public function testRun( + $maxMessages, + $isRun, + $php, + $command, + $arguments, + array $allowedConsumers, + $shellBackgroundExpects, + $isRunExpects + ) { + $consumerName = 'consumerName'; + $pidFilePath = 'consumerName.pid'; + + $this->deploymentConfigMock->expects($this->exactly(3)) + ->method('get') + ->willReturnMap([ + ['cron_consumers_runner/cron_run', true, true], + ['cron_consumers_runner/max_messages', 10000, $maxMessages], + ['cron_consumers_runner/consumers', [], $allowedConsumers], + ]); + + /** @var ConsumerConfigInterface|MockObject $firstCunsumer */ + $consumer = $this->getMockBuilder(ConsumerConfigItemInterface::class) + ->getMockForAbstractClass(); + $consumer->expects($this->any()) + ->method('getName') + ->willReturn($consumerName); + + $this->phpExecutableFinderMock->expects($this->once()) + ->method('find') + ->willReturn($php); + + $this->consumerConfigMock->expects($this->once()) + ->method('getConsumers') + ->willReturn([$consumer]); + + $this->pidConsumerManagerMock->expects($this->exactly($isRunExpects)) + ->method('isRun') + ->with($pidFilePath) + ->willReturn($isRun); + + $this->shellBackgroundMock->expects($this->exactly($shellBackgroundExpects)) + ->method('execute') + ->with($command, $arguments); + + $this->consumersRunner->run(); + } + + /** + * @return array + */ + public function runDataProvider() + { + return [ + [ + 'maxMessages' => 20000, + 'isRun' => false, + 'php' => '', + 'command' => 'php '. BP . '/bin/magento queue:consumers:start %s %s %s', + 'arguments' => ['consumerName', '--pid-file-path=consumerName.pid', '--max-messages=20000'], + 'allowedConsumers' => [], + 'shellBackgroundExpects' => 1, + 'isRunExpects' => 1, + ], + [ + 'maxMessages' => 10000, + 'isRun' => false, + 'php' => '', + 'command' => 'php '. BP . '/bin/magento queue:consumers:start %s %s %s', + 'arguments' => ['consumerName', '--pid-file-path=consumerName.pid', '--max-messages=10000'], + 'allowedConsumers' => [], + 'shellBackgroundExpects' => 1, + 'isRunExpects' => 1, + ], + [ + 'maxMessages' => 10000, + 'isRun' => false, + 'php' => '', + 'command' => 'php '. BP . '/bin/magento queue:consumers:start %s %s %s', + 'arguments' => ['consumerName', '--pid-file-path=consumerName.pid', '--max-messages=10000'], + 'allowedConsumers' => ['someConsumer'], + 'shellBackgroundExpects' => 0, + 'isRunExpects' => 0, + ], + [ + 'maxMessages' => 10000, + 'isRun' => true, + 'php' => '', + 'command' => 'php '. BP . '/bin/magento queue:consumers:start %s %s %s', + 'arguments' => ['consumerName', '--pid-file-path=consumerName.pid', '--max-messages=10000'], + 'allowedConsumers' => ['someConsumer'], + 'shellBackgroundExpects' => 0, + 'isRunExpects' => 0, + ], + [ + 'maxMessages' => 10000, + 'isRun' => true, + 'php' => '', + 'command' => 'php '. BP . '/bin/magento queue:consumers:start %s %s %s', + 'arguments' => ['consumerName', '--pid-file-path=consumerName.pid', '--max-messages=10000'], + 'allowedConsumers' => [], + 'shellBackgroundExpects' => 0, + 'isRunExpects' => 1, + ], + [ + 'maxMessages' => 10000, + 'isRun' => true, + 'php' => '', + 'command' => 'php '. BP . '/bin/magento queue:consumers:start %s %s %s', + 'arguments' => ['consumerName', '--pid-file-path=consumerName.pid', '--max-messages=10000'], + 'allowedConsumers' => ['consumerName'], + 'shellBackgroundExpects' => 0, + 'isRunExpects' => 1, + ], + [ + 'maxMessages' => 10000, + 'isRun' => false, + 'php' => '', + 'command' => 'php '. BP . '/bin/magento queue:consumers:start %s %s %s', + 'arguments' => ['consumerName', '--pid-file-path=consumerName.pid', '--max-messages=10000'], + 'allowedConsumers' => ['consumerName'], + 'shellBackgroundExpects' => 1, + 'isRunExpects' => 1, + ], + [ + 'maxMessages' => 0, + 'isRun' => false, + 'php' => '/bin/php', + 'command' => '/bin/php '. BP . '/bin/magento queue:consumers:start %s %s', + 'arguments' => ['consumerName', '--pid-file-path=consumerName.pid'], + 'allowedConsumers' => ['consumerName'], + 'shellBackgroundExpects' => 1, + 'isRunExpects' => 1, + ], + ]; + } +} diff --git a/app/code/Magento/MessageQueue/Test/Unit/Model/ResourceModel/LockTest.php b/app/code/Magento/MessageQueue/Test/Unit/Model/ResourceModel/LockTest.php new file mode 100644 index 0000000000000..547b54b895bee --- /dev/null +++ b/app/code/Magento/MessageQueue/Test/Unit/Model/ResourceModel/LockTest.php @@ -0,0 +1,79 @@ +objectManager = new ObjectManager($this); + $this->dateTimeMock = $this->getMockBuilder(DateTime::class)->disableOriginalConstructor()->getMock(); + $this->lockFactoryMock = $this->getMockBuilder(LockFactory::class)->disableOriginalConstructor()->getMock(); + $this->resourceConnectionMock = $this->getMockBuilder(ResourceConnection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->lockResourceModel = $this->objectManager->getObject( + LockResourceModel::class, + [ + 'resources' => $this->resourceConnectionMock, + 'dateTime' => $this->dateTimeMock, + 'lockFactory' => $this->lockFactoryMock, + ] + ); + parent::setUp(); + } + + public function testReleaseOutdatedLocks() + { + /** @var \Magento\Framework\DB\Adapter\AdapterInterface|\PHPUnit_Framework_MockObject_MockObject $adapterMock */ + $adapterMock = $this->getMockBuilder(AdapterInterface::class)->disableOriginalConstructor()->getMock(); + $this->resourceConnectionMock->expects($this->once())->method('getConnection')->willReturn($adapterMock); + $tableName = 'queue_lock_mock'; + $this->resourceConnectionMock->expects($this->once())->method('getTableName')->willReturn($tableName); + $this->dateTimeMock->expects($this->once())->method('gmtTimestamp')->willReturn(1000000000); + /** Date for timestamp 1000000000 + 86400 */ + $date = new \DateTime('2001-09-09T18:46:40-0700'); + + $adapterMock->expects($this->once())->method('delete')->with($tableName, ['created_at <= ?' => $date]); + $this->lockResourceModel->releaseOutdatedLocks(); + } +} diff --git a/app/code/Magento/MessageQueue/Test/Unit/_files/pid_consumer_functions_mocks.php b/app/code/Magento/MessageQueue/Test/Unit/_files/pid_consumer_functions_mocks.php new file mode 100644 index 0000000000000..042c6012f80b3 --- /dev/null +++ b/app/code/Magento/MessageQueue/Test/Unit/_files/pid_consumer_functions_mocks.php @@ -0,0 +1,26 @@ + + + + + 15 + 20 + 15 + 10 + 60 + 600 + 1 + + diff --git a/app/code/Magento/MessageQueue/etc/crontab.xml b/app/code/Magento/MessageQueue/etc/crontab.xml new file mode 100644 index 0000000000000..14c831a9c76e3 --- /dev/null +++ b/app/code/Magento/MessageQueue/etc/crontab.xml @@ -0,0 +1,17 @@ + + + + + + 0 * * * * + + + + + * * * * * + + + diff --git a/app/code/Magento/MessageQueue/etc/db_schema.xml b/app/code/Magento/MessageQueue/etc/db_schema.xml new file mode 100644 index 0000000000000..9cbad4d32a889 --- /dev/null +++ b/app/code/Magento/MessageQueue/etc/db_schema.xml @@ -0,0 +1,24 @@ + + + +
    + + + + + + + + + +
    +
    diff --git a/app/code/Magento/MessageQueue/etc/db_schema_whitelist.json b/app/code/Magento/MessageQueue/etc/db_schema_whitelist.json new file mode 100644 index 0000000000000..14c30198e87e4 --- /dev/null +++ b/app/code/Magento/MessageQueue/etc/db_schema_whitelist.json @@ -0,0 +1,13 @@ +{ + "queue_lock": { + "column": { + "id": true, + "message_code": true, + "created_at": true + }, + "constraint": { + "PRIMARY": true, + "QUEUE_LOCK_MESSAGE_CODE": true + } + } +} \ No newline at end of file diff --git a/app/code/Magento/MessageQueue/etc/di.xml b/app/code/Magento/MessageQueue/etc/di.xml new file mode 100644 index 0000000000000..c8f2edb862613 --- /dev/null +++ b/app/code/Magento/MessageQueue/etc/di.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + Magento\MessageQueue\Console\StartConsumerCommand\Proxy + Magento\MessageQueue\Console\ConsumerListCommand\Proxy + + + + + + Magento\Framework\MessageQueue\MergedMessageProcessor + Magento\Framework\MessageQueue\MessageProcessor + + + + + + + + 0 + + + + + RefreshLock + + + + + shellBackground + + + diff --git a/app/code/Magento/MessageQueue/etc/module.xml b/app/code/Magento/MessageQueue/etc/module.xml new file mode 100644 index 0000000000000..0bb5f9d40da5a --- /dev/null +++ b/app/code/Magento/MessageQueue/etc/module.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/code/Magento/MessageQueue/registration.php b/app/code/Magento/MessageQueue/registration.php new file mode 100644 index 0000000000000..e27fa71517427 --- /dev/null +++ b/app/code/Magento/MessageQueue/registration.php @@ -0,0 +1,9 @@ + */ diff --git a/app/code/Magento/Multishipping/Block/Checkout/Overview.php b/app/code/Magento/Multishipping/Block/Checkout/Overview.php index c62226dc8d063..2197598489358 100644 --- a/app/code/Magento/Multishipping/Block/Checkout/Overview.php +++ b/app/code/Magento/Multishipping/Block/Checkout/Overview.php @@ -120,11 +120,7 @@ public function getPaymentHtml() */ public function getPayment() { - if (!$this->hasData('payment')) { - $payment = new \Magento\Framework\DataObject($this->getRequest()->getPost('payment')); - $this->setData('payment', $payment); - } - return $this->_getData('payment'); + return $this->getCheckout()->getQuote()->getPayment(); } /** @@ -200,9 +196,9 @@ public function formatPrice($price) /** * @param Address $address - * @return mixed + * @return array */ - public function getShippingAddressItems($address) + public function getShippingAddressItems($address): array { return $address->getAllVisibleItems(); } @@ -309,16 +305,7 @@ public function getVirtualProductEditUrl() */ public function getVirtualItems() { - $items = []; - foreach ($this->getBillingAddress()->getItemsCollection() as $_item) { - if ($_item->isDeleted()) { - continue; - } - if ($_item->getProduct()->getIsVirtual() && !$_item->getParentItemId()) { - $items[] = $_item; - } - } - return $items; + return $this->getBillingAddress()->getAllVisibleItems(); } /** diff --git a/app/code/Magento/Multishipping/Block/Checkout/Results.php b/app/code/Magento/Multishipping/Block/Checkout/Results.php new file mode 100644 index 0000000000000..35c050d5ff8c1 --- /dev/null +++ b/app/code/Magento/Multishipping/Block/Checkout/Results.php @@ -0,0 +1,186 @@ +addressConfig = $addressConfig; + $this->orderRepository = $orderRepository; + $this->session = $session; + } + + /** + * Returns shipping addresses from quote. + * + * @return array + */ + public function getQuoteShippingAddresses(): array + { + return $this->_multishipping->getQuote()->getAllShippingAddresses(); + } + + /** + * Returns all failed addresses from quote. + * + * @return array + */ + public function getFailedAddresses(): array + { + $addresses = $this->getQuoteShippingAddresses(); + if ($this->getAddressError($this->getQuoteBillingAddress())) { + $addresses[] = $this->getQuoteBillingAddress(); + } + return $addresses; + } + + /** + * Retrieve order shipping address. + * + * @param int $orderId + * @return OrderAddress|null + */ + public function getOrderShippingAddress(int $orderId) + { + return $this->orderRepository->get($orderId)->getShippingAddress(); + } + + /** + * Retrieve quote billing address. + * + * @return QuoteAddress + */ + public function getQuoteBillingAddress(): QuoteAddress + { + return $this->getCheckout()->getQuote()->getBillingAddress(); + } + + /** + * Returns formatted shipping address from placed order. + * + * @param OrderAddress $address + * @return string + */ + public function formatOrderShippingAddress(OrderAddress $address): string + { + return $this->getAddressOneline($address->getData()); + } + + /** + * Returns formatted shipping address from quote. + * + * @param QuoteAddress $address + * @return string + */ + public function formatQuoteShippingAddress(QuoteAddress $address): string + { + return $this->getAddressOneline($address->getData()); + } + + /** + * Checks if address type is shipping. + * + * @param QuoteAddress $address + * @return bool + */ + public function isShippingAddress(QuoteAddress $address): bool + { + return $address->getAddressType() === QuoteAddress::ADDRESS_TYPE_SHIPPING; + } + + /** + * Get unescaped address formatted as one line string. + * + * @param array $address + * @return string + */ + private function getAddressOneline(array $address): string + { + $renderer = $this->addressConfig->getFormatByCode('oneline')->getRenderer(); + + return $renderer->renderArray($address); + } + + /** + * Returns address error. + * + * @param QuoteAddress $address + * @return string + */ + public function getAddressError(QuoteAddress $address): string + { + $errors = $this->session->getAddressErrors(); + + return $errors[$address->getId()] ?? ''; + } + + /** + * Add title to block head. + * + * @throws LocalizedException + * @return Success + */ + protected function _prepareLayout(): Success + { + /** @var Title $pageTitle */ + $pageTitle = $this->getLayout()->getBlock('page.main.title'); + if ($pageTitle) { + $title = $this->getOrderIds() ? $pageTitle->getPartlySuccessTitle() : $pageTitle->getFailedTitle(); + $pageTitle->setPageTitle($title); + } + + return parent::_prepareLayout(); + } +} diff --git a/app/code/Magento/Multishipping/Block/Checkout/Shipping.php b/app/code/Magento/Multishipping/Block/Checkout/Shipping.php index 77981c736b9e9..ef1aa6301b23d 100644 --- a/app/code/Magento/Multishipping/Block/Checkout/Shipping.php +++ b/app/code/Magento/Multishipping/Block/Checkout/Shipping.php @@ -9,7 +9,7 @@ use Magento\Quote\Model\Quote\Address; /** - * Mustishipping checkout shipping + * Multishipping checkout shipping * * @api * @author Magento Core Team diff --git a/app/code/Magento/Multishipping/Block/Checkout/Success.php b/app/code/Magento/Multishipping/Block/Checkout/Success.php index 3f18132d04a45..0f39e03c56c63 100644 --- a/app/code/Magento/Multishipping/Block/Checkout/Success.php +++ b/app/code/Magento/Multishipping/Block/Checkout/Success.php @@ -36,7 +36,7 @@ public function __construct( */ public function getOrderIds() { - $ids = $this->_session->getOrderIds(true); + $ids = $this->_session->getOrderIds(); if ($ids && is_array($ids)) { return $ids; } diff --git a/app/code/Magento/Multishipping/Block/DataProviders/Billing.php b/app/code/Magento/Multishipping/Block/DataProviders/Billing.php new file mode 100644 index 0000000000000..684ed32d9f112 --- /dev/null +++ b/app/code/Magento/Multishipping/Block/DataProviders/Billing.php @@ -0,0 +1,76 @@ +addressConfig = $addressConfig; + $this->configProvider = $configProvider; + $this->serializer = $serializer; + } + + /** + * Get address formatted as html string. + * + * @param Address $address + * @return string + */ + public function getAddressHtml(Address $address): string + { + $renderer = $this->addressConfig->getFormatByCode('html')->getRenderer(); + + return $renderer->renderArray($address->getData()); + } + + /** + * Returns serialized checkout config. + * + * @return string + * @throws \InvalidArgumentException + */ + public function getSerializedCheckoutConfigs(): string + { + return $this->serializer->serialize($this->configProvider->getConfig()); + } +} diff --git a/app/code/Magento/Multishipping/Block/DataProviders/Overview.php b/app/code/Magento/Multishipping/Block/DataProviders/Overview.php new file mode 100644 index 0000000000000..e630f14f61c79 --- /dev/null +++ b/app/code/Magento/Multishipping/Block/DataProviders/Overview.php @@ -0,0 +1,75 @@ +session = $session; + } + + /** + * Returns address error. + * + * @param Address $address + * @return string + */ + public function getAddressError(Address $address): string + { + $addressErrors = $this->getAddressErrors(); + + return $addressErrors[$address->getId()] ?? ''; + } + + /** + * Returns all stored errors. + * + * @return array + */ + public function getAddressErrors(): array + { + if (empty($this->addressErrors)) { + $this->addressErrors = $this->session->getAddressErrors(true); + } + + return $this->addressErrors ?? []; + } + + /** + * Creates anchor name for address Id. + * + * @param int $addressId + * @return string + */ + public function getAddressAnchorName(int $addressId): string + { + return 'a' . $addressId; + } +} diff --git a/app/code/Magento/Multishipping/Block/DataProviders/Success.php b/app/code/Magento/Multishipping/Block/DataProviders/Success.php new file mode 100644 index 0000000000000..4b7543c9f72fd --- /dev/null +++ b/app/code/Magento/Multishipping/Block/DataProviders/Success.php @@ -0,0 +1,19 @@ +getRequest()->getPost('payment', []); - $payment['checks'] = [ - \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_FOR_COUNTRY, - \Magento\Payment\Model\Method\AbstractMethod::CHECK_USE_FOR_CURRENCY, - \Magento\Payment\Model\Method\AbstractMethod::CHECK_ORDER_TOTAL_MIN_MAX, - \Magento\Payment\Model\Method\AbstractMethod::CHECK_ZERO_TOTAL, - ]; - $this->_getCheckout()->setPaymentMethod($payment); - + if (!empty($payment)) { + $payment['checks'] = [ + AbstractMethod::CHECK_USE_FOR_COUNTRY, + AbstractMethod::CHECK_USE_FOR_CURRENCY, + AbstractMethod::CHECK_ORDER_TOTAL_MIN_MAX, + AbstractMethod::CHECK_ZERO_TOTAL, + ]; + $this->_getCheckout()->setPaymentMethod($payment); + } $this->_getState()->setCompleteStep(State::STEP_BILLING); $this->_view->loadLayout(); $this->_view->renderLayout(); - } catch (\Magento\Framework\Exception\LocalizedException $e) { + } catch (LocalizedException $e) { $this->messageManager->addError($e->getMessage()); $this->_redirect('*/*/billing'); } catch (\Exception $e) { - $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); + $this->_objectManager->get(LoggerInterface::class)->critical($e); $this->messageManager->addException($e, __('We cannot open the overview page.')); $this->_redirect('*/*/billing'); } diff --git a/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php b/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php index d23f8863d0728..f05a7f43b8118 100644 --- a/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php +++ b/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php @@ -9,6 +9,7 @@ use Magento\Customer\Api\AccountManagementInterface; use Magento\Customer\Api\CustomerRepositoryInterface; use Magento\Framework\Exception\PaymentException; +use Magento\Framework\Session\SessionManagerInterface; /** * Class OverviewPost @@ -32,6 +33,11 @@ class OverviewPost extends \Magento\Multishipping\Controller\Checkout */ protected $agreementsValidator; + /** + * @var SessionManagerInterface + */ + private $session; + /** * @param \Magento\Framework\App\Action\Context $context * @param \Magento\Customer\Model\Session $customerSession @@ -40,6 +46,7 @@ class OverviewPost extends \Magento\Multishipping\Controller\Checkout * @param \Magento\Framework\Data\Form\FormKey\Validator $formKeyValidator * @param \Psr\Log\LoggerInterface $logger * @param \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator + * @param SessionManagerInterface $session */ public function __construct( \Magento\Framework\App\Action\Context $context, @@ -48,11 +55,14 @@ public function __construct( AccountManagementInterface $accountManagement, \Magento\Framework\Data\Form\FormKey\Validator $formKeyValidator, \Psr\Log\LoggerInterface $logger, - \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator + \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator, + SessionManagerInterface $session ) { $this->formKeyValidator = $formKeyValidator; $this->logger = $logger; $this->agreementsValidator = $agreementValidator; + $this->session = $session; + parent::__construct( $context, $customerSession, @@ -95,11 +105,17 @@ public function execute() $paymentInstance->setCcCid($payment['cc_cid']); } $this->_getCheckout()->createOrders(); - $this->_getState()->setActiveStep(State::STEP_SUCCESS); $this->_getState()->setCompleteStep(State::STEP_OVERVIEW); - $this->_getCheckout()->getCheckoutSession()->clearQuote(); - $this->_getCheckout()->getCheckoutSession()->setDisplaySuccess(true); - $this->_redirect('*/*/success'); + + if ($this->session->getAddressErrors()) { + $this->_getState()->setActiveStep(State::STEP_RESULTS); + $this->_redirect('*/*/results'); + } else { + $this->_getState()->setActiveStep(State::STEP_SUCCESS); + $this->_getCheckout()->getCheckoutSession()->clearQuote(); + $this->_getCheckout()->getCheckoutSession()->setDisplaySuccess(true); + $this->_redirect('*/*/success'); + } } catch (PaymentException $e) { $message = $e->getMessage(); if (!empty($message)) { diff --git a/app/code/Magento/Multishipping/Controller/Checkout/Results.php b/app/code/Magento/Multishipping/Controller/Checkout/Results.php new file mode 100644 index 0000000000000..1a18daf02ebd8 --- /dev/null +++ b/app/code/Magento/Multishipping/Controller/Checkout/Results.php @@ -0,0 +1,16 @@ +state = $state; + $this->multishipping = $multishipping; + + parent::__construct($context); + } + /** * Multishipping checkout success page * @@ -17,13 +49,13 @@ class Success extends \Magento\Multishipping\Controller\Checkout */ public function execute() { - if (!$this->_getState()->getCompleteStep(State::STEP_OVERVIEW)) { + if (!$this->state->getCompleteStep(State::STEP_OVERVIEW)) { $this->_redirect('*/*/addresses'); return; } $this->_view->loadLayout(); - $ids = $this->_getCheckout()->getOrderIds(); + $ids = $this->multishipping->getOrderIds(); $this->_eventManager->dispatch('multishipping_checkout_controller_success_action', ['order_ids' => $ids]); $this->_view->renderLayout(); } diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php index fdbe1d24ba1f5..70d480a448638 100644 --- a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php @@ -7,11 +7,14 @@ namespace Magento\Multishipping\Model\Checkout\Type; use Magento\Customer\Api\AddressRepositoryInterface; +use Magento\Framework\Exception\NotFoundException; use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Model\Order\Email\Sender\OrderSender; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\App\ObjectManager; use Magento\Directory\Model\AllowedCountries; +use Psr\Log\LoggerInterface; /** * Multishipping checkout model @@ -157,6 +160,16 @@ class Multishipping extends \Magento\Framework\DataObject */ private $shippingAssignmentProcessor; + /** + * @var Multishipping\PlaceOrderFactory + */ + private $placeOrderFactory; + + /** + * @var LoggerInterface + */ + private $logger; + /** * Constructor * @@ -184,6 +197,8 @@ class Multishipping extends \Magento\Framework\DataObject * @param array $data * @param \Magento\Quote\Api\Data\CartExtensionFactory|null $cartExtensionFactory * @param AllowedCountries|null $allowedCountryReader + * @param Multishipping\PlaceOrderFactory $placeOrderFactory + * @param LoggerInterface $logger * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -210,7 +225,9 @@ public function __construct( \Magento\Quote\Model\Quote\TotalsCollector $totalsCollector, array $data = [], \Magento\Quote\Api\Data\CartExtensionFactory $cartExtensionFactory = null, - AllowedCountries $allowedCountryReader = null + AllowedCountries $allowedCountryReader = null, + Multishipping\PlaceOrderFactory $placeOrderFactory = null, + LoggerInterface $logger = null ) { $this->_eventManager = $eventManager; $this->_scopeConfig = $scopeConfig; @@ -237,6 +254,10 @@ public function __construct( ->get(\Magento\Quote\Api\Data\CartExtensionFactory::class); $this->allowedCountryReader = $allowedCountryReader ?: ObjectManager::getInstance() ->get(AllowedCountries::class); + $this->placeOrderFactory = $placeOrderFactory ?: ObjectManager::getInstance() + ->get(Multishipping\PlaceOrderFactory::class); + $this->logger = $logger ?: ObjectManager::getInstance() + ->get(LoggerInterface::class); parent::__construct($data); $this->_init(); } @@ -764,21 +785,48 @@ public function createOrders() ); } + $paymentProviderCode = $this->getQuote()->getPayment()->getMethod(); + $placeOrderService = $this->placeOrderFactory->create($paymentProviderCode); + $exceptionList = $placeOrderService->place($orders); + $this->logExceptions($exceptionList); + + /** @var OrderInterface[] $failedOrders */ + $failedOrders = []; + /** @var OrderInterface[] $successfulOrders */ + $successfulOrders = []; foreach ($orders as $order) { - $order->place(); - $order->save(); + if (isset($exceptionList[$order->getIncrementId()])) { + $failedOrders[] = $order; + } else { + $successfulOrders[] = $order; + } + } + + $placedAddressItems = []; + foreach ($successfulOrders as $order) { + $orderIds[$order->getId()] = $order->getIncrementId(); if ($order->getCanSendNewEmailFlag()) { $this->orderSender->send($order); } - $orderIds[$order->getId()] = $order->getIncrementId(); + $placedAddressItems = array_merge($placedAddressItems, $this->getQuoteAddressItems($order)); } - $this->_session->setOrderIds($orderIds); - $this->_checkoutSession->setLastQuoteId($this->getQuote()->getId()); - - $this->getQuote()->setIsActive(false); - $this->quoteRepository->save($this->getQuote()); + $addressErrors = []; + if (!empty($failedOrders)) { + $this->removePlacedItemsFromQuote($shippingAddresses, $placedAddressItems); + $addressErrors = $this->getQuoteAddressErrors( + $failedOrders, + $shippingAddresses, + $exceptionList + ); + } else { + $this->_checkoutSession->setLastQuoteId($this->getQuote()->getId()); + $this->getQuote()->setIsActive(false); + $this->quoteRepository->save($this->getQuote()); + } + $this->_session->setOrderIds($orderIds); + $this->_session->setAddressErrors($addressErrors); $this->_eventManager->dispatch( 'checkout_submit_all_after', ['orders' => $orders, 'quote' => $this->getQuote()] @@ -791,6 +839,19 @@ public function createOrders() } } + /** + * Logs exceptions. + * + * @param \Exception[] $exceptionList + * @return void + */ + private function logExceptions(array $exceptionList) + { + foreach ($exceptionList as $exception) { + $this->logger->critical($exception); + } + } + /** * Collect quote totals and save quote object * @@ -877,7 +938,10 @@ public function getMinimumAmountError() public function getOrderIds($asAssoc = false) { $idsAssoc = $this->_session->getOrderIds(); - return $asAssoc ? $idsAssoc : array_keys($idsAssoc); + if ($idsAssoc !== null) { + return $asAssoc ? $idsAssoc : array_keys($idsAssoc); + } + return []; } /** @@ -1035,4 +1099,112 @@ private function getShippingAssignmentProcessor() } return $this->shippingAssignmentProcessor; } + + /** + * Remove successfully placed items from quote. + * + * @param \Magento\Quote\Model\Quote\Address[] $shippingAddresses + * @param int[] $placedAddressItems + * @return void + */ + private function removePlacedItemsFromQuote(array $shippingAddresses, array $placedAddressItems) + { + foreach ($shippingAddresses as $address) { + foreach ($address->getAllItems() as $addressItem) { + if (in_array($addressItem->getId(), $placedAddressItems)) { + if ($addressItem->getProduct()->getIsVirtual()) { + $addressItem->isDeleted(true); + } else { + $address->isDeleted(true); + } + + $this->decreaseQuoteItemQty($addressItem->getQuoteItemId(), $addressItem->getQty()); + } + } + } + $this->save(); + } + + /** + * Decrease quote item quantity. + * + * @param int $quoteItemId + * @param int $qty + * @return void + */ + private function decreaseQuoteItemQty(int $quoteItemId, int $qty) + { + $quoteItem = $this->getQuote()->getItemById($quoteItemId); + if ($quoteItem) { + $newItemQty = $quoteItem->getQty() - $qty; + if ($newItemQty > 0) { + $quoteItem->setQty($newItemQty); + } else { + $this->getQuote()->removeItem($quoteItem->getId()); + $this->getQuote()->setIsMultiShipping(1); + } + } + } + + /** + * Returns quote address id that was assigned to order. + * + * @param OrderInterface $order + * @param \Magento\Quote\Model\Quote\Address[] $addresses + * + * @return int + * @throws NotFoundException + */ + private function searchQuoteAddressId(OrderInterface $order, array $addresses): int + { + $items = $order->getItems(); + $item = array_pop($items); + foreach ($addresses as $address) { + foreach ($address->getAllItems() as $addressItem) { + if ($addressItem->getId() == $item->getQuoteItemId()) { + return (int)$address->getId(); + } + } + } + + throw new NotFoundException(__('Quote address for failed order not found.')); + } + + /** + * @param OrderInterface[] $orders + * @param \Magento\Quote\Model\Quote\Address[] $addresses + * @param \Exception[] $exceptionList + * + * @return string[] + * @throws NotFoundException + */ + private function getQuoteAddressErrors(array $orders, array $addresses, array $exceptionList): array + { + $addressErrors = []; + foreach ($orders as $failedOrder) { + if (!isset($exceptionList[$failedOrder->getIncrementId()])) { + throw new NotFoundException(__('Exception for failed order not found.')); + } + $addressId = $this->searchQuoteAddressId($failedOrder, $addresses); + $addressErrors[$addressId] = $exceptionList[$failedOrder->getIncrementId()]->getMessage(); + } + + return $addressErrors; + } + + /** + * Returns quote address item id. + * + * @param OrderInterface $order + * @return array + */ + private function getQuoteAddressItems(OrderInterface $order): array + { + $placedAddressItems = []; + foreach ($order->getItems() as $orderItem) { + $placedAddressItems[] = $orderItem->getQuoteItemId(); + } + + return $placedAddressItems; + } } diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderDefault.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderDefault.php new file mode 100644 index 0000000000000..584003fcf9abb --- /dev/null +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderDefault.php @@ -0,0 +1,48 @@ +orderManagement = $orderManagement; + } + + /** + * {@inheritdoc} + */ + public function place(array $orderList): array + { + $errorList = []; + foreach ($orderList as $order) { + try { + $this->orderManagement->place($order); + } catch (\Exception $e) { + $incrementId = $order->getIncrementId(); + $errorList[$incrementId] = $e; + } + } + + return $errorList; + } +} diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderFactory.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderFactory.php new file mode 100644 index 0000000000000..cf672d2898820 --- /dev/null +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderFactory.php @@ -0,0 +1,52 @@ +objectManager = $objectManager; + $this->placeOrderPool = $placeOrderPool; + } + + /** + * @param string $paymentProviderCode + * @return PlaceOrderInterface + */ + public function create(string $paymentProviderCode): PlaceOrderInterface + { + $service = $this->placeOrderPool->get($paymentProviderCode); + if ($service === null) { + $service = $this->objectManager->get(PlaceOrderDefault::class); + } + + return $service; + } +} diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderInterface.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderInterface.php new file mode 100644 index 0000000000000..5d384a5373d5e --- /dev/null +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/PlaceOrderInterface.php @@ -0,0 +1,26 @@ +services = $tmapFactory->createSharedObjectsMap( + [ + 'array' => $services, + 'type' => PlaceOrderInterface::class + ] + ); + } + + /** + * Returns place order service for defined payment provider. + * + * @param string $paymentProviderCode + * @return PlaceOrderInterface|null + */ + public function get(string $paymentProviderCode) + { + if (!isset($this->services[$paymentProviderCode])) { + return null; + } + + return $this->services[$paymentProviderCode]; + } +} diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/State.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/State.php index 2615ad1b540c8..2e53817351d3c 100644 --- a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/State.php +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping/State.php @@ -25,6 +25,8 @@ class State extends \Magento\Framework\DataObject const STEP_SUCCESS = 'multishipping_success'; + const STEP_RESULTS = 'multishipping_results'; + /** * Allow steps array * @@ -61,6 +63,7 @@ public function __construct(Session $checkoutSession, Multishipping $multishippi self::STEP_BILLING => new \Magento\Framework\DataObject(['label' => __('Billing Information')]), self::STEP_OVERVIEW => new \Magento\Framework\DataObject(['label' => __('Place Order')]), self::STEP_SUCCESS => new \Magento\Framework\DataObject(['label' => __('Order Success')]), + self::STEP_RESULTS => new \Magento\Framework\DataObject(['label' => __('Order Results')]), ]; foreach ($this->_steps as $step) { diff --git a/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/SuccessTest.php b/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/SuccessTest.php index a5074dbebf7fa..273ee41854cc5 100644 --- a/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/SuccessTest.php +++ b/app/code/Magento/Multishipping/Test/Unit/Block/Checkout/SuccessTest.php @@ -73,14 +73,14 @@ protected function setUp() public function testGetOrderIdsWithoutId() { - $this->sessionMock->expects($this->once())->method('getOrderIds')->with(true)->will($this->returnValue(null)); + $this->sessionMock->method('getOrderIds')->willReturn(null); $this->assertFalse($this->model->getOrderIds()); } public function testGetOrderIdsWithEmptyIdsArray() { - $this->sessionMock->expects($this->once())->method('getOrderIds')->with(true)->will($this->returnValue([])); + $this->sessionMock->method('getOrderIds')->willReturn([]); $this->assertFalse($this->model->getOrderIds()); } @@ -88,7 +88,7 @@ public function testGetOrderIdsWithEmptyIdsArray() public function testGetOrderIds() { $ids = [100, 102, 103]; - $this->sessionMock->expects($this->once())->method('getOrderIds')->with(true)->will($this->returnValue($ids)); + $this->sessionMock->method('getOrderIds')->willReturn($ids); $this->assertEquals($ids, $this->model->getOrderIds()); } diff --git a/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderDefaultTest.php b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderDefaultTest.php new file mode 100644 index 0000000000000..1878b5edb17ac --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderDefaultTest.php @@ -0,0 +1,73 @@ +orderManagement = $this->getMockForAbstractClass(OrderManagementInterface::class); + + $this->placeOrderDefault = new PlaceOrderDefault($this->orderManagement); + } + + public function testPlace() + { + $incrementId = '000000001'; + + $order = $this->getMockForAbstractClass(OrderInterface::class); + $order->method('getIncrementId')->willReturn($incrementId); + $orderList = [$order]; + + $this->orderManagement->expects($this->once()) + ->method('place') + ->with($order) + ->willReturn($order); + $errors = $this->placeOrderDefault->place($orderList); + + $this->assertEmpty($errors); + } + + public function testPlaceWithErrors() + { + $incrementId = '000000001'; + + $order = $this->getMockForAbstractClass(OrderInterface::class); + $order->method('getIncrementId')->willReturn($incrementId); + $orderList = [$order]; + + $exception = new \Exception('error'); + $this->orderManagement->method('place')->willThrowException($exception); + $errors = $this->placeOrderDefault->place($orderList); + + $this->assertEquals( + [$incrementId => $exception], + $errors + ); + } +} diff --git a/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderFactoryTest.php b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderFactoryTest.php new file mode 100644 index 0000000000000..d6363e0acbd35 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderFactoryTest.php @@ -0,0 +1,92 @@ +objectManager = $this->getMockForAbstractClass(ObjectManagerInterface::class); + + $this->placeOrderPool = $this->getMockBuilder(PlaceOrderPool::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->placeOrderFactory = new PlaceOrderFactory($this->objectManager, $this->placeOrderPool); + } + + /** + * Checks instantiation of place order service. + * + * @return void + */ + public function testCreate() + { + $paymentProviderCode = 'code'; + + $placeOrder = $this->getMockForAbstractClass(PlaceOrderInterface::class); + $this->placeOrderPool->method('get') + ->with($paymentProviderCode) + ->willReturn($placeOrder); + + $instance = $this->placeOrderFactory->create($paymentProviderCode); + + $this->assertInstanceOf(PlaceOrderInterface::class, $instance); + } + + /** + * Checks that default place order service is created when place order pull returns null. + * + * @return void + */ + public function testCreateWithDefault() + { + $paymentProviderCode = 'code'; + + $this->placeOrderPool->method('get') + ->with($paymentProviderCode) + ->willReturn(null); + $placeOrder = $this->getMockBuilder(PlaceOrderDefault::class) + ->disableOriginalConstructor() + ->getMock(); + $this->objectManager->method('get') + ->with(PlaceOrderDefault::class) + ->willReturn($placeOrder); + + $instance = $this->placeOrderFactory->create($paymentProviderCode); + + $this->assertInstanceOf(PlaceOrderDefault::class, $instance); + } +} diff --git a/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderPoolTest.php b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderPoolTest.php new file mode 100644 index 0000000000000..083160b10fda9 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Unit/Model/Checkout/Type/Multishipping/PlaceOrderPoolTest.php @@ -0,0 +1,54 @@ +getMockBuilder(TMapFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $tMapFactory->method('createSharedObjectsMap')->willReturn($placeOrderList); + + $placeOrderPool = new PlaceOrderPool($tMapFactory); + $result = $placeOrderPool->get($paymentProviderCode); + + $this->assertEquals($expectedResult, $result); + } + + /** + * @return array + */ + public function getDataProvider(): array + { + $placeOrder = $this->getMockForAbstractClass(PlaceOrderInterface::class); + $placeOrderList = ['payment_code' => $placeOrder]; + + return [ + 'code exists in pool' => ['payment_code', $placeOrderList, $placeOrder], + 'no code in pool' => ['some_code', $placeOrderList, null], + ]; + } +} diff --git a/app/code/Magento/Multishipping/composer.json b/app/code/Magento/Multishipping/composer.json index b483c4e3b0431..0d7982518d2c8 100644 --- a/app/code/Magento/Multishipping/composer.json +++ b/app/code/Magento/Multishipping/composer.json @@ -5,22 +5,19 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-payment": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*" - }, - "suggest": { - "magento/module-theme": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-checkout": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-payment": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-theme": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_billing.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_billing.xml index 8d78bad7a9ecc..5fcca5d9214a6 100644 --- a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_billing.xml +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_billing.xml @@ -15,6 +15,9 @@ + + Magento\Multishipping\Block\DataProviders\Billing + diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_overview.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_overview.xml index f6584d2dcb2fd..376bc7b7d8ca8 100644 --- a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_overview.xml +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_overview.xml @@ -17,6 +17,7 @@ + Magento\Multishipping\Block\DataProviders\Overview Magento_Multishipping::checkout/item/default.phtml Magento_Multishipping::checkout/overview/item.phtml diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_results.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_results.xml new file mode 100644 index 0000000000000..90f13ea28e02f --- /dev/null +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_results.xml @@ -0,0 +1,23 @@ + + + + + Order results + + + + + We could only complete part of your order. + We were unable to complete your order. + + + + + + + diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_success.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_success.xml index 98dd6e21910f8..d03282e551b9e 100644 --- a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_success.xml +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_success.xml @@ -12,11 +12,15 @@ - We received your order! + Thank you for your purchase! - + + + Magento\Multishipping\Block\DataProviders\Success + + diff --git a/app/code/Magento/Multishipping/view/frontend/requirejs-config.js b/app/code/Magento/Multishipping/view/frontend/requirejs-config.js index c00005138eee4..f14159ba0a85a 100644 --- a/app/code/Magento/Multishipping/view/frontend/requirejs-config.js +++ b/app/code/Magento/Multishipping/view/frontend/requirejs-config.js @@ -8,7 +8,8 @@ var config = { '*': { multiShipping: 'Magento_Multishipping/js/multi-shipping', orderOverview: 'Magento_Multishipping/js/overview', - payment: 'Magento_Multishipping/js/payment' + payment: 'Magento_Multishipping/js/payment', + billingLoader: 'Magento_Checkout/js/checkout-loader' } } }; diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/billing.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/billing.phtml index 277328c1b5b2a..d8514ca77f9c2 100644 --- a/app/code/Magento/Multishipping/view/frontend/templates/checkout/billing.phtml +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/billing.phtml @@ -4,52 +4,109 @@ * See COPYING.txt for license details. */ -// @codingStandardsIgnoreFile - -?> - -
    +
    +
    + <?= $block->escapeHtml(__('Loading...')); ?> +
    +
    + +
    + + +
    +
    - - + escapeHtml(__('Billing Address')); ?> + + escapeHtml(__('Change')); ?> +
    - getAddress() ?> -
    format('html') ?>
    +
    + getCheckoutData()->getAddressHtml($block->getAddress()); ?> +
    -
    + + escapeHtml(__('Payment Method')); ?> +
    getChildHtml('payment_methods_before') ?> -
    +
    getMethods(); - $_methodsCount = count($_methods); + $methods = $block->getMethods(); + $methodsCount = count($methods); + $methodsForms = $block->hasFormTemplates() ? $block->getFormTemplates(): []; + + foreach ($methods as $_method) : + $code = $_method->getCode(); + $checked = $block->getSelectedMethodCode() === $code; + + if (isset($methodsForms[$code])) { + $block->setMethodFormTemplate($code, $methodsForms[$code]); + } ?> - getCode() ?> -
    - 1): ?> - getSelectedMethodCode() == $_code): ?> checked="checked" class="radio"/> - - - - -
    - getChildHtml('payment.method.' . $_code)) : ?> -
    - +
    + 1) : ?> + + checked="checked" + + class="radio"/> + + + + +
    + getChildHtml('payment.method.' . $code)) : ?> +
    +
    @@ -63,29 +120,35 @@
    - +
    diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml index 2549eff3aca7d..d4d446a7567db 100644 --- a/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml @@ -4,105 +4,146 @@ * See COPYING.txt for license details. */ -// @codingStandardsIgnoreFile - /** @var \Magento\Multishipping\Block\Checkout\Overview $block */ ?> -
    - getBlockHtml('formkey') ?> +getCheckoutData()->getAddressErrors(); ?> + $error) : ?> +
    + escapeHtml($error); ?> + escapeHtml(__('Please see')); ?> + + escapeHtml(__('details below')); ?>. +
    + + + getBlockHtml('formkey'); ?>
    -
    +
    escapeHtml(__('Billing Information')); ?>
    - getBillingAddress() ?> + getBillingAddress() ?> - - + escapeHtml(__('Billing Address')); ?> + escapeHtml(__('Change')); ?>
    - format('html') ?> + format('html') ?>
    - - + escapeHtml(__('Payment Method')); ?> + escapeHtml(__('Change')); ?>
    - - - getPaymentHtml() ?> + + + getPaymentHtml() ?>
    -
    - helper('Magento\Tax\Helper\Data')->displayCartBothPrices() ? 2 : 1); ?> - getShippingAddresses() as $_index => $_address): ?> +
    escapeHtml(__('Shipping Information')); ?>
    + helper(Magento\Tax\Helper\Data::class)->displayCartBothPrices() ? 2 : 1); ?> + getShippingAddresses() as $index => $address) : ?>
    +
    - of %2', ($_index+1), $block->getShippingAddressCount()) ?> + escapeHtml(__('Address')); ?> escapeHtml($index + 1); ?> + + escapeHtml(__('of')); ?> + escapeHtml($block->getShippingAddressCount())?> + +
    + getCheckoutData()->getAddressError($address)) : ?> +
    escapeHtml($error); ?>
    +
    - - + escapeHtml(__('Shipping To')); ?> + escapeHtml(__('Change')); ?>
    - format('html') ?> + format('html') ?>
    - - + escapeHtml(__('Shipping Method')); ?> + escapeHtml(__('Change')); ?> - getShippingAddressRate($_address)): ?> + getShippingAddressRate($address)) : ?>
    - escapeHtml($_rate->getCarrierTitle()) ?> (escapeHtml($_rate->getMethodTitle()) ?>) - getShippingPriceExclTax($_address); ?> - getShippingPriceInclTax($_address); ?> - - helper('Magento\Tax\Helper\Data')->displayShippingBothPrices() && $_incl != $_excl): ?> - - - - helper('Magento\Tax\Helper\Data')->displayShippingBothPrices() && $_incl != $_excl): ?> - - - helper('Magento\Tax\Helper\Data')->displayShippingBothPrices() && $_incl != $_excl): ?> - - + escapeHtml($_rate->getCarrierTitle()) ?> + (escapeHtml($_rate->getMethodTitle()) ?>) + getShippingPriceExclTax($address); + $inclTax = $block->getShippingPriceInclTax($address); + $displayBothPrices = $this->helper(Magento\Tax\Helper\Data::class) + ->displayShippingBothPrices() && $inclTax !== $exclTax; + ?> + + + + + + + + + +
    - - +
    + - - - - + + + - getShippingAddressItems($_address) as $_item): ?> - getRowItemHtml($_item) ?> + getShippingAddressItems($address) as $item) : ?> + getRowItemHtml($item) ?> - renderTotals($block->getShippingAddressTotals($_address)) ?> + renderTotals( + $block->getShippingAddressTotals($address) + ); ?>
    escapeHtml(__('Order Review')); ?>
    - + escapeHtml(__('Item')); ?> + + escapeHtml(__('Edit')); ?> + escapeHtml(__('Price')); ?>escapeHtml(__('Qty')); ?>escapeHtml(__('Subtotal')); ?>
    @@ -112,33 +153,40 @@
    - getQuote()->hasVirtualItems()): ?> + getQuote()->hasVirtualItems()) : ?>
    -
    + getQuote()->getBillingAddress(); ?> + +
    escapeHtml(__('Other items in your order')); ?>
    + getCheckoutData()->getAddressError($billingAddress)) :?> +
    escapeHtml($error); ?>
    +
    - - + escapeHtml(__('Items')); ?> + escapeHtml(__('Edit Items')); ?> - helper('Magento\Tax\Helper\Data')->displayCartBothPrices() ? 2 : 1); ?> + helper(Magento\Tax\Helper\Data::class)->displayCartBothPrices() ? 2 : 1); ?>
    - + - - - - + + + + - getVirtualItems() as $_item): ?> - getRowItemHtml($_item) ?> + getVirtualItems() as $_item) : ?> + getRowItemHtml($_item) ?> - renderTotals($block->getBillinAddressTotals()) ?> + renderTotals($block->getBillinAddressTotals()); ?>
    escapeHtml(__('Items')); ?>
    escapeHtml(__('Product Name')); ?>escapeHtml(__('Price')); ?>escapeHtml(__('Qty')); ?>escapeHtml(__('Subtotal')); ?>
    @@ -146,23 +194,34 @@
    - getChildHtml('items_after') ?> + getChildHtml('items_after') ?>
    - getChildHtml('agreements') ?> + getChildHtml('agreements') ?>
    - - helper('Magento\Checkout\Helper\Data')->formatPrice($block->getTotal()) ?> + escapeHtml(__('Grand Total:')); ?> + + helper(Magento\Checkout\Helper\Data::class) + ->formatPrice($block->getTotal()); ?> +
    - +
    -
    diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml new file mode 100644 index 0000000000000..d6fdef6ae5f9a --- /dev/null +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/results.phtml @@ -0,0 +1,90 @@ +getOrderIds(); +?> +
    +

    + + escapeHtml(__('Not all items were included.')); ?> + + escapeHtml(__('For details, see')); ?> + escapeHtml(__('Failed to Order')); ?> + escapeHtml(__('section below')); ?> +

    + +

    + escapeHtml(__('For successfully ordered items, you\'ll receive a confirmation email '. + 'including order numbers, tracking information, and more details.')); ?> +

    +
    +

    escapeHtml(__('Successfully Ordered')); ?>

    +
      + $incrementId) : ?> +
    • + + getOrderShippingAddress($orderId); ?> +
      + + escapeHtml('Ship to:'); ?> + + escapeHtml($block->formatOrderShippingAddress($shippingAddress)); ?> + + + + escapeHtml(__('No shipping required.')); ?> + + +
      +
    • + +
    +
    + +
    +

    escapeHtml(__('Failed to Order')); ?>

    +
    +
    + escapeHtml(__('To purchase these items: Return to the')); ?> + + escapeHtml(__('Review page in Checkout')); ?>, + escapeHtml(__('resolve any errors, and place a new order.'))?> +
    +
    + getFailedAddresses() ?> + +
      + +
    1. +
      +
      + isShippingAddress($address)) : ?> + escapeHtml('Ship to:'); ?> + + escapeHtml($block->formatQuoteShippingAddress($address)); ?> + + + + escapeHtml(__('No shipping required.')); ?> + + +
      +
      + escapeHtml('Error:'); ?> + + getAddressError($address); ?> + +
      +
      +
    2. + +
    + +
    +
    diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/success.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/success.phtml index 3403c745e6495..c8e7c375089cd 100644 --- a/app/code/Magento/Multishipping/view/frontend/templates/checkout/success.phtml +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/success.phtml @@ -4,27 +4,48 @@ * See COPYING.txt for license details. */ -// @codingStandardsIgnoreFile - +/** @var \Magento\Multishipping\Block\Checkout\Success $block */ ?> -
    -

    escapeHtml(__('Thank you for your purchase!')) ?>

    -

    escapeHtml(__('Thanks for your order. We\'ll email you order details and tracking information.')) ?>

    - getOrderIds()): ?> -

    - - - 1): ?> - escapeHtml(__('Your order numbers are: ')) ?> - - escapeHtml(__('Your order number is: ')) ?> - - - $incrementId): ?> -

    - - getChildHtml() ?> -
    - escapeHtml(__('Continue Shopping')) ?> + +
    +

    escapeHtml(__('For successfully order items, you\'ll receive a confirmation email including '. + 'order numbers, tracking information and more details.')) ?>

    + getOrderIds()) : ?> +

    escapeHtml(__('Successfully ordered'))?>

    +
    +
      + $incrementId) : ?> +
    • + + getCheckoutData()->getOrderShippingAddress($orderId); ?> +
      + + escapeHtml('Ship to:'); ?> + + escapeHtml( + $block->getCheckoutData()->formatOrderShippingAddress($shippingAddress) + ); ?> + + + + escapeHtml(__('No shipping required.')); ?> + + +
      +
    • + +
    +
    + + getChildHtml() ?> +
    +
    + +
    +
    -
    + diff --git a/app/code/Magento/Multishipping/view/frontend/web/js/payment.js b/app/code/Magento/Multishipping/view/frontend/web/js/payment.js index 94987328bb278..da24b99597d42 100644 --- a/app/code/Magento/Multishipping/view/frontend/web/js/payment.js +++ b/app/code/Magento/Multishipping/view/frontend/web/js/payment.js @@ -63,9 +63,12 @@ define([ parentsDl = element.closest('dl'); parentsDl.find('dt input:radio').prop('checked', false); - parentsDl.find('.items').hide().find('[name^="payment["]').prop('disabled', true); + parentsDl.find('dd').addClass('no-display').end() + .find('.items').hide() + .find('[name^="payment["]').prop('disabled', true); element.prop('checked', true).parent() - .nextUntil('dt').find('.items').show().find('[name^="payment["]').prop('disabled', false); + .next('dd').removeClass('no-display') + .find('.items').show().find('[name^="payment["]').prop('disabled', false); }, /** @@ -122,16 +125,35 @@ define([ this.element.find(this.options.methodsContainer).show(); }, + /** + * Returns checked payment method. + * + * @private + */ + _getSelectedPaymentMethod: function () { + return this.element.find('input[name=\'payment[method]\']:checked'); + }, + /** * Validate before form submit * @private * @param {EventObject} e */ _submitHandler: function (e) { + var currentMethod, + submitButton; + e.preventDefault(); if (this._validatePaymentMethod()) { - this.element.submit(); + currentMethod = this._getSelectedPaymentMethod(); + submitButton = currentMethod.parent().next('dd').find('button[type=submit]'); + + if (submitButton.length) { + submitButton.first().trigger('click'); + } else { + this.element.submit(); + } } } }); diff --git a/app/code/Magento/MysqlMq/LICENSE.txt b/app/code/Magento/MysqlMq/LICENSE.txt new file mode 100644 index 0000000000000..49525fd99da9c --- /dev/null +++ b/app/code/Magento/MysqlMq/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/app/code/Magento/MysqlMq/LICENSE_AFL.txt b/app/code/Magento/MysqlMq/LICENSE_AFL.txt new file mode 100644 index 0000000000000..f39d641b18a19 --- /dev/null +++ b/app/code/Magento/MysqlMq/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/app/code/Magento/MysqlMq/Model/ConnectionTypeResolver.php b/app/code/Magento/MysqlMq/Model/ConnectionTypeResolver.php new file mode 100644 index 0000000000000..d58c708770acd --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/ConnectionTypeResolver.php @@ -0,0 +1,40 @@ +dbConnectionNames = $dbConnectionNames; + $this->dbConnectionNames[] = 'db'; + } + + /** + * {@inheritdoc} + */ + public function getConnectionType($connectionName) + { + return in_array($connectionName, $this->dbConnectionNames) ? 'db' : null; + } +} diff --git a/app/code/Magento/MysqlMq/Model/Driver/Bulk/Exchange.php b/app/code/Magento/MysqlMq/Model/Driver/Bulk/Exchange.php new file mode 100644 index 0000000000000..247a44667be06 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/Driver/Bulk/Exchange.php @@ -0,0 +1,55 @@ +messageQueueConfig = $messageQueueConfig; + $this->queueManagement = $queueManagement; + } + + /** + * @inheritdoc + */ + public function enqueue($topic, array $envelopes) + { + $queueNames = $this->messageQueueConfig->getQueuesByTopic($topic); + $messages = array_map( + function ($envelope) { + return $envelope->getBody(); + }, + $envelopes + ); + $this->queueManagement->addMessagesToQueues($topic, $messages, $queueNames); + + return null; + } +} diff --git a/app/code/Magento/MysqlMq/Model/Driver/Exchange.php b/app/code/Magento/MysqlMq/Model/Driver/Exchange.php new file mode 100644 index 0000000000000..b6050c6b3d0b6 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/Driver/Exchange.php @@ -0,0 +1,50 @@ +messageQueueConfig = $messageQueueConfig; + $this->queueManagement = $queueManagement; + } + + /** + * Send message + * + * @param string $topic + * @param EnvelopeInterface $envelope + * @return mixed + */ + public function enqueue($topic, EnvelopeInterface $envelope) + { + $queueNames = $this->messageQueueConfig->getQueuesByTopic($topic); + $this->queueManagement->addMessageToQueues($topic, $envelope->getBody(), $queueNames); + return null; + } +} diff --git a/app/code/Magento/MysqlMq/Model/Driver/ExchangeFactory.php b/app/code/Magento/MysqlMq/Model/Driver/ExchangeFactory.php new file mode 100644 index 0000000000000..8b9d08a2ed403 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/Driver/ExchangeFactory.php @@ -0,0 +1,50 @@ +objectManager = $objectManager; + $this->instanceName = $instanceName; + } + + /** + * {@inheritdoc} + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function create($connectionName, array $data = []) + { + return $this->objectManager->create($this->instanceName, $data); + } +} diff --git a/app/code/Magento/MysqlMq/Model/Driver/Queue.php b/app/code/Magento/MysqlMq/Model/Driver/Queue.php new file mode 100644 index 0000000000000..b8dab6fac7b24 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/Driver/Queue.php @@ -0,0 +1,153 @@ +queueManagement = $queueManagement; + $this->envelopeFactory = $envelopeFactory; + $this->queueName = $queueName; + $this->interval = $interval; + $this->maxNumberOfTrials = $maxNumberOfTrials; + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public function dequeue() + { + $envelope = null; + $messages = $this->queueManagement->readMessages($this->queueName, 1); + if (isset($messages[0])) { + $properties = $messages[0]; + + $body = $properties[QueueManagement::MESSAGE_BODY]; + unset($properties[QueueManagement::MESSAGE_BODY]); + + $envelope = $this->envelopeFactory->create(['body' => $body, 'properties' => $properties]); + } + + return $envelope; + } + + /** + * {@inheritdoc} + */ + public function acknowledge(EnvelopeInterface $envelope) + { + $properties = $envelope->getProperties(); + $relationId = $properties[QueueManagement::MESSAGE_QUEUE_RELATION_ID]; + + $this->queueManagement->changeStatus($relationId, QueueManagement::MESSAGE_STATUS_COMPLETE); + } + + /** + * {@inheritdoc} + */ + public function subscribe($callback) + { + while (true) { + while ($envelope = $this->dequeue()) { + try { + call_user_func($callback, $envelope); + $this->acknowledge($envelope); + } catch (\Exception $e) { + $this->reject($envelope); + } + } + sleep($this->interval); + } + } + + /** + * {@inheritdoc} + */ + public function reject(EnvelopeInterface $envelope, $requeue = true, $rejectionMessage = null) + { + $properties = $envelope->getProperties(); + $relationId = $properties[QueueManagement::MESSAGE_QUEUE_RELATION_ID]; + + if ($properties[QueueManagement::MESSAGE_NUMBER_OF_TRIALS] < $this->maxNumberOfTrials && $requeue) { + $this->queueManagement->pushToQueueForRetry($relationId); + } else { + $this->queueManagement->changeStatus([$relationId], QueueManagement::MESSAGE_STATUS_ERROR); + if ($rejectionMessage !== null) { + $this->logger->critical(__('Message has been rejected: %1', $rejectionMessage)); + } + } + } + + /** + * {@inheritDoc} + */ + public function push(EnvelopeInterface $envelope) + { + $properties = $envelope->getProperties(); + $this->queueManagement->addMessageToQueues( + $properties[QueueManagement::MESSAGE_TOPIC], + $envelope->getBody(), + [$this->queueName] + ); + } +} diff --git a/app/code/Magento/MysqlMq/Model/Driver/QueueFactory.php b/app/code/Magento/MysqlMq/Model/Driver/QueueFactory.php new file mode 100644 index 0000000000000..72188a6873f96 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/Driver/QueueFactory.php @@ -0,0 +1,54 @@ +objectManager = $objectManager; + $this->instanceName = $instanceName; + } + + /** + * {@inheritdoc} + */ + public function create($queueName, $connectionName) + { + return $this->objectManager->create( + $this->instanceName, + [ + 'queueName' => $queueName, + 'connectionName' => $connectionName + ] + ); + } +} diff --git a/app/code/Magento/MysqlMq/Model/Message.php b/app/code/Magento/MysqlMq/Model/Message.php new file mode 100644 index 0000000000000..27668f99a9073 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/Message.php @@ -0,0 +1,23 @@ +_init(\Magento\MysqlMq\Model\ResourceModel\Message::class); + } +} diff --git a/app/code/Magento/MysqlMq/Model/MessageStatus.php b/app/code/Magento/MysqlMq/Model/MessageStatus.php new file mode 100644 index 0000000000000..17ce2ab974df9 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/MessageStatus.php @@ -0,0 +1,23 @@ +_init(\Magento\MysqlMq\Model\ResourceModel\MessageStatus::class); + } +} diff --git a/app/code/Magento/MysqlMq/Model/Observer.php b/app/code/Magento/MysqlMq/Model/Observer.php new file mode 100644 index 0000000000000..523b2b95b87a3 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/Observer.php @@ -0,0 +1,36 @@ +queueManagement = $queueManagement; + } + + /** + * Clean up old messages from database + * @return void + */ + public function cleanupMessages() + { + $this->queueManagement->markMessagesForDelete(); + } +} diff --git a/app/code/Magento/MysqlMq/Model/Queue.php b/app/code/Magento/MysqlMq/Model/Queue.php new file mode 100644 index 0000000000000..0ab2c33e573b7 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/Queue.php @@ -0,0 +1,47 @@ +_init(\Magento\MysqlMq\Model\ResourceModel\Queue::class); + } + + /** + * Set queue name. + * + * @param string $name + * @return $this + */ + public function setName($name) + { + $this->setData(self::KEY_NAME, $name); + return $this; + } + + /** + * Get queue name. + * + * @return string + */ + public function getName() + { + return $this->getData(self::KEY_NAME); + } +} diff --git a/app/code/Magento/MysqlMq/Model/QueueManagement.php b/app/code/Magento/MysqlMq/Model/QueueManagement.php new file mode 100644 index 0000000000000..0840add2a5c42 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/QueueManagement.php @@ -0,0 +1,318 @@ +messageResource = $messageResource; + $this->scopeConfig = $scopeConfig; + $this->dateTime = $dateTime; + $this->messageStatusCollectionFactory = $messageStatusCollectionFactory; + } + + /** + * Add message to all specified queues. + * + * @param string $topic + * @param string $message + * @param string[] $queueNames + * @return $this + */ + public function addMessageToQueues($topic, $message, $queueNames) + { + $messageId = $this->messageResource->saveMessage($topic, $message); + $this->messageResource->linkQueues($messageId, $queueNames); + return $this; + } + + /** + * Add messages to all specified queues. + * + * @param string $topic + * @param array $messages + * @param string[] $queueNames + * @return $this + * @since 100.2.0 + */ + public function addMessagesToQueues($topic, $messages, $queueNames) + { + $messageIds = $this->messageResource->saveMessages($topic, $messages); + $this->messageResource->linkMessagesWithQueues($messageIds, $queueNames); + return $this; + } + + /** + * Mark messages to be deleted if sufficient amount of time passed since last update + * Delete marked messages + * + * @return void + */ + public function markMessagesForDelete() + { + $collection = $this->messageStatusCollectionFactory->create() + ->addFieldToFilter( + 'status', + ['in' => $this->getStatusesToClear()] + ); + + /** + * Update messages if lifetime is expired + */ + foreach ($collection as $messageStatus) { + $this->processMessagePerStatus($messageStatus); + } + + /** + * Delete all messages which has To BE DELETED status in all the queues + */ + $this->messageResource->deleteMarkedMessages(); + } + + /** + * Based on message status, updated date and timeout for the status, move it to next state + * + * @param MessageStatus $messageStatus + * @return void + */ + private function processMessagePerStatus($messageStatus) + { + $now = $this->dateTime->gmtTimestamp(); + + if ($messageStatus->getStatus() == self::MESSAGE_STATUS_COMPLETE + && strtotime($messageStatus->getUpdatedAt()) < ($now - $this->getCompletedMessageLifetime())) { + $messageStatus->setStatus(self::MESSAGE_STATUS_TO_BE_DELETED) + ->save(); + } elseif ($messageStatus->getStatus() == self::MESSAGE_STATUS_ERROR + && strtotime($messageStatus->getUpdatedAt()) < ($now - $this->getErrorMessageLifetime())) { + $messageStatus->setStatus(self::MESSAGE_STATUS_TO_BE_DELETED) + ->save(); + } elseif ($messageStatus->getStatus() == self::MESSAGE_STATUS_IN_PROGRESS + && strtotime($messageStatus->getUpdatedAt()) < ($now - $this->getInProgressRetryAfter()) + ) { + $this->pushToQueueForRetry($messageStatus->getId()); + } elseif ($messageStatus->getStatus() == self::MESSAGE_STATUS_NEW + && strtotime($messageStatus->getUpdatedAt()) < ($now - $this->getNewMessageLifetime()) + ) { + $messageStatus->setStatus(self::MESSAGE_STATUS_TO_BE_DELETED) + ->save(); + } + } + + /** + * Compose a set of statuses to track for deletion based on configuration. + * + * @return array + */ + private function getStatusesToClear() + { + /** + * Do not mark messages for deletion if configuration has 0 lifetime configured. + */ + $statusesToDelete = []; + if ($this->getCompletedMessageLifetime() > 0) { + $statusesToDelete[] = self::MESSAGE_STATUS_COMPLETE; + } + + if ($this->getErrorMessageLifetime() > 0) { + $statusesToDelete[] = self::MESSAGE_STATUS_ERROR; + } + + if ($this->getNewMessageLifetime() > 0) { + $statusesToDelete[] = self::MESSAGE_STATUS_NEW; + } + + if ($this->getInProgressRetryAfter() > 0) { + $statusesToDelete[] = self::MESSAGE_STATUS_IN_PROGRESS; + } + return $statusesToDelete; + } + + /** + * Completed message lifetime + * + * Indicates how long message in COMPLETE state will stay in table with statuses + * + * @return int + */ + private function getCompletedMessageLifetime() + { + return 60 * (int)$this->scopeConfig->getValue( + self::XML_PATH_SUCCESSFUL_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Failure message life time + * + * Indicates how long message in ERROR state will stay in table with statuses + * + * @return int + */ + private function getErrorMessageLifetime() + { + return 60 * (int)$this->scopeConfig->getValue( + self::XML_PATH_FAILED_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * In progress message delay befor retry + * + * Indicates how long message will stay in IN PROGRESS status before attempted to retry + * + * @return int + */ + private function getInProgressRetryAfter() + { + return 60 * (int)$this->scopeConfig->getValue( + self::XML_PATH_RETRY_IN_PROGRESS_AFTER, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * New message life time + * + * Indicates how long message in NEW state will stay in table with statuses + * + * @return int + */ + private function getNewMessageLifetime() + { + return 60 * (int)$this->scopeConfig->getValue( + self::XML_PATH_NEW_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Read the specified number of messages from the specified queue. + * + * If queue does not contain enough messages, method is not waiting for more messages. + * + * @param string $queue + * @param int|null $maxMessagesNumber + * @return array
    +     * [
    +     *     [
    +     *          self::MESSAGE_ID => $messageId,
    +     *          self::MESSAGE_QUEUE_ID => $queuId,
    +     *          self::MESSAGE_TOPIC => $topic,
    +     *          self::MESSAGE_BODY => $body,
    +     *          self::MESSAGE_STATUS => $status,
    +     *          self::MESSAGE_UPDATED_AT => $updatedAt,
    +     *          self::MESSAGE_QUEUE_NAME => $queueName
    +     *          self::MESSAGE_QUEUE_RELATION_ID => $relationId
    +     *     ],
    +     *     ...
    +     * ]
    + */ + public function readMessages($queue, $maxMessagesNumber = null) + { + $selectedMessages = $this->messageResource->getMessages($queue, $maxMessagesNumber); + /* The logic below allows to prevent the same message being processed by several consumers in parallel */ + $selectedMessagesRelatedIds = []; + foreach ($selectedMessages as &$message) { + /* Set message status here to avoid extra reading from DB after it is updated */ + $message[self::MESSAGE_STATUS] = self::MESSAGE_STATUS_IN_PROGRESS; + $selectedMessagesRelatedIds[] = $message[self::MESSAGE_QUEUE_RELATION_ID]; + } + $takenMessagesRelationIds = $this->messageResource->takeMessagesInProgress($selectedMessagesRelatedIds); + if (count($selectedMessages) == count($takenMessagesRelationIds)) { + return $selectedMessages; + } else { + $selectedMessages = array_combine($selectedMessagesRelatedIds, array_values($selectedMessages)); + return array_intersect_key($selectedMessages, array_flip($takenMessagesRelationIds)); + } + } + + /** + * Push message back to queue for one more processing trial. Affects message in particular queue only. + * + * @param int $messageRelationId + * @return void + */ + public function pushToQueueForRetry($messageRelationId) + { + $this->messageResource->pushBackForRetry($messageRelationId); + } + + /** + * Change status of messages. + * + * @param int[] $messageRelationIds + * @param int $status + * @return void + */ + public function changeStatus($messageRelationIds, $status) + { + $this->messageResource->changeStatus($messageRelationIds, $status); + } +} diff --git a/app/code/Magento/MysqlMq/Model/ResourceModel/Message.php b/app/code/Magento/MysqlMq/Model/ResourceModel/Message.php new file mode 100644 index 0000000000000..516a6d361947d --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/ResourceModel/Message.php @@ -0,0 +1,22 @@ +_init('queue_message', 'id'); + } +} diff --git a/app/code/Magento/MysqlMq/Model/ResourceModel/MessageCollection.php b/app/code/Magento/MysqlMq/Model/ResourceModel/MessageCollection.php new file mode 100644 index 0000000000000..cf77102319a19 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/ResourceModel/MessageCollection.php @@ -0,0 +1,22 @@ +_init(\Magento\MysqlMq\Model\Message::class, \Magento\MysqlMq\Model\ResourceModel\Message::class); + } +} diff --git a/app/code/Magento/MysqlMq/Model/ResourceModel/MessageStatus.php b/app/code/Magento/MysqlMq/Model/ResourceModel/MessageStatus.php new file mode 100644 index 0000000000000..c736277c279ec --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/ResourceModel/MessageStatus.php @@ -0,0 +1,22 @@ +_init('queue_message_status', 'id'); + } +} diff --git a/app/code/Magento/MysqlMq/Model/ResourceModel/MessageStatusCollection.php b/app/code/Magento/MysqlMq/Model/ResourceModel/MessageStatusCollection.php new file mode 100644 index 0000000000000..bdbfc68ec48e1 --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/ResourceModel/MessageStatusCollection.php @@ -0,0 +1,28 @@ +_init( + \Magento\MysqlMq\Model\MessageStatus::class, + \Magento\MysqlMq\Model\ResourceModel\MessageStatus::class + ); + } +} diff --git a/app/code/Magento/MysqlMq/Model/ResourceModel/Queue.php b/app/code/Magento/MysqlMq/Model/ResourceModel/Queue.php new file mode 100644 index 0000000000000..c1cdd23be622c --- /dev/null +++ b/app/code/Magento/MysqlMq/Model/ResourceModel/Queue.php @@ -0,0 +1,272 @@ +_init('queue', 'id'); + } + + /** + * Save message to 'queue_message' table. + * + * @param string $messageTopic + * @param string $messageBody + * @return int ID of the inserted record + */ + public function saveMessage($messageTopic, $messageBody) + { + $this->getConnection()->insert( + $this->getMessageTable(), + ['topic_name' => $messageTopic, 'body' => $messageBody] + ); + return $this->getConnection()->lastInsertId($this->getMessageTable()); + } + + /** + * Save messages in bulk to 'queue_message' table. + * + * @param string $messageTopic + * @param array $messages + * @return array List of IDs of inserted records + */ + public function saveMessages($messageTopic, array $messages) + { + $data = []; + foreach ($messages as $message) { + $data[] = ['topic_name' => $messageTopic, 'body' => $message]; + } + $rowCount = $this->getConnection()->insertMultiple($this->getMessageTable(), $data); + $firstId = $this->getConnection()->lastInsertId($this->getMessageTable()); + $select = $this->getConnection()->select() + ->from(['qm' => $this->getMessageTable()], ['id']) + ->where('qm.id >= ?', $firstId) + ->limit($rowCount); + return $this->getConnection()->fetchCol($select); + } + + /** + * Add associations between the specified message and queues. + * + * @param int $messageId + * @param string[] $queueNames + * @return $this + */ + public function linkQueues($messageId, $queueNames) + { + return $this->linkMessagesWithQueues([$messageId], $queueNames); + } + + /** + * Add associations between the specified messages and queues. + * + * @param array $messageIds + * @param string[] $queueNames + * @return $this + */ + public function linkMessagesWithQueues(array $messageIds, array $queueNames) + { + $connection = $this->getConnection(); + $queueIds = $this->getQueueIdsByNames($queueNames); + $data = []; + foreach ($messageIds as $messageId) { + foreach ($queueIds as $queueId) { + $data[] = [ + $queueId, + $messageId, + QueueManagement::MESSAGE_STATUS_NEW + ]; + } + } + if (!empty($data)) { + $connection->insertArray( + $this->getMessageStatusTable(), + ['queue_id', 'message_id', 'status'], + $data + ); + } + return $this; + } + + /** + * Retrieve array of queue IDs corresponding to the specified array of queue names. + * + * @param string[] $queueNames + * @return int[] + */ + protected function getQueueIdsByNames($queueNames) + { + $selectObject = $this->getConnection()->select(); + $selectObject->from(['queue' => $this->getQueueTable()]) + ->columns(['id']) + ->where('queue.name IN (?)', $queueNames); + return $this->getConnection()->fetchCol($selectObject); + } + + /** + * Retrieve messages from the specified queue. + * + * @param string $queueName + * @param int|null $limit + * @return array + */ + public function getMessages($queueName, $limit = null) + { + $connection = $this->getConnection(); + $select = $connection->select() + ->from( + ['queue_message' => $this->getMessageTable()], + [QueueManagement::MESSAGE_TOPIC => 'topic_name', QueueManagement::MESSAGE_BODY => 'body'] + )->join( + ['queue_message_status' => $this->getMessageStatusTable()], + 'queue_message.id = queue_message_status.message_id', + [ + QueueManagement::MESSAGE_QUEUE_RELATION_ID => 'id', + QueueManagement::MESSAGE_QUEUE_ID => 'queue_id', + QueueManagement::MESSAGE_ID => 'message_id', + QueueManagement::MESSAGE_STATUS => 'status', + QueueManagement::MESSAGE_UPDATED_AT => 'updated_at', + QueueManagement::MESSAGE_NUMBER_OF_TRIALS => 'number_of_trials' + ] + )->join( + ['queue' => $this->getQueueTable()], + 'queue.id = queue_message_status.queue_id', + [QueueManagement::MESSAGE_QUEUE_NAME => 'name'] + )->where( + 'queue_message_status.status IN (?)', + [QueueManagement::MESSAGE_STATUS_NEW, QueueManagement::MESSAGE_STATUS_RETRY_REQUIRED] + )->where('queue.name = ?', $queueName) + ->order('queue_message_status.updated_at DESC'); + + if ($limit) { + $select->limit($limit); + } + + return $connection->fetchAll($select); + } + + /** + * Delete messages if there is no queue whrere the message is not in status TO BE DELETED + * + * @return void + */ + public function deleteMarkedMessages() + { + $connection = $this->getConnection(); + + $select = $connection->select() + ->from(['queue_message_status' => $this->getMessageStatusTable()], ['message_id']) + ->where('status <> ?', QueueManagement::MESSAGE_STATUS_TO_BE_DELETED) + ->distinct(); + $messageIds = $connection->fetchCol($select); + + $condition = count($messageIds) > 0 ? ['id NOT IN (?)' => $messageIds] : null; + $connection->delete($this->getMessageTable(), $condition); + } + + /** + * Mark specified messages with 'in progress' status. + * + * @param int[] $relationIds + * @return int[] IDs of messages which should be taken in progress by current process. + */ + public function takeMessagesInProgress($relationIds) + { + $takenMessagesRelationIds = []; + foreach ($relationIds as $relationId) { + $affectedRows = $this->getConnection()->update( + $this->getMessageStatusTable(), + ['status' => QueueManagement::MESSAGE_STATUS_IN_PROGRESS], + ['id = ?' => $relationId] + ); + if ($affectedRows) { + /** + * If status was set to 'in progress' by some other process (due to race conditions), + * current process should not process the same message. + * So message will be processed only if current process was able to change its status. + */ + $takenMessagesRelationIds[] = $relationId; + } + } + return $takenMessagesRelationIds; + } + + /** + * Set status of message to 'retry required' and increment number of processing trials. + * + * @param int $relationId + * @return void + */ + public function pushBackForRetry($relationId) + { + $this->getConnection()->update( + $this->getMessageStatusTable(), + [ + 'status' => QueueManagement::MESSAGE_STATUS_RETRY_REQUIRED, + 'number_of_trials' => new \Zend_Db_Expr('number_of_trials+1') + ], + ['id = ?' => $relationId] + ); + } + + /** + * Change message status. + * + * @param int[] $relationIds + * @param int $status + * @return void + */ + public function changeStatus($relationIds, $status) + { + $this->getConnection()->update( + $this->getMessageStatusTable(), + ['status' => $status], + ['id IN (?)' => $relationIds] + ); + } + + /** + * Get name of table storing message statuses and associations to queues. + * + * @return string + */ + protected function getMessageStatusTable() + { + return $this->getTable('queue_message_status'); + } + + /** + * Get name of table storing declared queues. + * + * @return string + */ + protected function getQueueTable() + { + return $this->getTable('queue'); + } + + /** + * Get name of table storing message body and topic. + * + * @return string + */ + protected function getMessageTable() + { + return $this->getTable('queue_message'); + } +} diff --git a/app/code/Magento/MysqlMq/README.md b/app/code/Magento/MysqlMq/README.md new file mode 100644 index 0000000000000..3ebc6a10d104d --- /dev/null +++ b/app/code/Magento/MysqlMq/README.md @@ -0,0 +1,3 @@ +# MysqlMq + +**MysqlMq** provides message queue implementation based on MySQL. diff --git a/app/code/Magento/MysqlMq/Setup/Recurring.php b/app/code/Magento/MysqlMq/Setup/Recurring.php new file mode 100644 index 0000000000000..db3a39bf5fbd0 --- /dev/null +++ b/app/code/Magento/MysqlMq/Setup/Recurring.php @@ -0,0 +1,53 @@ +messageQueueConfig = $messageQueueConfig; + } + + /** + * {@inheritdoc} + */ + public function install(SchemaSetupInterface $setup, ModuleContextInterface $context) + { + $setup->startSetup(); + + $binds = $this->messageQueueConfig->getBinds(); + $queues = []; + foreach ($binds as $bind) { + $queues[] = $bind[MessageQueueConfig::BIND_QUEUE]; + } + $connection = $setup->getConnection(); + $existingQueues = $connection->fetchCol($connection->select()->from($setup->getTable('queue'), 'name')); + $queues = array_unique(array_diff($queues, $existingQueues)); + /** Populate 'queue' table */ + if (!empty($queues)) { + $connection->insertArray($setup->getTable('queue'), ['name'], $queues); + } + + $setup->endSetup(); + } +} diff --git a/app/code/Magento/MysqlMq/Test/Unit/Model/ConnectionTypeResolverTest.php b/app/code/Magento/MysqlMq/Test/Unit/Model/ConnectionTypeResolverTest.php new file mode 100644 index 0000000000000..68dbea2f7a9ab --- /dev/null +++ b/app/code/Magento/MysqlMq/Test/Unit/Model/ConnectionTypeResolverTest.php @@ -0,0 +1,28 @@ +assertEquals('db', $model->getConnectionType('db')); + $this->assertEquals(null, $model->getConnectionType('non-db')); + } + + public function testGetConnectionTypeWithCustomValues() + { + $model = new ConnectionTypeResolver(['test-connection']); + $this->assertEquals('db', $model->getConnectionType('db')); + $this->assertEquals('db', $model->getConnectionType('test-connection')); + } +} diff --git a/app/code/Magento/MysqlMq/Test/Unit/Model/Driver/Bulk/ExchangeTest.php b/app/code/Magento/MysqlMq/Test/Unit/Model/Driver/Bulk/ExchangeTest.php new file mode 100644 index 0000000000000..452825058c9d8 --- /dev/null +++ b/app/code/Magento/MysqlMq/Test/Unit/Model/Driver/Bulk/ExchangeTest.php @@ -0,0 +1,70 @@ +messageQueueConfig = $this->getMockBuilder(\Magento\Framework\MessageQueue\ConfigInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->queueManagement = $this->getMockBuilder(\Magento\MysqlMq\Model\QueueManagement::class) + ->disableOriginalConstructor()->getMock(); + + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->exchange = $objectManager->getObject( + \Magento\MysqlMq\Model\Driver\Bulk\Exchange::class, + [ + 'messageQueueConfig' => $this->messageQueueConfig, + 'queueManagement' => $this->queueManagement, + ] + ); + } + + /** + * Test for enqueue model. + * + * @return void + */ + public function testEnqueue() + { + $topicName = 'topic.name'; + $queueNames = ['queue0', 'queue1']; + $envelopeBody = 'serializedMessage'; + $this->messageQueueConfig->expects($this->once()) + ->method('getQueuesByTopic')->with($topicName)->willReturn($queueNames); + $envelope = $this->getMockBuilder(\Magento\Framework\MessageQueue\EnvelopeInterface::class) + ->disableOriginalConstructor()->getMock(); + $envelope->expects($this->once())->method('getBody')->willReturn($envelopeBody); + $this->queueManagement->expects($this->once()) + ->method('addMessagesToQueues')->with($topicName, [$envelopeBody], $queueNames); + $this->assertNull($this->exchange->enqueue($topicName, [$envelope])); + } +} diff --git a/app/code/Magento/MysqlMq/Test/Unit/Model/QueueManagementTest.php b/app/code/Magento/MysqlMq/Test/Unit/Model/QueueManagementTest.php new file mode 100644 index 0000000000000..906feb2b46cfd --- /dev/null +++ b/app/code/Magento/MysqlMq/Test/Unit/Model/QueueManagementTest.php @@ -0,0 +1,231 @@ +messageResource = $this->getMockBuilder(\Magento\MysqlMq\Model\ResourceModel\Queue::class) + ->disableOriginalConstructor()->getMock(); + $this->scopeConfig = $this->getMockBuilder(\Magento\Framework\App\Config\ScopeConfigInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->dateTime = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime\DateTime::class) + ->disableOriginalConstructor()->getMock(); + $this->messageStatusCollectionFactory = $this + ->getMockBuilder(\Magento\MysqlMq\Model\ResourceModel\MessageStatusCollectionFactory::class) + ->setMethods(['create']) + ->disableOriginalConstructor() + ->getMock(); + + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->queueManagement = $objectManager->getObject( + \Magento\MysqlMq\Model\QueueManagement::class, + [ + 'messageResource' => $this->messageResource, + 'scopeConfig' => $this->scopeConfig, + 'dateTime' => $this->dateTime, + 'messageStatusCollectionFactory' => $this->messageStatusCollectionFactory, + ] + ); + } + + /** + * Test for addMessageToQueues method. + * + * @return void + */ + public function testAddMessageToQueues() + { + $topicName = 'topic.name'; + $queueNames = ['queue0', 'queue1']; + $message = 'test_message'; + $messageId = 1; + $this->messageResource->expects($this->once()) + ->method('saveMessage')->with($topicName, $message)->willReturn($messageId); + $this->messageResource->expects($this->once()) + ->method('linkQueues')->with($messageId, $queueNames)->willReturnSelf(); + $this->assertEquals( + $this->queueManagement, + $this->queueManagement->addMessageToQueues($topicName, $message, $queueNames) + ); + } + + /** + * Test for addMessagesToQueues method. + * + * @return void + */ + public function testAddMessagesToQueues() + { + $topicName = 'topic.name'; + $queueNames = ['queue0', 'queue1']; + $messages = ['test_message0', 'test_message1']; + $messageIds = [1, 2]; + $this->messageResource->expects($this->once()) + ->method('saveMessages')->with($topicName, $messages)->willReturn($messageIds); + $this->messageResource->expects($this->once()) + ->method('linkMessagesWithQueues')->with($messageIds, $queueNames)->willReturnSelf(); + $this->assertEquals( + $this->queueManagement, + $this->queueManagement->addMessagesToQueues($topicName, $messages, $queueNames) + ); + } + + /** + * Test for markMessagesForDelete method. + * + * @return void + */ + public function testMarkMessagesForDelete() + { + $messageId = 99; + $collection = $this->getMockBuilder(\Magento\MysqlMq\Model\ResourceModel\MessageStatusCollection::class) + ->disableOriginalConstructor()->getMock(); + $this->messageStatusCollectionFactory->expects($this->once())->method('create')->willReturn($collection); + $this->scopeConfig->expects($this->exactly(8))->method('getValue') + ->withConsecutive( + [ + \Magento\MysqlMq\Model\QueueManagement::XML_PATH_SUCCESSFUL_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + ], + [ + \Magento\MysqlMq\Model\QueueManagement::XML_PATH_FAILED_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + ], + [ + \Magento\MysqlMq\Model\QueueManagement::XML_PATH_NEW_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + ], + [ + \Magento\MysqlMq\Model\QueueManagement::XML_PATH_RETRY_IN_PROGRESS_AFTER, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + ], + [ + \Magento\MysqlMq\Model\QueueManagement::XML_PATH_SUCCESSFUL_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + ], + [ + \Magento\MysqlMq\Model\QueueManagement::XML_PATH_FAILED_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + ], + [ + \Magento\MysqlMq\Model\QueueManagement::XML_PATH_NEW_MESSAGES_LIFETIME, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + ], + [ + \Magento\MysqlMq\Model\QueueManagement::XML_PATH_RETRY_IN_PROGRESS_AFTER, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + ] + )->willReturn(1); + $collection->expects($this->once())->method('addFieldToFilter') + ->with( + 'status', + [ + 'in' => [ + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_COMPLETE, + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_ERROR, + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_NEW, + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_IN_PROGRESS, + ] + ] + )->willReturnSelf(); + $messageStatuses = + [ + $this->getMessageStatusMock(), + $this->getMessageStatusMock(), + $this->getMessageStatusMock(), + $this->getMessageStatusMock(), + ]; + $this->dateTime->expects($this->exactly(4))->method('gmtTimestamp')->willReturn(1486741063); + $messageStatuses[0]->expects($this->atLeastOnce())->method('getStatus')->willReturn( + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_COMPLETE + ); + $messageStatuses[1]->expects($this->atLeastOnce())->method('getStatus')->willReturn( + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_ERROR + ); + $messageStatuses[2]->expects($this->atLeastOnce())->method('getStatus')->willReturn( + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_NEW + ); + $messageStatuses[3]->expects($this->atLeastOnce())->method('getStatus')->willReturn( + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_IN_PROGRESS + ); + $messageStatuses[0]->expects($this->once())->method('setStatus') + ->with(\Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_TO_BE_DELETED)->willReturnSelf(); + $messageStatuses[1]->expects($this->once())->method('setStatus') + ->with(\Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_TO_BE_DELETED)->willReturnSelf(); + $messageStatuses[2]->expects($this->once())->method('setStatus') + ->with(\Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_TO_BE_DELETED)->willReturnSelf(); + $messageStatuses[0]->expects($this->once())->method('save')->willReturnSelf(); + $messageStatuses[1]->expects($this->once())->method('save')->willReturnSelf(); + $messageStatuses[2]->expects($this->once())->method('save')->willReturnSelf(); + $messageStatuses[3]->expects($this->once())->method('getId')->willReturn($messageId); + $collection->expects($this->once())->method('getIterator')->willReturn(new \ArrayIterator($messageStatuses)); + $this->messageResource->expects($this->once())->method('pushBackForRetry')->with($messageId); + $this->messageResource->expects($this->once())->method('deleteMarkedMessages'); + $this->queueManagement->markMessagesForDelete(); + } + + /** + * Create mock of MessageStatus method. + * + * @return \PHPUnit_Framework_MockObject_MockObject + */ + private function getMessageStatusMock() + { + $messageStatus = $this->getMockBuilder(\Magento\MysqlMq\Model\MessageStatus::class) + ->setMethods(['getStatus', 'setStatus', 'save', 'getId', 'getUpdatedAt']) + ->disableOriginalConstructor()->getMock(); + $messageStatus->expects($this->once())->method('getUpdatedAt')->willReturn('2010-01-01 00:00:00'); + return $messageStatus; + } + + /** + * Test for changeStatus method. + */ + public function testChangeStatus() + { + $messageIds = [1, 2]; + $status = \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_TO_BE_DELETED; + $this->messageResource->expects($this->once())->method('changeStatus')->with($messageIds, $status); + $this->queueManagement->changeStatus($messageIds, $status); + } +} diff --git a/app/code/Magento/MysqlMq/Test/Unit/Model/ResourceModel/QueueTest.php b/app/code/Magento/MysqlMq/Test/Unit/Model/ResourceModel/QueueTest.php new file mode 100644 index 0000000000000..7f364054ec921 --- /dev/null +++ b/app/code/Magento/MysqlMq/Test/Unit/Model/ResourceModel/QueueTest.php @@ -0,0 +1,319 @@ +resources = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class) + ->disableOriginalConstructor()->getMock(); + + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->queue = $objectManager->getObject( + \Magento\MysqlMq\Model\ResourceModel\Queue::class, + [ + '_resources' => $this->resources, + ] + ); + } + + /** + * Test for saveMessage method. + * + * @return void + */ + public function testSaveMessage() + { + $messageTopic = 'topic.name'; + $message = 'messageBody'; + $tableName = 'queue_message'; + $messageId = 2; + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->setMethods(['insert', 'lastInsertId']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->resources->expects($this->exactly(2))->method('getConnection')->with('default')->willReturn($connection); + $this->resources->expects($this->once()) + ->method('getTableName')->with($tableName, 'default')->willReturn($tableName); + $connection->expects($this->once())->method('insert') + ->with($tableName, ['topic_name' => $messageTopic, 'body' => $message])->willReturn(1); + $connection->expects($this->once())->method('lastInsertId')->with($tableName)->willReturn($messageId); + $this->assertEquals($messageId, $this->queue->saveMessage($messageTopic, $message)); + } + + /** + * Test for saveMessages method. + * + * @return void + */ + public function testSaveMessages() + { + $messageTopic = 'topic.name'; + $messages = ['messageBody0', 'messageBody1']; + $tableName = 'queue_message'; + $messageIds = [3, 4]; + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->setMethods(['insertMultiple', 'lastInsertId']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->resources->expects($this->atLeastOnce()) + ->method('getConnection')->with('default')->willReturn($connection); + $this->resources->expects($this->once()) + ->method('getTableName')->with($tableName, 'default')->willReturn($tableName); + $connection->expects($this->once())->method('insertMultiple') + ->with( + $tableName, + [ + ['topic_name' => $messageTopic, 'body' => $messages[0]], + ['topic_name' => $messageTopic, 'body' => $messages[1]], + ] + )->willReturn(2); + $connection->expects($this->once())->method('lastInsertId')->with($tableName)->willReturn($messageIds[0]); + $select = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor()->getMock(); + $connection->expects($this->once())->method('select')->willReturn($select); + $select->expects($this->once())->method('from')->with(['qm' => $tableName], ['id'])->willReturnSelf(); + $select->expects($this->once())->method('where')->with('qm.id >= ?', $messageIds[0])->willReturnSelf(); + $select->expects($this->once())->method('limit')->with(2)->willReturnSelf(); + $connection->expects($this->once())->method('fetchCol')->with($select)->willReturn($messageIds); + $this->assertEquals($messageIds, $this->queue->saveMessages($messageTopic, $messages)); + } + + /** + * Test for linkQueues method. + * + * @return void + */ + public function testLinkQueues() + { + $messageId = 3; + $queueNames = ['queueName0', 'queueName1']; + $queueIds = [5, 6]; + $tableNames = ['queue', 'queue_message_status']; + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resources->expects($this->atLeastOnce()) + ->method('getConnection')->with('default')->willReturn($connection); + $this->resources->expects($this->exactly(2))->method('getTableName') + ->withConsecutive([$tableNames[0], 'default'], [$tableNames[1], 'default']) + ->willReturnOnConsecutiveCalls($tableNames[0], $tableNames[1]); + $select = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor()->getMock(); + $connection->expects($this->once())->method('select')->willReturn($select); + $select->expects($this->once())->method('from')->with(['queue' => $tableNames[0]])->willReturnSelf(); + $select->expects($this->once())->method('columns')->with(['id'])->willReturnSelf(); + $select->expects($this->once())->method('where')->with('queue.name IN (?)', $queueNames)->willReturnSelf(); + $connection->expects($this->once())->method('fetchCol')->with($select)->willReturn($queueIds); + $connection->expects($this->once())->method('insertArray')->with( + $tableNames[1], + ['queue_id', 'message_id', 'status'], + [ + [ + $queueIds[0], + $messageId, + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_NEW + ], + [ + $queueIds[1], + $messageId, + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_NEW + ], + ] + )->willReturn(4); + $this->assertEquals($this->queue, $this->queue->linkQueues($messageId, $queueNames)); + } + + /** + * Test for getMessages method. + * + * @return void + */ + public function testGetMessages() + { + $limit = 100; + $queueName = 'queueName0'; + $tableNames = ['queue_message', 'queue_message_status', 'queue']; + $messages = [['message0_data'], ['message1_data']]; + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resources->expects($this->atLeastOnce()) + ->method('getConnection')->with('default')->willReturn($connection); + $this->resources->expects($this->exactly(3))->method('getTableName') + ->withConsecutive([$tableNames[0], 'default'], [$tableNames[1], 'default'], [$tableNames[2], 'default']) + ->willReturnOnConsecutiveCalls($tableNames[0], $tableNames[1], $tableNames[2]); + $select = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor()->getMock(); + $connection->expects($this->once())->method('select')->willReturn($select); + $select->expects($this->once())->method('from')->with( + ['queue_message' => $tableNames[0]], + [ + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_TOPIC => 'topic_name', + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_BODY => 'body' + ] + )->willReturnSelf(); + $select->expects($this->exactly(2))->method('join')->withConsecutive( + [ + ['queue_message_status' => $tableNames[1]], + 'queue_message.id = queue_message_status.message_id', + [ + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_QUEUE_RELATION_ID => 'id', + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_QUEUE_ID => 'queue_id', + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_ID => 'message_id', + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS => 'status', + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_UPDATED_AT => 'updated_at', + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_NUMBER_OF_TRIALS => 'number_of_trials' + ] + ], + [ + ['queue' => $tableNames[2]], + 'queue.id = queue_message_status.queue_id', + [\Magento\MysqlMq\Model\QueueManagement::MESSAGE_QUEUE_NAME => 'name'] + ] + )->willReturnSelf(); + $select->expects($this->exactly(2))->method('where')->withConsecutive( + [ + 'queue_message_status.status IN (?)', + [ + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_NEW, + \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_RETRY_REQUIRED + ] + ], + [ + 'queue.name = ?', $queueName + ] + )->willReturnSelf(); + $select->expects($this->once()) + ->method('order')->with('queue_message_status.updated_at DESC')->willReturnSelf(); + $select->expects($this->once())->method('limit')->with($limit)->willReturnSelf(); + $connection->expects($this->once())->method('fetchAll')->with($select)->willReturn($messages); + $this->assertEquals($messages, $this->queue->getMessages($queueName, $limit)); + } + + /** + * Test for deleteMarkedMessages method. + * + * @return void + */ + public function testDeleteMarkedMessages() + { + $messageIds = [1, 2]; + $tableNames = ['queue_message_status', 'queue_message']; + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resources->expects($this->atLeastOnce()) + ->method('getConnection')->with('default')->willReturn($connection); + $this->resources->expects($this->exactly(2))->method('getTableName') + ->withConsecutive([$tableNames[0], 'default'], [$tableNames[1], 'default']) + ->willReturnOnConsecutiveCalls($tableNames[0], $tableNames[1]); + $select = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor()->getMock(); + $connection->expects($this->once())->method('select')->willReturn($select); + $select->expects($this->once()) + ->method('from')->with(['queue_message_status' => $tableNames[0]], ['message_id'])->willReturnSelf(); + $select->expects($this->once())->method('where') + ->with('status <> ?', \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_TO_BE_DELETED) + ->willReturnSelf(); + $select->expects($this->once())->method('distinct')->willReturnSelf(); + $connection->expects($this->once())->method('fetchCol')->with($select)->willReturn($messageIds); + $connection->expects($this->once())->method('delete') + ->with($tableNames[1], ['id NOT IN (?)' => $messageIds])->willReturn(2); + $this->queue->deleteMarkedMessages(); + } + + /** + * Test for takeMessagesInProgress method. + * + * @return void + */ + public function testTakeMessagesInProgress() + { + $relationIds = [1, 2]; + $tableName = 'queue_message_status'; + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resources->expects($this->atLeastOnce()) + ->method('getConnection')->with('default')->willReturn($connection); + $this->resources->expects($this->once())->method('getTableName')->with($tableName)->willReturn($tableName); + $connection->expects($this->exactly(2))->method('update')->withConsecutive( + [ + $tableName, + ['status' => \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_IN_PROGRESS], + ['id = ?' => $relationIds[0]] + ], + [ + $tableName, + ['status' => \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_IN_PROGRESS], + ['id = ?' => $relationIds[1]] + ] + )->willReturnOnConsecutiveCalls(1, 0); + $this->assertEquals([$relationIds[0]], $this->queue->takeMessagesInProgress($relationIds)); + } + + /** + * Test for pushBackForRetry method. + * + * @return void + */ + public function testPushBackForRetry() + { + $relationId = 1; + $tableName = 'queue_message_status'; + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resources->expects($this->atLeastOnce()) + ->method('getConnection')->with('default')->willReturn($connection); + $this->resources->expects($this->once())->method('getTableName')->with($tableName)->willReturn($tableName); + $connection->expects($this->once())->method('update')->with( + $tableName, + [ + 'status' => \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_RETRY_REQUIRED, + 'number_of_trials' => new \Zend_Db_Expr('number_of_trials+1') + ], + ['id = ?' => $relationId] + )->willReturn(1); + $this->queue->pushBackForRetry($relationId); + } + + /** + * Test for changeStatus method. + * + * @return void + */ + public function testChangeStatus() + { + $relationIds = [1, 2]; + $status = \Magento\MysqlMq\Model\QueueManagement::MESSAGE_STATUS_RETRY_REQUIRED; + $tableName = 'queue_message_status'; + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->disableOriginalConstructor()->getMock(); + $this->resources->expects($this->atLeastOnce()) + ->method('getConnection')->with('default')->willReturn($connection); + $this->resources->expects($this->once())->method('getTableName')->with($tableName)->willReturn($tableName); + $connection->expects($this->once()) + ->method('update')->with($tableName, ['status' => $status], ['id IN (?)' => $relationIds])->willReturn(1); + $this->queue->changeStatus($relationIds, $status); + } +} diff --git a/app/code/Magento/MysqlMq/Test/Unit/Setup/RecurringTest.php b/app/code/Magento/MysqlMq/Test/Unit/Setup/RecurringTest.php new file mode 100644 index 0000000000000..e2e7ad3c4c92d --- /dev/null +++ b/app/code/Magento/MysqlMq/Test/Unit/Setup/RecurringTest.php @@ -0,0 +1,100 @@ +objectManager = new ObjectManager($this); + $this->messageQueueConfig = $this->getMockBuilder(\Magento\Framework\MessageQueue\ConfigInterface::class) + ->getMockForAbstractClass(); + $this->model = $this->objectManager->getObject( + \Magento\MysqlMq\Setup\Recurring::class, + [ + 'messageQueueConfig' => $this->messageQueueConfig, + ] + ); + } + + /** + * Test for install method + */ + public function testInstall() + { + $binds = [ + 'first_bind' => [ + 'queue' => 'queue_name_1', + 'exchange' => 'magento-db', + 'topic' => 'queue.topic.1' + ], + 'second_bind' => [ + 'queue' => 'queue_name_2', + 'exchange' => 'magento-db', + 'topic' => 'queue.topic.2' + ], + 'third_bind' => [ + 'queue' => 'queue_name_3', + 'exchange' => 'magento-db', + 'topic' => 'queue.topic.3' + ] + ]; + $dbQueues = [ + 'queue_name_1', + 'queue_name_2', + ]; + $queuesToInsert = [ + 2 => 'queue_name_3' + ]; + $queueTableName = 'queue_table'; + + $setup = $this->getMockBuilder(\Magento\Framework\Setup\SchemaSetupInterface::class) + ->getMockForAbstractClass(); + $context = $this->getMockBuilder(\Magento\Framework\Setup\ModuleContextInterface::class) + ->getMockForAbstractClass(); + + $setup->expects($this->once())->method('startSetup')->willReturnSelf(); + $this->messageQueueConfig->expects($this->once())->method('getBinds')->willReturn($binds); + $connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) + ->getMockForAbstractClass(); + $setup->expects($this->once())->method('getConnection')->willReturn($connection); + $setup->expects($this->any())->method('getTable')->with('queue')->willReturn($queueTableName); + $select = $this->getMockBuilder(\Magento\Framework\DB\Select::class) + ->disableOriginalConstructor() + ->getMock(); + $connection->expects($this->once())->method('select')->willReturn($select); + $select->expects($this->once())->method('from')->with($queueTableName, 'name')->willReturnSelf(); + $connection->expects($this->once())->method('fetchCol')->with($select)->willReturn($dbQueues); + $connection->expects($this->once())->method('insertArray')->with($queueTableName, ['name'], $queuesToInsert); + $setup->expects($this->once())->method('endSetup')->willReturnSelf(); + + $this->model->install($setup, $context); + } +} diff --git a/app/code/Magento/MysqlMq/composer.json b/app/code/Magento/MysqlMq/composer.json new file mode 100644 index 0000000000000..3d7ee4f8b037b --- /dev/null +++ b/app/code/Magento/MysqlMq/composer.json @@ -0,0 +1,25 @@ +{ + "name": "magento/module-mysql-mq", + "description": "N/A", + "config": { + "sort-packages": true + }, + "require": { + "magento/framework": "*", + "magento/magento-composer-installer": "*", + "magento/module-store": "*", + "php": "~7.1.3||~7.2.0" + }, + "type": "magento2-module", + "license": [ + "proprietary" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\MysqlMq\\": "" + } + } +} diff --git a/app/code/Magento/MysqlMq/etc/adminhtml/system.xml b/app/code/Magento/MysqlMq/etc/adminhtml/system.xml new file mode 100644 index 0000000000000..2684f2e0c98bf --- /dev/null +++ b/app/code/Magento/MysqlMq/etc/adminhtml/system.xml @@ -0,0 +1,29 @@ + + + + +
    + + + All the times are in minutes. Use "0" if you want to skip automatic clearance. + + + + + + + + + + + + + +
    +
    +
    diff --git a/app/code/Magento/MysqlMq/etc/config.xml b/app/code/Magento/MysqlMq/etc/config.xml new file mode 100644 index 0000000000000..f1a59c5375a98 --- /dev/null +++ b/app/code/Magento/MysqlMq/etc/config.xml @@ -0,0 +1,19 @@ + + + + + + + 1440 + 10080 + 10080 + 10080 + + + + diff --git a/app/code/Magento/MysqlMq/etc/crontab.xml b/app/code/Magento/MysqlMq/etc/crontab.xml new file mode 100644 index 0000000000000..9f4546206eaff --- /dev/null +++ b/app/code/Magento/MysqlMq/etc/crontab.xml @@ -0,0 +1,14 @@ + + + + + + 30 6,15 * * * + + + diff --git a/app/code/Magento/MysqlMq/etc/db_schema.xml b/app/code/Magento/MysqlMq/etc/db_schema.xml new file mode 100644 index 0000000000000..9ef1fc15f1fb5 --- /dev/null +++ b/app/code/Magento/MysqlMq/etc/db_schema.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + +
    + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + +
    +
    diff --git a/app/code/Magento/MysqlMq/etc/db_schema_whitelist.json b/app/code/Magento/MysqlMq/etc/db_schema_whitelist.json new file mode 100644 index 0000000000000..9d224cd8cb724 --- /dev/null +++ b/app/code/Magento/MysqlMq/etc/db_schema_whitelist.json @@ -0,0 +1,41 @@ +{ + "queue": { + "column": { + "id": true, + "name": true + }, + "constraint": { + "PRIMARY": true, + "QUEUE_NAME": true + } + }, + "queue_message": { + "column": { + "id": true, + "topic_name": true, + "body": true + }, + "constraint": { + "PRIMARY": true + } + }, + "queue_message_status": { + "column": { + "id": true, + "queue_id": true, + "message_id": true, + "updated_at": true, + "status": true, + "number_of_trials": true + }, + "index": { + "QUEUE_MESSAGE_STATUS_STATUS_UPDATED_AT": true + }, + "constraint": { + "PRIMARY": true, + "QUEUE_MESSAGE_ID_QUEUE_MESSAGE_STATUS_MESSAGE_ID": true, + "QUEUE_ID_QUEUE_MESSAGE_STATUS_QUEUE_ID": true, + "QUEUE_MESSAGE_STATUS_QUEUE_ID_MESSAGE_ID": true + } + } +} \ No newline at end of file diff --git a/app/code/Magento/MysqlMq/etc/di.xml b/app/code/Magento/MysqlMq/etc/di.xml new file mode 100644 index 0000000000000..958ffb5281618 --- /dev/null +++ b/app/code/Magento/MysqlMq/etc/di.xml @@ -0,0 +1,60 @@ + + + + + + + + Magento\Framework\MessageQueue\Publisher + + + + + + + + + Magento\Framework\MessageQueue\Bulk\Publisher + + + + + + + + Magento\MysqlMq\Model\ConnectionTypeResolver + + + + + + + \Magento\MysqlMq\Model\Driver\ExchangeFactory + + + + + + + \Magento\MysqlMq\Model\Driver\Bulk\ExchangeFactory + + + + + + + Magento\MysqlMq\Model\Driver\QueueFactory + + + + + + \Magento\MysqlMq\Model\Driver\Bulk\Exchange + + + diff --git a/app/code/Magento/MysqlMq/etc/module.xml b/app/code/Magento/MysqlMq/etc/module.xml new file mode 100644 index 0000000000000..deac621ad1ccf --- /dev/null +++ b/app/code/Magento/MysqlMq/etc/module.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/code/Magento/MysqlMq/i18n/en_US.csv b/app/code/Magento/MysqlMq/i18n/en_US.csv new file mode 100644 index 0000000000000..8a124eb6c7c8e --- /dev/null +++ b/app/code/Magento/MysqlMq/i18n/en_US.csv @@ -0,0 +1,7 @@ +"Message has been rejected: %1","Message has been rejected: %1" +"MySQL Message Queue Cleanup","MySQL Message Queue Cleanup" +"All the times are in minutes. Use ""0"" if you want to skip automatic clearance.","All the times are in minutes. Use ""0"" if you want to skip automatic clearance." +"Retry Messages In Progress After","Retry Messages In Progress After" +"Successful Messages Lifetime","Successful Messages Lifetime" +"Failed Messages Lifetime","Failed Messages Lifetime" +"New Messages Lifetime","New Messages Lifetime" diff --git a/app/code/Magento/MysqlMq/registration.php b/app/code/Magento/MysqlMq/registration.php new file mode 100644 index 0000000000000..e13a38b468005 --- /dev/null +++ b/app/code/Magento/MysqlMq/registration.php @@ -0,0 +1,9 @@ +setName("newrelic:create:deploy-marker"); $this->setDescription("Check the deploy queue for entries and create an appropriate deploy marker.") - ->addArgument( - 'message', - InputArgument::REQUIRED, - 'Deploy Message?' - ) - ->addArgument( - 'change_log', - InputArgument::REQUIRED, - 'Change Log?' - ) - ->addArgument( - 'user', - InputArgument::OPTIONAL, - 'Deployment User' - ); + ->addArgument( + 'message', + InputArgument::REQUIRED, + 'Deploy Message?' + ) + ->addArgument( + 'change_log', + InputArgument::REQUIRED, + 'Change Log?' + ) + ->addArgument( + 'user', + InputArgument::OPTIONAL, + 'Deployment User' + ); parent::configure(); } diff --git a/app/code/Magento/NewRelicReporting/Model/Observer/ReportApplicationHandledExceptionToNewRelic.php b/app/code/Magento/NewRelicReporting/Model/Observer/ReportApplicationHandledExceptionToNewRelic.php index 724a488570207..ce7e95950c937 100644 --- a/app/code/Magento/NewRelicReporting/Model/Observer/ReportApplicationHandledExceptionToNewRelic.php +++ b/app/code/Magento/NewRelicReporting/Model/Observer/ReportApplicationHandledExceptionToNewRelic.php @@ -37,6 +37,9 @@ public function __construct( $this->newRelicWrapper = $newRelicWrapper; } + /** + * @param Observer $observer + */ public function execute(Observer $observer) { if ($this->config->isNewRelicEnabled()) { diff --git a/app/code/Magento/NewRelicReporting/composer.json b/app/code/Magento/NewRelicReporting/composer.json index 43d2b69816a26..25e7193ce0e2f 100644 --- a/app/code/Magento/NewRelicReporting/composer.json +++ b/app/code/Magento/NewRelicReporting/composer.json @@ -5,18 +5,17 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", "magento/magento-composer-installer": "*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-config": "100.3.*", - "magento/module-configurable-product": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-store": "100.3.*" + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-config": "*", + "magento/module-configurable-product": "*", + "magento/module-customer": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/NewRelicReporting/etc/db_schema.xml b/app/code/Magento/NewRelicReporting/etc/db_schema.xml index af4c014beb953..6f9ce29436f65 100644 --- a/app/code/Magento/NewRelicReporting/etc/db_schema.xml +++ b/app/code/Magento/NewRelicReporting/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> getTemplateSenderName()) . ' '; } if ($row->getTemplateSenderEmail()) { - $str .= '[' . $row->getTemplateSenderEmail() . ']'; + $str .= '[' . htmlspecialchars($row->getTemplateSenderEmail()) . ']'; } if ($str == '') { $str .= '---'; } + return $str; } } diff --git a/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php b/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php index b82d6fe06918f..22b31575debbc 100644 --- a/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php +++ b/app/code/Magento/Newsletter/Model/Plugin/CustomerPlugin.php @@ -8,6 +8,9 @@ use Magento\Customer\Api\CustomerRepositoryInterface as CustomerRepository; use Magento\Customer\Api\Data\CustomerInterface; use Magento\Newsletter\Model\SubscriberFactory; +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Newsletter\Model\ResourceModel\Subscriber; +use Magento\Customer\Api\Data\CustomerExtensionInterface; class CustomerPlugin { @@ -18,14 +21,36 @@ class CustomerPlugin */ private $subscriberFactory; + /** + * @var ExtensionAttributesFactory + */ + private $extensionFactory; + + /** + * @var Subscriber + */ + private $subscriberResource; + + /** + * @var array + */ + private $customerSubscriptionStatus = []; + /** * Initialize dependencies. * * @param SubscriberFactory $subscriberFactory + * @param ExtensionAttributesFactory $extensionFactory + * @param Subscriber $subscriberResource */ - public function __construct(SubscriberFactory $subscriberFactory) - { + public function __construct( + SubscriberFactory $subscriberFactory, + ExtensionAttributesFactory $extensionFactory, + Subscriber $subscriberResource + ) { $this->subscriberFactory = $subscriberFactory; + $this->extensionFactory = $extensionFactory; + $this->subscriberResource = $subscriberResource; } /** @@ -41,14 +66,34 @@ public function __construct(SubscriberFactory $subscriberFactory) */ public function afterSave(CustomerRepository $subject, CustomerInterface $result, CustomerInterface $customer) { - $this->subscriberFactory->create()->updateSubscription($result->getId()); - if ($result->getId() && $customer->getExtensionAttributes()) { - if ($customer->getExtensionAttributes()->getIsSubscribed() === true) { - $this->subscriberFactory->create()->subscribeCustomerById($result->getId()); - } elseif ($customer->getExtensionAttributes()->getIsSubscribed() === false) { - $this->subscriberFactory->create()->unsubscribeCustomerById($result->getId()); + $resultId = $result->getId(); + /** @var \Magento\Newsletter\Model\Subscriber $subscriber */ + $subscriber = $this->subscriberFactory->create(); + + $subscriber->updateSubscription($resultId); + // update the result only if the original customer instance had different value. + $initialExtensionAttributes = $result->getExtensionAttributes(); + if ($initialExtensionAttributes === null) { + /** @var CustomerExtensionInterface $initialExtensionAttributes */ + $initialExtensionAttributes = $this->extensionFactory->create(CustomerInterface::class); + $result->setExtensionAttributes($initialExtensionAttributes); + } + + $newExtensionAttributes = $customer->getExtensionAttributes(); + if ($newExtensionAttributes + && $initialExtensionAttributes->getIsSubscribed() !== $newExtensionAttributes->getIsSubscribed() + ) { + if ($newExtensionAttributes->getIsSubscribed()) { + $subscriber->subscribeCustomerById($resultId); + } else { + $subscriber->unsubscribeCustomerById($resultId); } } + + $isSubscribed = $subscriber->isSubscribed(); + $this->customerSubscriptionStatus[$resultId] = $isSubscribed; + $initialExtensionAttributes->setIsSubscribed($isSubscribed); + return $result; } @@ -94,4 +139,47 @@ public function afterDelete(CustomerRepository $subject, $result, CustomerInterf } return $result; } + + /** + * Plugin after getById customer that obtains newsletter subscription status for given customer. + * + * @param CustomerRepository $subject + * @param CustomerInterface $customer + * @return CustomerInterface + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetById(CustomerRepository $subject, CustomerInterface $customer) + { + $extensionAttributes = $customer->getExtensionAttributes(); + + if ($extensionAttributes === null) { + /** @var CustomerExtensionInterface $extensionAttributes */ + $extensionAttributes = $this->extensionFactory->create(CustomerInterface::class); + $customer->setExtensionAttributes($extensionAttributes); + } + if ($extensionAttributes->getIsSubscribed() === null) { + $isSubscribed = $this->isSubscribed($customer); + $extensionAttributes->setIsSubscribed($isSubscribed); + } + + return $customer; + } + + /** + * This method returns newsletters subscription status for given customer. + * + * @param CustomerInterface $customer + * @return bool + */ + private function isSubscribed(CustomerInterface $customer) + { + $customerId = $customer->getId(); + if (!isset($this->customerSubscriptionStatus[$customerId])) { + $subscriber = $this->subscriberResource->loadByCustomerData($customer); + $this->customerSubscriptionStatus[$customerId] = isset($subscriber['subscriber_status']) + && $subscriber['subscriber_status'] == 1; + } + + return $this->customerSubscriptionStatus[$customerId]; + } } diff --git a/app/code/Magento/Newsletter/Test/Unit/Block/Adminhtml/Template/Grid/Renderer/SenderTest.php b/app/code/Magento/Newsletter/Test/Unit/Block/Adminhtml/Template/Grid/Renderer/SenderTest.php new file mode 100644 index 0000000000000..8d760100d1469 --- /dev/null +++ b/app/code/Magento/Newsletter/Test/Unit/Block/Adminhtml/Template/Grid/Renderer/SenderTest.php @@ -0,0 +1,94 @@ +objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->sender = $this->objectManagerHelper->getObject( + \Magento\Newsletter\Block\Adminhtml\Template\Grid\Renderer\Sender::class + ); + } + + /** + * @dataProvider rendererDataProvider + * @param array $expectedSender + * @param array $passedSender + * + * @return void + */ + public function testRender(array $passedSender, array $expectedSender) + { + $row = $this->getMockBuilder(\Magento\Framework\DataObject::class) + ->setMethods(['getTemplateSenderName', 'getTemplateSenderEmail']) + ->getMock(); + $row->expects($this->atLeastOnce())->method('getTemplateSenderName') + ->willReturn($passedSender['sender']); + $row->expects($this->atLeastOnce())->method('getTemplateSenderEmail') + ->willReturn($passedSender['sender_email']); + $this->assertEquals( + $expectedSender['sender'] . ' [' . $expectedSender['sender_email'] . ']', + $this->sender->render($row) + ); + } + + /** + * @return array + */ + public function rendererDataProvider() + { + return [ + [ + [ + 'sender' => 'Sender', + 'sender_email' => 'sender@example.com', + ], + [ + 'sender' => 'Sender', + 'sender_email' => 'sender@example.com', + ], + ], + [ + [ + 'sender' => "
    'Sender'
    ", + 'sender_email' => "
    'email@example.com'
    ", + ], + [ + 'sender' => "<br>'Sender'</br>", + 'sender_email' => "<br>'email@example.com'</br>", + ], + ], + [ + [ + 'sender' => '""@example.com', + 'sender_email' => '""@example.com', + ], + [ + 'sender' => '"<script>alert(document.domain)</script>"@example.com', + 'sender_email' => '"<script>alert(document.domain)</script>"@example.com', + ], + ], + ]; + } +} diff --git a/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php b/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php index 47d4584857bde..e809b7e37a432 100644 --- a/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php +++ b/app/code/Magento/Newsletter/Test/Unit/Model/Plugin/CustomerPluginTest.php @@ -7,13 +7,16 @@ use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\ResourceModel\CustomerRepository; +use Magento\Customer\Api\Data\CustomerExtensionInterface; +use Magento\Framework\Api\ExtensionAttributesFactory; +use Magento\Newsletter\Model\ResourceModel\Subscriber; class CustomerPluginTest extends \PHPUnit\Framework\TestCase { /** * @var \Magento\Newsletter\Model\Plugin\CustomerPlugin */ - protected $plugin; + private $plugin; /** * @var \Magento\Newsletter\Model\SubscriberFactory|\PHPUnit_Framework_MockObject_MockObject @@ -28,7 +31,27 @@ class CustomerPluginTest extends \PHPUnit\Framework\TestCase /** * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager */ - protected $objectManager; + private $objectManager; + + /** + * @var ExtensionAttributesFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $extensionFactoryMock; + + /** + * @var CustomerExtensionInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerExtensionMock; + + /** + * @var Subscriber|\PHPUnit_Framework_MockObject_MockObject + */ + private $subscriberResourceMock; + + /** + * @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerMock; protected function setUp() { @@ -44,92 +67,102 @@ protected function setUp() 'delete', 'updateSubscription', 'subscribeCustomerById', - 'unsubscribeCustomerById' + 'unsubscribeCustomerById', + 'isSubscribed', ] )->disableOriginalConstructor() ->getMock(); + $this->extensionFactoryMock = $this->getMockBuilder(ExtensionAttributesFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->customerExtensionMock = $this->getMockBuilder(CustomerExtensionInterface::class) + ->setMethods(['getIsSubscribed', 'setIsSubscribed']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->subscriberResourceMock = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->getMock(); + $this->customerMock = $this->getMockBuilder(CustomerInterface::class) + ->setMethods(['getExtensionAttributes']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); $this->subscriberFactory->expects($this->any())->method('create')->willReturn($this->subscriber); - $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->plugin = $this->objectManager->getObject( \Magento\Newsletter\Model\Plugin\CustomerPlugin::class, [ - 'subscriberFactory' => $this->subscriberFactory + 'subscriberFactory' => $this->subscriberFactory, + 'extensionFactory' => $this->extensionFactoryMock, + 'subscriberResource' => $this->subscriberResourceMock, ] ); } - public function testAfterSaveWithoutIsSubscribed() + /** + * @param bool $subscriptionOriginalValue + * @param bool $subscriptionNewValue + * @dataProvider afterSaveDataProvider + * @return void + */ + public function testAfterSave($subscriptionOriginalValue, $subscriptionNewValue) { $customerId = 1; - /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $result */ + $result = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); /** @var CustomerRepository | \PHPUnit_Framework_MockObject_MockObject $subject */ $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); - $customer->expects($this->atLeastOnce()) - ->method("getId") - ->willReturn($customerId); + /** @var CustomerExtensionInterface|\PHPUnit_Framework_MockObject_MockObject $resultExtensionAttributes */ + $resultExtensionAttributes = $this->getMockBuilder(CustomerExtensionInterface::class) + ->setMethods(['getIsSubscribed', 'setIsSubscribed']) + ->getMockForAbstractClass(); + $result->expects($this->atLeastOnce())->method('getId')->willReturn($customerId); + $result->expects($this->any())->method('getExtensionAttributes')->willReturn(null); + $this->extensionFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($resultExtensionAttributes); + $result->expects($this->once()) + ->method('setExtensionAttributes') + ->with($resultExtensionAttributes) + ->willReturnSelf(); + $this->customerMock->expects($this->once()) + ->method('getExtensionAttributes') + ->willReturn($this->customerExtensionMock); + $resultExtensionAttributes->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn($subscriptionOriginalValue); + $this->customerExtensionMock->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn($subscriptionNewValue); + + if ($subscriptionOriginalValue !== $subscriptionNewValue) { + if ($subscriptionNewValue) { + $this->subscriber->expects($this->once())->method('subscribeCustomerById')->with($customerId); + } else { + $this->subscriber->expects($this->once())->method('unsubscribeCustomerById')->with($customerId); + } + $this->subscriber->expects($this->once())->method('isSubscribed')->willReturn($subscriptionNewValue); + $resultExtensionAttributes->expects($this->once())->method('setIsSubscribed')->with($subscriptionNewValue); + } - $this->assertEquals($customer, $this->plugin->afterSave($subject, $customer, $customer)); + $this->assertEquals($result, $this->plugin->afterSave($subject, $result, $this->customerMock)); } /** * @return array */ - public function afterSaveExtensionAttributeDataProvider() + public function afterSaveDataProvider() { return [ [true, true], - [false, false] + [false, false], + [true, false], + [false, true], ]; } - /** - * @param boolean $isSubscribed - * @param boolean $subscribeIsCreated - * @dataProvider afterSaveExtensionAttributeDataProvider - */ - public function testAfterSaveWithIsSubscribed($isSubscribed, $subscribeIsCreated) - { - $customerId = 1; - /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ - $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); - $extensionAttributes = $this - ->getMockBuilder(\Magento\Customer\Api\Data\CustomerExtensionInterface::class) - ->setMethods(["getIsSubscribed", "setIsSubscribed"]) - ->getMockForAbstractClass(); - - $extensionAttributes - ->expects($this->atLeastOnce()) - ->method("getIsSubscribed") - ->willReturn($isSubscribed); - - $customer->expects($this->atLeastOnce()) - ->method("getExtensionAttributes") - ->willReturn($extensionAttributes); - - if ($subscribeIsCreated) { - $this->subscriber->expects($this->once()) - ->method("subscribeCustomerById") - ->with($customerId); - } else { - $this->subscriber->expects($this->once()) - ->method("unsubscribeCustomerById") - ->with($customerId); - } - - /** @var CustomerRepository | \PHPUnit_Framework_MockObject_MockObject $subject */ - $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); - - $customer->expects($this->atLeastOnce()) - ->method("getId") - ->willReturn($customerId); - - $this->assertEquals($customer, $this->plugin->afterSave($subject, $customer, $customer)); - } - public function testAfterDelete() { $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); @@ -158,4 +191,80 @@ public function testAroundDeleteById() $this->assertEquals(true, $this->plugin->aroundDeleteById($subject, $deleteCustomerById, $customerId)); } + + /** + * @param int|null $subscriberStatusKey + * @param int|null $subscriberStatusValue + * @param bool $isSubscribed + * @dataProvider afterGetByIdDataProvider + * @return void + */ + public function testAfterGetByIdCreatesExtensionAttributesIfItIsNotSet( + $subscriberStatusKey, + $subscriberStatusValue, + $isSubscribed + ) { + $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); + $subscriber = [$subscriberStatusKey => $subscriberStatusValue]; + + $this->extensionFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($this->customerExtensionMock); + $this->customerMock->expects($this->once()) + ->method('setExtensionAttributes') + ->with($this->customerExtensionMock) + ->willReturnSelf(); + $this->customerMock->expects($this->any()) + ->method('getId') + ->willReturn(1); + $this->subscriberResourceMock->expects($this->once()) + ->method('loadByCustomerData') + ->with($this->customerMock) + ->willReturn($subscriber); + $this->customerExtensionMock->expects($this->once())->method('setIsSubscribed')->with($isSubscribed); + + $this->assertEquals( + $this->customerMock, + $this->plugin->afterGetById($subject, $this->customerMock) + ); + } + + public function testAfterGetByIdSetsIsSubscribedFlagIfItIsNotSet() + { + $subject = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); + $subscriber = ['subscriber_id' => 1, 'subscriber_status' => 1]; + + $this->customerMock->expects($this->any()) + ->method('getExtensionAttributes') + ->willReturn($this->customerExtensionMock); + $this->customerExtensionMock->expects($this->any()) + ->method('getIsSubscribed') + ->willReturn(null); + $this->subscriberResourceMock->expects($this->once()) + ->method('loadByCustomerData') + ->with($this->customerMock) + ->willReturn($subscriber); + $this->customerExtensionMock->expects($this->once()) + ->method('setIsSubscribed') + ->willReturnSelf(); + + $this->assertEquals( + $this->customerMock, + $this->plugin->afterGetById($subject, $this->customerMock) + ); + } + + /** + * @return array + */ + public function afterGetByIdDataProvider() + { + return [ + ['subscriber_status', 1, true], + ['subscriber_status', 2, false], + ['subscriber_status', 3, false], + ['subscriber_status', 4, false], + [null, null, false], + ]; + } } diff --git a/app/code/Magento/Newsletter/composer.json b/app/code/Magento/Newsletter/composer.json index eaa4f37a1c540..dc1334af295c8 100644 --- a/app/code/Magento/Newsletter/composer.json +++ b/app/code/Magento/Newsletter/composer.json @@ -5,19 +5,18 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-cms": "101.2.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-email": "100.3.*", - "magento/module-require-js": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-widget": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-cms": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-email": "*", + "magento/module-require-js": "*", + "magento/module-store": "*", + "magento/module-widget": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Newsletter/etc/db_schema.xml b/app/code/Magento/Newsletter/etc/db_schema.xml index 35ee306a27df5..5084b8b6d01e7 100644 --- a/app/code/Magento/Newsletter/etc/db_schema.xml +++ b/app/code/Magento/Newsletter/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/OfflinePayments/composer.json b/app/code/Magento/OfflinePayments/composer.json index d9791f9f63428..aa2e45b01e9f2 100644 --- a/app/code/Magento/OfflinePayments/composer.json +++ b/app/code/Magento/OfflinePayments/composer.json @@ -5,16 +5,15 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-payment": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-checkout": "*", + "magento/module-payment": "*" }, "suggest": { - "magento/module-config": "100.3.*" + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php b/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php index a258223e06777..1bd55cf5f1720 100644 --- a/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php +++ b/app/code/Magento/OfflineShipping/Block/Adminhtml/Form/Field/Export.php @@ -21,7 +21,7 @@ class Export extends \Magento\Framework\Data\Form\Element\AbstractElement * @param \Magento\Framework\Data\Form\Element\Factory $factoryElement * @param \Magento\Framework\Data\Form\Element\CollectionFactory $factoryCollection * @param \Magento\Framework\Escaper $escaper - * @param \Magento\Backend\Helper\Data $helper + * @param \Magento\Backend\Model\UrlInterface $backendUrl * @param array $data */ public function __construct( diff --git a/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php b/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php index 5a3ad76f0410f..5b03ef0cb02bd 100644 --- a/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php +++ b/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate/RateQuery.php @@ -99,7 +99,7 @@ public function getBindings() } } else { $bind[':condition_name'] = $this->request->getConditionName(); - $bind[':condition_value'] = $this->request->getData($this->request->getConditionName()); + $bind[':condition_value'] = round($this->request->getData($this->request->getConditionName()), 4); } return $bind; diff --git a/app/code/Magento/OfflineShipping/Setup/Patch/Data/UpdateQuoteShippingAddresses.php b/app/code/Magento/OfflineShipping/Setup/Patch/Data/UpdateQuoteShippingAddresses.php index e88da3b8a77e7..b89942230c50c 100644 --- a/app/code/Magento/OfflineShipping/Setup/Patch/Data/UpdateQuoteShippingAddresses.php +++ b/app/code/Magento/OfflineShipping/Setup/Patch/Data/UpdateQuoteShippingAddresses.php @@ -10,8 +10,8 @@ use Magento\Framework\Setup\ModuleDataSetupInterface; use Magento\Framework\Setup\UpgradeDataInterface; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; class UpdateQuoteShippingAddresses implements DataPatchInterface, PatchVersionInterface { diff --git a/app/code/Magento/OfflineShipping/composer.json b/app/code/Magento/OfflineShipping/composer.json index d07f9303137c2..a3e1dad5de854 100644 --- a/app/code/Magento/OfflineShipping/composer.json +++ b/app/code/Magento/OfflineShipping/composer.json @@ -5,24 +5,23 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-config": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-sales-rule": "100.3.*", - "magento/module-shipping": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-config": "*", + "magento/module-directory": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-sales-rule": "*", + "magento/module-shipping": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-checkout": "100.3.*", - "magento/module-offline-shipping-sample-data": "Sample Data version:100.3.*" + "magento/module-checkout": "*", + "magento/module-offline-shipping-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/OfflineShipping/etc/db_schema.xml b/app/code/Magento/OfflineShipping/etc/db_schema.xml index 4e277cb80bd21..80f4b56a1723d 100644 --- a/app/code/Magento/OfflineShipping/etc/db_schema.xml +++ b/app/code/Magento/OfflineShipping/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    @@ -45,15 +45,15 @@
    - +
    - +
    - +
    diff --git a/app/code/Magento/PageCache/composer.json b/app/code/Magento/PageCache/composer.json index c4232235f5dab..56385fcc528de 100644 --- a/app/code/Magento/PageCache/composer.json +++ b/app/code/Magento/PageCache/composer.json @@ -5,14 +5,13 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-config": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/PageCache/etc/varnish4.vcl b/app/code/Magento/PageCache/etc/varnish4.vcl index 3e8acfba18762..f6b7859af7c96 100644 --- a/app/code/Magento/PageCache/etc/varnish4.vcl +++ b/app/code/Magento/PageCache/etc/varnish4.vcl @@ -141,6 +141,10 @@ sub vcl_backend_response { set beresp.do_gzip = true; } + if (beresp.http.X-Magento-Debug) { + set beresp.http.X-Magento-Cache-Control = beresp.http.Cache-Control; + } + # cache only successfully responses and 404s if (beresp.status != 200 && beresp.status != 404) { set beresp.ttl = 0s; @@ -152,10 +156,6 @@ sub vcl_backend_response { return (deliver); } - if (beresp.http.X-Magento-Debug) { - set beresp.http.X-Magento-Cache-Control = beresp.http.Cache-Control; - } - # validate if we need to cache it and prevent from setting cookie if (beresp.ttl > 0s && (bereq.method == "GET" || bereq.method == "HEAD")) { unset beresp.http.set-cookie; @@ -163,12 +163,15 @@ sub vcl_backend_response { # If page is not cacheable then bypass varnish for 2 minutes as Hit-For-Pass if (beresp.ttl <= 0s || - beresp.http.Surrogate-control ~ "no-store" || - (!beresp.http.Surrogate-Control && beresp.http.Vary == "*")) { - # Mark as Hit-For-Pass for the next 2 minutes + beresp.http.Surrogate-control ~ "no-store" || + (!beresp.http.Surrogate-Control && + beresp.http.Cache-Control ~ "no-cache|no-store") || + beresp.http.Vary == "*") { + # Mark as Hit-For-Pass for the next 2 minutes set beresp.ttl = 120s; set beresp.uncacheable = true; } + return (deliver); } @@ -184,6 +187,13 @@ sub vcl_deliver { unset resp.http.Age; } + # Not letting browser to cache non-static files. + if (resp.http.Cache-Control !~ "private" && req.url !~ "^/(pub/)?(media|static)/") { + set resp.http.Pragma = "no-cache"; + set resp.http.Expires = "-1"; + set resp.http.Cache-Control = "no-store, no-cache, must-revalidate, max-age=0"; + } + unset resp.http.X-Magento-Debug; unset resp.http.X-Magento-Tags; unset resp.http.X-Powered-By; diff --git a/app/code/Magento/PageCache/etc/varnish5.vcl b/app/code/Magento/PageCache/etc/varnish5.vcl index c060090aa91ed..388157af184a4 100644 --- a/app/code/Magento/PageCache/etc/varnish5.vcl +++ b/app/code/Magento/PageCache/etc/varnish5.vcl @@ -142,6 +142,10 @@ sub vcl_backend_response { set beresp.do_gzip = true; } + if (beresp.http.X-Magento-Debug) { + set beresp.http.X-Magento-Cache-Control = beresp.http.Cache-Control; + } + # cache only successfully responses and 404s if (beresp.status != 200 && beresp.status != 404) { set beresp.ttl = 0s; @@ -153,10 +157,6 @@ sub vcl_backend_response { return (deliver); } - if (beresp.http.X-Magento-Debug) { - set beresp.http.X-Magento-Cache-Control = beresp.http.Cache-Control; - } - # validate if we need to cache it and prevent from setting cookie if (beresp.ttl > 0s && (bereq.method == "GET" || bereq.method == "HEAD")) { unset beresp.http.set-cookie; @@ -164,12 +164,15 @@ sub vcl_backend_response { # If page is not cacheable then bypass varnish for 2 minutes as Hit-For-Pass if (beresp.ttl <= 0s || - beresp.http.Surrogate-control ~ "no-store" || - (!beresp.http.Surrogate-Control && beresp.http.Vary == "*")) { + beresp.http.Surrogate-control ~ "no-store" || + (!beresp.http.Surrogate-Control && + beresp.http.Cache-Control ~ "no-cache|no-store") || + beresp.http.Vary == "*") { # Mark as Hit-For-Pass for the next 2 minutes set beresp.ttl = 120s; set beresp.uncacheable = true; } + return (deliver); } @@ -185,6 +188,13 @@ sub vcl_deliver { unset resp.http.Age; } + # Not letting browser to cache non-static files. + if (resp.http.Cache-Control !~ "private" && req.url !~ "^/(pub/)?(media|static)/") { + set resp.http.Pragma = "no-cache"; + set resp.http.Expires = "-1"; + set resp.http.Cache-Control = "no-store, no-cache, must-revalidate, max-age=0"; + } + unset resp.http.X-Magento-Debug; unset resp.http.X-Magento-Tags; unset resp.http.X-Powered-By; diff --git a/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php b/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php index a6f9d4383918c..2ba3034072a52 100644 --- a/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php +++ b/app/code/Magento/Payment/Gateway/Command/GatewayCommand.php @@ -5,14 +5,15 @@ */ namespace Magento\Payment\Gateway\Command; -use Magento\Framework\Phrase; use Magento\Payment\Gateway\CommandInterface; +use Magento\Payment\Gateway\ErrorMapper\ErrorMessageMapperInterface; +use Magento\Payment\Gateway\Http\ClientException; use Magento\Payment\Gateway\Http\ClientInterface; +use Magento\Payment\Gateway\Http\ConverterException; use Magento\Payment\Gateway\Http\TransferFactoryInterface; -use Magento\Payment\Gateway\Request; use Magento\Payment\Gateway\Request\BuilderInterface; -use Magento\Payment\Gateway\Response; use Magento\Payment\Gateway\Response\HandlerInterface; +use Magento\Payment\Gateway\Validator\ResultInterface; use Magento\Payment\Gateway\Validator\ValidatorInterface; use Psr\Log\LoggerInterface; @@ -54,6 +55,11 @@ class GatewayCommand implements CommandInterface */ private $logger; + /** + * @var ErrorMessageMapperInterface + */ + private $errorMessageMapper; + /** * @param BuilderInterface $requestBuilder * @param TransferFactoryInterface $transferFactory @@ -61,6 +67,7 @@ class GatewayCommand implements CommandInterface * @param LoggerInterface $logger * @param HandlerInterface $handler * @param ValidatorInterface $validator + * @param ErrorMessageMapperInterface|null $errorMessageMapper */ public function __construct( BuilderInterface $requestBuilder, @@ -68,7 +75,8 @@ public function __construct( ClientInterface $client, LoggerInterface $logger, HandlerInterface $handler = null, - ValidatorInterface $validator = null + ValidatorInterface $validator = null, + ErrorMessageMapperInterface $errorMessageMapper = null ) { $this->requestBuilder = $requestBuilder; $this->transferFactory = $transferFactory; @@ -76,6 +84,7 @@ public function __construct( $this->handler = $handler; $this->validator = $validator; $this->logger = $logger; + $this->errorMessageMapper = $errorMessageMapper; } /** @@ -84,6 +93,8 @@ public function __construct( * @param array $commandSubject * @return void * @throws CommandException + * @throws ClientException + * @throws ConverterException */ public function execute(array $commandSubject) { @@ -98,10 +109,7 @@ public function execute(array $commandSubject) array_merge($commandSubject, ['response' => $response]) ); if (!$result->isValid()) { - $this->logExceptions($result->getFailsDescription()); - throw new CommandException( - __('Transaction has been declined. Please try again later.') - ); + $this->processErrors($result); } } @@ -114,13 +122,34 @@ public function execute(array $commandSubject) } /** - * @param Phrase[] $fails - * @return void + * Tries to map error messages from validation result and logs processed message. + * Throws an exception with mapped message or default error. + * + * @param ResultInterface $result + * @throws CommandException */ - private function logExceptions(array $fails) + private function processErrors(ResultInterface $result) { - foreach ($fails as $failPhrase) { - $this->logger->critical((string) $failPhrase); + $messages = []; + $errorsSource = array_merge($result->getErrorCodes(), $result->getFailsDescription()); + foreach ($errorsSource as $errorCodeOrMessage) { + $errorCodeOrMessage = (string) $errorCodeOrMessage; + + // error messages mapper can be not configured if payment method doesn't have custom error messages. + if ($this->errorMessageMapper !== null) { + $mapped = (string) $this->errorMessageMapper->getMessage($errorCodeOrMessage); + if (!empty($mapped)) { + $messages[] = $mapped; + $errorCodeOrMessage = $mapped; + } + } + $this->logger->critical('Payment Error: ' . $errorCodeOrMessage); } + + throw new CommandException( + !empty($messages) + ? __(implode(PHP_EOL, $messages)) + : __('Transaction has been declined. Please try again later.') + ); } } diff --git a/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php new file mode 100644 index 0000000000000..2072615a39b92 --- /dev/null +++ b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapper.php @@ -0,0 +1,44 @@ +messageMapping = $messageMapping; + } + + /** + * @inheritdoc + */ + public function getMessage(string $code) + { + $message = $this->messageMapping->get($code); + return $message ? __($message) : null; + } +} diff --git a/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php new file mode 100644 index 0000000000000..f09f49b7f8100 --- /dev/null +++ b/app/code/Magento/Payment/Gateway/ErrorMapper/ErrorMessageMapperInterface.php @@ -0,0 +1,27 @@ +message` format and converts it to [code => message] array format. + */ +class XmlToArrayConverter implements ConverterInterface +{ + /** + * @inheritdoc + */ + public function convert($source) + { + $result = []; + $messageList = $source->getElementsByTagName('message'); + foreach ($messageList as $messageNode) { + $result[(string) $messageNode->getAttribute('code')] = (string) $messageNode->nodeValue; + } + return $result; + } +} diff --git a/app/code/Magento/Payment/Gateway/Validator/AbstractValidator.php b/app/code/Magento/Payment/Gateway/Validator/AbstractValidator.php index 45c6d61baeee6..f1a8950514152 100644 --- a/app/code/Magento/Payment/Gateway/Validator/AbstractValidator.php +++ b/app/code/Magento/Payment/Gateway/Validator/AbstractValidator.php @@ -32,14 +32,16 @@ public function __construct( * * @param bool $isValid * @param array $fails - * @return ResultInterface + * @param array $errorCodes + * @return void */ - protected function createResult($isValid, array $fails = []) + protected function createResult($isValid, array $fails = [], array $errorCodes = []) { return $this->resultInterfaceFactory->create( [ 'isValid' => (bool)$isValid, - 'failsDescription' => $fails + 'failsDescription' => $fails, + 'errorCodes' => $errorCodes ] ); } diff --git a/app/code/Magento/Payment/Gateway/Validator/Result.php b/app/code/Magento/Payment/Gateway/Validator/Result.php index 7266e4745d633..2567414473045 100644 --- a/app/code/Magento/Payment/Gateway/Validator/Result.php +++ b/app/code/Magento/Payment/Gateway/Validator/Result.php @@ -19,35 +19,47 @@ class Result implements ResultInterface */ private $failsDescription; + /** + * @var string[] + */ + private $errorCodes; + /** * @param bool $isValid * @param array $failsDescription + * @param array $errorCodes */ public function __construct( $isValid, - array $failsDescription = [] + array $failsDescription = [], + array $errorCodes = [] ) { $this->isValid = (bool)$isValid; $this->failsDescription = $failsDescription; + $this->errorCodes = $errorCodes; } /** - * Returns validation result - * - * @return bool + * {@inheritdoc} */ - public function isValid() + public function isValid(): bool { return $this->isValid; } /** - * Returns list of fails description - * - * @return Phrase[] + * {@inheritdoc} */ - public function getFailsDescription() + public function getFailsDescription(): array { return $this->failsDescription; } + + /** + * {@inheritdoc} + */ + public function getErrorCodes(): array + { + return $this->errorCodes; + } } diff --git a/app/code/Magento/Payment/Gateway/Validator/ResultInterface.php b/app/code/Magento/Payment/Gateway/Validator/ResultInterface.php index 9c93efe73f344..c1ad947e49c5b 100644 --- a/app/code/Magento/Payment/Gateway/Validator/ResultInterface.php +++ b/app/code/Magento/Payment/Gateway/Validator/ResultInterface.php @@ -28,4 +28,11 @@ public function isValid(); * @return Phrase[] */ public function getFailsDescription(); + + /** + * Returns list of error codes. + * + * @return string[] + */ + public function getErrorCodes(); } diff --git a/app/code/Magento/Payment/Helper/Data.php b/app/code/Magento/Payment/Helper/Data.php index f3565ea324290..5fd23c195f0c4 100644 --- a/app/code/Magento/Payment/Helper/Data.php +++ b/app/code/Magento/Payment/Helper/Data.php @@ -293,7 +293,9 @@ public function getPaymentMethodList($sorted = true, $asLabelValue = false, $wit foreach ($methods as $code => $title) { if (isset($groups[$code])) { $labelValues[$code]['label'] = $title; - $labelValues[$code]['value'] = null; + if (!isset($labelValues[$code]['value'])) { + $labelValues[$code]['value'] = null; + } } elseif (isset($groupRelations[$code])) { unset($labelValues[$code]); $labelValues[$groupRelations[$code]]['value'][$code] = ['value' => $code, 'label' => $title]; diff --git a/app/code/Magento/Payment/Model/Method/AbstractMethod.php b/app/code/Magento/Payment/Model/Method/AbstractMethod.php index 00ada033f2210..33200014c7ec1 100644 --- a/app/code/Magento/Payment/Model/Method/AbstractMethod.php +++ b/app/code/Magento/Payment/Model/Method/AbstractMethod.php @@ -6,12 +6,14 @@ namespace Magento\Payment\Model\Method; +use Magento\Framework\App\ObjectManager; use Magento\Framework\DataObject; use Magento\Payment\Model\InfoInterface; use Magento\Payment\Model\MethodInterface; use Magento\Payment\Observer\AbstractDataAssignObserver; use Magento\Quote\Api\Data\PaymentMethodInterface; use Magento\Sales\Model\Order\Payment; +use Magento\Directory\Helper\Data as DirectoryHelper; /** * Payment method abstract model @@ -29,12 +31,6 @@ abstract class AbstractMethod extends \Magento\Framework\Model\AbstractExtensibl MethodInterface, PaymentMethodInterface { - const ACTION_ORDER = 'order'; - - const ACTION_AUTHORIZE = 'authorize'; - - const ACTION_AUTHORIZE_CAPTURE = 'authorize_capture'; - const STATUS_UNKNOWN = 'UNKNOWN'; const STATUS_APPROVED = 'APPROVED'; @@ -47,23 +43,6 @@ abstract class AbstractMethod extends \Magento\Framework\Model\AbstractExtensibl const STATUS_SUCCESS = 'SUCCESS'; - /** - * Different payment method checks. - */ - const CHECK_USE_FOR_COUNTRY = 'country'; - - const CHECK_USE_FOR_CURRENCY = 'currency'; - - const CHECK_USE_CHECKOUT = 'checkout'; - - const CHECK_USE_INTERNAL = 'internal'; - - const CHECK_ORDER_TOTAL_MIN_MAX = 'total'; - - const CHECK_ZERO_TOTAL = 'zero_total'; - - const GROUP_OFFLINE = 'offline'; - /** * @var string */ @@ -217,6 +196,11 @@ abstract class AbstractMethod extends \Magento\Framework\Model\AbstractExtensibl */ protected $logger; + /** + * @var DirectoryHelper + */ + private $directory; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -228,6 +212,7 @@ abstract class AbstractMethod extends \Magento\Framework\Model\AbstractExtensibl * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection * @param array $data + * @param DirectoryHelper $directory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -240,7 +225,8 @@ public function __construct( \Magento\Payment\Model\Method\Logger $logger, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + DirectoryHelper $directory = null ) { parent::__construct( $context, @@ -254,6 +240,7 @@ public function __construct( $this->_paymentData = $paymentData; $this->_scopeConfig = $scopeConfig; $this->logger = $logger; + $this->directory = $directory ?: ObjectManager::getInstance()->get(DirectoryHelper::class); $this->initializeData($data); } @@ -607,11 +594,14 @@ public function validate() } else { $billingCountry = $paymentInfo->getQuote()->getBillingAddress()->getCountryId(); } + $billingCountry = $billingCountry ?: $this->directory->getDefaultCountry(); + if (!$this->canUseForCountry($billingCountry)) { throw new \Magento\Framework\Exception\LocalizedException( __('You can\'t use the payment type you selected to make payments to the billing country.') ); } + return $this; } diff --git a/app/code/Magento/Payment/Model/Method/Logger.php b/app/code/Magento/Payment/Model/Method/Logger.php index 74068c3b6fef0..9bf84ef4e4775 100644 --- a/app/code/Magento/Payment/Model/Method/Logger.php +++ b/app/code/Magento/Payment/Model/Method/Logger.php @@ -5,12 +5,11 @@ */ namespace Magento\Payment\Model\Method; +use Magento\Payment\Gateway\ConfigInterface; use Psr\Log\LoggerInterface; /** - * Class Logger for payment related information (request, response, etc.) which is used for debug - * - * @author Magento Core Team + * Class Logger for payment related information (request, response, etc.) which is used for debug. * * @api * @since 100.0.2 @@ -25,17 +24,17 @@ class Logger protected $logger; /** - * @var \Magento\Payment\Gateway\ConfigInterface + * @var ConfigInterface */ private $config; /** * @param LoggerInterface $logger - * @param \Magento\Payment\Gateway\ConfigInterface $config + * @param ConfigInterface|null $config */ public function __construct( LoggerInterface $logger, - \Magento\Payment\Gateway\ConfigInterface $config = null + ConfigInterface $config = null ) { $this->logger = $logger; $this->config = $config; @@ -69,7 +68,7 @@ public function debug(array $data, array $maskKeys = null, $forceDebug = null) */ private function getDebugReplaceFields() { - if ($this->config and $this->config->getValue('debugReplaceKeys')) { + if ($this->config && $this->config->getValue('debugReplaceKeys')) { return explode(',', $this->config->getValue('debugReplaceKeys')); } return []; @@ -82,7 +81,7 @@ private function getDebugReplaceFields() */ private function isDebugOn() { - return $this->config and (bool)$this->config->getValue('debug'); + return $this->config && (bool)$this->config->getValue('debug'); } /** diff --git a/app/code/Magento/Payment/Model/MethodInterface.php b/app/code/Magento/Payment/Model/MethodInterface.php index f756ed564c066..fe80bb25c77ee 100644 --- a/app/code/Magento/Payment/Model/MethodInterface.php +++ b/app/code/Magento/Payment/Model/MethodInterface.php @@ -15,6 +15,32 @@ */ interface MethodInterface { + /** + * Different payment actions. + */ + const ACTION_ORDER = 'order'; + + const ACTION_AUTHORIZE = 'authorize'; + + const ACTION_AUTHORIZE_CAPTURE = 'authorize_capture'; + + /** + * Different payment method checks. + */ + const CHECK_USE_FOR_COUNTRY = 'country'; + + const CHECK_USE_FOR_CURRENCY = 'currency'; + + const CHECK_USE_CHECKOUT = 'checkout'; + + const CHECK_USE_INTERNAL = 'internal'; + + const CHECK_ORDER_TOTAL_MIN_MAX = 'total'; + + const CHECK_ZERO_TOTAL = 'zero_total'; + + const GROUP_OFFLINE = 'offline'; + /** * Retrieve payment method code * diff --git a/app/code/Magento/Payment/Test/Unit/Block/Transparent/FormTest.php b/app/code/Magento/Payment/Test/Unit/Block/Transparent/FormTest.php index eb1e3d3a91b92..dd18d9f256d93 100644 --- a/app/code/Magento/Payment/Test/Unit/Block/Transparent/FormTest.php +++ b/app/code/Magento/Payment/Test/Unit/Block/Transparent/FormTest.php @@ -290,10 +290,8 @@ public function testGetMethodSuccess() public function testGetMethodNotTransparentInterface() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - __('We cannot retrieve the transparent payment method model object.') - ); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage((string)__('We cannot retrieve the transparent payment method model object.')); $methodMock = $this->getMockBuilder(\Magento\Payment\Model\MethodInterface::class) ->getMockForAbstractClass(); diff --git a/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php b/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php index df8bdc9bca54b..7acb595384332 100644 --- a/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php +++ b/app/code/Magento/Payment/Test/Unit/Gateway/Command/GatewayCommandTest.php @@ -6,11 +6,15 @@ namespace Magento\Payment\Test\Unit\Gateway\Command; use Magento\Payment\Gateway\Command\GatewayCommand; +use Magento\Payment\Gateway\ErrorMapper\ErrorMessageMapperInterface; use Magento\Payment\Gateway\Http\ClientInterface; use Magento\Payment\Gateway\Http\TransferFactoryInterface; +use Magento\Payment\Gateway\Http\TransferInterface; use Magento\Payment\Gateway\Request\BuilderInterface; use Magento\Payment\Gateway\Response\HandlerInterface; +use Magento\Payment\Gateway\Validator\ResultInterface; use Magento\Payment\Gateway\Validator\ValidatorInterface; +use PHPUnit_Framework_MockObject_MockObject as MockObject; use Psr\Log\LoggerInterface; /** @@ -18,175 +22,186 @@ */ class GatewayCommandTest extends \PHPUnit\Framework\TestCase { - /** @var GatewayCommand */ - protected $command; + /** + * @var GatewayCommand + */ + private $command; /** - * @var BuilderInterface|\PHPUnit_Framework_MockObject_MockObject + * @var BuilderInterface|MockObject */ - protected $requestBuilderMock; + private $requestBuilder; /** - * @var TransferFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + * @var TransferFactoryInterface|MockObject */ - protected $transferFactoryMock; + private $transferFactory; /** - * @var ClientInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ClientInterface|MockObject */ - protected $clientMock; + private $client; /** - * @var HandlerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var HandlerInterface|MockObject */ - protected $responseHandlerMock; + private $responseHandler; /** - * @var ValidatorInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ValidatorInterface|MockObject */ - protected $validatorMock; + private $validator; /** - * @var LoggerInterface |\PHPUnit_Framework_MockObject_MockObject + * @var LoggerInterface|MockObject */ private $logger; + /** + * @var ErrorMessageMapperInterface|MockObject + */ + private $errorMessageMapper; + protected function setUp() { - $this->requestBuilderMock = $this->createMock( - BuilderInterface::class - ); - $this->transferFactoryMock = $this->createMock( - TransferFactoryInterface::class - ); - $this->clientMock = $this->createMock( - ClientInterface::class - ); - $this->responseHandlerMock = $this->createMock( - HandlerInterface::class - ); - $this->validatorMock = $this->createMock( - ValidatorInterface::class - ); + $this->requestBuilder = $this->createMock(BuilderInterface::class); + $this->transferFactory = $this->createMock(TransferFactoryInterface::class); + $this->client = $this->createMock(ClientInterface::class); + $this->responseHandler = $this->createMock(HandlerInterface::class); + $this->validator = $this->createMock(ValidatorInterface::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->errorMessageMapper = $this->createMock(ErrorMessageMapperInterface::class); $this->command = new GatewayCommand( - $this->requestBuilderMock, - $this->transferFactoryMock, - $this->clientMock, + $this->requestBuilder, + $this->transferFactory, + $this->client, $this->logger, - $this->responseHandlerMock, - $this->validatorMock + $this->responseHandler, + $this->validator, + $this->errorMessageMapper ); } public function testExecute() { $commandSubject = ['authorize']; - $request = [ - 'request_field1' => 'request_value1', - 'request_field2' => 'request_value2' - ]; - $response = ['response_field1' => 'response_value1']; - $validationResult = $this->getMockBuilder( - \Magento\Payment\Gateway\Validator\ResultInterface::class - ) - ->getMockForAbstractClass(); + $this->processRequest($commandSubject, true); - $transferO = $this->getMockBuilder( - \Magento\Payment\Gateway\Http\TransferInterface::class - ) - ->getMockForAbstractClass(); + $this->responseHandler->method('handle') + ->with($commandSubject, ['response_field1' => 'response_value1']); - $this->requestBuilderMock->expects(static::once()) - ->method('build') - ->with($commandSubject) - ->willReturn($request); + $this->command->execute($commandSubject); + } - $this->transferFactoryMock->expects(static::once()) - ->method('create') - ->with($request) - ->willReturn($transferO); + /** + * Checks a case when request fails. + * + * @expectedException \Magento\Payment\Gateway\Command\CommandException + * @expectedExceptionMessage Transaction has been declined. Please try again later. + */ + public function testExecuteValidationFail() + { + $commandSubject = ['authorize']; + $validationFailures = [ + __('Failure #1'), + __('Failure #2'), + ]; - $this->clientMock->expects(static::once()) - ->method('placeRequest') - ->with($transferO) - ->willReturn($response); - $this->validatorMock->expects(static::once()) - ->method('validate') - ->with(array_merge($commandSubject, ['response' =>$response])) - ->willReturn($validationResult); - $validationResult->expects(static::once()) - ->method('isValid') - ->willReturn(true); + $this->processRequest($commandSubject, false, $validationFailures); - $this->responseHandlerMock->expects(static::once()) - ->method('handle') - ->with($commandSubject, $response); + $this->logger->expects(self::exactly(count($validationFailures))) + ->method('critical') + ->withConsecutive( + [self::equalTo('Payment Error: ' . $validationFailures[0])], + [self::equalTo('Payment Error: ' . $validationFailures[1])] + ); $this->command->execute($commandSubject); } - public function testExecuteValidationFail() + /** + * Checks a case when request fails and response errors are mapped. + * + * @expectedException \Magento\Payment\Gateway\Command\CommandException + * @expectedExceptionMessage Failure Mapped + */ + public function testExecuteValidationFailWithMappedErrors() { - $this->expectException( - \Magento\Payment\Gateway\Command\CommandException::class - ); - $commandSubject = ['authorize']; - $request = [ - 'request_field1' => 'request_value1', - 'request_field2' => 'request_value2' - ]; - $response = ['response_field1' => 'response_value1']; $validationFailures = [ __('Failure #1'), __('Failure #2'), ]; - $validationResult = $this->getMockBuilder( - \Magento\Payment\Gateway\Validator\ResultInterface::class - ) - ->getMockForAbstractClass(); + $errorCodes = ['401']; + + $this->processRequest($commandSubject, false, $validationFailures, $errorCodes); + + $this->errorMessageMapper->method('getMessage') + ->willReturnMap( + [ + ['401', 'Unauthorized'], + ['Failure #1', 'Failure Mapped'], + ['Failure #2', null] + ] + ); + + $this->logger->expects(self::exactly(count(array_merge($validationFailures, $errorCodes)))) + ->method('critical') + ->withConsecutive( + [self::equalTo('Payment Error: Unauthorized')], + [self::equalTo('Payment Error: Failure Mapped')], + [self::equalTo('Payment Error: Failure #2')] + ); - $transferO = $this->getMockBuilder( - \Magento\Payment\Gateway\Http\TransferInterface::class - ) + $this->command->execute($commandSubject); + } + + /** + * Performs command actions like request, response and validation. + * + * @param array $commandSubject + * @param bool $validationResult + * @param array $validationFailures + * @param array $errorCodes + */ + private function processRequest( + array $commandSubject, + bool $validationResult, + array $validationFailures = [], + array $errorCodes = [] + ) { + $request = [ + 'request_field1' => 'request_value1', + 'request_field2' => 'request_value2' + ]; + $response = ['response_field1' => 'response_value1']; + $transferO = $this->getMockBuilder(TransferInterface::class) ->getMockForAbstractClass(); - $this->requestBuilderMock->expects(static::once()) - ->method('build') + $this->requestBuilder->method('build') ->with($commandSubject) ->willReturn($request); - $this->transferFactoryMock->expects(static::once()) - ->method('create') + $this->transferFactory->method('create') ->with($request) ->willReturn($transferO); - $this->clientMock->expects(static::once()) - ->method('placeRequest') + $this->client->method('placeRequest') ->with($transferO) ->willReturn($response); - $this->validatorMock->expects(static::once()) - ->method('validate') - ->with(array_merge($commandSubject, ['response' =>$response])) - ->willReturn($validationResult); - $validationResult->expects(static::once()) - ->method('isValid') - ->willReturn(false); - $validationResult->expects(static::once()) - ->method('getFailsDescription') - ->willReturn( - $validationFailures - ); - $this->logger->expects(static::exactly(count($validationFailures))) - ->method('critical') - ->withConsecutive( - [$validationFailures[0]], - [$validationFailures[1]] - ); + $result = $this->getMockBuilder(ResultInterface::class) + ->getMockForAbstractClass(); - $this->command->execute($commandSubject); + $this->validator->method('validate') + ->with(array_merge($commandSubject, ['response' => $response])) + ->willReturn($result); + $result->method('isValid') + ->willReturn($validationResult); + $result->method('getFailsDescription') + ->willReturn($validationFailures); + $result->method('getErrorCodes') + ->willReturn($errorCodes); } } diff --git a/app/code/Magento/Payment/Test/Unit/Gateway/Validator/CountryValidatorTest.php b/app/code/Magento/Payment/Test/Unit/Gateway/Validator/CountryValidatorTest.php index 0c808cb7c1faa..3054a705373e4 100644 --- a/app/code/Magento/Payment/Test/Unit/Gateway/Validator/CountryValidatorTest.php +++ b/app/code/Magento/Payment/Test/Unit/Gateway/Validator/CountryValidatorTest.php @@ -62,7 +62,7 @@ public function testValidateAllowspecificTrue($storeId, $country, $allowspecific $this->resultFactoryMock->expects($this->once()) ->method('create') - ->with(['isValid' => $isValid, 'failsDescription' => []]) + ->with(['isValid' => $isValid, 'failsDescription' => [], 'errorCodes' => []]) ->willReturn($this->resultMock); $this->assertSame($this->resultMock, $this->model->validate($validationSubject)); @@ -90,7 +90,7 @@ public function testValidateAllowspecificFalse($storeId, $allowspecific, $isVali $this->resultFactoryMock->expects($this->once()) ->method('create') - ->with(['isValid' => $isValid, 'failsDescription' => []]) + ->with(['isValid' => $isValid, 'failsDescription' => [], 'errorCodes' => []]) ->willReturn($this->resultMock); $this->assertSame($this->resultMock, $this->model->validate($validationSubject)); diff --git a/app/code/Magento/Payment/Test/Unit/Gateway/Validator/ValidatorCompositeTest.php b/app/code/Magento/Payment/Test/Unit/Gateway/Validator/ValidatorCompositeTest.php index 7b86db369b977..7352cb7a4ac6d 100644 --- a/app/code/Magento/Payment/Test/Unit/Gateway/Validator/ValidatorCompositeTest.php +++ b/app/code/Magento/Payment/Test/Unit/Gateway/Validator/ValidatorCompositeTest.php @@ -75,7 +75,8 @@ public function testValidate() ->with( [ 'isValid' => false, - 'failsDescription' => ['Fail'] + 'failsDescription' => ['Fail'], + 'errorCodes' => [] ] ) ->willReturn($compositeResult); diff --git a/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php b/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php deleted file mode 100644 index f0cb19ef0fa0f..0000000000000 --- a/app/code/Magento/Payment/Test/Unit/Model/Method/FactoryTest.php +++ /dev/null @@ -1,89 +0,0 @@ -_objectManagerMock = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); - $this->_factory = $objectManagerHelper->getObject( - \Magento\Payment\Model\Method\Factory::class, - ['objectManager' => $this->_objectManagerMock] - ); - } - - public function testCreateMethod() - { - $className = \Magento\Payment\Model\Method\AbstractMethod::class; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - [] - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->_factory->create($className)); - } - - public function testCreateMethodWithArguments() - { - $className = \Magento\Payment\Model\Method\AbstractMethod::class; - $data = ['param1', 'param2']; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - $data - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->_factory->create($className, $data)); - } - - /** - * @expectedException \Magento\Framework\Exception\LocalizedException - * @expectedExceptionMessage WrongClass class doesn't implement \Magento\Payment\Model\MethodInterface - */ - public function testWrongTypeException() - { - $className = 'WrongClass'; - $methodMock = $this->createMock($className); - $this->_objectManagerMock->expects( - $this->once() - )->method( - 'create' - )->with( - $className, - [] - )->will( - $this->returnValue($methodMock) - ); - - $this->_factory->create($className); - } -} diff --git a/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php b/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php deleted file mode 100644 index 9bdc90829f6fe..0000000000000 --- a/app/code/Magento/Payment/Test/Unit/Model/Method/Specification/FactoryTest.php +++ /dev/null @@ -1,71 +0,0 @@ -objectManagerMock = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); - - $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->factory = $objectManagerHelper->getObject( - \Magento\Payment\Model\Method\Specification\Factory::class, - ['objectManager' => $this->objectManagerMock] - ); - } - - public function testCreateMethod() - { - $className = \Magento\Payment\Model\Method\SpecificationInterface::class; - $methodMock = $this->createMock($className); - $this->objectManagerMock->expects( - $this->once() - )->method( - 'get' - )->with( - $className - )->will( - $this->returnValue($methodMock) - ); - - $this->assertEquals($methodMock, $this->factory->create($className)); - } - - /** - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage Specification must implement SpecificationInterface - */ - public function testWrongTypeException() - { - $className = 'WrongClass'; - $methodMock = $this->createMock($className); - $this->objectManagerMock->expects( - $this->once() - )->method( - 'get' - )->with( - $className - )->will( - $this->returnValue($methodMock) - ); - - $this->factory->create($className); - } -} diff --git a/app/code/Magento/Payment/composer.json b/app/code/Magento/Payment/composer.json index c8b4266f3116d..293d36e093954 100644 --- a/app/code/Magento/Payment/composer.json +++ b/app/code/Magento/Payment/composer.json @@ -5,17 +5,16 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-checkout": "*", + "magento/module-config": "*", + "magento/module-directory": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Payment/etc/di.xml b/app/code/Magento/Payment/etc/di.xml index e2de2244bff89..74f553cc64094 100644 --- a/app/code/Magento/Payment/etc/di.xml +++ b/app/code/Magento/Payment/etc/di.xml @@ -12,6 +12,7 @@ + @@ -36,4 +37,47 @@ Magento\Payment\Gateway\Config\Config + + + + Magento_Payment + error_mapping.xsd + + + + + Magento\Payment\Gateway\ErrorMapper\XmlToArrayConverter + Magento\Payment\Gateway\ErrorMapper\VirtualSchemaLocator + error_mapping.xml + + + + + Magento\Payment\Gateway\ErrorMapper\VirtualConfigReader + payment_error_mapper + + + + + Magento\Payment\Gateway\ErrorMapper\NullMappingData + + + + + + /var/log/payment.log + + + + + + Magento\Payment\Model\Method\VirtualDebug + + + + + + Magento\Payment\Model\Method\VirtualLogger + + diff --git a/app/code/Magento/Payment/etc/error_mapping.xsd b/app/code/Magento/Payment/etc/error_mapping.xsd new file mode 100644 index 0000000000000..97f3c181beb37 --- /dev/null +++ b/app/code/Magento/Payment/etc/error_mapping.xsd @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml b/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml index bbd06cd7c48f3..f1988f1ca86bb 100644 --- a/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml +++ b/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml @@ -46,6 +46,18 @@ $params = $block->getParams(); }); } ); + + var require = window.top.require; + require( + [ + 'jquery' + ], + function($) { + var parent = window.top; + $(parent).trigger('clearTimeout'); + $(parent.document).find('#multishipping-billing-form').submit(); + } + ); window.top.location = "escapeUrl($params['order_success']) ?>"; diff --git a/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php b/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php new file mode 100644 index 0000000000000..0cbd82798a2c1 --- /dev/null +++ b/app/code/Magento/Paypal/Block/Adminhtml/Order/View.php @@ -0,0 +1,114 @@ +express = $express; + + parent::__construct( + $context, + $registry, + $salesConfig, + $reorderHelper, + $data + ); + } + + /** + * Constructor. + * + * @return void + * @throws LocalizedException + */ + protected function _construct() + { + parent::_construct(); + + $order = $this->getOrder(); + if ($order === null) { + return; + } + $message = __('Are you sure you want to authorize full order amount?'); + if ($this->_isAllowedAction('Magento_Paypal::authorization') && $this->canAuthorize($order)) { + $this->addButton( + 'order_authorize', + [ + 'label' => __('Authorize'), + 'class' => 'authorize', + 'onclick' => "confirmSetLocation('{$message}', '{$this->getPaymentAuthorizationUrl()}')", + ] + ); + } + } + + /** + * Returns URL for authorization of full order amount. + * + * @return string + */ + private function getPaymentAuthorizationUrl(): string + { + return $this->getUrl('paypal/express/authorization'); + } + + /** + * Checks if order available for payment authorization. + * + * @param Order $order + * @return bool + * @throws LocalizedException + */ + public function canAuthorize(Order $order): bool + { + if ($order->canUnhold() || $order->isPaymentReview()) { + return false; + } + + $state = $order->getState(); + if ($order->isCanceled() || $state === Order::STATE_COMPLETE || $state === Order::STATE_CLOSED) { + return false; + } + + return $this->express->isOrderAuthorizationAllowed($order->getPayment()); + } +} diff --git a/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php b/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php new file mode 100644 index 0000000000000..c6744734ded61 --- /dev/null +++ b/app/code/Magento/Paypal/Controller/Adminhtml/Express/Authorization.php @@ -0,0 +1,118 @@ +express = $express; + + parent::__construct( + $context, + $coreRegistry, + $fileFactory, + $translateInline, + $resultPageFactory, + $resultJsonFactory, + $resultLayoutFactory, + $resultRawFactory, + $orderManagement, + $orderRepository, + $logger + ); + } + + /** + * Authorize full order payment amount. + * + * @return Redirect + */ + public function execute(): Redirect + { + $resultRedirect = $this->resultRedirectFactory->create(); + $order = $this->_initOrder(); + if ($order !== false) { + try { + $this->express->authorizeOrder($order); + $this->orderRepository->save($order); + $this->messageManager->addSuccessMessage(__('Payment authorization has been successfully created.')); + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + } catch (\Throwable $e) { + $this->messageManager->addErrorMessage(__('Unable to make payment authorization.')); + } + + $resultRedirect->setPath('sales/order/view', ['order_id' => $order->getId()]); + } else { + $resultRedirect->setPath('sales/order/index'); + } + + return $resultRedirect; + } +} diff --git a/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php b/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php index eb4c35b02696c..f0fce97da512a 100644 --- a/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php +++ b/app/code/Magento/Paypal/Controller/Express/AbstractExpress/PlaceOrder.php @@ -7,6 +7,7 @@ namespace Magento\Paypal\Controller\Express\AbstractExpress; +use Magento\Framework\Exception\LocalizedException; use Magento\Paypal\Model\Api\ProcessableException as ApiProcessableException; /** @@ -118,15 +119,27 @@ public function execute() return; } catch (ApiProcessableException $e) { $this->_processPaypalApiError($e); + } catch (LocalizedException $e) { + $this->processException($e, $e->getRawMessage()); } catch (\Exception $e) { - $this->messageManager->addExceptionMessage( - $e, - __('We can\'t place the order.') - ); - $this->_redirect('*/*/review'); + $this->processException($e, 'We can\'t place the order.'); } } + /** + * Process exception. + * + * @param \Exception $exception + * @param string $message + * + * @return void + */ + private function processException(\Exception $exception, string $message): void + { + $this->messageManager->addExceptionMessage($exception, __($message)); + $this->_redirect('*/*/review'); + } + /** * Process PayPal API's processable errors * diff --git a/app/code/Magento/Paypal/Model/Adminhtml/Express.php b/app/code/Magento/Paypal/Model/Adminhtml/Express.php new file mode 100644 index 0000000000000..6724f115dc8a7 --- /dev/null +++ b/app/code/Magento/Paypal/Model/Adminhtml/Express.php @@ -0,0 +1,181 @@ +authCommand = $authCommand; + + parent::__construct( + $context, + $registry, + $extensionFactory, + $customAttributeFactory, + $paymentData, + $scopeConfig, + $logger, + $proFactory, + $storeManager, + $urlBuilder, + $cartFactory, + $checkoutSession, + $exception, + $transactionRepository, + $transactionBuilder, + $resource, + $resourceCollection, + $data + ); + } + + /** + * Creates an authorization of requested amount. + * + * @param OrderInterface $order + * @return $this + * @throws LocalizedException + */ + public function authorizeOrder(OrderInterface $order) + { + $baseTotalDue = $order->getBaseTotalDue(); + + /** @var $payment Payment */ + $payment = $order->getPayment(); + if (!$payment || !$this->isOrderAuthorizationAllowed($payment)) { + throw new LocalizedException(__('Authorization is not allowed.')); + } + + $orderTransaction = $this->getOrderTransaction($payment); + + $api = $this->_callDoAuthorize($baseTotalDue, $payment, $orderTransaction->getTxnId()); + $this->_pro->importPaymentInfo($api, $payment); + + $payment->resetTransactionAdditionalInfo() + ->setIsTransactionClosed(false) + ->setTransactionId($api->getTransactionId()) + ->setParentTransactionId($orderTransaction->getTxnId()); + + $transaction = $payment->addTransaction(Transaction::TYPE_AUTH, null, true); + $message = $this->authCommand->execute($payment, $baseTotalDue, $payment->getOrder()); + $message = $payment->prependMessage($message); + + $payment->addTransactionCommentsToOrder($transaction, $message); + $payment->setAmountAuthorized($order->getTotalDue()); + $payment->setBaseAmountAuthorized($baseTotalDue); + + return $this; + } + + /** + * Checks if payment has authorization transactions. + * + * @param Payment $payment + * @return bool + */ + private function hasAuthorization(Payment $payment): bool + { + return (bool) ($payment->getAmountAuthorized() ?? 0); + } + + /** + * Checks if payment authorization allowed. + * + * @param Payment $payment + * @return bool + * @throws LocalizedException + */ + public function isOrderAuthorizationAllowed(Payment $payment): bool + { + if ($payment->getMethod() === Config::METHOD_EXPRESS && + $payment->getMethodInstance()->getConfigPaymentAction() === AbstractMethod::ACTION_ORDER + ) { + return !$this->hasAuthorization($payment); + } + + return false; + } +} diff --git a/app/code/Magento/Paypal/Model/Api/Nvp.php b/app/code/Magento/Paypal/Model/Api/Nvp.php index 6933c613ef748..25883590350f4 100644 --- a/app/code/Magento/Paypal/Model/Api/Nvp.php +++ b/app/code/Magento/Paypal/Model/Api/Nvp.php @@ -1025,7 +1025,7 @@ public function callGetPalDetails() } /** - * Set Customer BillingA greement call + * Set Customer BillingAgreement call * * @return void * @link https://cms.paypal.com/us/cgi-bin/?&cmd=_render-content&content_ID=developer/e_howto_api_nvp_r_SetCustomerBillingAgreement @@ -1425,7 +1425,7 @@ protected function _deformatNVP($nvpstr) $nvpstr = strpos($nvpstr, "\r\n\r\n") !== false ? substr($nvpstr, strpos($nvpstr, "\r\n\r\n") + 4) : $nvpstr; while (strlen($nvpstr)) { - //postion of Key + //position of Key $keypos = strpos($nvpstr, '='); //position of value $valuepos = strpos($nvpstr, '&') ? strpos($nvpstr, '&') : strlen($nvpstr); @@ -1433,7 +1433,7 @@ protected function _deformatNVP($nvpstr) /*getting the Key and Value values and storing in a Associative Array*/ $keyval = substr($nvpstr, $intial, $keypos); $valval = substr($nvpstr, $keypos + 1, $valuepos - $keypos - 1); - //decoding the respose + //decoding the response $nvpArray[urldecode($keyval)] = urldecode($valval); $nvpstr = substr($nvpstr, $valuepos + 1, strlen($nvpstr)); } diff --git a/app/code/Magento/Paypal/Model/Config.php b/app/code/Magento/Paypal/Model/Config.php index 64a2a2943359c..34e40ac7509d6 100644 --- a/app/code/Magento/Paypal/Model/Config.php +++ b/app/code/Magento/Paypal/Model/Config.php @@ -224,6 +224,7 @@ class Config extends AbstractConfig 'TWD', 'THB', 'USD', + 'INR', ]; /** diff --git a/app/code/Magento/Paypal/Model/Express.php b/app/code/Magento/Paypal/Model/Express.php index accb22b265335..4684abdc9be6d 100644 --- a/app/code/Magento/Paypal/Model/Express.php +++ b/app/code/Magento/Paypal/Model/Express.php @@ -10,6 +10,7 @@ use Magento\Paypal\Model\Express\Checkout as ExpressCheckout; use Magento\Quote\Api\Data\PaymentInterface; use Magento\Sales\Api\Data\OrderPaymentInterface; +use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Payment; use Magento\Sales\Model\Order\Payment\Transaction; use Magento\Quote\Model\Quote; @@ -363,7 +364,6 @@ public function getConfigData($field, $storeId = null) * @param \Magento\Framework\DataObject|\Magento\Payment\Model\InfoInterface|Payment $payment * @param float $amount * @return $this - * @throws \Magento\Framework\Exception\LocalizedException */ public function order(\Magento\Payment\Model\InfoInterface $payment, $amount) { @@ -375,63 +375,12 @@ public function order(\Magento\Payment\Model\InfoInterface $payment, $amount) } $payment->setAdditionalInformation($this->_isOrderPaymentActionKey, true); - if ($payment->getIsFraudDetected()) { return $this; } - $order = $payment->getOrder(); - $orderTransactionId = $payment->getTransactionId(); - - $api = $this->_callDoAuthorize($amount, $payment, $orderTransactionId); - - $state = \Magento\Sales\Model\Order::STATE_PROCESSING; - $status = true; - - $formattedPrice = $order->getBaseCurrency()->formatTxt($amount); - if ($payment->getIsTransactionPending()) { - $message = __('The ordering amount of %1 is pending approval on the payment gateway.', $formattedPrice); - $state = \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW; - } else { - $message = __('Ordered amount of %1', $formattedPrice); - } - - $transaction = $this->transactionBuilder->setPayment($payment) - ->setOrder($order) - ->setTransactionId($payment->getTransactionId()) - ->build(Transaction::TYPE_ORDER); - $payment->addTransactionCommentsToOrder($transaction, $message); - - $this->_pro->importPaymentInfo($api, $payment); - - if ($payment->getIsTransactionPending()) { - $message = __( - 'We\'ll authorize the amount of %1 as soon as the payment gateway approves it.', - $formattedPrice - ); - $state = \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW; - if ($payment->getIsFraudDetected()) { - $status = \Magento\Sales\Model\Order::STATUS_FRAUD; - } - } else { - $message = __('The authorized amount is %1.', $formattedPrice); - } - - $payment->resetTransactionAdditionalInfo(); - - $payment->setTransactionId($api->getTransactionId()); - $payment->setParentTransactionId($orderTransactionId); - - $transaction = $this->transactionBuilder->setPayment($payment) - ->setOrder($order) - ->setTransactionId($payment->getTransactionId()) - ->build(Transaction::TYPE_AUTH); - $payment->addTransactionCommentsToOrder($transaction, $message); - - $order->setState($state) - ->setStatus($status); + $payment->getOrder()->setActionFlag(Order::ACTION_FLAG_INVOICE, false); - $payment->setSkipOrderProcessing(true); return $this; } diff --git a/app/code/Magento/Paypal/Model/Express/Checkout.php b/app/code/Magento/Paypal/Model/Express/Checkout.php index e9f2c1b8415a8..9c9b4dc3e87a7 100644 --- a/app/code/Magento/Paypal/Model/Express/Checkout.php +++ b/app/code/Magento/Paypal/Model/Express/Checkout.php @@ -809,7 +809,9 @@ public function place($token, $shippingMethodCode = null) case \Magento\Sales\Model\Order::STATE_PROCESSING: case \Magento\Sales\Model\Order::STATE_COMPLETE: case \Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW: - $this->orderSender->send($order); + if (!$order->getEmailSent()) { + $this->orderSender->send($order); + } $this->_checkoutSession->start(); break; default: @@ -897,7 +899,12 @@ protected function _setExportedAddressData($address, $exportedAddress) { // Exported data is more priority if we came from Express Checkout button $isButton = (bool)$this->_quote->getPayment()->getAdditionalInformation(self::PAYMENT_INFO_BUTTON); - if (!$isButton) { + + // Since country is required field for billing and shipping address, + // we consider the address information to be empty if country is empty. + $isEmptyAddress = ($address->getCountryId() === null); + + if (!$isButton && !$isEmptyAddress) { return; } diff --git a/app/code/Magento/Paypal/Model/Pro.php b/app/code/Magento/Paypal/Model/Pro.php index ce96829dafbe2..5e9159bd2c243 100644 --- a/app/code/Magento/Paypal/Model/Pro.php +++ b/app/code/Magento/Paypal/Model/Pro.php @@ -256,7 +256,7 @@ public function capture(\Magento\Framework\DataObject $payment, $amount) } $api = $this->getApi() ->setAuthorizationId($authTransactionId) - ->setIsCaptureComplete($payment->getShouldCloseParentTransaction()) + ->setIsCaptureComplete($payment->isCaptureFinal($amount)) ->setAmount($amount); $order = $payment->getOrder(); diff --git a/app/code/Magento/Paypal/Model/Report/Settlement.php b/app/code/Magento/Paypal/Model/Report/Settlement.php index 12307977d7e36..5dc51518f0b11 100644 --- a/app/code/Magento/Paypal/Model/Report/Settlement.php +++ b/app/code/Magento/Paypal/Model/Report/Settlement.php @@ -369,7 +369,8 @@ public function parseCsv($localCsv, $format = 'new') // Section columns. // In case ever the column order is changed, we will have the items recorded properly // anyway. We have named, not numbered columns. - for ($i = 1; $i < count($line); $i++) { + $count = count($line); + for ($i = 1; $i < $count; $i++) { $sectionColumns[$line[$i]] = $i; } $flippedSectionColumns = array_flip($sectionColumns); diff --git a/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php b/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php new file mode 100644 index 0000000000000..87abdf8264503 --- /dev/null +++ b/app/code/Magento/Paypal/Plugin/OrderCanInvoice.php @@ -0,0 +1,49 @@ +express = $express; + } + + /** + * Checks a possibility to invoice of PayPal Express payments when payment action is "order". + * + * @param Order $order + * @param bool $result + * @return bool + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function afterCanInvoice(Order $order, bool $result): bool + { + if ($this->express->isOrderAuthorizationAllowed($order->getPayment())) { + return false; + } + + return $result; + } +} diff --git a/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php b/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php new file mode 100644 index 0000000000000..d8582b25c7be8 --- /dev/null +++ b/app/code/Magento/Paypal/Plugin/ValidatorCanInvoice.php @@ -0,0 +1,54 @@ +express = $express; + } + + /** + * Checks a possibility to invoice of PayPal Express payments when payment action is "order". + * + * @param CanInvoice $subject + * @param array $result + * @param OrderInterface $order + * @return array + * @throws LocalizedException + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterValidate(CanInvoice $subject, array $result, OrderInterface $order): array + { + if ($this->express->isOrderAuthorizationAllowed($order->getPayment())) { + $result[] = __('An invoice cannot be created when none of authorization transactions available.'); + } + + return $result; + } +} diff --git a/app/code/Magento/Paypal/Setup/Patch/Data/AddPaypalOrderStatuses.php b/app/code/Magento/Paypal/Setup/Patch/Data/AddPaypalOrderStatuses.php index 2689d6ab26b82..495b7618cce49 100644 --- a/app/code/Magento/Paypal/Setup/Patch/Data/AddPaypalOrderStatuses.php +++ b/app/code/Magento/Paypal/Setup/Patch/Data/AddPaypalOrderStatuses.php @@ -9,8 +9,8 @@ use Magento\Quote\Setup\QuoteSetupFactory; use Magento\Sales\Setup\SalesSetupFactory; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class AddPaypalOrderStates diff --git a/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php b/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php new file mode 100644 index 0000000000000..c08d83ac00505 --- /dev/null +++ b/app/code/Magento/Paypal/Test/Unit/Block/Adminhtml/Order/ViewTest.php @@ -0,0 +1,128 @@ +order = $this->createPartialMock( + Order::class, + ['canUnhold', 'isPaymentReview', 'getState', 'isCanceled', 'getPayment'] + ); + + $this->express = $this->createPartialMock( + Express::class, + ['isOrderAuthorizationAllowed'] + ); + + $this->payment = $this->createMock(Payment::class); + + $this->view = $objectManager->getObject( + View::class, + [ + 'express' => $this->express, + 'data' => [], + ] + ); + } + + /** + * Tests if authorization action is allowed for order. + * + * @param bool $canUnhold + * @param bool $isPaymentReview + * @param bool $isCanceled + * @param bool $authAllowed + * @param string $orderState + * @param bool $canAuthorize + * @throws LocalizedException + * @dataProvider orderDataProvider + */ + public function testIsOrderAuthorizationAllowed( + bool $canUnhold, + bool $isPaymentReview, + bool $isCanceled, + bool $authAllowed, + string $orderState, + bool $canAuthorize + ) { + $this->order->method('canUnhold') + ->willReturn($canUnhold); + + $this->order->method('isPaymentReview') + ->willReturn($isPaymentReview); + + $this->order->method('isCanceled') + ->willReturn($isCanceled); + + $this->order->method('getState') + ->willReturn($orderState); + + $this->order->method('getPayment') + ->willReturn($this->payment); + + $this->express->method('isOrderAuthorizationAllowed') + ->with($this->payment) + ->willReturn($authAllowed); + + $this->assertEquals($canAuthorize, $this->view->canAuthorize($this->order)); + } + + /** + * Data provider for order methods call. + * + * @return array + */ + public function orderDataProvider(): array + { + return [ + [true, false, false, true, Order::STATE_PROCESSING, false], + [false, true, false, true, Order::STATE_PROCESSING, false], + [false, false, true, true, Order::STATE_PROCESSING, false], + [false, false, false, false, Order::STATE_PROCESSING, false], + [false, false, false, true, Order::STATE_COMPLETE, false], + [false, false, false, true, Order::STATE_CLOSED, false], + [false, false, false, true, Order::STATE_PROCESSING, true], + ]; + } +} diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php new file mode 100644 index 0000000000000..0b7b9aeb2a7bb --- /dev/null +++ b/app/code/Magento/Paypal/Test/Unit/Model/Adminhtml/ExpressTest.php @@ -0,0 +1,218 @@ +nvp = $this->createPartialMock( + Nvp::class, + ['getData','setProcessableErrors', 'callDoAuthorization'] + ); + $this->nvp->method('getData')->willReturn([]); + $this->nvp->method('setProcessableErrors')->willReturnSelf(); + + $this->pro = $this->createPartialMock( + Pro::class, + ['setMethod', 'getApi', 'importPaymentInfo'] + ); + $this->pro->method('getApi')->willReturn($this->nvp); + + $this->transaction = $this->getMockForAbstractClass(TransactionInterface::class); + $this->transactionRepository = $this->createPartialMock( + TransactionRepository::class, + ['getByTransactionType'] + ); + $this->transactionRepository->method('getByTransactionType')->willReturn($this->transaction); + + $this->express = $objectManager->getObject( + Express::class, + [ + 'data' => [$this->pro], + 'transactionRepository' => $this->transactionRepository, + ] + ); + + $this->paymentInstance = $this->getMockForAbstractClass(MethodInterface::class); + $this->payment = $this->createPartialMock( + Payment::class, + [ + 'getAmountAuthorized', + 'getMethod', + 'getMethodInstance', + 'getId', + 'getOrder', + 'addTransaction', + 'addTransactionCommentsToOrder', + 'setAmountAuthorized', + ] + ); + $this->payment->method('getMethodInstance') + ->willReturn($this->paymentInstance); + + $this->payment->method('addTransaction') + ->willReturn($this->transaction); + } + + /** + * Tests payment authorization flow for order. + * + * @throws LocalizedException + */ + public function testAuthorizeOrder() + { + $this->order = $this->createPartialMock( + Order::class, + ['getId', 'getPayment', 'getTotalDue', 'getBaseTotalDue'] + ); + $this->order->method('getPayment') + ->willReturn($this->payment); + $this->order->method('getId') + ->willReturn(1); + + $totalDue = 15; + $baseTotalDue = 10; + + $this->order->method('getTotalDue') + ->willReturn($totalDue); + $this->order->method('getBaseTotalDue') + ->willReturn($baseTotalDue); + + $this->payment->method('getMethod') + ->willReturn('paypal_express'); + $this->payment->method('getId') + ->willReturn(1); + $this->payment->method('getOrder') + ->willReturn($this->order); + $this->payment->method('getAmountAuthorized') + ->willReturn(0); + + $this->paymentInstance->method('getConfigPaymentAction') + ->willReturn('order'); + + $this->nvp->expects(static::once()) + ->method('callDoAuthorization') + ->willReturnSelf(); + + $this->payment->expects(static::once()) + ->method('addTransaction') + ->with(Transaction::TYPE_AUTH) + ->willReturn($this->transaction); + + $this->payment->method('addTransactionCommentsToOrder') + ->with($this->transaction); + + $this->payment->method('setAmountAuthorized') + ->with($totalDue); + + $this->express->authorizeOrder($this->order); + } + + /** + * Checks if payment authorization is allowed. + * + * @param string $method + * @param string $action + * @param float $authorizedAmount + * @param bool $isAuthAllowed + * @throws LocalizedException + * @dataProvider paymentDataProvider + */ + public function testIsOrderAuthorizationAllowed( + string $method, + string $action, + float $authorizedAmount, + bool $isAuthAllowed + ) { + $this->payment->method('getMethod') + ->willReturn($method); + + $this->paymentInstance->method('getConfigPaymentAction') + ->willReturn($action); + + $this->payment->method('getAmountAuthorized') + ->willReturn($authorizedAmount); + + static::assertEquals($isAuthAllowed, $this->express->isOrderAuthorizationAllowed($this->payment)); + } + + /** + * Data provider for payment methods call. + * + * @return array + */ + public function paymentDataProvider(): array + { + return [ + ['paypal_express', 'sale', 10, false], + ['paypal_express', 'order', 50, false], + ['paypal_express', 'capture', 0, false], + ['paypal_express', 'order', 0, true], + ['braintree', 'authorize', 10, false], + ['braintree', 'authorize', 0, false], + ]; + } +} diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php index 10c432da77202..2ef08e4d4226b 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Api/NvpTest.php @@ -132,7 +132,9 @@ protected function _invokeNvpProperty(\Magento\Paypal\Model\Api\Nvp $nvpObject, public function testCall($response, $processableErrors, $exception, $exceptionMessage = '', $exceptionCode = null) { if (isset($exception)) { - $this->expectException($exception, $exceptionMessage, $exceptionCode); + $this->expectException($exception); + $this->expectExceptionMessage($exceptionMessage); + $this->expectExceptionCode($exceptionCode); } $this->curl->expects($this->once()) ->method('read') diff --git a/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php b/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php index 1b8c33622e784..abedc573558f1 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/ExpressTest.php @@ -5,13 +5,21 @@ */ namespace Magento\Paypal\Test\Unit\Model; +use Magento\Checkout\Model\Session; use Magento\Framework\DataObject; use Magento\Framework\Event\ManagerInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Payment\Model\InfoInterface; use Magento\Payment\Observer\AbstractDataAssignObserver; +use Magento\Paypal\Model\Api\Nvp; use Magento\Paypal\Model\Api\ProcessableException as ApiProcessableException; use Magento\Paypal\Model\Express; +use Magento\Paypal\Model\Pro; use Magento\Quote\Api\Data\PaymentInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; +use Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface; +use \PHPUnit_Framework_MockObject_MockObject as MockObject; /** * Class ExpressTest @@ -38,123 +46,122 @@ class ExpressTest extends \PHPUnit\Framework\TestCase /** * @var Express */ - protected $_model; + private $model; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Session|MockObject */ - protected $_checkoutSession; + private $checkoutSession; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Pro|MockObject */ - protected $_pro; + private $pro; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Nvp|MockObject */ - protected $_nvp; + private $nvp; /** - * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager + * @var ObjectManager */ - protected $_helper; + private $helper; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var BuilderInterface|MockObject */ - protected $transactionBuilder; + private $transactionBuilder; /** - * @var ManagerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ManagerInterface|MockObject */ - private $eventManagerMock; + private $eventManager; protected function setUp() { - $this->_checkoutSession = $this->createPartialMock( - \Magento\Checkout\Model\Session::class, + $this->checkoutSession = $this->createPartialMock( + Session::class, ['getPaypalTransactionData', 'setPaypalTransactionData'] ); $this->transactionBuilder = $this->getMockForAbstractClass( - \Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface::class, + BuilderInterface::class, [], '', false, false ); - $this->_nvp = $this->createPartialMock( - \Magento\Paypal\Model\Api\Nvp::class, - ['setProcessableErrors', 'setAmount', 'setCurrencyCode', 'setTransactionId', 'callDoAuthorization'] + $this->nvp = $this->createPartialMock( + Nvp::class, + [ + 'setProcessableErrors', + 'setAmount', + 'setCurrencyCode', + 'setTransactionId', + 'callDoAuthorization', + 'setData', + ] ); - $this->_pro = $this->createPartialMock( - \Magento\Paypal\Model\Pro::class, + $this->pro = $this->createPartialMock( + Pro::class, ['setMethod', 'getApi', 'importPaymentInfo', 'resetApi'] ); - $this->eventManagerMock = $this->getMockBuilder(ManagerInterface::class) + $this->eventManager = $this->getMockBuilder(ManagerInterface::class) ->setMethods(['dispatch']) ->getMockForAbstractClass(); - $this->_pro->expects($this->any())->method('getApi')->will($this->returnValue($this->_nvp)); - $this->_helper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->pro->expects($this->any())->method('getApi')->will($this->returnValue($this->nvp)); + $this->helper = new ObjectManager($this); } public function testSetApiProcessableErrors() { - $this->_nvp->expects($this->once())->method('setProcessableErrors')->with($this->errorCodes); + $this->nvp->expects($this->once())->method('setProcessableErrors')->with($this->errorCodes); - $this->_model = $this->_helper->getObject( + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, - 'transactionBuilder' => $this->transactionBuilder + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession, + 'transactionBuilder' => $this->transactionBuilder, ] ); } + /** + * Tests order payment action. + * + * @return void + */ public function testOrder() { - $this->_nvp->expects($this->any())->method('setProcessableErrors')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setAmount')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setCurrencyCode')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('setTransactionId')->will($this->returnSelf()); - $this->_nvp->expects($this->any())->method('callDoAuthorization')->will($this->returnSelf()); - - $this->_checkoutSession->expects($this->once())->method('getPaypalTransactionData')->will( - $this->returnValue([]) - ); - $this->_checkoutSession->expects($this->once())->method('setPaypalTransactionData')->with([]); - - $currency = $this->createPartialMock(\Magento\Directory\Model\Currency::class, ['__wakeup', 'formatTxt']); - $paymentModel = $this->createPartialMock(\Magento\Sales\Model\Order\Payment::class, [ - '__wakeup', - 'getBaseCurrency', - 'getOrder', - 'getIsTransactionPending', - 'addStatusHistoryComment', - 'addTransactionCommentsToOrder' - ]); - $order = $this->createPartialMock( - \Magento\Sales\Model\Order::class, - ['setState', 'getBaseCurrency', 'getBaseCurrencyCode', 'setStatus'] - ); - $paymentModel->expects($this->any())->method('getOrder')->willReturn($order); - $order->expects($this->any())->method('getBaseCurrency')->willReturn($currency); - $order->expects($this->any())->method('setState')->with('payment_review')->willReturnSelf(); - $paymentModel->expects($this->any())->method('getIsTransactionPending')->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setOrder')->with($order)->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setPayment')->will($this->returnSelf()); - $this->transactionBuilder->expects($this->any())->method('setTransactionId')->will($this->returnSelf()); - $this->_model = $this->_helper->getObject( + $transactionData = ['TOKEN' => 'EC-7NJ4634216284232D']; + $this->checkoutSession + ->method('getPaypalTransactionData') + ->willReturn($transactionData); + + $order = $this->createPartialMock(Order::class, ['setActionFlag']); + $order->method('setActionFlag') + ->with(Order::ACTION_FLAG_INVOICE, false) + ->willReturnSelf(); + + $paymentModel = $this->createPartialMock(Payment::class, ['getOrder']); + $paymentModel->method('getOrder') + ->willReturn($order); + + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, - 'transactionBuilder' => $this->transactionBuilder + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession, ] ); - $this->assertEquals($this->_model, $this->_model->order($paymentModel, 12.3)); + + $this->nvp->method('setData') + ->with($transactionData) + ->willReturnSelf(); + + static::assertEquals($this->model, $this->model->order($paymentModel, 12.3)); } public function testAssignData() @@ -180,18 +187,18 @@ public function testAssignData() ] ); - $this->_model = $this->_helper->getObject( + $this->model = $this->helper->getObject( \Magento\Paypal\Model\Express::class, [ - 'data' => [$this->_pro], - 'checkoutSession' => $this->_checkoutSession, + 'data' => [$this->pro], + 'checkoutSession' => $this->checkoutSession, 'transactionBuilder' => $this->transactionBuilder, - 'eventDispatcher' => $this->eventManagerMock, + 'eventDispatcher' => $this->eventManager, ] ); $paymentInfo = $this->createMock(InfoInterface::class); - $this->_model->setInfoInstance($paymentInfo); + $this->model->setInfoInstance($paymentInfo); $this->parentAssignDataExpectation($data); @@ -203,7 +210,7 @@ public function testAssignData() [Express\Checkout::PAYMENT_INFO_TRANSPORT_TOKEN, $transportValue] ); - $this->_model->assignData($data); + $this->model->assignData($data); } /** @@ -213,21 +220,21 @@ private function parentAssignDataExpectation(DataObject $data) { $eventData = [ AbstractDataAssignObserver::METHOD_CODE => $this, - AbstractDataAssignObserver::MODEL_CODE => $this->_model->getInfoInstance(), + AbstractDataAssignObserver::MODEL_CODE => $this->model->getInfoInstance(), AbstractDataAssignObserver::DATA_CODE => $data ]; - $this->eventManagerMock->expects(static::exactly(2)) + $this->eventManager->expects(static::exactly(2)) ->method('dispatch') ->willReturnMap( [ [ - 'payment_method_assign_data_' . $this->_model->getCode(), - $eventData + 'payment_method_assign_data_' . $this->model->getCode(), + $eventData, ], [ 'payment_method_assign_data', - $eventData + $eventData, ] ] ); diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Response/Handler/HandlerCompositeTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Response/Handler/HandlerCompositeTest.php index b24ca640dea2d..dd1b4c3242732 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Response/Handler/HandlerCompositeTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Payflow/Service/Response/Handler/HandlerCompositeTest.php @@ -23,8 +23,8 @@ public function testConstructorSuccess() public function testConstructorException() { - $this->expectException( - 'LogicException', + $this->expectException('LogicException'); + $this->expectExceptionMessage( 'Type mismatch. Expected type: HandlerInterface. Actual: string, Code: weird_handler' ); diff --git a/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php b/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php index b0bc3d641743c..c8998f0c24129 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/ProTest.php @@ -119,6 +119,9 @@ public function testCapture() ->method('getOrder') ->willReturn($orderMock); + $paymentMock->method('isCaptureFinal') + ->willReturn(true); + $this->apiMock->expects(static::once()) ->method('getTransactionId') ->willReturn(45); @@ -236,16 +239,14 @@ protected function getPaymentMock() $paymentMock = $this->getMockBuilder(\Magento\Payment\Model\Info::class) ->disableOriginalConstructor() ->setMethods([ - 'getParentTransactionId', 'getOrder', 'getShouldCloseParentTransaction' + 'getParentTransactionId', 'getOrder', 'getShouldCloseParentTransaction', 'isCaptureFinal', ]) ->getMock(); $parentTransactionId = 43; $paymentMock->expects(static::once()) ->method('getParentTransactionId') ->willReturn($parentTransactionId); - $paymentMock->expects(static::once()) - ->method('getShouldCloseParentTransaction') - ->willReturn(true); + return $paymentMock; } diff --git a/app/code/Magento/Paypal/composer.json b/app/code/Magento/Paypal/composer.json index 65a6952e55600..8bc05710452bb 100644 --- a/app/code/Magento/Paypal/composer.json +++ b/app/code/Magento/Paypal/composer.json @@ -5,31 +5,30 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "lib-libxml": "*", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-checkout": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-instant-purchase": "100.3.*", - "magento/module-payment": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-vault": "100.3.*" + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-checkout": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-instant-purchase": "*", + "magento/module-payment": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-theme": "*", + "magento/module-ui": "*", + "magento/module-vault": "*" }, "suggest": { - "magento/module-checkout-agreements": "100.3.*" + "magento/module-checkout-agreements": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "proprietary" ], diff --git a/app/code/Magento/Paypal/etc/acl.xml b/app/code/Magento/Paypal/etc/acl.xml index 0344af13519e5..4373ebc4c12bf 100644 --- a/app/code/Magento/Paypal/etc/acl.xml +++ b/app/code/Magento/Paypal/etc/acl.xml @@ -33,6 +33,11 @@ + + + + + diff --git a/app/code/Magento/Paypal/etc/db_schema.xml b/app/code/Magento/Paypal/etc/db_schema.xml index 5d9a1faa2675e..2703ee4f5be30 100644 --- a/app/code/Magento/Paypal/etc/db_schema.xml +++ b/app/code/Magento/Paypal/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> @@ -145,7 +145,7 @@
    - +
    diff --git a/app/code/Magento/Paypal/etc/di.xml b/app/code/Magento/Paypal/etc/di.xml index 3071907af4717..c0141bbb3215e 100644 --- a/app/code/Magento/Paypal/etc/di.xml +++ b/app/code/Magento/Paypal/etc/di.xml @@ -67,6 +67,12 @@ + + + + + + Magento\Paypal\Model\Config::METHOD_WPP_PE_EXPRESS diff --git a/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml b/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml new file mode 100644 index 0000000000000..3c11109b7fd63 --- /dev/null +++ b/app/code/Magento/Paypal/view/adminhtml/layout/sales_order_view.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml index 4f514806eeb89..73c44faff5a57 100644 --- a/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/Paypal/view/frontend/layout/checkout_index_index.xml @@ -44,6 +44,15 @@ true + + false + + + false + + + false + diff --git a/app/code/Magento/Persistent/composer.json b/app/code/Magento/Persistent/composer.json index 83a9655c6a647..4a304c549b760 100644 --- a/app/code/Magento/Persistent/composer.json +++ b/app/code/Magento/Persistent/composer.json @@ -5,17 +5,16 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-cron": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-page-cache": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-checkout": "*", + "magento/module-cron": "*", + "magento/module-customer": "*", + "magento/module-page-cache": "*", + "magento/module-quote": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Persistent/etc/db_schema.xml b/app/code/Magento/Persistent/etc/db_schema.xml index 5035f3332ea4c..68678fc60f096 100644 --- a/app/code/Magento/Persistent/etc/db_schema.xml +++ b/app/code/Magento/Persistent/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    @@ -37,7 +37,7 @@
    - +
    diff --git a/app/code/Magento/ProductAlert/Block/Product/ImageProvider.php b/app/code/Magento/ProductAlert/Block/Product/ImageProvider.php index 0971dd4c27664..61d8d1987c2d7 100644 --- a/app/code/Magento/ProductAlert/Block/Product/ImageProvider.php +++ b/app/code/Magento/ProductAlert/Block/Product/ImageProvider.php @@ -60,10 +60,7 @@ public function getImage(Product $product, $imageId, $attributes = []) $this->appEmulation->startEnvironmentEmulation($storeId, Area::AREA_FRONTEND, true); try { - $image = $this->imageBuilder->setProduct($product) - ->setImageId($imageId) - ->setAttributes($attributes) - ->create(); + $image = $this->imageBuilder->create($product, $imageId, $attributes); } catch (\Exception $exception) { $this->appEmulation->stopEnvironmentEmulation(); throw $exception; diff --git a/app/code/Magento/ProductAlert/Model/Observer.php b/app/code/Magento/ProductAlert/Model/Observer.php index df046c4e4af8f..1870989f11dc7 100644 --- a/app/code/Magento/ProductAlert/Model/Observer.php +++ b/app/code/Magento/ProductAlert/Model/Observer.php @@ -113,6 +113,11 @@ class Observer */ protected $inlineTranslation; + /** + * @var ProductSalability + */ + protected $productSalability; + /** * @param \Magento\Catalog\Helper\Data $catalogData * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig @@ -125,6 +130,7 @@ class Observer * @param \Magento\Framework\Mail\Template\TransportBuilder $transportBuilder * @param \Magento\ProductAlert\Model\EmailFactory $emailFactory * @param \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation + * @param ProductSalability|null $productSalability * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -138,7 +144,8 @@ public function __construct( \Magento\ProductAlert\Model\ResourceModel\Stock\CollectionFactory $stockColFactory, \Magento\Framework\Mail\Template\TransportBuilder $transportBuilder, \Magento\ProductAlert\Model\EmailFactory $emailFactory, - \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation + \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation, + ProductSalability $productSalability = null ) { $this->_catalogData = $catalogData; $this->_scopeConfig = $scopeConfig; @@ -151,6 +158,8 @@ public function __construct( $this->_transportBuilder = $transportBuilder; $this->_emailFactory = $emailFactory; $this->inlineTranslation = $inlineTranslation; + $objectManager = \Magento\Framework\App\ObjectManager::getInstance(); + $this->productSalability = $productSalability ?: $objectManager->get(ProductSalability::class); } /** @@ -326,7 +335,7 @@ protected function _processStock(\Magento\ProductAlert\Model\Email $email) $product->setCustomerGroupId($customer->getGroupId()); - if ($product->isSalable()) { + if ($this->productSalability->isSalable($product, $website)) { $email->addStockProduct($product); $alert->setSendDate($this->_dateFactory->create()->gmtDate()); diff --git a/app/code/Magento/ProductAlert/Model/ProductSalability.php b/app/code/Magento/ProductAlert/Model/ProductSalability.php new file mode 100644 index 0000000000000..dffb69ee092d8 --- /dev/null +++ b/app/code/Magento/ProductAlert/Model/ProductSalability.php @@ -0,0 +1,28 @@ +isSalable(); + } +} diff --git a/app/code/Magento/ProductAlert/Test/Unit/Block/Product/ImageProviderTest.php b/app/code/Magento/ProductAlert/Test/Unit/Block/Product/ImageProviderTest.php index 39b07161934f8..172e5f486f30b 100644 --- a/app/code/Magento/ProductAlert/Test/Unit/Block/Product/ImageProviderTest.php +++ b/app/code/Magento/ProductAlert/Test/Unit/Block/Product/ImageProviderTest.php @@ -54,23 +54,16 @@ public function testGetImage() $imageId = 'test_image_id'; $attributes = []; - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) - ->disableOriginalConstructor() - ->getMock(); - $imageMock = $this->getMockBuilder(\Magento\Catalog\Block\Product\Image::class) - ->disableOriginalConstructor() - ->getMock(); - $storeMock = $this->getMockBuilder(\Magento\Store\Api\Data\StoreInterface::class) - ->disableOriginalConstructor() - ->getMock(); + $productMock = $this->createMock(\Magento\Catalog\Model\Product::class); + $imageMock = $this->createMock(\Magento\Catalog\Block\Product\Image::class); + $storeMock = $this->createMock(\Magento\Store\Api\Data\StoreInterface::class); $this->storeManagerMock->expects($this->atLeastOnce())->method('getStore')->willReturn($storeMock); - $storeMock->expects($this->atLeastOnce())->method('getId')->willReturn(42); $this->emulationMock->expects($this->once())->method('startEnvironmentEmulation'); - $this->imageBuilderMock->expects($this->once())->method('setProduct')->with($productMock)->willReturnSelf(); - $this->imageBuilderMock->expects($this->once())->method('setImageId')->with($imageId)->willReturnSelf(); - $this->imageBuilderMock->expects($this->once())->method('setAttributes')->with($attributes)->willReturnSelf(); - $this->imageBuilderMock->expects($this->once())->method('create')->willReturn($imageMock); + $this->imageBuilderMock->expects($this->once()) + ->method('create') + ->with($productMock, $imageId, $attributes) + ->willReturn($imageMock); $this->emulationMock->expects($this->once())->method('stopEnvironmentEmulation'); $this->assertEquals($imageMock, $this->model->getImage($productMock, $imageId, $attributes)); @@ -84,6 +77,7 @@ public function testGetImage() */ public function testGetImageThrowsAnException() { + $imageId = 1; $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) ->disableOriginalConstructor() ->getMock(); @@ -93,13 +87,13 @@ public function testGetImageThrowsAnException() $this->emulationMock->expects($this->once())->method('startEnvironmentEmulation'); $this->storeManagerMock->expects($this->atLeastOnce())->method('getStore')->willReturn($storeMock); - $storeMock->expects($this->atLeastOnce())->method('getId')->willReturn(42); $this->imageBuilderMock->expects($this->once()) - ->method('setProduct') + ->method('create') + ->with($productMock, $imageId) ->willThrowException(new \Exception("Image Builder Exception")); $this->emulationMock->expects($this->once())->method('stopEnvironmentEmulation'); - $this->model->getImage($productMock, 1); + $this->model->getImage($productMock, $imageId); } } diff --git a/app/code/Magento/ProductAlert/Test/Unit/Model/ObserverTest.php b/app/code/Magento/ProductAlert/Test/Unit/Model/ObserverTest.php index 54376caa653cc..9cd8546a0180b 100644 --- a/app/code/Magento/ProductAlert/Test/Unit/Model/ObserverTest.php +++ b/app/code/Magento/ProductAlert/Test/Unit/Model/ObserverTest.php @@ -6,6 +6,7 @@ namespace Magento\ProductAlert\Test\Unit\Model; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\ProductAlert\Model\ProductSalability; /** * Class ObserverTest @@ -109,6 +110,11 @@ class ObserverTest extends \PHPUnit\Framework\TestCase */ private $objectManagerMock; + /** + * @var ProductSalability|\PHPUnit_Framework_MockObject_MockObject + */ + private $productSalabilityMock; + protected function setUp() { $this->objectManagerMock = $this->getMockBuilder(\Magento\Framework\ObjectManagerInterface::class) @@ -170,10 +176,11 @@ protected function setUp() [ 'setCustomerGroupId', 'getFinalPrice', - 'isSalable', ] )->getMock(); + $this->productSalabilityMock = $this->createPartialMock(ProductSalability::class, ['isSalable']); + $this->objectManager = new ObjectManager($this); $this->observer = $this->objectManager->getObject( \Magento\ProductAlert\Model\Observer::class, @@ -187,7 +194,8 @@ protected function setUp() 'priceColFactory' => $this->priceColFactoryMock, 'stockColFactory' => $this->stockColFactoryMock, 'customerRepository' => $this->customerRepositoryMock, - 'productRepository' => $this->productRepositoryMock + 'productRepository' => $this->productRepositoryMock, + 'productSalability' => $this->productSalabilityMock ] ); } @@ -391,7 +399,7 @@ public function testProcessStockEmailThrowsException() $this->customerRepositoryMock->expects($this->once())->method('getById')->willReturn($customer); $this->productMock->expects($this->once())->method('setCustomerGroupId')->willReturnSelf(); - $this->productMock->expects($this->once())->method('isSalable')->willReturn(false); + $this->productSalabilityMock->expects($this->once())->method('isSalable')->willReturn(false); $this->productRepositoryMock->expects($this->once())->method('getById')->willReturn($this->productMock); $this->emailMock->expects($this->once())->method('send')->willThrowException(new \Exception()); diff --git a/app/code/Magento/ProductAlert/composer.json b/app/code/Magento/ProductAlert/composer.json index be3c30aa94626..e8488187f839a 100644 --- a/app/code/Magento/ProductAlert/composer.json +++ b/app/code/Magento/ProductAlert/composer.json @@ -5,18 +5,17 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-customer": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-customer": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-config": "100.3.*" + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ProductAlert/etc/db_schema.xml b/app/code/Magento/ProductAlert/etc/db_schema.xml index 8aa149277c62a..ddf8be8a37e9c 100644 --- a/app/code/Magento/ProductAlert/etc/db_schema.xml +++ b/app/code/Magento/ProductAlert/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> diff --git a/app/code/Magento/ProductVideo/Block/Product/View/Gallery.php b/app/code/Magento/ProductVideo/Block/Product/View/Gallery.php index 78730d625e44c..2277aa980f66c 100644 --- a/app/code/Magento/ProductVideo/Block/Product/View/Gallery.php +++ b/app/code/Magento/ProductVideo/Block/Product/View/Gallery.php @@ -11,6 +11,8 @@ */ namespace Magento\ProductVideo\Block\Product\View; +use Magento\Catalog\Model\Product\Image\UrlBuilder; + /** * @api * @since 100.0.2 @@ -28,7 +30,7 @@ class Gallery extends \Magento\Catalog\Block\Product\View\Gallery * @param \Magento\Framework\Json\EncoderInterface $jsonEncoder * @param \Magento\ProductVideo\Helper\Media $mediaHelper * @param array $data - * @param \Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface $imagesConfigFactory + * @param \Magento\Catalog\Model\Product\Gallery\ImagesConfigFactoryInterface|null $imagesConfigFactory * @param array $galleryImagesConfig */ public function __construct( diff --git a/app/code/Magento/ProductVideo/composer.json b/app/code/Magento/ProductVideo/composer.json index 5292eaab2311f..829816321c1a5 100644 --- a/app/code/Magento/ProductVideo/composer.json +++ b/app/code/Magento/ProductVideo/composer.json @@ -5,21 +5,20 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", "magento/magento-composer-installer": "*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-eav": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-store": "100.3.*" + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-eav": "*", + "magento/module-media-storage": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-customer": "100.3.*", - "magento/module-config": "100.3.*" + "magento/module-customer": "*", + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "proprietary" ], diff --git a/app/code/Magento/ProductVideo/etc/db_schema.xml b/app/code/Magento/ProductVideo/etc/db_schema.xml index b17252ecf127d..ceaf4d7fbd85d 100644 --- a/app/code/Magento/ProductVideo/etc/db_schema.xml +++ b/app/code/Magento/ProductVideo/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    userContext = $userContext; + } + + /** + * {@inheritdoc} + */ + public function isAllowed(CartInterface $quote): bool + { + switch ($this->userContext->getUserType()) { + case UserContextInterface::USER_TYPE_CUSTOMER: + $isAllowed = ($quote->getCustomerId() == $this->userContext->getUserId()); + break; + case UserContextInterface::USER_TYPE_GUEST: + $isAllowed = ($quote->getCustomerId() === null); + break; + case UserContextInterface::USER_TYPE_ADMIN: + case UserContextInterface::USER_TYPE_INTEGRATION: + $isAllowed = true; + break; + default: + $isAllowed = false; + } + + return $isAllowed; + } +} diff --git a/app/code/Magento/Quote/Model/Quote.php b/app/code/Magento/Quote/Model/Quote.php index 5039af9a9081d..ad90b75cbb0d5 100644 --- a/app/code/Magento/Quote/Model/Quote.php +++ b/app/code/Magento/Quote/Model/Quote.php @@ -14,6 +14,7 @@ use Magento\Quote\Model\Quote\Address; use Magento\Quote\Model\Quote\Address\Total as AddressTotal; use Magento\Sales\Model\Status; +use Magento\Framework\App\ObjectManager; /** * Quote model @@ -353,6 +354,11 @@ class Quote extends AbstractExtensibleModel implements \Magento\Quote\Api\Data\C */ protected $shippingAddressesItems; + /** + * @var \Magento\Sales\Model\OrderIncrementIdChecker + */ + private $orderIncrementIdChecker; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\Registry $registry @@ -394,6 +400,7 @@ class Quote extends AbstractExtensibleModel implements \Magento\Quote\Api\Data\C * @param \Magento\Framework\Model\ResourceModel\AbstractResource|null $resource * @param \Magento\Framework\Data\Collection\AbstractDb|null $resourceCollection * @param array $data + * @param \Magento\Sales\Model\OrderIncrementIdChecker|null $orderIncrementIdChecker * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -436,7 +443,8 @@ public function __construct( \Magento\Quote\Model\ShippingAssignmentFactory $shippingAssignmentFactory, \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, - array $data = [] + array $data = [], + \Magento\Sales\Model\OrderIncrementIdChecker $orderIncrementIdChecker = null ) { $this->quoteValidator = $quoteValidator; $this->_catalogProduct = $catalogProduct; @@ -471,6 +479,8 @@ public function __construct( $this->totalsReader = $totalsReader; $this->shippingFactory = $shippingFactory; $this->shippingAssignmentFactory = $shippingAssignmentFactory; + $this->orderIncrementIdChecker = $orderIncrementIdChecker ?: ObjectManager::getInstance() + ->get(\Magento\Sales\Model\OrderIncrementIdChecker::class); parent::__construct( $context, $registry, @@ -2184,7 +2194,7 @@ public function reserveOrderId() } else { //checking if reserved order id was already used for some order //if yes reserving new one if not using old one - if ($this->_getResource()->isOrderIncrementIdUsed($this->getReservedOrderId())) { + if ($this->orderIncrementIdChecker->isIncrementIdUsed($this->getReservedOrderId())) { $this->setReservedOrderId($this->_getResource()->getReservedOrderId($this)); } } @@ -2378,8 +2388,9 @@ protected function _afterLoad() { // collect totals and save me, if required if (1 == $this->getTriggerRecollect()) { - $this->collectTotals()->save(); - $this->setTriggerRecollect(0); + $this->collectTotals() + ->setTriggerRecollect(0) + ->save(); } return parent::_afterLoad(); } diff --git a/app/code/Magento/Quote/Model/QuoteManagement.php b/app/code/Magento/Quote/Model/QuoteManagement.php index d062c0b4d05c1..13021ee412754 100644 --- a/app/code/Magento/Quote/Model/QuoteManagement.php +++ b/app/code/Magento/Quote/Model/QuoteManagement.php @@ -411,17 +411,24 @@ public function submit(QuoteEntity $quote, $orderData = []) */ protected function resolveItems(QuoteEntity $quote) { - $quoteItems = []; - foreach ($quote->getAllItems() as $quoteItem) { - /** @var \Magento\Quote\Model\ResourceModel\Quote\Item $quoteItem */ - $quoteItems[$quoteItem->getId()] = $quoteItem; - } $orderItems = []; - foreach ($quoteItems as $quoteItem) { - $parentItem = (isset($orderItems[$quoteItem->getParentItemId()])) ? - $orderItems[$quoteItem->getParentItemId()] : null; - $orderItems[$quoteItem->getId()] = - $this->quoteItemToOrderItem->convert($quoteItem, ['parent_item' => $parentItem]); + foreach ($quote->getAllItems() as $quoteItem) { + $itemId = $quoteItem->getId(); + + if (!empty($orderItems[$itemId])) { + continue; + } + + $parentItemId = $quoteItem->getParentItemId(); + /** @var \Magento\Quote\Model\ResourceModel\Quote\Item $parentItem */ + if ($parentItemId && !isset($orderItems[$parentItemId])) { + $orderItems[$parentItemId] = $this->quoteItemToOrderItem->convert( + $quoteItem->getParentItem(), + ['parent_item' => null] + ); + } + $parentItem = isset($orderItems[$parentItemId]) ? $orderItems[$parentItemId] : null; + $orderItems[$itemId] = $this->quoteItemToOrderItem->convert($quoteItem, ['parent_item' => $parentItem]); } return array_values($orderItems); } diff --git a/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php b/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php index 3eff09faac1f5..79b518fc54534 100644 --- a/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php +++ b/app/code/Magento/Quote/Model/QuoteRepository/Plugin/AccessChangeQuoteControl.php @@ -3,10 +3,10 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Quote\Model\QuoteRepository\Plugin; -use Magento\Authorization\Model\UserContextInterface; -use Magento\Quote\Model\Quote; +use Magento\Quote\Api\ChangeQuoteControlInterface; use Magento\Framework\Exception\StateException; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Api\Data\CartInterface; @@ -17,24 +17,23 @@ class AccessChangeQuoteControl { /** - * @var UserContextInterface + * @var ChangeQuoteControlInterface $changeQuoteControl */ - private $userContext; + private $changeQuoteControl; /** - * @param UserContextInterface $userContext + * @param ChangeQuoteControlInterface $changeQuoteControl */ - public function __construct( - UserContextInterface $userContext - ) { - $this->userContext = $userContext; + public function __construct(ChangeQuoteControlInterface $changeQuoteControl) + { + $this->changeQuoteControl = $changeQuoteControl; } /** * Checks if change quote's customer id is allowed for current user. * * @param CartRepositoryInterface $subject - * @param Quote $quote + * @param CartInterface $quote * @throws StateException if Guest has customer_id or Customer's customer_id not much with user_id * or unknown user's type * @return void @@ -42,34 +41,8 @@ public function __construct( */ public function beforeSave(CartRepositoryInterface $subject, CartInterface $quote) { - if (!$this->isAllowed($quote)) { + if (! $this->changeQuoteControl->isAllowed($quote)) { throw new StateException(__("Invalid state change requested")); } } - - /** - * Checks if user is allowed to change the quote. - * - * @param Quote $quote - * @return bool - */ - private function isAllowed(Quote $quote) - { - switch ($this->userContext->getUserType()) { - case UserContextInterface::USER_TYPE_CUSTOMER: - $isAllowed = ($quote->getCustomerId() == $this->userContext->getUserId()); - break; - case UserContextInterface::USER_TYPE_GUEST: - $isAllowed = ($quote->getCustomerId() === null); - break; - case UserContextInterface::USER_TYPE_ADMIN: - case UserContextInterface::USER_TYPE_INTEGRATION: - $isAllowed = true; - break; - default: - $isAllowed = false; - } - - return $isAllowed; - } } diff --git a/app/code/Magento/Quote/Model/ResourceModel/Quote.php b/app/code/Magento/Quote/Model/ResourceModel/Quote.php index e171d88dae6f1..946c0e0c5f3b8 100644 --- a/app/code/Magento/Quote/Model/ResourceModel/Quote.php +++ b/app/code/Magento/Quote/Model/ResourceModel/Quote.php @@ -170,29 +170,6 @@ public function getReservedOrderId($quote) ->getNextValue(); } - /** - * Check if order increment ID is already used. - * Method can be used to avoid collisions of order IDs. - * - * @param int $orderIncrementId - * @return bool - */ - public function isOrderIncrementIdUsed($orderIncrementId) - { - /** @var \Magento\Framework\DB\Adapter\AdapterInterface $adapter */ - $adapter = $this->getConnection(); - $bind = [':increment_id' => $orderIncrementId]; - /** @var \Magento\Framework\DB\Select $select */ - $select = $adapter->select(); - $select->from($this->getTable('sales_order'), 'entity_id')->where('increment_id = :increment_id'); - $entity_id = $adapter->fetchOne($select, $bind); - if ($entity_id > 0) { - return true; - } - - return false; - } - /** * Mark quotes - that depend on catalog price rules - to be recollected on demand * diff --git a/app/code/Magento/Quote/Model/ResourceModel/Quote/Item/Collection.php b/app/code/Magento/Quote/Model/ResourceModel/Quote/Item/Collection.php index 6a04c34a23ec4..0487d7e46eb26 100644 --- a/app/code/Magento/Quote/Model/ResourceModel/Quote/Item/Collection.php +++ b/app/code/Magento/Quote/Model/ResourceModel/Quote/Item/Collection.php @@ -156,18 +156,20 @@ protected function _afterLoad() { parent::_afterLoad(); - /** - * Assign parent items - */ + $productIds = []; foreach ($this as $item) { + // Assign parent items if ($item->getParentItemId()) { $item->setParentItem($this->getItemById($item->getParentItemId())); } if ($this->_quote) { $item->setQuote($this->_quote); } + // Collect quote products ids + $productIds[] = (int)$item->getProductId(); } - + $this->_productIds = array_merge($this->_productIds, $productIds); + $this->removeItemsWithAbsentProducts(); /** * Assign options and products */ @@ -205,12 +207,6 @@ protected function _assignOptions() protected function _assignProducts() { \Magento\Framework\Profiler::start('QUOTE:' . __METHOD__, ['group' => 'QUOTE', 'method' => __METHOD__]); - $productIds = []; - foreach ($this as $item) { - $productIds[] = (int)$item->getProductId(); - } - $this->_productIds = array_merge($this->_productIds, $productIds); - $productCollection = $this->_productCollectionFactory->create()->setStoreId( $this->getStoreId() )->addIdFilter( @@ -305,4 +301,24 @@ private function addTierPriceData(ProductCollection $productCollection) $productCollection->addTierPriceDataByGroupId($this->_quote->getCustomerGroupId()); } } + + /** + * Find and remove quote items with non existing products + * + * @return void + */ + private function removeItemsWithAbsentProducts() + { + $productCollection = $this->_productCollectionFactory->create()->addIdFilter($this->_productIds); + $existingProductsIds = $productCollection->getAllIds(); + $absentProductsIds = array_diff($this->_productIds, $existingProductsIds); + // Remove not existing products from items collection + if (!empty($absentProductsIds)) { + foreach ($absentProductsIds as $productIdToExclude) { + /** @var \Magento\Quote\Model\Quote\Item $quoteItem */ + $quoteItem = $this->getItemByColumnValue('product_id', $productIdToExclude); + $this->removeItemByKey($quoteItem->getId()); + } + } + } } diff --git a/app/code/Magento/Quote/Observer/Webapi/SubmitObserver.php b/app/code/Magento/Quote/Observer/SubmitObserver.php similarity index 94% rename from app/code/Magento/Quote/Observer/Webapi/SubmitObserver.php rename to app/code/Magento/Quote/Observer/SubmitObserver.php index 4f1e66dcc724b..1213636e5966b 100644 --- a/app/code/Magento/Quote/Observer/Webapi/SubmitObserver.php +++ b/app/code/Magento/Quote/Observer/SubmitObserver.php @@ -3,7 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ -namespace Magento\Quote\Observer\Webapi; +namespace Magento\Quote\Observer; use Magento\Sales\Model\Order\Email\Sender\OrderSender; use Magento\Framework\Event\ObserverInterface; @@ -13,12 +13,12 @@ class SubmitObserver implements ObserverInterface /** * @var \Psr\Log\LoggerInterface */ - protected $logger; + private $logger; /** * @var OrderSender */ - protected $orderSender; + private $orderSender; /** * @param \Psr\Log\LoggerInterface $logger diff --git a/app/code/Magento/Quote/Setup/Patch/Data/ConvertSerializedDataToJson.php b/app/code/Magento/Quote/Setup/Patch/Data/ConvertSerializedDataToJson.php index eb190f001409d..f537280272227 100644 --- a/app/code/Magento/Quote/Setup/Patch/Data/ConvertSerializedDataToJson.php +++ b/app/code/Magento/Quote/Setup/Patch/Data/ConvertSerializedDataToJson.php @@ -9,8 +9,8 @@ use Magento\Framework\App\ResourceConnection; use Magento\Quote\Setup\ConvertSerializedDataToJsonFactory; use Magento\Quote\Setup\QuoteSetupFactory; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ConvertSerializedDataToJson diff --git a/app/code/Magento/Quote/Setup/Patch/Data/InstallEntityTypes.php b/app/code/Magento/Quote/Setup/Patch/Data/InstallEntityTypes.php index 59c821a0afeec..24ff3ecad58e6 100644 --- a/app/code/Magento/Quote/Setup/Patch/Data/InstallEntityTypes.php +++ b/app/code/Magento/Quote/Setup/Patch/Data/InstallEntityTypes.php @@ -9,8 +9,8 @@ use Magento\Framework\DB\Ddl\Table; use Magento\Quote\Setup\QuoteSetup; use Magento\Quote\Setup\QuoteSetupFactory; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /*** * Class InstallEntityTypes diff --git a/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php b/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php index f330ebda17317..043e04319362d 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/QuoteRepository/Plugin/AccessChangeQuoteControlTest.php @@ -3,9 +3,11 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Quote\Test\Unit\Model\QuoteRepository\Plugin; use Magento\Authorization\Model\UserContextInterface; +use Magento\Quote\Model\ChangeQuoteControl; use Magento\Quote\Model\QuoteRepository\Plugin\AccessChangeQuoteControl; use Magento\Quote\Model\Quote; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; @@ -34,6 +36,11 @@ class AccessChangeQuoteControlTest extends \PHPUnit\Framework\TestCase */ private $quoteRepositoryMock; + /** + * @var ChangeQuoteControl|MockObject + */ + private $changeQuoteControlMock; + protected function setUp() { $this->userContextMock = $this->getMockBuilder(UserContextInterface::class) @@ -50,15 +57,19 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->changeQuoteControlMock = $this->getMockBuilder(ChangeQuoteControl::class) + ->disableOriginalConstructor() + ->getMock(); + $objectManagerHelper = new ObjectManager($this); $this->accessChangeQuoteControl = $objectManagerHelper->getObject( AccessChangeQuoteControl::class, - ['userContext' => $this->userContextMock] + ['changeQuoteControl' => $this->changeQuoteControlMock] ); } /** - * User with role Customer and customer_id much with context user_id. + * User with role Customer and customer_id matches context user_id. */ public function testBeforeSaveForCustomer() { @@ -68,6 +79,9 @@ public function testBeforeSaveForCustomer() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -81,11 +95,15 @@ public function testBeforeSaveForCustomer() */ public function testBeforeSaveException() { - $this->userContextMock->method('getUserType') - ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); $this->quoteMock->method('getCustomerId') ->willReturn(2); + $this->userContextMock->method('getUserType') + ->willReturn(UserContextInterface::USER_TYPE_CUSTOMER); + + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } @@ -100,6 +118,9 @@ public function testBeforeSaveForAdmin() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_ADMIN); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -116,6 +137,9 @@ public function testBeforeSaveForGuest() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_GUEST); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(true); + $result = $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); $this->assertNull($result); @@ -135,6 +159,9 @@ public function testBeforeSaveForGuestException() $this->userContextMock->method('getUserType') ->willReturn(UserContextInterface::USER_TYPE_GUEST); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } @@ -152,6 +179,9 @@ public function testBeforeSaveForUnknownUserTypeException() $this->userContextMock->method('getUserType') ->willReturn(10); + $this->changeQuoteControlMock->method('isAllowed') + ->willReturn(false); + $this->accessChangeQuoteControl->beforeSave($this->quoteRepositoryMock, $this->quoteMock); } } diff --git a/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php b/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php index 451382abb4684..a92d360bad35b 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php @@ -145,6 +145,11 @@ class QuoteTest extends \PHPUnit\Framework\TestCase */ private $itemProcessor; + /** + * @var \Magento\Sales\Model\OrderIncrementIdChecker|\PHPUnit_Framework_MockObject_MockObject + */ + private $orderIncrementIdChecker; + /** * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -256,6 +261,7 @@ protected function setUp() \Magento\Customer\Api\Data\CustomerInterfaceFactory::class, ['create'] ); + $this->orderIncrementIdChecker = $this->createMock(\Magento\Sales\Model\OrderIncrementIdChecker::class); $this->quote = (new ObjectManager($this)) ->getObject( \Magento\Quote\Model\Quote::class, @@ -280,9 +286,10 @@ protected function setUp() 'extensionAttributesJoinProcessor' => $this->extensionAttributesJoinProcessorMock, 'customerDataFactory' => $this->customerDataFactoryMock, 'itemProcessor' => $this->itemProcessor, + 'orderIncrementIdChecker' => $this->orderIncrementIdChecker, 'data' => [ - 'reserved_order_id' => 1000001 - ] + 'reserved_order_id' => 1000001, + ], ] ); } @@ -1222,28 +1229,32 @@ public function testGetAllItems() } /** - * Test to verify if existing reserved_order_id in use + * Test to verify if existing reserved_order_id in use. * * @param bool $isReservedOrderIdExist * @param int $reservedOrderId + * @return void * @dataProvider reservedOrderIdDataProvider */ - public function testReserveOrderId($isReservedOrderIdExist, $reservedOrderId) + public function testReserveOrderId(bool $isReservedOrderIdExist, int $reservedOrderId): void { - $this->resourceMock + $this->orderIncrementIdChecker ->expects($this->once()) - ->method('isOrderIncrementIdUsed') + ->method('isIncrementIdUsed') ->with(1000001)->willReturn($isReservedOrderIdExist); $this->resourceMock->expects($this->any())->method('getReservedOrderId')->willReturn($reservedOrderId); $this->quote->reserveOrderId(); $this->assertEquals($reservedOrderId, $this->quote->getReservedOrderId()); } - public function reservedOrderIdDataProvider() + /** + * @return array + */ + public function reservedOrderIdDataProvider(): array { return [ 'id_already_in_use' => [true, 100002], - 'id_not_in_use' => [false, 1000001] + 'id_not_in_use' => [false, 1000001], ]; } } diff --git a/app/code/Magento/Quote/Test/Unit/Model/ResourceModel/QuoteTest.php b/app/code/Magento/Quote/Test/Unit/Model/ResourceModel/QuoteTest.php index a121cd55a98b4..7136e8260a880 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/ResourceModel/QuoteTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/ResourceModel/QuoteTest.php @@ -6,35 +6,17 @@ namespace Magento\Quote\Test\Unit\Model\ResourceModel; -use Magento\Framework\App\ResourceConnection; -use Magento\Framework\DB\Adapter\Pdo\Mysql; -use Magento\Framework\DB\Select; use Magento\Framework\DB\Sequence\SequenceInterface; -use Magento\Framework\Model\ResourceModel\Db\Context; -use Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite; -use Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Quote\Model\Quote; use Magento\Quote\Model\ResourceModel\Quote as QuoteResource; use Magento\SalesSequence\Model\Manager; +/** + * Unit test for \Magento\Quote\Model\ResourceModel\Quote. + */ class QuoteTest extends \PHPUnit\Framework\TestCase { - /** - * @var ResourceConnection - */ - private $resourceMock; - - /** - * @var Mysql - */ - private $adapterMock; - - /** - * @var Select - */ - private $selectMock; - /** * @var Quote|\PHPUnit_Framework_MockObject_MockObject */ @@ -55,98 +37,31 @@ class QuoteTest extends \PHPUnit\Framework\TestCase */ private $model; + /** + * @inheritdoc + */ protected function setUp() { $objectManagerHelper = new ObjectManager($this); - $this->selectMock = $this->getMockBuilder(Select::class) - ->disableOriginalConstructor() - ->getMock(); - $this->selectMock->expects($this->any())->method('from')->will($this->returnSelf()); - $this->selectMock->expects($this->any())->method('where'); - - $this->adapterMock = $this->getMockBuilder(Mysql::class) - ->disableOriginalConstructor() - ->getMock(); - $this->adapterMock->expects($this->any())->method('select')->will($this->returnValue($this->selectMock)); - - $this->resourceMock = $this->getMockBuilder(ResourceConnection::class) - ->disableOriginalConstructor() - ->getMock(); - $this->resourceMock->expects( - $this->any() - )->method( - 'getConnection' - )->will( - $this->returnValue($this->adapterMock) - ); - - $context = $this->getMockBuilder(Context::class) - ->disableOriginalConstructor() - ->getMock(); - $context->expects( - $this->once() - )->method( - 'getResources' - )->will( - $this->returnValue($this->resourceMock) - ); - - $snapshot = $this->getMockBuilder(Snapshot::class) - ->disableOriginalConstructor() - ->getMock(); - $relationComposite = $this->getMockBuilder(RelationComposite::class) - ->disableOriginalConstructor() - ->getMock(); - $this->quoteMock = $this->getMockBuilder(Quote::class) - ->disableOriginalConstructor() - ->getMock(); - $this->sequenceManagerMock = $this->getMockBuilder(Manager::class) - ->disableOriginalConstructor() - ->getMock(); - $this->sequenceMock = $this->getMockBuilder(SequenceInterface::class) - ->disableOriginalConstructor() - ->getMock(); + $this->quoteMock = $this->createMock(Quote::class); + $this->sequenceManagerMock = $this->createMock(Manager::class); + $this->sequenceMock = $this->createMock(SequenceInterface::class); $this->model = $objectManagerHelper->getObject( QuoteResource::class, [ - 'context' => $context, - 'entitySnapshot' => $snapshot, - 'entityRelationComposite' => $relationComposite, 'sequenceManager' => $this->sequenceManagerMock, - 'connectionName' => null, ] ); } /** - * Unit test to verify if isOrderIncrementIdUsed method works with different types increment ids - * - * @param array $value - * @dataProvider isOrderIncrementIdUsedDataProvider - */ - public function testIsOrderIncrementIdUsed($value) - { - $expectedBind = [':increment_id' => $value]; - $this->adapterMock->expects($this->once())->method('fetchOne')->with($this->selectMock, $expectedBind); - $this->model->isOrderIncrementIdUsed($value); - } - - /** - * @return array - */ - public function isOrderIncrementIdUsedDataProvider() - { - return [[100000001], ['10000000001'], ['M10000000001']]; - } - - /** - * /** - * @param $entityType - * @param $storeId - * @param $reservedOrderId + * @param string $entityType + * @param int $storeId + * @param string $reservedOrderId + * @return void * @dataProvider getReservedOrderIdDataProvider */ - public function testGetReservedOrderId($entityType, $storeId, $reservedOrderId) + public function testGetReservedOrderId(string $entityType, int $storeId, string $reservedOrderId): void { $this->sequenceManagerMock->expects($this->once()) ->method('getSequence') @@ -170,7 +85,7 @@ public function getReservedOrderIdDataProvider(): array return [ [\Magento\Sales\Model\Order::ENTITY, 1, '1000000001'], [\Magento\Sales\Model\Order::ENTITY, 2, '2000000001'], - [\Magento\Sales\Model\Order::ENTITY, 3, '3000000001'] + [\Magento\Sales\Model\Order::ENTITY, 3, '3000000001'], ]; } } diff --git a/app/code/Magento/Quote/Test/Unit/Observer/Backend/CustomerQuoteObserverTest.php b/app/code/Magento/Quote/Test/Unit/Observer/Backend/CustomerQuoteObserverTest.php index 682045c0cdb25..b82d1fa6c7839 100644 --- a/app/code/Magento/Quote/Test/Unit/Observer/Backend/CustomerQuoteObserverTest.php +++ b/app/code/Magento/Quote/Test/Unit/Observer/Backend/CustomerQuoteObserverTest.php @@ -160,8 +160,8 @@ public function testDispatch($isWebsiteScope, $websites) public function dispatchDataProvider() { return [ - [true, ['website1']], - [true, ['website1', 'website2']], + [true, [['website1']]], + [true, [['website1'], ['website2']]], [false, ['website1']], [false, ['website1', 'website2']], ]; diff --git a/app/code/Magento/Quote/Test/Unit/Observer/Webapi/SubmitObserverTest.php b/app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php similarity index 95% rename from app/code/Magento/Quote/Test/Unit/Observer/Webapi/SubmitObserverTest.php rename to app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php index 618a633fd62e0..c19606a7b8f5d 100644 --- a/app/code/Magento/Quote/Test/Unit/Observer/Webapi/SubmitObserverTest.php +++ b/app/code/Magento/Quote/Test/Unit/Observer/SubmitObserverTest.php @@ -3,12 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ -namespace Magento\Quote\Test\Unit\Observer\Webapi; +namespace Magento\Quote\Test\Unit\Observer; class SubmitObserverTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Quote\Observer\Webapi\SubmitObserver + * @var \Magento\Quote\Observer\SubmitObserver */ protected $model; @@ -59,7 +59,7 @@ protected function setUp() $eventMock->expects($this->once())->method('getQuote')->willReturn($this->quoteMock); $eventMock->expects($this->once())->method('getOrder')->willReturn($this->orderMock); $this->quoteMock->expects($this->once())->method('getPayment')->willReturn($this->paymentMock); - $this->model = new \Magento\Quote\Observer\Webapi\SubmitObserver( + $this->model = new \Magento\Quote\Observer\SubmitObserver( $this->loggerMock, $this->orderSenderMock ); diff --git a/app/code/Magento/Quote/composer.json b/app/code/Magento/Quote/composer.json index 760c4267ddd9d..0281c75507f54 100644 --- a/app/code/Magento/Quote/composer.json +++ b/app/code/Magento/Quote/composer.json @@ -5,28 +5,27 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-authorization": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-payment": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-sales-sequence": "100.3.*", - "magento/module-shipping": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-authorization": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-checkout": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-payment": "*", + "magento/module-sales": "*", + "magento/module-sales-sequence": "*", + "magento/module-shipping": "*", + "magento/module-store": "*", + "magento/module-tax": "*" }, "suggest": { - "magento/module-webapi": "100.3.*" + "magento/module-webapi": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Quote/etc/db_schema.xml b/app/code/Magento/Quote/etc/db_schema.xml index 3d76bee3da760..3bd7122e65d7f 100644 --- a/app/code/Magento/Quote/etc/db_schema.xml +++ b/app/code/Magento/Quote/etc/db_schema.xml @@ -6,8 +6,8 @@ */ --> -
    + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> +
    - + @@ -101,7 +101,7 @@
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    + diff --git a/app/code/Magento/Quote/etc/frontend/events.xml b/app/code/Magento/Quote/etc/frontend/events.xml new file mode 100644 index 0000000000000..1e9822bbf3ef8 --- /dev/null +++ b/app/code/Magento/Quote/etc/frontend/events.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/code/Magento/Quote/etc/webapi_rest/events.xml b/app/code/Magento/Quote/etc/webapi_rest/events.xml index 7b94434f3f20a..1e9822bbf3ef8 100644 --- a/app/code/Magento/Quote/etc/webapi_rest/events.xml +++ b/app/code/Magento/Quote/etc/webapi_rest/events.xml @@ -7,6 +7,6 @@ --> - + diff --git a/app/code/Magento/Quote/etc/webapi_soap/events.xml b/app/code/Magento/Quote/etc/webapi_soap/events.xml index 7b94434f3f20a..1e9822bbf3ef8 100644 --- a/app/code/Magento/Quote/etc/webapi_soap/events.xml +++ b/app/code/Magento/Quote/etc/webapi_soap/events.xml @@ -7,6 +7,6 @@ --> - + diff --git a/app/code/Magento/QuoteAnalytics/composer.json b/app/code/Magento/QuoteAnalytics/composer.json index c683a5e25c889..90dae1ec2adca 100644 --- a/app/code/Magento/QuoteAnalytics/composer.json +++ b/app/code/Magento/QuoteAnalytics/composer.json @@ -2,12 +2,11 @@ "name": "magento/module-quote-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-quote": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-quote": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ReleaseNotification/Ui/DataProvider/Modifier/Notifications.php b/app/code/Magento/ReleaseNotification/Ui/DataProvider/Modifier/Notifications.php index 96722d010770c..e82d61d499663 100644 --- a/app/code/Magento/ReleaseNotification/Ui/DataProvider/Modifier/Notifications.php +++ b/app/code/Magento/ReleaseNotification/Ui/DataProvider/Modifier/Notifications.php @@ -192,9 +192,9 @@ private function hideNotification(array $meta) */ private function getNotificationContent() { - $version = $this->getTargetVersion(); - $edition = $this->productMetadata->getEdition(); - $locale = $this->session->getUser()->getInterfaceLocale(); + $version = strtolower($this->getTargetVersion()); + $edition = strtolower($this->productMetadata->getEdition()); + $locale = strtolower($this->session->getUser()->getInterfaceLocale()); $cacheKey = self::$cachePrefix . $version . "-" . $edition . "-" . $locale; $modalContent = $this->cacheStorage->load($cacheKey); diff --git a/app/code/Magento/ReleaseNotification/Ui/Renderer/NotificationRenderer.php b/app/code/Magento/ReleaseNotification/Ui/Renderer/NotificationRenderer.php index a43b33b5a8cdf..c4760bd9d28c3 100644 --- a/app/code/Magento/ReleaseNotification/Ui/Renderer/NotificationRenderer.php +++ b/app/code/Magento/ReleaseNotification/Ui/Renderer/NotificationRenderer.php @@ -174,7 +174,7 @@ private function buildFooter(array $footer) * correct HTML format. * * @param string $content - * @returns string + * @return string */ private function formatContentWithLinks($content) { diff --git a/app/code/Magento/ReleaseNotification/composer.json b/app/code/Magento/ReleaseNotification/composer.json index 9e728b1e01729..97b312b74ff74 100644 --- a/app/code/Magento/ReleaseNotification/composer.json +++ b/app/code/Magento/ReleaseNotification/composer.json @@ -2,17 +2,16 @@ "name": "magento/module-release-notification", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/module-user": "101.0.*", - "magento/module-backend": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/framework": "101.0.*" + "php": "~7.1.3||~7.2.0", + "magento/module-user": "*", + "magento/module-backend": "*", + "magento/module-ui": "*", + "magento/framework": "*" }, "suggest": { - "magento/module-config": "100.3.*" + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ReleaseNotification/etc/config.xml b/app/code/Magento/ReleaseNotification/etc/config.xml index 8ceeb59b5415e..de67dc155d602 100644 --- a/app/code/Magento/ReleaseNotification/etc/config.xml +++ b/app/code/Magento/ReleaseNotification/etc/config.xml @@ -9,7 +9,7 @@ - raw.githubusercontent.com/magento-arcticfoxes/release-notification/master + magento.com/release_notifications 1 diff --git a/app/code/Magento/ReleaseNotification/etc/db_schema.xml b/app/code/Magento/ReleaseNotification/etc/db_schema.xml index 5574f1722b4cf..367957fc17732 100644 --- a/app/code/Magento/ReleaseNotification/etc/db_schema.xml +++ b/app/code/Magento/ReleaseNotification/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    _authorization->isAllowed('Magento_Reports::coupons'); break; - case 'shipping': - return $this->_authorization->isAllowed('Magento_Reports::shipping'); - break; case 'bestsellers': return $this->_authorization->isAllowed('Magento_Reports::bestsellers'); break; diff --git a/app/code/Magento/Reports/Setup/Patch/Data/InitializeReportEntityTypesAndPages.php b/app/code/Magento/Reports/Setup/Patch/Data/InitializeReportEntityTypesAndPages.php index e66d1f72737f2..77824689b8557 100644 --- a/app/code/Magento/Reports/Setup/Patch/Data/InitializeReportEntityTypesAndPages.php +++ b/app/code/Magento/Reports/Setup/Patch/Data/InitializeReportEntityTypesAndPages.php @@ -8,8 +8,8 @@ use Magento\Cms\Model\PageFactory; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class InitializeReportEntityTypesAndPages diff --git a/app/code/Magento/Reports/composer.json b/app/code/Magento/Reports/composer.json index f1e6719a59d0b..f2ead8fedff74 100644 --- a/app/code/Magento/Reports/composer.json +++ b/app/code/Magento/Reports/composer.json @@ -5,27 +5,26 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-cms": "101.2.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-downloadable": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-review": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-sales-rule": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*", - "magento/module-widget": "100.3.*", - "magento/module-wishlist": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-cms": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-downloadable": "*", + "magento/module-eav": "*", + "magento/module-quote": "*", + "magento/module-review": "*", + "magento/module-sales": "*", + "magento/module-sales-rule": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-widget": "*", + "magento/module-wishlist": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Reports/etc/db_schema.xml b/app/code/Magento/Reports/etc/db_schema.xml index 7b19d0bc7266b..f6c8074411bfc 100644 --- a/app/code/Magento/Reports/etc/db_schema.xml +++ b/app/code/Magento/Reports/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    getColumns()); type="text" id="getSuffixId('period_date_from') ?>" name="report_from" - value="getFilter('report_from') ?>"> + value="escapeHtml($block->getFilter('report_from')) ?>"> @@ -44,7 +44,7 @@ $numColumns = sizeof($block->getColumns()); type="text" id="getSuffixId('period_date_to') ?>" name="report_to" - value="getFilter('report_to') ?>"/> + value="escapeHtml($block->getFilter('report_to')) ?>"/> diff --git a/app/code/Magento/RequireJs/Model/FileManager.php b/app/code/Magento/RequireJs/Model/FileManager.php index 019c2cbedb75c..ec41c4238967f 100644 --- a/app/code/Magento/RequireJs/Model/FileManager.php +++ b/app/code/Magento/RequireJs/Model/FileManager.php @@ -183,6 +183,9 @@ public function createBundleJsPool() } foreach ($libDir->read($bundleDir) as $bundleFile) { + if (pathinfo($bundleFile, PATHINFO_EXTENSION) !== 'js') { + continue; + } $relPath = $libDir->getRelativePath($bundleFile); $bundles[] = $this->assetRepo->createArbitrary($relPath, ''); } diff --git a/app/code/Magento/RequireJs/Test/Unit/Model/FileManagerTest.php b/app/code/Magento/RequireJs/Test/Unit/Model/FileManagerTest.php index 6b6d709cbb608..834ee5b68485e 100644 --- a/app/code/Magento/RequireJs/Test/Unit/Model/FileManagerTest.php +++ b/app/code/Magento/RequireJs/Test/Unit/Model/FileManagerTest.php @@ -153,7 +153,7 @@ public function testCreateBundleJsPool() ->expects($this->once()) ->method('read') ->with('path/to/bundle/dir/js/bundle') - ->willReturn(['bundle1.js', 'bundle2.js']); + ->willReturn(['bundle1.js', 'bundle2.js', 'some_file.not_js']); $dirRead ->expects($this->exactly(2)) ->method('getRelativePath') diff --git a/app/code/Magento/RequireJs/composer.json b/app/code/Magento/RequireJs/composer.json index d54dfeafc6f74..e48082a69319c 100644 --- a/app/code/Magento/RequireJs/composer.json +++ b/app/code/Magento/RequireJs/composer.json @@ -5,11 +5,10 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Review/Block/Product/ReviewRenderer.php b/app/code/Magento/Review/Block/Product/ReviewRenderer.php index 8aa10d2437cbb..3cd15aba30420 100644 --- a/app/code/Magento/Review/Block/Product/ReviewRenderer.php +++ b/app/code/Magento/Review/Block/Product/ReviewRenderer.php @@ -18,8 +18,8 @@ class ReviewRenderer extends \Magento\Framework\View\Element\Template implements * @var array */ protected $_availableTemplates = [ - self::FULL_VIEW => 'helper/summary.phtml', - self::SHORT_VIEW => 'helper/summary_short.phtml', + self::FULL_VIEW => 'Magento_Review::helper/summary.phtml', + self::SHORT_VIEW => 'Magento_Review::helper/summary_short.phtml', ]; /** diff --git a/app/code/Magento/Review/Controller/Product.php b/app/code/Magento/Review/Controller/Product.php index b90ad29aa49e7..a88fcb4193df0 100644 --- a/app/code/Magento/Review/Controller/Product.php +++ b/app/code/Magento/Review/Controller/Product.php @@ -219,6 +219,11 @@ protected function loadProduct($productId) try { $product = $this->productRepository->getById($productId); + + if (!in_array($this->storeManager->getStore()->getWebsiteId(), $product->getWebsiteIds())) { + throw new NoSuchEntityException(); + } + if (!$product->isVisibleInCatalog() || !$product->isVisibleInSiteVisibility()) { throw new NoSuchEntityException(); } diff --git a/app/code/Magento/Review/Setup/Patch/Data/InitReviewStatusesAndData.php b/app/code/Magento/Review/Setup/Patch/Data/InitReviewStatusesAndData.php index 0878169eeb35f..0ad7042668c0e 100644 --- a/app/code/Magento/Review/Setup/Patch/Data/InitReviewStatusesAndData.php +++ b/app/code/Magento/Review/Setup/Patch/Data/InitReviewStatusesAndData.php @@ -7,8 +7,8 @@ namespace Magento\Review\Setup\Patch\Data; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; class InitReviewStatusesAndData implements DataPatchInterface, PatchVersionInterface { diff --git a/app/code/Magento/Review/Test/Unit/Controller/Product/PostTest.php b/app/code/Magento/Review/Test/Unit/Controller/Product/PostTest.php index 3186c0fcc3c57..1526e80f8190a 100644 --- a/app/code/Magento/Review/Test/Unit/Controller/Product/PostTest.php +++ b/app/code/Magento/Review/Test/Unit/Controller/Product/PostTest.php @@ -147,7 +147,11 @@ protected function setUp() $ratingFactory->expects($this->once())->method('create')->willReturn($this->rating); $this->messageManager = $this->createMock(\Magento\Framework\Message\ManagerInterface::class); - $this->store = $this->createPartialMock(\Magento\Store\Model\Store::class, ['getId']); + $this->store = $this->createPartialMock( + \Magento\Store\Model\Store::class, + ['getId', 'getWebsiteId'] + ); + $storeManager = $this->getMockForAbstractClass(\Magento\Store\Model\StoreManagerInterface::class); $storeManager->expects($this->any())->method('getStore')->willReturn($this->store); @@ -219,7 +223,7 @@ public function testExecute() ->willReturn(1); $product = $this->createPartialMock( \Magento\Catalog\Model\Product::class, - ['__wakeup', 'isVisibleInCatalog', 'isVisibleInSiteVisibility', 'getId'] + ['__wakeup', 'isVisibleInCatalog', 'isVisibleInSiteVisibility', 'getId', 'getWebsiteIds'] ); $product->expects($this->once()) ->method('isVisibleInCatalog') @@ -227,6 +231,15 @@ public function testExecute() $product->expects($this->once()) ->method('isVisibleInSiteVisibility') ->willReturn(true); + + $product->expects($this->once()) + ->method('getWebsiteIds') + ->willReturn([1]); + + $this->store->expects($this->once()) + ->method('getWebsiteId') + ->willReturn(1); + $this->productRepository->expects($this->any())->method('getById') ->with(1) ->willReturn($product); diff --git a/app/code/Magento/Review/composer.json b/app/code/Magento/Review/composer.json index a4f12e758ac83..17e0d9bebcf50 100644 --- a/app/code/Magento/Review/composer.json +++ b/app/code/Magento/Review/composer.json @@ -5,23 +5,22 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-newsletter": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-newsletter": "*", + "magento/module-store": "*", + "magento/module-theme": "*", + "magento/module-ui": "*" }, "suggest": { - "magento/module-cookie": "100.3.*", - "magento/module-review-sample-data": "Sample Data version:100.3.*" + "magento/module-cookie": "*", + "magento/module-review-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Review/etc/adminhtml/menu.xml b/app/code/Magento/Review/etc/adminhtml/menu.xml index 2ed92a48ad421..e3532483f88af 100644 --- a/app/code/Magento/Review/etc/adminhtml/menu.xml +++ b/app/code/Magento/Review/etc/adminhtml/menu.xml @@ -9,7 +9,7 @@ - + diff --git a/app/code/Magento/Review/etc/db_schema.xml b/app/code/Magento/Review/etc/db_schema.xml index 0db686d16ed61..1a2ff588180ce 100644 --- a/app/code/Magento/Review/etc/db_schema.xml +++ b/app/code/Magento/Review/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Review/i18n/en_US.csv b/app/code/Magento/Review/i18n/en_US.csv index cb5452f2f0c39..b3ea21dfcae90 100644 --- a/app/code/Magento/Review/i18n/en_US.csv +++ b/app/code/Magento/Review/i18n/en_US.csv @@ -133,3 +133,5 @@ Summary,Summary Active,Active Inactive,Inactive "Please select one of each of the ratings above.","Please select one of each of the ratings above." +star,star +stars,stars diff --git a/app/code/Magento/Review/view/adminhtml/web/js/rating.js b/app/code/Magento/Review/view/adminhtml/web/js/rating.js index cc72d386dc053..b8d1b1b241b8f 100644 --- a/app/code/Magento/Review/view/adminhtml/web/js/rating.js +++ b/app/code/Magento/Review/view/adminhtml/web/js/rating.js @@ -27,7 +27,7 @@ define([ _bind: function () { this._labels.on({ click: $.proxy(function (e) { - $('[id="' + $(e.currentTarget).attr('for') + '"]').prop('checked', true); + $(e.currentTarget).prev().prop('checked', true); this._updateRating(); }, this), diff --git a/app/code/Magento/Review/view/frontend/templates/form.phtml b/app/code/Magento/Review/view/frontend/templates/form.phtml index ca5d9e96e916e..71295d15f8622 100644 --- a/app/code/Magento/Review/view/frontend/templates/form.phtml +++ b/app/code/Magento/Review/view/frontend/templates/form.phtml @@ -40,9 +40,9 @@ diff --git a/app/code/Magento/Review/view/frontend/templates/redirect.phtml b/app/code/Magento/Review/view/frontend/templates/redirect.phtml index fc74cadacb319..2fdb5e90a9c18 100644 --- a/app/code/Magento/Review/view/frontend/templates/redirect.phtml +++ b/app/code/Magento/Review/view/frontend/templates/redirect.phtml @@ -8,9 +8,6 @@ ?> getProduct()->getProductUrl()}#info-product_reviews"); exit; ?> diff --git a/app/code/Magento/Review/view/frontend/templates/view.phtml b/app/code/Magento/Review/view/frontend/templates/view.phtml index 564a6e1a7c537..205ce5c20572a 100644 --- a/app/code/Magento/Review/view/frontend/templates/view.phtml +++ b/app/code/Magento/Review/view/frontend/templates/view.phtml @@ -49,7 +49,7 @@
    - escapeHtml(__('Back to Product Reviews')) ?> + escapeHtml(__('Back to Product Reviews')) ?>
    diff --git a/app/code/Magento/Review/view/frontend/web/js/process-reviews.js b/app/code/Magento/Review/view/frontend/web/js/process-reviews.js index 08464e3da6f42..d1c40959e3ec2 100644 --- a/app/code/Magento/Review/view/frontend/web/js/process-reviews.js +++ b/app/code/Magento/Review/view/frontend/web/js/process-reviews.js @@ -50,15 +50,15 @@ define([ $(function () { $('.product-info-main .reviews-actions a').click(function (event) { - var acnchor; + var anchor; event.preventDefault(); - acnchor = $(this).attr('href').replace(/^.*?(#|$)/, ''); + anchor = $(this).attr('href').replace(/^.*?(#|$)/, ''); $('.product.data.items [data-role="content"]').each(function (index) { //eslint-disable-line if (this.id == 'reviews') { //eslint-disable-line eqeqeq $('.product.data.items').tabs('activate', index); $('html, body').animate({ - scrollTop: $('#' + acnchor).offset().top - 50 + scrollTop: $('#' + anchor).offset().top - 50 }, 300); } }); diff --git a/app/code/Magento/ReviewAnalytics/composer.json b/app/code/Magento/ReviewAnalytics/composer.json index 9f0b58b529a25..73f534451580c 100644 --- a/app/code/Magento/ReviewAnalytics/composer.json +++ b/app/code/Magento/ReviewAnalytics/composer.json @@ -2,12 +2,11 @@ "name": "magento/module-review-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-review": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-review": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Robots/composer.json b/app/code/Magento/Robots/composer.json index b04f3bff15fa2..6f812857873b8 100644 --- a/app/code/Magento/Robots/composer.json +++ b/app/code/Magento/Robots/composer.json @@ -5,15 +5,14 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-theme": "100.3.*" + "magento/module-theme": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Rss/Model/Rss.php b/app/code/Magento/Rss/Model/Rss.php index 7461c780fb230..e37ee263b8301 100644 --- a/app/code/Magento/Rss/Model/Rss.php +++ b/app/code/Magento/Rss/Model/Rss.php @@ -3,11 +3,15 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Rss\Model; use Magento\Framework\App\ObjectManager; use Magento\Framework\App\Rss\DataProviderInterface; use Magento\Framework\Serialize\SerializerInterface; +use Magento\Framework\App\FeedFactoryInterface; /** * Provides functionality to work with RSS feeds @@ -27,6 +31,11 @@ class Rss */ protected $cache; + /** + * @var \Magento\Framework\App\FeedFactoryInterface + */ + private $feedFactory; + /** * @var SerializerInterface */ @@ -37,13 +46,16 @@ class Rss * * @param \Magento\Framework\App\CacheInterface $cache * @param SerializerInterface|null $serializer + * @param FeedFactoryInterface|null $feedFactory */ public function __construct( \Magento\Framework\App\CacheInterface $cache, - SerializerInterface $serializer = null + SerializerInterface $serializer = null, + FeedFactoryInterface $feedFactory = null ) { $this->cache = $cache; $this->serializer = $serializer ?: ObjectManager::getInstance()->get(SerializerInterface::class); + $this->feedFactory = $feedFactory ?: ObjectManager::getInstance()->get(FeedFactoryInterface::class); } /** @@ -89,10 +101,12 @@ public function setDataProvider(DataProviderInterface $dataProvider) /** * @return string + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\RuntimeException */ public function createRssXml() { - $rssFeedFromArray = \Zend_Feed::importArray($this->getFeeds(), 'rss'); - return $rssFeedFromArray->saveXML(); + $feed = $this->feedFactory->create($this->getFeeds(), FeedFactoryInterface::FORMAT_RSS); + return $feed->getFormattedContent(); } } diff --git a/app/code/Magento/Rss/Test/Unit/Controller/Adminhtml/Feed/IndexTest.php b/app/code/Magento/Rss/Test/Unit/Controller/Adminhtml/Feed/IndexTest.php index 32aab6ffb92bc..a601f8fb2d1d7 100644 --- a/app/code/Magento/Rss/Test/Unit/Controller/Adminhtml/Feed/IndexTest.php +++ b/app/code/Magento/Rss/Test/Unit/Controller/Adminhtml/Feed/IndexTest.php @@ -6,6 +6,7 @@ namespace Magento\Rss\Test\Unit\Controller\Adminhtml\Feed; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Zend\Feed\Writer\Exception\InvalidArgumentException; /** * Class IndexTest @@ -103,14 +104,22 @@ public function testExecuteWithException() $dataProvider = $this->createMock(\Magento\Framework\App\Rss\DataProviderInterface::class); $dataProvider->expects($this->once())->method('isAllowed')->will($this->returnValue(true)); - $rssModel = $this->createPartialMock(\Magento\Rss\Model\Rss::class, ['setDataProvider']); + $rssModel = $this->createPartialMock(\Magento\Rss\Model\Rss::class, ['setDataProvider', 'createRssXml']); $rssModel->expects($this->once())->method('setDataProvider')->will($this->returnSelf()); + $exceptionMock = new \Magento\Framework\Exception\RuntimeException( + new \Magento\Framework\Phrase('Any message') + ); + + $rssModel->expects($this->once())->method('createRssXml')->will( + $this->throwException($exceptionMock) + ); + $this->response->expects($this->once())->method('setHeader')->will($this->returnSelf()); $this->rssFactory->expects($this->once())->method('create')->will($this->returnValue($rssModel)); $this->rssManager->expects($this->once())->method('getProvider')->will($this->returnValue($dataProvider)); - $this->expectException('\Zend_Feed_Builder_Exception'); + $this->expectException(\Magento\Framework\Exception\RuntimeException::class); $this->controller->execute(); } } diff --git a/app/code/Magento/Rss/Test/Unit/Controller/Feed/IndexTest.php b/app/code/Magento/Rss/Test/Unit/Controller/Feed/IndexTest.php index 71802deee0a8d..30415155d5f6e 100644 --- a/app/code/Magento/Rss/Test/Unit/Controller/Feed/IndexTest.php +++ b/app/code/Magento/Rss/Test/Unit/Controller/Feed/IndexTest.php @@ -6,6 +6,7 @@ namespace Magento\Rss\Test\Unit\Controller\Feed; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Zend\Feed\Writer\Exception\InvalidArgumentException; /** * Class IndexTest @@ -52,6 +53,7 @@ protected function setUp() ->disableOriginalConstructor()->getMock(); $objectManagerHelper = new ObjectManagerHelper($this); + $this->controller = $objectManagerHelper->getObject( \Magento\Rss\Controller\Feed\Index::class, [ @@ -90,14 +92,22 @@ public function testExecuteWithException() $dataProvider = $this->createMock(\Magento\Framework\App\Rss\DataProviderInterface::class); $dataProvider->expects($this->once())->method('isAllowed')->will($this->returnValue(true)); - $rssModel = $this->createPartialMock(\Magento\Rss\Model\Rss::class, ['setDataProvider']); + $rssModel = $this->createPartialMock(\Magento\Rss\Model\Rss::class, ['setDataProvider', 'createRssXml']); $rssModel->expects($this->once())->method('setDataProvider')->will($this->returnSelf()); + $exceptionMock = new \Magento\Framework\Exception\RuntimeException( + new \Magento\Framework\Phrase('Any message') + ); + + $rssModel->expects($this->once())->method('createRssXml')->will( + $this->throwException($exceptionMock) + ); + $this->response->expects($this->once())->method('setHeader')->will($this->returnSelf()); $this->rssFactory->expects($this->once())->method('create')->will($this->returnValue($rssModel)); $this->rssManager->expects($this->once())->method('getProvider')->will($this->returnValue($dataProvider)); - $this->expectException('\Zend_Feed_Builder_Exception'); + $this->expectException(\Magento\Framework\Exception\RuntimeException::class); $this->controller->execute(); } } diff --git a/app/code/Magento/Rss/Test/Unit/Model/RssTest.php b/app/code/Magento/Rss/Test/Unit/Model/RssTest.php index 6f98b9f202e30..f2888e4296b40 100644 --- a/app/code/Magento/Rss/Test/Unit/Model/RssTest.php +++ b/app/code/Magento/Rss/Test/Unit/Model/RssTest.php @@ -19,7 +19,7 @@ class RssTest extends \PHPUnit\Framework\TestCase /** * @var array */ - protected $feedData = [ + private $feedData = [ 'title' => 'Feed Title', 'link' => 'http://magento.com/rss/link', 'description' => 'Feed Description', @@ -33,6 +33,27 @@ class RssTest extends \PHPUnit\Framework\TestCase ], ]; + /** + * @var string + */ + private $feedXml = ' + + + <![CDATA[Feed Title]]> + http://magento.com/rss/link + + Sat, 22 Apr 2017 13:21:12 +0200 + Zend\Feed + http://blogs.law.harvard.edu/tech/rss + + <![CDATA[Feed 1 Title]]> + http://magento.com/rss/link/id/1 + + Sat, 22 Apr 2017 13:21:12 +0200 + + +'; + /** * @var ObjectManagerHelper */ @@ -43,6 +64,16 @@ class RssTest extends \PHPUnit\Framework\TestCase */ private $cacheMock; + /** + * @var \Magento\Framework\App\FeedFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $feedFactoryMock; + + /** + * @var \Magento\Framework\App\FeedInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $feedMock; + /** * @var SerializerInterface|\PHPUnit_Framework_MockObject_MockObject */ @@ -52,11 +83,15 @@ protected function setUp() { $this->cacheMock = $this->createMock(\Magento\Framework\App\CacheInterface::class); $this->serializerMock = $this->createMock(SerializerInterface::class); + $this->feedFactoryMock = $this->createMock(\Magento\Framework\App\FeedFactoryInterface::class); + $this->feedMock = $this->createMock(\Magento\Framework\App\FeedInterface::class); + $this->objectManagerHelper = new ObjectManagerHelper($this); $this->rss = $this->objectManagerHelper->getObject( \Magento\Rss\Model\Rss::class, [ 'cache' => $this->cacheMock, + 'feedFactory' => $this->feedFactoryMock, 'serializer' => $this->serializerMock ] ); @@ -116,14 +151,16 @@ public function testCreateRssXml() $dataProvider->expects($this->any())->method('getCacheLifetime')->will($this->returnValue(100)); $dataProvider->expects($this->any())->method('getRssData')->will($this->returnValue($this->feedData)); + $this->feedMock->expects($this->once()) + ->method('getFormattedContent') + ->willReturn($this->feedXml); + + $this->feedFactoryMock->expects($this->once()) + ->method('create') + ->with($this->feedData, \Magento\Framework\App\FeedFactoryInterface::FORMAT_RSS) + ->will($this->returnValue($this->feedMock)); + $this->rss->setDataProvider($dataProvider); - $result = $this->rss->createRssXml(); - $this->assertContains('', $result); - $this->assertContains('<![CDATA[Feed Title]]>', $result); - $this->assertContains('<![CDATA[Feed 1 Title]]>', $result); - $this->assertContains('http://magento.com/rss/link', $result); - $this->assertContains('http://magento.com/rss/link/id/1', $result); - $this->assertContains('', $result); - $this->assertContains('', $result); + $this->assertNotNull($this->rss->createRssXml()); } } diff --git a/app/code/Magento/Rss/composer.json b/app/code/Magento/Rss/composer.json index 7fe788c0494ae..5b6c34688c31a 100644 --- a/app/code/Magento/Rss/composer.json +++ b/app/code/Magento/Rss/composer.json @@ -5,14 +5,13 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-customer": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Rule/Model/Condition/AbstractCondition.php b/app/code/Magento/Rule/Model/Condition/AbstractCondition.php index 01e715f06a27f..e3bec2d9959b1 100644 --- a/app/code/Magento/Rule/Model/Condition/AbstractCondition.php +++ b/app/code/Magento/Rule/Model/Condition/AbstractCondition.php @@ -24,7 +24,6 @@ abstract class AbstractCondition extends \Magento\Framework\DataObject implement { /** * Defines which operators will be available for this condition - * * @var string */ protected $_inputType = null; @@ -84,17 +83,13 @@ public function __construct(Context $context, array $data = []) $options = $this->getAttributeOptions(); if ($options) { - foreach (array_keys($options) as $attr) { - $this->setAttribute($attr); - break; - } + reset($options); + $this->setAttribute(key($options)); } $options = $this->getOperatorOptions(); if ($options) { - foreach (array_keys($options) as $operator) { - $this->setOperator($operator); - break; - } + reset($options); + $this->setOperator(key($options)); } } @@ -160,14 +155,13 @@ public function getForm() */ public function asArray(array $arrAttributes = []) { - $out = [ + return [ 'type' => $this->getType(), 'attribute' => $this->getAttribute(), 'operator' => $this->getOperator(), 'value' => $this->getValue(), 'is_value_processed' => $this->getIsValueParsed(), ]; - return $out; } /** @@ -205,7 +199,7 @@ public function getMappedSqlField() */ public function asXml() { - $xml = "" . + return "" . $this->getType() . "" . "" . @@ -217,7 +211,6 @@ public function asXml() "" . $this->getValue() . ""; - return $xml; } /** @@ -244,8 +237,7 @@ public function loadXml($xml) if (is_string($xml)) { $xml = simplexml_load_string($xml); } - $arr = (array)$xml; - $this->loadArray($arr); + $this->loadArray((array)$xml); return $this; } @@ -304,10 +296,7 @@ public function loadOperatorOptions() */ public function getInputType() { - if (null === $this->_inputType) { - return 'string'; - } - return $this->_inputType; + return null === $this->_inputType ? 'string' : $this->_inputType; } /** @@ -348,12 +337,11 @@ public function loadValueOptions() */ public function getValueSelectOptions() { - $valueOption = $opt = []; + $opt = []; if ($this->hasValueOption()) { - $valueOption = (array)$this->getValueOption(); - } - foreach ($valueOption as $key => $value) { - $opt[] = ['value' => $key, 'label' => $value]; + foreach ((array)$this->getValueOption() as $key => $value) { + $opt[] = ['value' => $key, 'label' => $value]; + } } return $opt; } @@ -470,13 +458,12 @@ public function getNewChildName() */ public function asHtml() { - $html = $this->getTypeElementHtml() . + return $this->getTypeElementHtml() . $this->getAttributeElementHtml() . $this->getOperatorElementHtml() . $this->getValueElementHtml() . $this->getRemoveLinkHtml() . $this->getChooserContainerHtml(); - return $html; } /** @@ -484,8 +471,7 @@ public function asHtml() */ public function asHtmlRecursive() { - $html = $this->asHtml(); - return $html; + return $this->asHtml(); } /** @@ -520,9 +506,10 @@ public function getTypeElementHtml() public function getAttributeElement() { if (null === $this->getAttribute()) { - foreach (array_keys($this->getAttributeOption()) as $option) { - $this->setAttribute($option); - break; + $options = $this->getAttributeOption(); + if ($options) { + reset($options); + $this->setAttribute(key($options)); } } return $this->getForm()->addField( @@ -558,10 +545,8 @@ public function getOperatorElement() { $options = $this->getOperatorSelectOptions(); if ($this->getOperator() === null) { - foreach ($options as $option) { - $this->setOperator($option['value']); - break; - } + $option = reset($options); + $this->setOperator($option['value']); } $elementId = sprintf('%s__%s__operator', $this->getPrefix(), $this->getId()); @@ -654,8 +639,7 @@ public function getValueElementHtml() public function getAddLinkHtml() { $src = $this->_assetRepo->getUrl('images/rule_component_add.gif'); - $html = ''; - return $html; + return ''; } /** @@ -676,11 +660,7 @@ public function getRemoveLinkHtml() public function getChooserContainerHtml() { $url = $this->getValueElementChooserUrl(); - $html = ''; - if ($url) { - $html = '
    '; - } - return $html; + return $url ? '
    ' : ''; } /** @@ -690,8 +670,7 @@ public function getChooserContainerHtml() */ public function asString($format = '') { - $str = $this->getAttributeName() . ' ' . $this->getOperatorName() . ' ' . $this->getValueName(); - return $str; + return $this->getAttributeName() . ' ' . $this->getOperatorName() . ' ' . $this->getValueName(); } /** @@ -700,8 +679,7 @@ public function asString($format = '') */ public function asStringRecursive($level = 0) { - $str = str_pad('', $level * 3, ' ', STR_PAD_LEFT) . $this->asString(); - return $str; + return str_pad('', $level * 3, ' ', STR_PAD_LEFT) . $this->asString(); } /** @@ -740,12 +718,10 @@ public function validateAttribute($validatedValue) case '==': case '!=': if (is_array($value)) { - if (is_array($validatedValue)) { - $result = array_intersect($value, $validatedValue); - $result = !empty($result); - } else { + if (!is_array($validatedValue)) { return false; } + $result = !empty(array_intersect($value, $validatedValue)); } else { if (is_array($validatedValue)) { $result = count($validatedValue) == 1 && array_shift($validatedValue) == $value; @@ -759,18 +735,16 @@ public function validateAttribute($validatedValue) case '>': if (!is_scalar($validatedValue)) { return false; - } else { - $result = $validatedValue <= $value; } + $result = $validatedValue <= $value; break; case '>=': case '<': if (!is_scalar($validatedValue)) { return false; - } else { - $result = $validatedValue >= $value; } + $result = $validatedValue >= $value; break; case '{}': @@ -783,12 +757,11 @@ public function validateAttribute($validatedValue) } } } elseif (is_array($value)) { - if (is_array($validatedValue)) { - $result = array_intersect($value, $validatedValue); - $result = !empty($result); - } else { + if (!is_array($validatedValue)) { return false; } + $result = array_intersect($value, $validatedValue); + $result = !empty($result); } else { if (is_array($validatedValue)) { $result = in_array($value, $validatedValue); @@ -833,13 +806,13 @@ protected function _compareValues($validatedValue, $value, $strict = true) { if ($strict && is_numeric($validatedValue) && is_numeric($value)) { return $validatedValue == $value; - } else { - $validatePattern = preg_quote($validatedValue, '~'); - if ($strict) { - $validatePattern = '^' . $validatePattern . '$'; - } - return (bool)preg_match('~' . $validatePattern . '~iu', $value); } + + $validatePattern = preg_quote($validatedValue, '~'); + if ($strict) { + $validatePattern = '^' . $validatePattern . '$'; + } + return (bool)preg_match('~' . $validatePattern . '~iu', $value); } /** diff --git a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php index 41a55f4c25166..e1c9bf99f2675 100644 --- a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php +++ b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php @@ -6,9 +6,14 @@ namespace Magento\Rule\Model\Condition\Sql; +use Magento\Framework\App\ObjectManager; use Magento\Framework\DB\Select; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Rule\Model\Condition\AbstractCondition; use Magento\Rule\Model\Condition\Combine; +use Magento\Eav\Api\AttributeRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Eav\Model\Entity\Collection\AbstractCollection; /** * Class SQL Builder @@ -41,12 +46,22 @@ class Builder */ protected $_expressionFactory; + /** + * @var AttributeRepositoryInterface + */ + private $attributeRepository; + /** * @param ExpressionFactory $expressionFactory + * @param AttributeRepositoryInterface|null $attributeRepository */ - public function __construct(ExpressionFactory $expressionFactory) - { + public function __construct( + ExpressionFactory $expressionFactory, + AttributeRepositoryInterface $attributeRepository = null + ) { $this->_expressionFactory = $expressionFactory; + $this->attributeRepository = $attributeRepository ?: + ObjectManager::getInstance()->get(AttributeRepositoryInterface::class); } /** @@ -88,14 +103,14 @@ protected function _getChildCombineTablesToJoin(Combine $combine, $tables = []) /** * Join tables from conditions combination to collection * - * @param \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection + * @param AbstractCollection $collection * @param Combine $combine * @return $this */ protected function _joinTablesToCollection( - \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection, + AbstractCollection $collection, Combine $combine - ) { + ): Builder { foreach ($this->_getCombineTablesToJoin($combine) as $alias => $joinTable) { /** @var $condition AbstractCondition */ $collection->getSelect()->joinLeft( @@ -104,6 +119,7 @@ protected function _joinTablesToCollection( isset($joinTable['columns']) ? $joinTable['columns'] : '*' ); } + return $this; } @@ -112,11 +128,15 @@ protected function _joinTablesToCollection( * * @param AbstractCondition $condition * @param string $value + * @param bool $isDefaultStoreUsed * @return string * @throws \Magento\Framework\Exception\LocalizedException */ - protected function _getMappedSqlCondition(AbstractCondition $condition, $value = '') - { + protected function _getMappedSqlCondition( + AbstractCondition $condition, + string $value = '', + bool $isDefaultStoreUsed = true + ): string { $argument = $condition->getMappedSqlField(); // If rule hasn't valid argument - create negative expression to prevent incorrect rule behavior. @@ -130,9 +150,16 @@ protected function _getMappedSqlCondition(AbstractCondition $condition, $value = throw new \Magento\Framework\Exception\LocalizedException(__('Unknown condition operator')); } + $defaultValue = 0; + // Check if attribute has a table with default value and add it to the query + if ($this->canAttributeHaveDefaultValue($condition->getAttribute(), $isDefaultStoreUsed)) { + $defaultField = 'at_' . $condition->getAttribute() . '_default.value'; + $defaultValue = $this->_connection->quoteIdentifier($defaultField); + } + $sql = str_replace( ':field', - $this->_connection->getIfNullSql($this->_connection->quoteIdentifier($argument), 0), + $this->_connection->getIfNullSql($this->_connection->quoteIdentifier($argument), $defaultValue), $this->_conditionOperatorMap[$conditionOperator] ); @@ -144,11 +171,15 @@ protected function _getMappedSqlCondition(AbstractCondition $condition, $value = /** * @param Combine $combine * @param string $value + * @param bool $isDefaultStoreUsed * @return string * @SuppressWarnings(PHPMD.NPathComplexity) */ - protected function _getMappedSqlCombination(Combine $combine, $value = '') - { + protected function _getMappedSqlCombination( + Combine $combine, + string $value = '', + bool $isDefaultStoreUsed = true + ): string { $out = (!empty($value) ? $value : ''); $value = ($combine->getValue() ? '' : ' NOT '); $getAggregator = $combine->getAggregator(); @@ -158,33 +189,68 @@ protected function _getMappedSqlCombination(Combine $combine, $value = '') $con = ($getAggregator == 'any' ? Select::SQL_OR : Select::SQL_AND); $con = (isset($conditions[$key+1]) ? $con : ''); if ($condition instanceof Combine) { - $out .= $this->_getMappedSqlCombination($condition, $value); + $out .= $this->_getMappedSqlCombination($condition, $value, $isDefaultStoreUsed); } else { - $out .= $this->_getMappedSqlCondition($condition, $value); + $out .= $this->_getMappedSqlCondition($condition, $value, $isDefaultStoreUsed); } $out .= $out ? (' ' . $con) : ''; } + return $this->_expressionFactory->create(['expression' => $out]); } /** * Attach conditions filter to collection * - * @param \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection + * @param AbstractCollection $collection * @param Combine $combine - * * @return void */ public function attachConditionToCollection( - \Magento\Eav\Model\Entity\Collection\AbstractCollection $collection, + AbstractCollection $collection, Combine $combine - ) { + ): void { $this->_connection = $collection->getResource()->getConnection(); $this->_joinTablesToCollection($collection, $combine); - $whereExpression = (string)$this->_getMappedSqlCombination($combine); + $isDefaultStoreUsed = $this->checkIsDefaultStoreUsed($collection); + $whereExpression = (string)$this->_getMappedSqlCombination($combine, '', $isDefaultStoreUsed); if (!empty($whereExpression)) { // Select ::where method adds braces even on empty expression $collection->getSelect()->where($whereExpression); } } + + /** + * Check is default store used. + * + * @param AbstractCollection $collection + * @return bool + */ + private function checkIsDefaultStoreUsed(AbstractCollection $collection): bool + { + return (int)$collection->getStoreId() === (int)$collection->getDefaultStoreId(); + } + + /** + * Check if attribute can have default value. + * + * @param string $attributeCode + * @param bool $isDefaultStoreUsed + * @return bool + */ + private function canAttributeHaveDefaultValue(string $attributeCode, bool $isDefaultStoreUsed): bool + { + if ($isDefaultStoreUsed) { + return false; + } + + try { + $attribute = $this->attributeRepository->get(Product::ENTITY, $attributeCode); + } catch (NoSuchEntityException $e) { + // It's not exceptional case as we want to check if we have such attribute or not + return false; + } + + return !$attribute->isScopeGlobal(); + } } diff --git a/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php b/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php index f53098c4bb97e..daf7b1462c722 100644 --- a/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php +++ b/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php @@ -35,7 +35,12 @@ public function testAttachConditionToCollection() { $collection = $this->createPartialMock( \Magento\Eav\Model\Entity\Collection\AbstractCollection::class, - ['getResource', 'getSelect'] + [ + 'getResource', + 'getSelect', + 'getStoreId', + 'getDefaultStoreId', + ] ); $combine = $this->createPartialMock(\Magento\Rule\Model\Condition\Combine::class, ['getConditions']); $resource = $this->createPartialMock(\Magento\Framework\DB\Adapter\Pdo\Mysql::class, ['getConnection']); @@ -53,10 +58,15 @@ public function testAttachConditionToCollection() $collection->expects($this->once()) ->method('getResource') ->will($this->returnValue($resource)); - $collection->expects($this->any()) ->method('getSelect') ->will($this->returnValue($select)); + $collection->expects($this->once()) + ->method('getStoreId') + ->willReturn(1); + $collection->expects($this->once()) + ->method('getDefaultStoreId') + ->willReturn(1); $resource->expects($this->once()) ->method('getConnection') diff --git a/app/code/Magento/Rule/Test/Unit/Model/ConditionFactoryTest.php b/app/code/Magento/Rule/Test/Unit/Model/ConditionFactoryTest.php index d8c0cc470f55e..f78ee4f345d0d 100644 --- a/app/code/Magento/Rule/Test/Unit/Model/ConditionFactoryTest.php +++ b/app/code/Magento/Rule/Test/Unit/Model/ConditionFactoryTest.php @@ -78,7 +78,8 @@ public function testCreateExceptionClass() ->expects($this->never()) ->method('create'); - $this->expectException(\InvalidArgumentException::class, 'Class does not exist'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Class does not exist'); $this->conditionFactory->create($type); } @@ -92,7 +93,8 @@ public function testCreateExceptionType() ->method('create') ->with($type) ->willReturn(new \stdClass()); - $this->expectException(\InvalidArgumentException::class, 'Class does not implement condition interface'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Class does not implement condition interface'); $this->conditionFactory->create($type); } } diff --git a/app/code/Magento/Rule/composer.json b/app/code/Magento/Rule/composer.json index 2a2aff2dc77f0..15f72b8ec24a0 100644 --- a/app/code/Magento/Rule/composer.json +++ b/app/code/Magento/Rule/composer.json @@ -5,16 +5,15 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "lib-libxml": "*", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-eav": "100.3.*", - "magento/module-store": "100.3.*" + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-eav": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Rule/view/adminhtml/web/conditions-data-normalizer.js b/app/code/Magento/Rule/view/adminhtml/web/conditions-data-normalizer.js new file mode 100644 index 0000000000000..c9c36c4fa585a --- /dev/null +++ b/app/code/Magento/Rule/view/adminhtml/web/conditions-data-normalizer.js @@ -0,0 +1,140 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'underscore' +], function ($, _) { + 'use strict'; + + /** + * @constructor + */ + var ConditionsDataNormalizer = function () { + this.patterns = { + validate: /^[a-z0-9_-][a-z0-9_-]*(?:\[(?:\d*|[a-z0-9_-]+)\])*$/i, + key: /[a-z0-9_-]+|(?=\[\])/gi, + push: /^$/, + fixed: /^\d+$/, + named: /^[a-z0-9_-]+$/i + }; + }; + + ConditionsDataNormalizer.prototype = { + /** + * Will convert an object: + * { + * "foo[bar][1][baz]": 123, + * "foo[bar][1][blah]": 321 + * "foo[bar][1--1][ah]": 456 + * } + * + * to + * { + * "foo": { + * "bar": { + * "1": { + * "baz": 123, + * "blah": 321 + * }, + * "1--1": { + * "ah": 456 + * } + * } + * } + * } + */ + normalize: function normalize(value) { + var el, _this = this; + + this.pushes = {}; + this.data = {}; + + _.each(value, function (e, i) { + el = {}; + el[i] = e; + + _this._addPair({ + name: i, + value: e + }); + }); + + return this.data; + }, + + /** + * @param {Object} base + * @param {String} key + * @param {String} value + * @return {Object} + * @private + */ + _build: function build(base, key, value) { + base[key] = value; + + return base; + }, + + /** + * @param {Object} root + * @param {String} value + * @return {*} + * @private + */ + _makeObject: function makeObject(root, value) { + var keys = root.match(this.patterns.key), + k, idx; // nest, nest, ..., nest + + while ((k = keys.pop()) !== undefined) { + // foo[] + if (this.patterns.push.test(k)) { + idx = this._incrementPush(root.replace(/\[\]$/, '')); + value = this._build([], idx, value); + } // foo[n] + else if (this.patterns.fixed.test(k)) { + value = this._build({}, k, value); + } // foo; foo[bar] + else if (this.patterns.named.test(k)) { + value = this._build({}, k, value); + } + } + + return value; + }, + + /** + * @param {String} key + * @return {Number} + * @private + */ + _incrementPush: function incrementPush(key) { + if (this.pushes[key] === undefined) { + this.pushes[key] = 0; + } + + return this.pushes[key]++; + }, + + /** + * @param {Object} pair + * @return {Object} + * @private + */ + _addPair: function addPair(pair) { + var obj = this._makeObject(pair.name, pair.value); + + if (!this.patterns.validate.test(pair.name)) { + return this; + } + + this.data = $.extend(true, this.data, obj); + + return this; + } + }; + + return ConditionsDataNormalizer; +}); diff --git a/app/code/Magento/Rule/view/adminhtml/web/rules.js b/app/code/Magento/Rule/view/adminhtml/web/rules.js index 5c4be367b9cb3..b094b9818364a 100644 --- a/app/code/Magento/Rule/view/adminhtml/web/rules.js +++ b/app/code/Magento/Rule/view/adminhtml/web/rules.js @@ -137,7 +137,7 @@ define([ }, onSuccess: function (transport) { if (this._processSuccess(transport)) { - $(chooser).update(transport.responseText); + jQuery(chooser).html(transport.responseText); this.showChooserLoaded(chooser, transport); jQuery(chooser).trigger('contentUpdated'); } diff --git a/app/code/Magento/Sales/Api/OrderCustomerDelegateInterface.php b/app/code/Magento/Sales/Api/OrderCustomerDelegateInterface.php new file mode 100644 index 0000000000000..2902903b0b7d0 --- /dev/null +++ b/app/code/Magento/Sales/Api/OrderCustomerDelegateInterface.php @@ -0,0 +1,25 @@ + __('unhold'), 'id' => 'order-view-unhold-button', 'data_attribute' => [ - 'url' => $this->getUnHoldUrl() + 'url' => $this->getUnholdUrl() ] ] ); diff --git a/app/code/Magento/Sales/Block/Adminhtml/Report/Filter/Form/Order.php b/app/code/Magento/Sales/Block/Adminhtml/Report/Filter/Form/Order.php index 686f28aadf041..a94a7b9d3f557 100644 --- a/app/code/Magento/Sales/Block/Adminhtml/Report/Filter/Form/Order.php +++ b/app/code/Magento/Sales/Block/Adminhtml/Report/Filter/Form/Order.php @@ -24,8 +24,6 @@ class Order extends \Magento\Sales\Block\Adminhtml\Report\Filter\Form protected function _prepareForm() { parent::_prepareForm(); - $form = $this->getForm(); - $htmlIdPrefix = $form->getHtmlIdPrefix(); /** @var \Magento\Framework\Data\Form\Element\Fieldset $fieldset */ $fieldset = $this->getForm()->getElement('base_fieldset'); diff --git a/app/code/Magento/Sales/Block/Order/Recent.php b/app/code/Magento/Sales/Block/Order/Recent.php index e57aa1fe420a0..7e5be0ebfbba2 100644 --- a/app/code/Magento/Sales/Block/Order/Recent.php +++ b/app/code/Magento/Sales/Block/Order/Recent.php @@ -5,6 +5,13 @@ */ namespace Magento\Sales\Block\Order; +use Magento\Framework\View\Element\Template\Context; +use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; +use Magento\Customer\Model\Session; +use Magento\Sales\Model\Order\Config; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\App\ObjectManager; + /** * Sales order history block * @@ -13,6 +20,11 @@ */ class Recent extends \Magento\Framework\View\Element\Template { + /** + * Limit of orders + */ + const ORDER_LIMIT = 5; + /** * @var \Magento\Sales\Model\ResourceModel\Order\CollectionFactory */ @@ -28,25 +40,34 @@ class Recent extends \Magento\Framework\View\Element\Template */ protected $_orderConfig; + /** + * @var \Magento\Store\Model\StoreManagerInterface + */ + private $storeManager; + /** * @param \Magento\Framework\View\Element\Template\Context $context * @param \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory * @param \Magento\Customer\Model\Session $customerSession * @param \Magento\Sales\Model\Order\Config $orderConfig * @param array $data + * @param \Magento\Store\Model\StoreManagerInterface $storeManager */ public function __construct( - \Magento\Framework\View\Element\Template\Context $context, - \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory, - \Magento\Customer\Model\Session $customerSession, - \Magento\Sales\Model\Order\Config $orderConfig, - array $data = [] + Context $context, + CollectionFactory $orderCollectionFactory, + Session $customerSession, + Config $orderConfig, + array $data = [], + StoreManagerInterface $storeManager = null ) { $this->_orderCollectionFactory = $orderCollectionFactory; $this->_customerSession = $customerSession; $this->_orderConfig = $orderConfig; - parent::__construct($context, $data); $this->_isScopePrivate = true; + $this->storeManager = $storeManager ?: ObjectManager::getInstance() + ->get(StoreManagerInterface::class); + parent::__construct($context, $data); } /** @@ -55,11 +76,22 @@ public function __construct( protected function _construct() { parent::_construct(); + $this->getRecentOrders(); + } + + /** + * Get recently placed orders. By default they will be limited by 5. + */ + private function getRecentOrders() + { $orders = $this->_orderCollectionFactory->create()->addAttributeToSelect( '*' )->addAttributeToFilter( 'customer_id', $this->_customerSession->getCustomerId() + )->addAttributeToFilter( + 'store_id', + $this->storeManager->getStore()->getId() )->addAttributeToFilter( 'status', ['in' => $this->_orderConfig->getVisibleOnFrontStatuses()] @@ -67,7 +99,7 @@ protected function _construct() 'created_at', 'desc' )->setPageSize( - '5' + self::ORDER_LIMIT )->load(); $this->setOrders($orders); } diff --git a/app/code/Magento/Sales/Block/Order/Totals.php b/app/code/Magento/Sales/Block/Order/Totals.php index f910b654f4d8c..3720db76b5778 100644 --- a/app/code/Magento/Sales/Block/Order/Totals.php +++ b/app/code/Magento/Sales/Block/Order/Totals.php @@ -293,6 +293,12 @@ public function removeTotal($code) */ public function applySortOrder($order) { + \uksort( + $this->_totals, + function ($code1, $code2) use ($order) { + return ($order[$code1] ?? 0) <=> ($order[$code2] ?? 0); + } + ); return $this; } diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/Create/Save.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/Create/Save.php index 74b7ad2165332..621705c7937cb 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/Create/Save.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/Create/Save.php @@ -63,6 +63,8 @@ public function execute() } $resultRedirect->setPath('sales/*/'); } catch (\Magento\Framework\Exception\LocalizedException $e) { + // customer can be created before place order flow is completed and should be stored in current session + $this->_getSession()->setCustomerId($this->_getSession()->getQuote()->getCustomerId()); $message = $e->getMessage(); if (!empty($message)) { $this->messageManager->addError($message); diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/MassCancel.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/MassCancel.php index 2ba8467ff6864..8488e402caf69 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/MassCancel.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/MassCancel.php @@ -9,6 +9,7 @@ use Magento\Backend\App\Action\Context; use Magento\Ui\Component\MassAction\Filter; use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; +use Magento\Sales\Api\OrderManagementInterface; class MassCancel extends \Magento\Sales\Controller\Adminhtml\Order\AbstractMassAction { @@ -16,16 +17,29 @@ class MassCancel extends \Magento\Sales\Controller\Adminhtml\Order\AbstractMassA * Authorization level of a basic admin session */ const ADMIN_RESOURCE = 'Magento_Sales::cancel'; + + /** + * @var OrderManagementInterface + */ + private $orderManagement; /** * @param Context $context * @param Filter $filter * @param CollectionFactory $collectionFactory + * @param OrderManagementInterface|null $orderManagement */ - public function __construct(Context $context, Filter $filter, CollectionFactory $collectionFactory) - { + public function __construct( + Context $context, + Filter $filter, + CollectionFactory $collectionFactory, + OrderManagementInterface $orderManagement = null + ) { parent::__construct($context, $filter); $this->collectionFactory = $collectionFactory; + $this->orderManagement = $orderManagement ?: \Magento\Framework\App\ObjectManager::getInstance()->get( + \Magento\Sales\Api\OrderManagementInterface::class + ); } /** @@ -38,11 +52,10 @@ protected function massAction(AbstractCollection $collection) { $countCancelOrder = 0; foreach ($collection->getItems() as $order) { - if (!$order->canCancel()) { + $isCanceled = $this->orderManagement->cancel($order->getEntityId()); + if ($isCanceled === false) { continue; } - $order->cancel(); - $order->save(); $countCancelOrder++; } $countNonCancelOrder = $collection->count() - $countCancelOrder; diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/MassUnhold.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/MassUnhold.php index ebd6ff4a79b06..2eb54c9814ef7 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/MassUnhold.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/MassUnhold.php @@ -9,6 +9,7 @@ use Magento\Backend\App\Action\Context; use Magento\Ui\Component\MassAction\Filter; use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; +use Magento\Sales\Api\OrderManagementInterface; class MassUnhold extends AbstractMassAction { @@ -16,16 +17,29 @@ class MassUnhold extends AbstractMassAction * Authorization level of a basic admin session */ const ADMIN_RESOURCE = 'Magento_Sales::unhold'; + + /** + * @var OrderManagementInterface + */ + private $orderManagement; /** * @param Context $context * @param Filter $filter * @param CollectionFactory $collectionFactory + * @param OrderManagementInterface|null $orderManagement */ - public function __construct(Context $context, Filter $filter, CollectionFactory $collectionFactory) - { + public function __construct( + Context $context, + Filter $filter, + CollectionFactory $collectionFactory, + OrderManagementInterface $orderManagement = null + ) { parent::__construct($context, $filter); $this->collectionFactory = $collectionFactory; + $this->orderManagement = $orderManagement ?: \Magento\Framework\App\ObjectManager::getInstance()->get( + \Magento\Sales\Api\OrderManagementInterface::class + ); } /** @@ -40,12 +54,10 @@ protected function massAction(AbstractCollection $collection) /** @var \Magento\Sales\Model\Order $order */ foreach ($collection->getItems() as $order) { - $order->load($order->getId()); if (!$order->canUnhold()) { continue; } - $order->unhold(); - $order->save(); + $this->orderManagement->unHold($order->getEntityId()); $countUnHoldOrder++; } diff --git a/app/code/Magento/Sales/CustomerData/LastOrderedItems.php b/app/code/Magento/Sales/CustomerData/LastOrderedItems.php index 3cd3afbfa4d22..f2f36979f30b2 100644 --- a/app/code/Magento/Sales/CustomerData/LastOrderedItems.php +++ b/app/code/Magento/Sales/CustomerData/LastOrderedItems.php @@ -6,6 +6,9 @@ namespace Magento\Sales\CustomerData; use Magento\Customer\CustomerData\SectionSourceInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Psr\Log\LoggerInterface; /** * Returns information for "Recently Ordered" widget. @@ -54,25 +57,41 @@ class LastOrderedItems implements SectionSourceInterface */ private $_storeManager; + /** + * @var \Magento\Catalog\Api\ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory * @param \Magento\Sales\Model\Order\Config $orderConfig * @param \Magento\Customer\Model\Session $customerSession * @param \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param ProductRepositoryInterface $productRepository + * @param LoggerInterface $logger */ public function __construct( \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory, \Magento\Sales\Model\Order\Config $orderConfig, \Magento\Customer\Model\Session $customerSession, \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry, - \Magento\Store\Model\StoreManagerInterface $storeManager + \Magento\Store\Model\StoreManagerInterface $storeManager, + ProductRepositoryInterface $productRepository, + LoggerInterface $logger ) { $this->_orderCollectionFactory = $orderCollectionFactory; $this->_orderConfig = $orderConfig; $this->_customerSession = $customerSession; $this->stockRegistry = $stockRegistry; $this->_storeManager = $storeManager; + $this->productRepository = $productRepository; + $this->logger = $logger; } /** @@ -108,11 +127,23 @@ protected function getItems() $website = $this->_storeManager->getStore()->getWebsiteId(); /** @var \Magento\Sales\Model\Order\Item $item */ foreach ($order->getParentItemsRandomCollection($limit) as $item) { - if ($item->hasData('product') && in_array($website, $item->getProduct()->getWebsiteIds())) { + /** @var \Magento\Catalog\Model\Product $product */ + try { + $product = $this->productRepository->getById( + $item->getProductId(), + false, + $this->_storeManager->getStore()->getId() + ); + } catch (NoSuchEntityException $noEntityException) { + $this->logger->critical($noEntityException); + continue; + } + if (isset($product) && in_array($website, $product->getWebsiteIds())) { + $url = $product->isVisibleInSiteVisibility() ? $product->getProductUrl() : null; $items[] = [ 'id' => $item->getId(), 'name' => $item->getName(), - 'url' => $item->getProduct()->getProductUrl(), + 'url' => $url, 'is_saleable' => $this->isItemAvailableForReorder($item), ]; } @@ -136,7 +167,7 @@ protected function isItemAvailableForReorder(\Magento\Sales\Model\Order\Item $or $orderItem->getStore()->getWebsiteId() ); return $stockItem->getIsInStock(); - } catch (\Magento\Framework\Exception\NoSuchEntityException $noEntityException) { + } catch (NoSuchEntityException $noEntityException) { return false; } } diff --git a/app/code/Magento/Sales/Helper/Admin.php b/app/code/Magento/Sales/Helper/Admin.php index 1b8d95441dfc5..45a6dd1252ba3 100644 --- a/app/code/Magento/Sales/Helper/Admin.php +++ b/app/code/Magento/Sales/Helper/Admin.php @@ -84,7 +84,6 @@ public function displayPriceAttribute($dataObject, $code, $strong = false, $sepa */ public function displayPrices($dataObject, $basePrice, $price, $strong = false, $separator = '
    ') { - $order = false; if ($dataObject instanceof \Magento\Sales\Model\Order) { $order = $dataObject; } else { diff --git a/app/code/Magento/Sales/Helper/Guest.php b/app/code/Magento/Sales/Helper/Guest.php index dd8845008d79e..8407ce5a8d7cb 100644 --- a/app/code/Magento/Sales/Helper/Guest.php +++ b/app/code/Magento/Sales/Helper/Guest.php @@ -83,7 +83,7 @@ class Guest extends \Magento\Framework\App\Helper\AbstractHelper /** * @var \Magento\Store\Model\StoreManagerInterface */ - private $_storeManager; + private $storeManager; /** * @var string @@ -119,7 +119,7 @@ public function __construct( \Magento\Framework\Api\SearchCriteriaBuilder $searchCriteria = null ) { $this->coreRegistry = $coreRegistry; - $this->_storeManager = $storeManager; + $this->storeManager = $storeManager; $this->customerSession = $customerSession; $this->cookieManager = $cookieManager; $this->cookieMetadataFactory = $cookieMetadataFactory; @@ -158,9 +158,10 @@ public function loadValidOrder(App\RequestInterface $request) // It is unique place in the class that process exception and only InputException. It is need because by // input data we found order and one more InputException could be throws deeper in stack trace try { - $order = (!empty($post) && isset($post['oar_order_id'], $post['oar_type'])) + $order = (!empty($post) + && isset($post['oar_order_id'], $post['oar_type']) + && !$this->hasPostDataEmptyFields($post)) ? $this->loadFromPost($post) : $this->loadFromCookie($fromCookie); - $this->validateOrderStoreId($order->getStoreId()); $this->coreRegistry->register('current_order', $order); return true; } catch (InputException $e) { @@ -186,7 +187,7 @@ public function getBreadcrumbs(\Magento\Framework\View\Result\Page $resultPage) [ 'label' => __('Home'), 'title' => __('Go to Home Page'), - 'link' => $this->_storeManager->getStore()->getBaseUrl() + 'link' => $this->storeManager->getStore()->getBaseUrl() ] ); $breadcrumbs->addCrumb( @@ -247,12 +248,9 @@ private function loadFromCookie($fromCookie) */ private function loadFromPost(array $postData) { - if ($this->hasPostDataEmptyFields($postData)) { - throw new InputException(); - } /** @var $order \Magento\Sales\Model\Order */ $order = $this->getOrderRecord($postData['oar_order_id']); - if (!$this->compareSoredBillingDataWithInput($order, $postData)) { + if (!$this->compareStoredBillingDataWithInput($order, $postData)) { throw new InputException(__('You entered incorrect data. Please try again.')); } $toCookie = base64_encode($order->getProtectCode() . ':' . $postData['oar_order_id']); @@ -267,7 +265,7 @@ private function loadFromPost(array $postData) * @param array $postData * @return bool */ - private function compareSoredBillingDataWithInput(Order $order, array $postData) + private function compareStoredBillingDataWithInput(Order $order, array $postData) { $type = $postData['oar_type']; $email = $postData['oar_email']; @@ -288,7 +286,7 @@ private function compareSoredBillingDataWithInput(Order $order, array $postData) private function hasPostDataEmptyFields(array $postData) { return empty($postData['oar_order_id']) || empty($postData['oar_billing_lastname']) || - empty($postData['oar_type']) || empty($this->_storeManager->getStore()->getId()) || + empty($postData['oar_type']) || empty($this->storeManager->getStore()->getId()) || !in_array($postData['oar_type'], ['email', 'zip'], true) || ('email' === $postData['oar_type'] && empty($postData['oar_email'])) || ('zip' === $postData['oar_type'] && empty($postData['oar_zip'])); @@ -306,26 +304,15 @@ private function getOrderRecord($incrementId) $records = $this->orderRepository->getList( $this->searchCriteriaBuilder ->addFilter('increment_id', $incrementId) + ->addFilter('store_id', $this->storeManager->getStore()->getId()) ->create() ); - if ($records->getTotalCount() < 1) { - throw new InputException(__($this->inputExceptionMessage)); - } - $items = $records->getItems(); - return array_shift($items); - } - /** - * Check that store_id from order are equals with system - * - * @param int $orderStoreId - * @return void - * @throws InputException - */ - private function validateOrderStoreId($orderStoreId) - { - if ($orderStoreId != $this->_storeManager->getStore()->getId()) { + $items = $records->getItems(); + if (empty($items)) { throw new InputException(__($this->inputExceptionMessage)); } + + return array_shift($items); } } diff --git a/app/code/Magento/Sales/Model/AdminOrder/Create.php b/app/code/Magento/Sales/Model/AdminOrder/Create.php index 679693abd5540..f34f8a681085d 100644 --- a/app/code/Magento/Sales/Model/AdminOrder/Create.php +++ b/app/code/Magento/Sales/Model/AdminOrder/Create.php @@ -714,9 +714,10 @@ public function getCustomerCart() $this->_cart = $this->quoteFactory->create(); $customerId = (int)$this->getSession()->getCustomerId(); + $storeId = (int)$this->getSession()->getStoreId(); if ($customerId) { try { - $this->_cart = $this->quoteRepository->getForCustomer($customerId); + $this->_cart = $this->quoteRepository->getForCustomer($customerId, [$storeId]); } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { $this->_cart->setStore($this->getSession()->getStore()); $customerData = $this->customerRepository->getById($customerId); diff --git a/app/code/Magento/Sales/Model/Order/Creditmemo/CommentRepository.php b/app/code/Magento/Sales/Model/Order/Creditmemo/CommentRepository.php index 14d4ccae22446..991453bc6745e 100644 --- a/app/code/Magento/Sales/Model/Order/Creditmemo/CommentRepository.php +++ b/app/code/Magento/Sales/Model/Order/Creditmemo/CommentRepository.php @@ -7,6 +7,7 @@ use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Sales\Api\CreditmemoCommentRepositoryInterface; @@ -14,7 +15,13 @@ use Magento\Sales\Api\Data\CreditmemoCommentInterfaceFactory; use Magento\Sales\Api\Data\CreditmemoCommentSearchResultInterfaceFactory; use Magento\Sales\Model\Spi\CreditmemoCommentResourceInterface; +use Magento\Sales\Model\Order\Email\Sender\CreditmemoCommentSender; +use Magento\Sales\Api\CreditmemoRepositoryInterface; +use Psr\Log\LoggerInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CommentRepository implements CreditmemoCommentRepositoryInterface { /** @@ -37,22 +44,48 @@ class CommentRepository implements CreditmemoCommentRepositoryInterface */ private $collectionProcessor; + /** + * @var CreditmemoCommentSender + */ + private $creditmemoCommentSender; + + /** + * @var CreditmemoRepositoryInterface + */ + private $creditmemoRepository; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param CreditmemoCommentResourceInterface $commentResource * @param CreditmemoCommentInterfaceFactory $commentFactory * @param CreditmemoCommentSearchResultInterfaceFactory $searchResultFactory * @param CollectionProcessorInterface $collectionProcessor + * @param CreditmemoCommentSender|null $creditmemoCommentSender + * @param CreditmemoRepositoryInterface|null $creditmemoRepository + * @param LoggerInterface|null $logger */ public function __construct( CreditmemoCommentResourceInterface $commentResource, CreditmemoCommentInterfaceFactory $commentFactory, CreditmemoCommentSearchResultInterfaceFactory $searchResultFactory, - CollectionProcessorInterface $collectionProcessor + CollectionProcessorInterface $collectionProcessor, + CreditmemoCommentSender $creditmemoCommentSender = null, + CreditmemoRepositoryInterface $creditmemoRepository = null, + LoggerInterface $logger = null ) { $this->commentResource = $commentResource; $this->commentFactory = $commentFactory; $this->searchResultFactory = $searchResultFactory; $this->collectionProcessor = $collectionProcessor; + $this->creditmemoCommentSender = $creditmemoCommentSender + ?: ObjectManager::getInstance()->get(CreditmemoCommentSender::class); + $this->creditmemoRepository = $creditmemoRepository + ?: ObjectManager::getInstance()->get(CreditmemoRepositoryInterface::class); + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -97,8 +130,16 @@ public function save(CreditmemoCommentInterface $entity) try { $this->commentResource->save($entity); } catch (\Exception $e) { - throw new CouldNotSaveException(__('Could not save the comment.'), $e); + throw new CouldNotSaveException(__('Could not save the creditmemo comment.'), $e); } + + try { + $creditmemo = $this->creditmemoRepository->get($entity->getParentId()); + $this->creditmemoCommentSender->send($creditmemo, $entity->getIsCustomerNotified(), $entity->getComment()); + } catch (\Exception $exception) { + $this->logger->critical($exception); + } + return $entity; } } diff --git a/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php b/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php index 5f1bba4c8f2c2..e61c2597975bb 100644 --- a/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php +++ b/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php @@ -5,13 +5,16 @@ */ namespace Magento\Sales\Model\Order; +use Magento\Bundle\Ui\DataProvider\Product\Listing\Collector\BundlePrice; +use Magento\Sales\Api\Data\OrderItemInterface; + /** * Factory class for @see \Magento\Sales\Model\Order\Creditmemo */ class CreditmemoFactory { /** - * Quote convert object + * Order convert object. * * @var \Magento\Sales\Model\Convert\Order */ @@ -63,31 +66,15 @@ public function createByOrder(\Magento\Sales\Model\Order $order, array $data = [ { $totalQty = 0; $creditmemo = $this->convertor->toCreditmemo($order); - $qtys = isset($data['qtys']) ? $data['qtys'] : []; + $qtyList = isset($data['qtys']) ? $data['qtys'] : []; foreach ($order->getAllItems() as $orderItem) { - if (!$this->canRefundItem($orderItem, $qtys)) { + if (!$this->canRefundItem($orderItem, $qtyList)) { continue; } $item = $this->convertor->itemToCreditmemoItem($orderItem); - if ($orderItem->isDummy()) { - if (isset($data['qtys'][$orderItem->getParentItemId()])) { - $parentQty = $data['qtys'][$orderItem->getParentItemId()]; - } else { - $parentQty = $orderItem->getParentItem() ? $orderItem->getParentItem()->getQtyToRefund() : 1; - } - $qty = $this->calculateProductOptions($orderItem, $parentQty); - $orderItem->setLockedDoShip(true); - } else { - if (isset($qtys[$orderItem->getId()])) { - $qty = (double)$qtys[$orderItem->getId()]; - } elseif (!count($qtys)) { - $qty = $orderItem->getQtyToRefund(); - } else { - continue; - } - } + $qty = $this->getQtyToRefund($orderItem, $qtyList); $totalQty += $qty; $item->setQty($qty); $creditmemo->addItem($item); @@ -106,72 +93,31 @@ public function createByOrder(\Magento\Sales\Model\Order $order, array $data = [ * @param \Magento\Sales\Model\Order\Invoice $invoice * @param array $data * @return Creditmemo - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) */ public function createByInvoice(\Magento\Sales\Model\Order\Invoice $invoice, array $data = []) { $order = $invoice->getOrder(); $totalQty = 0; - $qtys = isset($data['qtys']) ? $data['qtys'] : []; + $qtyList = isset($data['qtys']) ? $data['qtys'] : []; $creditmemo = $this->convertor->toCreditmemo($order); $creditmemo->setInvoice($invoice); - $invoiceQtysRefunded = []; - foreach ($invoice->getOrder()->getCreditmemosCollection() as $createdCreditmemo) { - if ($createdCreditmemo->getState() != Creditmemo::STATE_CANCELED && - $createdCreditmemo->getInvoiceId() == $invoice->getId() - ) { - foreach ($createdCreditmemo->getAllItems() as $createdCreditmemoItem) { - $orderItemId = $createdCreditmemoItem->getOrderItem()->getId(); - if (isset($invoiceQtysRefunded[$orderItemId])) { - $invoiceQtysRefunded[$orderItemId] += $createdCreditmemoItem->getQty(); - } else { - $invoiceQtysRefunded[$orderItemId] = $createdCreditmemoItem->getQty(); - } - } - } - } - - $invoiceQtysRefundLimits = []; - foreach ($invoice->getAllItems() as $invoiceItem) { - $invoiceQtyCanBeRefunded = $invoiceItem->getQty(); - $orderItemId = $invoiceItem->getOrderItem()->getId(); - if (isset($invoiceQtysRefunded[$orderItemId])) { - $invoiceQtyCanBeRefunded = $invoiceQtyCanBeRefunded - $invoiceQtysRefunded[$orderItemId]; - } - $invoiceQtysRefundLimits[$orderItemId] = $invoiceQtyCanBeRefunded; - } + $invoiceRefundLimitsQtyList = $this->getInvoiceRefundLimitsQtyList($invoice); foreach ($invoice->getAllItems() as $invoiceItem) { + /** @var OrderItemInterface $orderItem */ $orderItem = $invoiceItem->getOrderItem(); - if (!$this->canRefundItem($orderItem, $qtys, $invoiceQtysRefundLimits)) { + if (!$this->canRefundItem($orderItem, $qtyList, $invoiceRefundLimitsQtyList)) { continue; } - $item = $this->convertor->itemToCreditmemoItem($orderItem); - if ($orderItem->isDummy()) { - if (isset($data['qtys'][$orderItem->getParentItemId()])) { - $parentQty = $data['qtys'][$orderItem->getParentItemId()]; - } else { - $parentQty = $orderItem->getParentItem() ? $orderItem->getParentItem()->getQtyToRefund() : 1; - } - $qty = $this->calculateProductOptions($orderItem, $parentQty); - } else { - if (isset($qtys[$orderItem->getId()])) { - $qty = (double)$qtys[$orderItem->getId()]; - } elseif (!count($qtys)) { - $qty = $orderItem->getQtyToRefund(); - } else { - continue; - } - if (isset($invoiceQtysRefundLimits[$orderItem->getId()])) { - $qty = min($qty, $invoiceQtysRefundLimits[$orderItem->getId()]); - } - } - $qty = min($qty, $invoiceItem->getQty()); + $qty = min( + $this->getQtyToRefund($orderItem, $qtyList, $invoiceRefundLimitsQtyList), + $invoiceItem->getQty() + ); $totalQty += $qty; + $item = $this->convertor->itemToCreditmemoItem($orderItem); $item->setQty($qty); $creditmemo->addItem($item); } @@ -179,15 +125,7 @@ public function createByInvoice(\Magento\Sales\Model\Order\Invoice $invoice, arr $this->initData($creditmemo, $data); if (!isset($data['shipping_amount'])) { - $isShippingInclTax = $this->taxConfig->displaySalesShippingInclTax($order->getStoreId()); - if ($isShippingInclTax) { - $baseAllowedAmount = $order->getBaseShippingInclTax() - - $order->getBaseShippingRefunded() - - $order->getBaseShippingTaxRefunded(); - } else { - $baseAllowedAmount = $order->getBaseShippingAmount() - $order->getBaseShippingRefunded(); - $baseAllowedAmount = min($baseAllowedAmount, $invoice->getBaseShippingAmount()); - } + $baseAllowedAmount = $this->getShippingAmount($invoice); $creditmemo->setBaseShippingAmount($baseAllowedAmount); } @@ -272,11 +210,11 @@ protected function initData($creditmemo, $data) } /** - * @param \Magento\Sales\Api\Data\OrderItemInterface $orderItem + * @param Item $orderItem * @param int $parentQty * @return int */ - private function calculateProductOptions(\Magento\Sales\Api\Data\OrderItemInterface $orderItem, $parentQty) + private function calculateProductOptions(Item $orderItem, int $parentQty): int { $qty = $parentQty; $productOptions = $orderItem->getProductOptions(); @@ -290,4 +228,113 @@ private function calculateProductOptions(\Magento\Sales\Api\Data\OrderItemInterf } return $qty; } + + /** + * Gets list of quantities based on invoice refunded items. + * + * @param Invoice $invoice + * @return array + */ + private function getInvoiceRefundedQtyList(Invoice $invoice): array + { + $invoiceRefundedQtyList = []; + foreach ($invoice->getOrder()->getCreditmemosCollection() as $creditmemo) { + if ($creditmemo->getState() !== Creditmemo::STATE_CANCELED && + $creditmemo->getInvoiceId() === $invoice->getId() + ) { + foreach ($creditmemo->getAllItems() as $creditmemoItem) { + $orderItemId = $creditmemoItem->getOrderItem()->getId(); + if (isset($invoiceRefundedQtyList[$orderItemId])) { + $invoiceRefundedQtyList[$orderItemId] += $creditmemoItem->getQty(); + } else { + $invoiceRefundedQtyList[$orderItemId] = $creditmemoItem->getQty(); + } + } + } + } + + return $invoiceRefundedQtyList; + } + + /** + * Gets limits of refund based on invoice items. + * + * @param Invoice $invoice + * @return array + */ + private function getInvoiceRefundLimitsQtyList(Invoice $invoice): array + { + $invoiceRefundLimitsQtyList = []; + $invoiceRefundedQtyList = $this->getInvoiceRefundedQtyList($invoice); + + foreach ($invoice->getAllItems() as $invoiceItem) { + $qtyCanBeRefunded = $invoiceItem->getQty(); + $orderItemId = $invoiceItem->getOrderItem()->getId(); + if (isset($invoiceRefundedQtyList[$orderItemId])) { + $qtyCanBeRefunded = $qtyCanBeRefunded - $invoiceRefundedQtyList[$orderItemId]; + } + $invoiceRefundLimitsQtyList[$orderItemId] = $qtyCanBeRefunded; + } + + return $invoiceRefundLimitsQtyList; + } + + /** + * Gets quantity of items to refund based on order item. + * + * @param Item $orderItem + * @param array $qtyList + * @param array $refundLimits + * @return float + */ + private function getQtyToRefund(Item $orderItem, array $qtyList, array $refundLimits = []): float + { + $qty = 0; + if ($orderItem->isDummy()) { + if (isset($qtyList[$orderItem->getParentItemId()])) { + $parentQty = $qtyList[$orderItem->getParentItemId()]; + } elseif ($orderItem->getProductType() === BundlePrice::PRODUCT_TYPE) { + $parentQty = $orderItem->getQtyInvoiced(); + } else { + $parentQty = $orderItem->getParentItem() ? $orderItem->getParentItem()->getQtyToRefund() : 1; + } + $qty = $this->calculateProductOptions($orderItem, $parentQty); + } else { + if (isset($qtyList[$orderItem->getId()])) { + $qty = $qtyList[$orderItem->getId()]; + } elseif (!count($qtyList)) { + $qty = $orderItem->getQtyToRefund(); + } else { + return (float)$qty; + } + + if (isset($refundLimits[$orderItem->getId()])) { + $qty = min($qty, $refundLimits[$orderItem->getId()]); + } + } + + return (float)$qty; + } + + /** + * Gets shipping amount based on invoice. + * + * @param Invoice $invoice + * @return float + */ + private function getShippingAmount(Invoice $invoice): float + { + $order = $invoice->getOrder(); + $isShippingInclTax = $this->taxConfig->displaySalesShippingInclTax($order->getStoreId()); + if ($isShippingInclTax) { + $amount = $order->getBaseShippingInclTax() - + $order->getBaseShippingRefunded() - + $order->getBaseShippingTaxRefunded(); + } else { + $amount = $order->getBaseShippingAmount() - $order->getBaseShippingRefunded(); + $amount = min($amount, $invoice->getBaseShippingAmount()); + } + + return (float)$amount; + } } diff --git a/app/code/Magento/Sales/Model/Order/CreditmemoRepository.php b/app/code/Magento/Sales/Model/Order/CreditmemoRepository.php index a57839b933617..1d82c17549140 100644 --- a/app/code/Magento/Sales/Model/Order/CreditmemoRepository.php +++ b/app/code/Magento/Sales/Model/Order/CreditmemoRepository.php @@ -7,7 +7,6 @@ namespace Magento\Sales\Model\Order; use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; -use Magento\Sales\Model\ResourceModel\Order\Creditmemo as Resource; use Magento\Sales\Model\ResourceModel\Metadata; use Magento\Sales\Api\Data\CreditmemoSearchResultInterfaceFactory as SearchResultFactory; use Magento\Framework\Exception\NoSuchEntityException; diff --git a/app/code/Magento/Sales/Model/Order/CustomerManagement.php b/app/code/Magento/Sales/Model/Order/CustomerManagement.php index 466f3ff8adddb..ae3f940dbb2ba 100644 --- a/app/code/Magento/Sales/Model/Order/CustomerManagement.php +++ b/app/code/Magento/Sales/Model/Order/CustomerManagement.php @@ -6,12 +6,18 @@ namespace Magento\Sales\Model\Order; +use Magento\Customer\Api\Data\AddressInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\AlreadyExistsException; +use Magento\Sales\Api\Data\OrderAddressInterface; use Magento\Quote\Model\Quote\Address as QuoteAddress; +use Magento\Quote\Model\Quote\AddressFactory as QuoteAddressFactory; +use Magento\Sales\Api\Data\OrderInterface; /** * Class CustomerManagement + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class CustomerManagement implements \Magento\Sales\Api\OrderCustomerManagementInterface { @@ -21,17 +27,17 @@ class CustomerManagement implements \Magento\Sales\Api\OrderCustomerManagementIn protected $accountManagement; /** - * @var \Magento\Customer\Api\Data\CustomerInterfaceFactory + * @deprecated */ protected $customerFactory; /** - * @var \Magento\Customer\Api\Data\AddressInterfaceFactory + * @deprecated */ protected $addressFactory; /** - * @var \Magento\Customer\Api\Data\RegionInterfaceFactory + * @deprecated */ protected $regionFactory; @@ -41,15 +47,20 @@ class CustomerManagement implements \Magento\Sales\Api\OrderCustomerManagementIn protected $orderRepository; /** - * @var \Magento\Framework\DataObject\Copy + * @deprecated */ protected $objectCopyService; /** - * @var \Magento\Quote\Model\Quote\AddressFactory + * @var QuoteAddressFactory */ private $quoteAddressFactory; + /** + * @var OrderCustomerExtractor + */ + private $customerExtractor; + /** * @param \Magento\Framework\DataObject\Copy $objectCopyService * @param \Magento\Customer\Api\AccountManagementInterface $accountManagement @@ -57,7 +68,8 @@ class CustomerManagement implements \Magento\Sales\Api\OrderCustomerManagementIn * @param \Magento\Customer\Api\Data\AddressInterfaceFactory $addressFactory * @param \Magento\Customer\Api\Data\RegionInterfaceFactory $regionFactory * @param \Magento\Sales\Api\OrderRepositoryInterface $orderRepository - * @param \Magento\Quote\Model\Quote\AddressFactory|null $quoteAddressFactory + * @param QuoteAddressFactory|null $quoteAddressFactory + * @param OrderCustomerExtractor|null $orderCustomerExtractor */ public function __construct( \Magento\Framework\DataObject\Copy $objectCopyService, @@ -66,7 +78,8 @@ public function __construct( \Magento\Customer\Api\Data\AddressInterfaceFactory $addressFactory, \Magento\Customer\Api\Data\RegionInterfaceFactory $regionFactory, \Magento\Sales\Api\OrderRepositoryInterface $orderRepository, - \Magento\Quote\Model\Quote\AddressFactory $quoteAddressFactory = null + QuoteAddressFactory $quoteAddressFactory = null, + OrderCustomerExtractor $orderCustomerExtractor = null ) { $this->objectCopyService = $objectCopyService; $this->accountManagement = $accountManagement; @@ -74,9 +87,10 @@ public function __construct( $this->customerFactory = $customerFactory; $this->addressFactory = $addressFactory; $this->regionFactory = $regionFactory; - $this->quoteAddressFactory = $quoteAddressFactory ?: ObjectManager::getInstance()->get( - \Magento\Quote\Model\Quote\AddressFactory::class - ); + $this->quoteAddressFactory = $quoteAddressFactory + ?: ObjectManager::getInstance()->get(QuoteAddressFactory::class); + $this->customerExtractor = $orderCustomerExtractor + ?? ObjectManager::getInstance()->get(OrderCustomerExtractor::class); } /** @@ -86,50 +100,23 @@ public function create($orderId) { $order = $this->orderRepository->get($orderId); if ($order->getCustomerId()) { - throw new AlreadyExistsException(__("This order already has associated customer account")); - } - $customerData = $this->objectCopyService->copyFieldsetToTarget( - 'order_address', - 'to_customer', - $order->getBillingAddress(), - [] - ); - $addresses = $order->getAddresses(); - foreach ($addresses as $address) { - if (!$this->isNeededToSaveAddress($address->getData('quote_address_id'))) { - continue; - } - $addressData = $this->objectCopyService->copyFieldsetToTarget( - 'order_address', - 'to_customer_address', - $address, - [] + throw new AlreadyExistsException( + __('This order already has associated customer account') ); - /** @var \Magento\Customer\Api\Data\AddressInterface $customerAddress */ - $customerAddress = $this->addressFactory->create(['data' => $addressData]); - switch ($address->getAddressType()) { - case QuoteAddress::ADDRESS_TYPE_BILLING: - $customerAddress->setIsDefaultBilling(true); - break; - case QuoteAddress::ADDRESS_TYPE_SHIPPING: - $customerAddress->setIsDefaultShipping(true); - break; - } + } - if (is_string($address->getRegion())) { - /** @var \Magento\Customer\Api\Data\RegionInterface $region */ - $region = $this->regionFactory->create(); - $region->setRegion($address->getRegion()); - $region->setRegionCode($address->getRegionCode()); - $region->setRegionId($address->getRegionId()); - $customerAddress->setRegion($region); + $customer = $this->customerExtractor->extract($orderId); + /** @var AddressInterface[] $filteredAddresses */ + $filteredAddresses = []; + foreach ($customer->getAddresses() as $address) { + if ($this->needToSaveAddress($order, $address)) { + $filteredAddresses[] = $address; } - $customerData['addresses'][] = $customerAddress; } + $customer->setAddresses($filteredAddresses); - /** @var \Magento\Customer\Api\Data\CustomerInterface $customer */ - $customer = $this->customerFactory->create(['data' => $customerData]); $account = $this->accountManagement->createAccount($customer); + $order = $this->orderRepository->get($orderId); $order->setCustomerId($account->getId()); $order->setCustomerIsGuest(0); $this->orderRepository->save($order); @@ -138,21 +125,36 @@ public function create($orderId) } /** - * Check if we need to save address in address book. - * - * @param int $quoteAddressId + * @param OrderInterface $order + * @param AddressInterface $address * * @return bool */ - private function isNeededToSaveAddress($quoteAddressId) - { - $saveInAddressBook = true; + private function needToSaveAddress( + OrderInterface $order, + AddressInterface $address + ): bool { + /** @var OrderAddressInterface|null $orderAddress */ + $orderAddress = null; + if ($address->isDefaultBilling()) { + $orderAddress = $order->getBillingAddress(); + } elseif ($address->isDefaultShipping()) { + $orderAddress = $order->getShippingAddress(); + } + if ($orderAddress) { + $quoteAddressId = $orderAddress->getData('quote_address_id'); + if ($quoteAddressId) { + /** @var QuoteAddress $quote */ + $quote = $this->quoteAddressFactory->create() + ->load($quoteAddressId); + if ($quote && $quote->getId()) { + return (bool)(int)$quote->getData('save_in_address_book'); + } + } - $quoteAddress = $this->quoteAddressFactory->create()->load($quoteAddressId); - if ($quoteAddress && $quoteAddress->getId()) { - $saveInAddressBook = (int)$quoteAddress->getData('save_in_address_book'); + return true; } - return $saveInAddressBook; + return false; } } diff --git a/app/code/Magento/Sales/Model/Order/Email/Sender.php b/app/code/Magento/Sales/Model/Order/Email/Sender.php index 8ada6a3f321d2..6d4480c4c45e0 100644 --- a/app/code/Magento/Sales/Model/Order/Email/Sender.php +++ b/app/code/Magento/Sales/Model/Order/Email/Sender.php @@ -84,6 +84,8 @@ protected function checkAndSend(Order $order) $sender->sendCopyTo(); } catch (\Exception $e) { $this->logger->error($e->getMessage()); + + return false; } return true; diff --git a/app/code/Magento/Sales/Model/Order/Email/Sender/OrderSender.php b/app/code/Magento/Sales/Model/Order/Email/Sender/OrderSender.php index 2944b8ccef647..92d00d0436634 100644 --- a/app/code/Magento/Sales/Model/Order/Email/Sender/OrderSender.php +++ b/app/code/Magento/Sales/Model/Order/Email/Sender/OrderSender.php @@ -131,14 +131,17 @@ protected function prepareTemplate(Order $order) 'formattedShippingAddress' => $this->getFormattedShippingAddress($order), 'formattedBillingAddress' => $this->getFormattedBillingAddress($order), ]; - $transport = new DataObject($transport); + $transportObject = new DataObject($transport); + /** + * Event argument `transport` is @deprecated. Use `transportObject` instead. + */ $this->eventManager->dispatch( 'email_order_set_template_vars_before', - ['sender' => $this, 'transport' => $transport] + ['sender' => $this, 'transport' => $transportObject->getData(), 'transportObject' => $transportObject] ); - $this->templateContainer->setTemplateVars($transport->getData()); + $this->templateContainer->setTemplateVars($transportObject->getData()); parent::prepareTemplate($order); } diff --git a/app/code/Magento/Sales/Model/Order/Invoice/CommentRepository.php b/app/code/Magento/Sales/Model/Order/Invoice/CommentRepository.php index 858490132e0c7..9ad06cea775e8 100644 --- a/app/code/Magento/Sales/Model/Order/Invoice/CommentRepository.php +++ b/app/code/Magento/Sales/Model/Order/Invoice/CommentRepository.php @@ -7,6 +7,7 @@ use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Sales\Api\Data\InvoiceCommentInterface; @@ -14,7 +15,13 @@ use Magento\Sales\Api\Data\InvoiceCommentSearchResultInterfaceFactory; use Magento\Sales\Api\InvoiceCommentRepositoryInterface; use Magento\Sales\Model\Spi\InvoiceCommentResourceInterface; +use Magento\Sales\Model\Order\Email\Sender\InvoiceCommentSender; +use Magento\Sales\Api\InvoiceRepositoryInterface; +use Psr\Log\LoggerInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CommentRepository implements InvoiceCommentRepositoryInterface { /** @@ -37,22 +44,48 @@ class CommentRepository implements InvoiceCommentRepositoryInterface */ private $collectionProcessor; + /** + * @var InvoiceCommentSender + */ + private $invoiceCommentSender; + + /** + * @var InvoiceRepositoryInterface + */ + private $invoiceRepository; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param InvoiceCommentResourceInterface $commentResource * @param InvoiceCommentInterfaceFactory $commentFactory * @param InvoiceCommentSearchResultInterfaceFactory $searchResultFactory * @param CollectionProcessorInterface $collectionProcessor + * @param InvoiceCommentSender|null $invoiceCommentSender + * @param InvoiceRepositoryInterface|null $invoiceRepository + * @param LoggerInterface|null $logger */ public function __construct( InvoiceCommentResourceInterface $commentResource, InvoiceCommentInterfaceFactory $commentFactory, InvoiceCommentSearchResultInterfaceFactory $searchResultFactory, - CollectionProcessorInterface $collectionProcessor + CollectionProcessorInterface $collectionProcessor, + InvoiceCommentSender $invoiceCommentSender = null, + InvoiceRepositoryInterface $invoiceRepository = null, + LoggerInterface $logger = null ) { $this->commentResource = $commentResource; $this->commentFactory = $commentFactory; $this->searchResultFactory = $searchResultFactory; $this->collectionProcessor = $collectionProcessor; + $this->invoiceCommentSender = $invoiceCommentSender + ?:ObjectManager::getInstance()->get(InvoiceCommentSender::class); + $this->invoiceRepository = $invoiceRepository + ?:ObjectManager::getInstance()->get(InvoiceRepositoryInterface::class); + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -99,6 +132,14 @@ public function save(InvoiceCommentInterface $entity) } catch (\Exception $e) { throw new CouldNotSaveException(__('Could not save the invoice comment.'), $e); } + + try { + $invoice = $this->invoiceRepository->get($entity->getParentId()); + $this->invoiceCommentSender->send($invoice, $entity->getIsCustomerNotified(), $entity->getComment()); + } catch (\Exception $exception) { + $this->logger->critical($exception); + } + return $entity; } } diff --git a/app/code/Magento/Sales/Model/Order/Invoice/Total/Grand.php b/app/code/Magento/Sales/Model/Order/Invoice/Total/Grand.php index ce415d3f0b758..f4e9b0d5a6f76 100644 --- a/app/code/Magento/Sales/Model/Order/Invoice/Total/Grand.php +++ b/app/code/Magento/Sales/Model/Order/Invoice/Total/Grand.php @@ -8,17 +8,14 @@ class Grand extends AbstractTotal { /** + * Collect invoice grand total + * * @param \Magento\Sales\Model\Order\Invoice $invoice * @return $this + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function collect(\Magento\Sales\Model\Order\Invoice $invoice) { - /** - * Check order grand total and invoice amounts - */ - if ($invoice->isLast()) { - // - } return $this; } } diff --git a/app/code/Magento/Sales/Model/Order/Item.php b/app/code/Magento/Sales/Model/Order/Item.php index da2a06c2db25a..d2f5f5ce56692 100644 --- a/app/code/Magento/Sales/Model/Order/Item.php +++ b/app/code/Magento/Sales/Model/Order/Item.php @@ -233,7 +233,7 @@ public function getQtyToShip() public function getSimpleQtyToShip() { $qty = $this->getQtyOrdered() - $this->getQtyShipped() - $this->getQtyRefunded() - $this->getQtyCanceled(); - return max($qty, 0); + return max(round($qty, 8), 0); } /** @@ -248,7 +248,7 @@ public function getQtyToInvoice() } $qty = $this->getQtyOrdered() - $this->getQtyInvoiced() - $this->getQtyCanceled(); - return max($qty, 0); + return max(round($qty, 8), 0); } /** diff --git a/app/code/Magento/Sales/Model/Order/ItemRepository.php b/app/code/Magento/Sales/Model/Order/ItemRepository.php index 32741a1a7f943..7916eb9db2b80 100644 --- a/app/code/Magento/Sales/Model/Order/ItemRepository.php +++ b/app/code/Magento/Sales/Model/Order/ItemRepository.php @@ -16,7 +16,6 @@ use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Sales\Api\Data\OrderItemInterface; -use Magento\Sales\Api\Data\OrderItemSearchResultInterface; use Magento\Sales\Api\Data\OrderItemSearchResultInterfaceFactory; use Magento\Sales\Api\OrderItemRepositoryInterface; use Magento\Sales\Model\ResourceModel\Metadata; @@ -118,6 +117,7 @@ public function get($id) } $this->addProductOption($orderItem); + $this->addParentItem($orderItem); $this->registry[$id] = $orderItem; } return $this->registry[$id]; @@ -217,6 +217,20 @@ protected function addProductOption(OrderItemInterface $orderItem) return $this; } + /** + * Set parent item. + * + * @param OrderItemInterface $orderItem + * @throws InputException + * @throws NoSuchEntityException + */ + private function addParentItem(OrderItemInterface $orderItem) + { + if ($parentId = $orderItem->getParentItemId()) { + $orderItem->setParentItem($this->get($parentId)); + } + } + /** * Set product options data * diff --git a/app/code/Magento/Sales/Model/Order/OrderCustomerDelegate.php b/app/code/Magento/Sales/Model/Order/OrderCustomerDelegate.php new file mode 100644 index 0000000000000..5d0cd4f37df5a --- /dev/null +++ b/app/code/Magento/Sales/Model/Order/OrderCustomerDelegate.php @@ -0,0 +1,54 @@ +customerExtractor = $customerExtractor; + $this->delegateService = $delegateService; + } + + /** + * {@inheritdoc} + */ + public function delegateNew(int $orderId): Redirect + { + return $this->delegateService->createRedirectForNew( + $this->customerExtractor->extract($orderId), + ['__sales_assign_order_id' => $orderId] + ); + } +} diff --git a/app/code/Magento/Sales/Model/Order/OrderCustomerExtractor.php b/app/code/Magento/Sales/Model/Order/OrderCustomerExtractor.php new file mode 100644 index 0000000000000..2a93f389e569f --- /dev/null +++ b/app/code/Magento/Sales/Model/Order/OrderCustomerExtractor.php @@ -0,0 +1,142 @@ +orderRepository = $orderRepository; + $this->customerRepository = $customerRepository; + $this->objectCopyService = $objectCopyService; + $this->addressFactory = $addressFactory; + $this->regionFactory = $regionFactory; + $this->customerFactory = $customerFactory; + $this->quoteAddressFactory = $quoteAddressFactory; + } + + /** + * @param int $orderId + * + * @return CustomerInterface + */ + public function extract(int $orderId): CustomerInterface + { + $order = $this->orderRepository->get($orderId); + + //Simply return customer from DB. + if ($order->getCustomerId()) { + return $this->customerRepository->getById($order->getCustomerId()); + } + + //Prepare customer data from order data if customer doesn't exist yet. + $customerData = $this->objectCopyService->copyFieldsetToTarget( + 'order_address', + 'to_customer', + $order->getBillingAddress(), + [] + ); + $addresses = $order->getAddresses(); + foreach ($addresses as $address) { + $addressData = $this->objectCopyService->copyFieldsetToTarget( + 'order_address', + 'to_customer_address', + $address, + [] + ); + /** @var AddressInterface $customerAddress */ + $customerAddress = $this->addressFactory->create(['data' => $addressData]); + switch ($address->getAddressType()) { + case QuoteAddress::ADDRESS_TYPE_BILLING: + $customerAddress->setIsDefaultBilling(true); + break; + case QuoteAddress::ADDRESS_TYPE_SHIPPING: + $customerAddress->setIsDefaultShipping(true); + break; + } + + if (is_string($address->getRegion())) { + /** @var RegionInterface $region */ + $region = $this->regionFactory->create(); + $region->setRegion($address->getRegion()); + $region->setRegionCode($address->getRegionCode()); + $region->setRegionId($address->getRegionId()); + $customerAddress->setRegion($region); + } + $customerData['addresses'][] = $customerAddress; + } + + return $this->customerFactory->create(['data' => $customerData]); + } +} diff --git a/app/code/Magento/Sales/Model/Order/OrderValidator.php b/app/code/Magento/Sales/Model/Order/OrderValidator.php index 1b04e6ca8c362..880b85a993d11 100644 --- a/app/code/Magento/Sales/Model/Order/OrderValidator.php +++ b/app/code/Magento/Sales/Model/Order/OrderValidator.php @@ -6,7 +6,6 @@ namespace Magento\Sales\Model\Order; use Magento\Sales\Api\Data\OrderInterface; -use Magento\Sales\Exception\DocumentValidationException; /** * Class OrderValidator diff --git a/app/code/Magento/Sales/Model/Order/Pdf/AbstractPdf.php b/app/code/Magento/Sales/Model/Order/Pdf/AbstractPdf.php index 57cc7021b79bc..401fdcd2b04ac 100644 --- a/app/code/Magento/Sales/Model/Order/Pdf/AbstractPdf.php +++ b/app/code/Magento/Sales/Model/Order/Pdf/AbstractPdf.php @@ -861,7 +861,7 @@ protected function _drawItem( protected function _setFontRegular($object, $size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_Re-4.4.1.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerif.ttf') ); $object->setFont($font, $size); return $font; @@ -877,7 +877,7 @@ protected function _setFontRegular($object, $size = 7) protected function _setFontBold($object, $size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_Bd-2.8.1.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifBold.ttf') ); $object->setFont($font, $size); return $font; @@ -893,7 +893,7 @@ protected function _setFontBold($object, $size = 7) protected function _setFontItalic($object, $size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_It-2.8.2.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifItalic.ttf') ); $object->setFont($font, $size); return $font; diff --git a/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php b/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php index c0d7a43bed862..422ff1746c9a6 100644 --- a/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php +++ b/app/code/Magento/Sales/Model/Order/Pdf/Items/AbstractItems.php @@ -338,7 +338,7 @@ public function getItemOptions() protected function _setFontRegular($size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_Re-4.4.1.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerif.ttf') ); $this->getPage()->setFont($font, $size); return $font; @@ -353,7 +353,7 @@ protected function _setFontRegular($size = 7) protected function _setFontBold($size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_Bd-2.8.1.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifBold.ttf') ); $this->getPage()->setFont($font, $size); return $font; @@ -368,7 +368,7 @@ protected function _setFontBold($size = 7) protected function _setFontItalic($size = 7) { $font = \Zend_Pdf_Font::fontWithPath( - $this->_rootDirectory->getAbsolutePath('lib/internal/LinLibertineFont/LinLibertine_It-2.8.2.ttf') + $this->_rootDirectory->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifItalic.ttf') ); $this->getPage()->setFont($font, $size); return $font; diff --git a/app/code/Magento/Sales/Model/Order/Shipment.php b/app/code/Magento/Sales/Model/Order/Shipment.php index ae0c00505940a..e23d7eaef2f0a 100644 --- a/app/code/Magento/Sales/Model/Order/Shipment.php +++ b/app/code/Magento/Sales/Model/Order/Shipment.php @@ -262,8 +262,6 @@ public function register() if (!$item->getOrderItem()->isDummy(true)) { $totalQty += $item->getQty(); } - } else { - $item->isDeleted(true); } } @@ -326,7 +324,10 @@ public function addItem(\Magento\Sales\Model\Order\Shipment\Item $item) { $item->setShipment($this)->setParentId($this->getId())->setStoreId($this->getStoreId()); if (!$item->getId()) { - $this->getItemsCollection()->addItem($item); + $this->setItems(array_merge( + $this->getItems() ?? [], + [$item] + )); } return $this; } @@ -537,7 +538,11 @@ public function getItems() $this->setData(ShipmentInterface::ITEMS, $collection->getItems()); } } - return $this->getData(ShipmentInterface::ITEMS); + $shipmentItems = $this->getData(ShipmentInterface::ITEMS); + if ($shipmentItems !== null && !is_array($shipmentItems)) { + $shipmentItems = $shipmentItems->getItems(); + } + return $shipmentItems; } /** diff --git a/app/code/Magento/Sales/Model/Order/Shipment/CommentRepository.php b/app/code/Magento/Sales/Model/Order/Shipment/CommentRepository.php index b0d3b03aec4ed..8efc9f8461914 100644 --- a/app/code/Magento/Sales/Model/Order/Shipment/CommentRepository.php +++ b/app/code/Magento/Sales/Model/Order/Shipment/CommentRepository.php @@ -7,6 +7,7 @@ use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Sales\Api\Data\ShipmentCommentInterface; @@ -14,7 +15,13 @@ use Magento\Sales\Api\Data\ShipmentCommentSearchResultInterfaceFactory; use Magento\Sales\Api\ShipmentCommentRepositoryInterface; use Magento\Sales\Model\Spi\ShipmentCommentResourceInterface; +use Magento\Sales\Model\Order\Email\Sender\ShipmentCommentSender; +use Magento\Sales\Api\ShipmentRepositoryInterface; +use Psr\Log\LoggerInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CommentRepository implements ShipmentCommentRepositoryInterface { /** @@ -37,22 +44,48 @@ class CommentRepository implements ShipmentCommentRepositoryInterface */ private $collectionProcessor; + /** + * @var ShipmentCommentSender + */ + private $shipmentCommentSender; + + /** + * @var ShipmentRepositoryInterface + */ + private $shipmentRepository; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param ShipmentCommentResourceInterface $commentResource * @param ShipmentCommentInterfaceFactory $commentFactory * @param ShipmentCommentSearchResultInterfaceFactory $searchResultFactory * @param CollectionProcessorInterface $collectionProcessor + * @param ShipmentCommentSender|null $shipmentCommentSender + * @param ShipmentRepositoryInterface|null $shipmentRepository + * @param LoggerInterface|null $logger */ public function __construct( ShipmentCommentResourceInterface $commentResource, ShipmentCommentInterfaceFactory $commentFactory, ShipmentCommentSearchResultInterfaceFactory $searchResultFactory, - CollectionProcessorInterface $collectionProcessor + CollectionProcessorInterface $collectionProcessor, + ShipmentCommentSender $shipmentCommentSender = null, + ShipmentRepositoryInterface $shipmentRepository = null, + LoggerInterface $logger = null ) { $this->commentResource = $commentResource; $this->commentFactory = $commentFactory; $this->searchResultFactory = $searchResultFactory; $this->collectionProcessor = $collectionProcessor; + $this->shipmentCommentSender = $shipmentCommentSender + ?: ObjectManager::getInstance()->get(ShipmentCommentSender::class); + $this->shipmentRepository = $shipmentRepository + ?: ObjectManager::getInstance()->get(ShipmentRepositoryInterface::class); + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -99,6 +132,14 @@ public function save(ShipmentCommentInterface $entity) } catch (\Exception $e) { throw new CouldNotSaveException(__('Could not save the shipment comment.'), $e); } + + try { + $shipment = $this->shipmentRepository->get($entity->getParentId()); + $this->shipmentCommentSender->send($shipment, $entity->getIsCustomerNotified(), $entity->getComment()); + } catch (\Exception $exception) { + $this->logger->critical($exception); + } + return $entity; } } diff --git a/app/code/Magento/Sales/Model/Order/Shipment/Item.php b/app/code/Magento/Sales/Model/Order/Shipment/Item.php index 342fd3fa8f21e..0da936e74ca6c 100644 --- a/app/code/Magento/Sales/Model/Order/Shipment/Item.php +++ b/app/code/Magento/Sales/Model/Order/Shipment/Item.php @@ -144,7 +144,7 @@ public function getOrderItem() * Declare qty * * @param float $qty - * @return \Magento\Sales\Model\Order\Invoice\Item + * @return \Magento\Sales\Model\Order\Shipment\Item * @throws \Magento\Framework\Exception\LocalizedException */ public function setQty($qty) diff --git a/app/code/Magento/Sales/Model/Order/Shipment/OrderRegistrar.php b/app/code/Magento/Sales/Model/Order/Shipment/OrderRegistrar.php index 9001267f6bc4a..69077749902b5 100644 --- a/app/code/Magento/Sales/Model/Order/Shipment/OrderRegistrar.php +++ b/app/code/Magento/Sales/Model/Order/Shipment/OrderRegistrar.php @@ -17,12 +17,19 @@ class OrderRegistrar implements \Magento\Sales\Model\Order\Shipment\OrderRegistr */ public function register(OrderInterface $order, ShipmentInterface $shipment) { - /** @var \Magento\Sales\Api\Data\ShipmentItemInterface|\Magento\Sales\Model\Order\Shipment\Item $item */ + $totalQty = 0; + /** @var \Magento\Sales\Model\Order\Shipment\Item $item */ foreach ($shipment->getItems() as $item) { if ($item->getQty() > 0) { $item->register(); + + if (!$item->getOrderItem()->isDummy(true)) { + $totalQty += $item->getQty(); + } } } + $shipment->setTotalQty($totalQty); + return $order; } } diff --git a/app/code/Magento/Sales/Model/Order/ShipmentDocumentFactory.php b/app/code/Magento/Sales/Model/Order/ShipmentDocumentFactory.php index 3e2dabbd8dba8..c0a3f84e8846d 100644 --- a/app/code/Magento/Sales/Model/Order/ShipmentDocumentFactory.php +++ b/app/code/Magento/Sales/Model/Order/ShipmentDocumentFactory.php @@ -14,6 +14,7 @@ use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\Data\ShipmentCommentCreationInterface; use Magento\Sales\Api\Data\ShipmentCreationArgumentsInterface; +use Magento\Sales\Api\Data\OrderItemInterface; /** * Class ShipmentDocumentFactory @@ -77,13 +78,23 @@ public function create( array $packages = [], ShipmentCreationArgumentsInterface $arguments = null ) { - $shipmentItems = $this->itemsToArray($items); + $shipmentItems = empty($items) + ? $this->getQuantitiesFromOrderItems($order->getItems()) + : $this->getQuantitiesFromShipmentItems($items); + /** @var Shipment $shipment */ $shipment = $this->shipmentFactory->create( $order, $shipmentItems ); - $this->prepareTracks($shipment, $tracks); + + foreach ($tracks as $track) { + $hydrator = $this->hydratorPool->getHydrator( + \Magento\Sales\Api\Data\ShipmentTrackCreationInterface::class + ); + $shipment->addTrack($this->trackFactory->create(['data' => $hydrator->extract($track)])); + } + if ($comment) { $shipment->addComment( $comment->getComment(), @@ -101,30 +112,29 @@ public function create( } /** - * Adds tracks to the shipment. + * Translate OrderItemInterface array to product id => product quantity array. * - * @param ShipmentInterface $shipment - * @param ShipmentTrackCreationInterface[] $tracks - * @return ShipmentInterface + * @param OrderItemInterface[] $items + * @return int[] */ - private function prepareTracks(\Magento\Sales\Api\Data\ShipmentInterface $shipment, array $tracks) + private function getQuantitiesFromOrderItems(array $items) { - foreach ($tracks as $track) { - $hydrator = $this->hydratorPool->getHydrator( - \Magento\Sales\Api\Data\ShipmentTrackCreationInterface::class - ); - $shipment->addTrack($this->trackFactory->create(['data' => $hydrator->extract($track)])); + $shipmentItems = []; + foreach ($items as $item) { + if (!$item->getIsVirtual() && (!$item->getParentItem() || $item->isShipSeparately())) { + $shipmentItems[$item->getItemId()] = $item->getQtyOrdered(); + } } - return $shipment; + return $shipmentItems; } /** - * Convert items to array + * Translate ShipmentItemCreationInterface array to product id => product quantity array. * * @param ShipmentItemCreationInterface[] $items - * @return array + * @return int[] */ - private function itemsToArray(array $items = []) + private function getQuantitiesFromShipmentItems(array $items) { $shipmentItems = []; foreach ($items as $item) { diff --git a/app/code/Magento/Sales/Model/Order/ShipmentFactory.php b/app/code/Magento/Sales/Model/Order/ShipmentFactory.php index dcb2cdf1d8ed9..21b42abeb293d 100644 --- a/app/code/Magento/Sales/Model/Order/ShipmentFactory.php +++ b/app/code/Magento/Sales/Model/Order/ShipmentFactory.php @@ -5,13 +5,13 @@ */ namespace Magento\Sales\Model\Order; -use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\LocalizedException; -use Magento\Sales\Model\Order\Shipment\ShipmentValidatorInterface; use Magento\Framework\Serialize\Serializer\Json; /** * Factory class for @see \Magento\Sales\Api\Data\ShipmentInterface + * + * @api */ class ShipmentFactory { @@ -96,14 +96,17 @@ protected function prepareItems( \Magento\Sales\Model\Order $order, array $items = [] ) { - $totalQty = 0; + $shipmentItems = []; foreach ($order->getAllItems() as $orderItem) { - if (!$this->canShipItem($orderItem, $items)) { + if ($this->validateItem($orderItem, $items) === false) { continue; } /** @var \Magento\Sales\Model\Order\Shipment\Item $item */ $item = $this->converter->itemToShipmentItem($orderItem); + if ($orderItem->getIsVirtual() || ($orderItem->getParentItemId() && !$orderItem->isShipSeparately())) { + $item->isDeleted(true); + } if ($orderItem->isDummy(true)) { $qty = 0; @@ -121,8 +124,7 @@ protected function prepareItems( $qty = min($qty, $orderItem->getSimpleQtyToShip()); $item->setQty($this->castQty($orderItem, $qty)); - $shipment->addItem($item); - + $shipmentItems[] = $item; continue; } else { $qty = 1; @@ -141,10 +143,65 @@ protected function prepareItems( } } - $totalQty += $qty; - $item->setQty($this->castQty($orderItem, $qty)); - $shipment->addItem($item); + $shipmentItems[] = $item; + } + return $this->setItemsToShipment($shipment, $shipmentItems); + } + + /** + * Validate order item before shipment + * + * @param Item $orderItem + * @param array $items + * @return bool + */ + private function validateItem(\Magento\Sales\Model\Order\Item $orderItem, array $items) + { + if (!$this->canShipItem($orderItem, $items)) { + return false; + } + + // Remove from shipment items without qty or with qty=0 + if (!$orderItem->isDummy(true) + && (!isset($items[$orderItem->getId()]) || $items[$orderItem->getId()] <= 0) + ) { + return false; + } + return true; + } + + /** + * Set prepared items to shipment document + * + * @param \Magento\Sales\Api\Data\ShipmentInterface $shipment + * @param array $shipmentItems + * @return \Magento\Sales\Api\Data\ShipmentInterface + */ + private function setItemsToShipment(\Magento\Sales\Api\Data\ShipmentInterface $shipment, $shipmentItems) + { + $totalQty = 0; + + /** + * Verify that composite products shipped separately has children, if not -> remove from collection + */ + /** @var \Magento\Sales\Model\Order\Shipment\Item $shipmentItem */ + foreach ($shipmentItems as $key => $shipmentItem) { + if ($shipmentItem->getOrderItem()->getHasChildren() + && $shipmentItem->getOrderItem()->isShipSeparately() + ) { + $containerId = $shipmentItem->getOrderItem()->getId(); + $childItems = array_filter($shipmentItems, function ($item) use ($containerId) { + return $containerId == $item->getOrderItem()->getParentItemId(); + }); + + if (count($childItems) <= 0) { + unset($shipmentItems[$key]); + continue; + } + } + $totalQty += $shipmentItem->getQty(); + $shipment->addItem($shipmentItem); } return $shipment->setTotalQty($totalQty); } diff --git a/app/code/Magento/Sales/Model/OrderIncrementIdChecker.php b/app/code/Magento/Sales/Model/OrderIncrementIdChecker.php new file mode 100644 index 0000000000000..0af61835a9c9b --- /dev/null +++ b/app/code/Magento/Sales/Model/OrderIncrementIdChecker.php @@ -0,0 +1,52 @@ +resourceModel = $resourceModel; + } + + /** + * Check if order increment ID is already used. + * + * Method can be used to avoid collisions of order IDs. + * + * @param string|int $orderIncrementId + * @return bool + */ + public function isIncrementIdUsed($orderIncrementId): bool + { + /** @var \Magento\Framework\DB\Adapter\AdapterInterface $adapter */ + $adapter = $this->resourceModel->getConnection(); + $bind = [':increment_id' => $orderIncrementId]; + /** @var \Magento\Framework\DB\Select $select */ + $select = $adapter->select(); + $select->from($this->resourceModel->getMainTable(), $this->resourceModel->getIdFieldName()) + ->where('increment_id = :increment_id'); + $entity_id = $adapter->fetchOne($select, $bind); + if ($entity_id > 0) { + return true; + } + + return false; + } +} diff --git a/app/code/Magento/Sales/Model/ResourceModel/AbstractGrid.php b/app/code/Magento/Sales/Model/ResourceModel/AbstractGrid.php index d6b81039214e9..87c5b917f6963 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/AbstractGrid.php +++ b/app/code/Magento/Sales/Model/ResourceModel/AbstractGrid.php @@ -48,7 +48,6 @@ protected function _construct() /** * Returns connection * - * @todo: make method protected * @return AdapterInterface */ public function getConnection() diff --git a/app/code/Magento/Sales/Model/ResourceModel/Grid/Collection.php b/app/code/Magento/Sales/Model/ResourceModel/Grid/Collection.php index 82d436cb6e047..b3bc05056a1b1 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/Grid/Collection.php +++ b/app/code/Magento/Sales/Model/ResourceModel/Grid/Collection.php @@ -6,7 +6,7 @@ namespace Magento\Sales\Model\ResourceModel\Grid; use Magento\Framework\Api\Search\SearchResultInterface; -use Magento\Framework\Search\AggregationInterface; +use Magento\Framework\Api\Search\AggregationInterface; use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection; /** @@ -78,6 +78,7 @@ public function getAggregations() public function setAggregations($aggregations) { $this->aggregations = $aggregations; + return $this; } /** diff --git a/app/code/Magento/Sales/Model/ResourceModel/Provider/UpdatedAtListProvider.php b/app/code/Magento/Sales/Model/ResourceModel/Provider/UpdatedAtListProvider.php new file mode 100644 index 0000000000000..846fa46572fde --- /dev/null +++ b/app/code/Magento/Sales/Model/ResourceModel/Provider/UpdatedAtListProvider.php @@ -0,0 +1,60 @@ +connection = $resourceConnection->getConnection('sales'); + $this->resourceConnection = $resourceConnection; + } + + /** + * @inheritdoc + */ + public function getIds($mainTableName, $gridTableName) + { + $mainTableName = $this->resourceConnection->getTableName($mainTableName); + $gridTableName = $this->resourceConnection->getTableName($gridTableName); + $select = $this->connection->select() + ->from($mainTableName, [$mainTableName . '.entity_id']) + ->joinInner( + [$gridTableName => $gridTableName], + sprintf( + '%s.entity_id = %s.entity_id AND %s.updated_at > %s.updated_at', + $mainTableName, + $gridTableName, + $mainTableName, + $gridTableName + ), + [] + ); + + return $this->connection->fetchAll($select, [], \Zend_Db::FETCH_COLUMN); + } +} diff --git a/app/code/Magento/Sales/Model/ResourceModel/Provider/UpdatedIdListProvider.php b/app/code/Magento/Sales/Model/ResourceModel/Provider/UpdatedIdListProvider.php index 59906c79215fa..42c6e9d650315 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/Provider/UpdatedIdListProvider.php +++ b/app/code/Magento/Sales/Model/ResourceModel/Provider/UpdatedIdListProvider.php @@ -38,10 +38,12 @@ public function __construct( */ public function getIds($mainTableName, $gridTableName) { + $mainTableName = $this->resourceConnection->getTableName($mainTableName); + $gridTableName = $this->resourceConnection->getTableName($gridTableName); $select = $this->getConnection()->select() - ->from($this->getConnection()->getTableName($mainTableName), [$mainTableName . '.entity_id']) + ->from($mainTableName, [$mainTableName . '.entity_id']) ->joinLeft( - [$gridTableName => $this->getConnection()->getTableName($gridTableName)], + [$gridTableName => $gridTableName], sprintf( '%s.%s = %s.%s', $mainTableName, diff --git a/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php b/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php new file mode 100644 index 0000000000000..cade86d18e935 --- /dev/null +++ b/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php @@ -0,0 +1,54 @@ +orderRepository = $orderRepository; + } + + /** + * {@inheritdoc} + */ + public function execute(Observer $observer) + { + $event = $observer->getEvent(); + /** @var CustomerInterface $customer */ + $customer = $event->getData('customer_data_object'); + /** @var array $delegateData */ + $delegateData = $event->getData('delegate_data'); + if (array_key_exists('__sales_assign_order_id', $delegateData)) { + $orderId = $delegateData['__sales_assign_order_id']; + $order = $this->orderRepository->get($orderId); + if (!$order->getCustomerId()) { + //if customer ID wasn't already assigned then assigning. + $order->setCustomerId($customer->getId()); + $order->setCustomerIsGuest(0); + $this->orderRepository->save($order); + } + } + } +} diff --git a/app/code/Magento/Sales/Setup/Patch/Data/ConvertSerializedDataToJson.php b/app/code/Magento/Sales/Setup/Patch/Data/ConvertSerializedDataToJson.php index d29a2e019b01b..542476c7dd902 100644 --- a/app/code/Magento/Sales/Setup/Patch/Data/ConvertSerializedDataToJson.php +++ b/app/code/Magento/Sales/Setup/Patch/Data/ConvertSerializedDataToJson.php @@ -22,8 +22,8 @@ use Magento\Sales\Setup\SalesSetup; use Magento\Sales\Setup\SalesSetupFactory; use Magento\Sales\Setup\SerializedDataConverter; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ConvertSerializedDataToJson diff --git a/app/code/Magento/Sales/Setup/Patch/Data/FillQuoteAddressIdInSalesOrderAddress.php b/app/code/Magento/Sales/Setup/Patch/Data/FillQuoteAddressIdInSalesOrderAddress.php index 601aa58c8424b..0ad2245a6287e 100644 --- a/app/code/Magento/Sales/Setup/Patch/Data/FillQuoteAddressIdInSalesOrderAddress.php +++ b/app/code/Magento/Sales/Setup/Patch/Data/FillQuoteAddressIdInSalesOrderAddress.php @@ -13,8 +13,8 @@ use Magento\Sales\Model\ResourceModel\Order\Address\CollectionFactory as AddressCollectionFactory; use Magento\Framework\App\ResourceConnection; use Magento\Sales\Setup\SalesSetupFactory; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; class FillQuoteAddressIdInSalesOrderAddress implements DataPatchInterface, PatchVersionInterface { diff --git a/app/code/Magento/Sales/Setup/Patch/Data/InstallOrderStatusesAndInitialSalesConfig.php b/app/code/Magento/Sales/Setup/Patch/Data/InstallOrderStatusesAndInitialSalesConfig.php index 4f4ec5f12f68f..ba0535875d96b 100644 --- a/app/code/Magento/Sales/Setup/Patch/Data/InstallOrderStatusesAndInitialSalesConfig.php +++ b/app/code/Magento/Sales/Setup/Patch/Data/InstallOrderStatusesAndInitialSalesConfig.php @@ -8,8 +8,8 @@ use Magento\Sales\Setup\SalesSetupFactory; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class InstallOrderStatusesAndInitialSalesConfig diff --git a/app/code/Magento/Sales/Setup/Patch/Data/UpdateEntityTypeModelForInvoice.php b/app/code/Magento/Sales/Setup/Patch/Data/UpdateEntityTypeModelForInvoice.php index 4d918924240c1..99e15df8420ef 100644 --- a/app/code/Magento/Sales/Setup/Patch/Data/UpdateEntityTypeModelForInvoice.php +++ b/app/code/Magento/Sales/Setup/Patch/Data/UpdateEntityTypeModelForInvoice.php @@ -9,8 +9,8 @@ use Magento\Eav\Model\Config; use Magento\Framework\App\ResourceConnection; use Magento\Sales\Setup\SalesSetupFactory; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; class UpdateEntityTypeModelForInvoice implements DataPatchInterface, PatchVersionInterface { diff --git a/app/code/Magento/Sales/Setup/Patch/Data/UpdateEntityTypes.php b/app/code/Magento/Sales/Setup/Patch/Data/UpdateEntityTypes.php index af31152acde3b..9e39976203631 100644 --- a/app/code/Magento/Sales/Setup/Patch/Data/UpdateEntityTypes.php +++ b/app/code/Magento/Sales/Setup/Patch/Data/UpdateEntityTypes.php @@ -9,8 +9,8 @@ use Magento\Eav\Model\Config; use Magento\Framework\App\ResourceConnection; use Magento\Sales\Setup\SalesSetupFactory; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; class UpdateEntityTypes implements DataPatchInterface, PatchVersionInterface { diff --git a/app/code/Magento/Sales/Setup/SalesSetup.php b/app/code/Magento/Sales/Setup/SalesSetup.php index bfc05c549ddb3..4be2b38b074e7 100644 --- a/app/code/Magento/Sales/Setup/SalesSetup.php +++ b/app/code/Magento/Sales/Setup/SalesSetup.php @@ -303,6 +303,9 @@ public function getEncryptor() return $this->encryptor; } + /** + * @return \Magento\Framework\DB\Adapter\AdapterInterface + */ public function getConnection() { return $this->getSetup()->getConnection(self::$connectionName); diff --git a/app/code/Magento/Sales/Test/Unit/Block/Order/RecentTest.php b/app/code/Magento/Sales/Test/Unit/Block/Order/RecentTest.php index 99528983a13c9..96162aca42e12 100644 --- a/app/code/Magento/Sales/Test/Unit/Block/Order/RecentTest.php +++ b/app/code/Magento/Sales/Test/Unit/Block/Order/RecentTest.php @@ -5,6 +5,15 @@ */ namespace Magento\Sales\Test\Unit\Block\Order; +use Magento\Framework\View\Element\Template\Context; +use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; +use Magento\Customer\Model\Session; +use Magento\Sales\Model\Order\Config; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\View\Layout; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Sales\Model\ResourceModel\Order\Collection; + class RecentTest extends \PHPUnit\Framework\TestCase { /** @@ -32,26 +41,33 @@ class RecentTest extends \PHPUnit\Framework\TestCase */ protected $orderConfig; + /** + * @var \Magento\Store\Model\StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + protected $storeManagerMock; + protected function setUp() { - $this->context = $this->createMock(\Magento\Framework\View\Element\Template\Context::class); + $this->context = $this->createMock(Context::class); $this->orderCollectionFactory = $this->createPartialMock( - \Magento\Sales\Model\ResourceModel\Order\CollectionFactory::class, + CollectionFactory::class, ['create'] ); - $this->customerSession = $this->createPartialMock(\Magento\Customer\Model\Session::class, ['getCustomerId']); + $this->customerSession = $this->createPartialMock(Session::class, ['getCustomerId']); $this->orderConfig = $this->createPartialMock( - \Magento\Sales\Model\Order\Config::class, + Config::class, ['getVisibleOnFrontStatuses'] ); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); } public function testConstructMethod() { - $data = []; - $attribute = ['customer_id', 'status']; + $attribute = ['customer_id', 'store_id', 'status']; $customerId = 25; - $layout = $this->createPartialMock(\Magento\Framework\View\Layout::class, ['getBlock']); + $storeId = 4; + $layout = $this->createPartialMock(Layout::class, ['getBlock']); $this->context->expects($this->once()) ->method('getLayout') ->will($this->returnValue($layout)); @@ -64,14 +80,20 @@ public function testConstructMethod() ->method('getVisibleOnFrontStatuses') ->will($this->returnValue($statuses)); - $orderCollection = $this->createPartialMock(\Magento\Sales\Model\ResourceModel\Order\Collection::class, [ - 'addAttributeToSelect', - 'addFieldToFilter', - 'addAttributeToFilter', - 'addAttributeToSort', - 'setPageSize', - 'load' - ]); + $this->storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class) + ->getMockForAbstractClass(); + $storeMock = $this->getMockBuilder(StoreInterface::class)->getMockForAbstractClass(); + $this->storeManagerMock->expects($this->once())->method('getStore')->willReturn($storeMock); + $storeMock->expects($this->any())->method('getId')->willReturn($storeId); + + $orderCollection = $this->createPartialMock(Collection::class, [ + 'addAttributeToSelect', + 'addFieldToFilter', + 'addAttributeToFilter', + 'addAttributeToSort', + 'setPageSize', + 'load' + ]); $this->orderCollectionFactory->expects($this->once()) ->method('create') ->will($this->returnValue($orderCollection)); @@ -85,17 +107,21 @@ public function testConstructMethod() ->willReturnSelf(); $orderCollection->expects($this->at(2)) ->method('addAttributeToFilter') - ->with($attribute[1], $this->equalTo(['in' => $statuses])) - ->will($this->returnSelf()); + ->with($attribute[1], $this->equalTo($storeId)) + ->willReturnSelf(); $orderCollection->expects($this->at(3)) + ->method('addAttributeToFilter') + ->with($attribute[2], $this->equalTo(['in' => $statuses])) + ->will($this->returnSelf()); + $orderCollection->expects($this->at(4)) ->method('addAttributeToSort') ->with('created_at', 'desc') ->will($this->returnSelf()); - $orderCollection->expects($this->at(4)) + $orderCollection->expects($this->at(5)) ->method('setPageSize') ->with('5') ->will($this->returnSelf()); - $orderCollection->expects($this->at(5)) + $orderCollection->expects($this->at(6)) ->method('load') ->will($this->returnSelf()); $this->block = new \Magento\Sales\Block\Order\Recent( @@ -103,7 +129,8 @@ public function testConstructMethod() $this->orderCollectionFactory, $this->customerSession, $this->orderConfig, - $data + [], + $this->storeManagerMock ); $this->assertEquals($orderCollection, $this->block->getOrders()); } diff --git a/app/code/Magento/Sales/Test/Unit/Block/Order/TotalsTest.php b/app/code/Magento/Sales/Test/Unit/Block/Order/TotalsTest.php new file mode 100644 index 0000000000000..a8ce2ad4ff034 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Block/Order/TotalsTest.php @@ -0,0 +1,61 @@ +context = $this->createMock(\Magento\Framework\View\Element\Template\Context::class); + $this->block = new Totals($this->context, new Registry); + $this->block->setOrder($this->createMock(Order::class)); + } + + public function testApplySortOrder() + { + $this->block->addTotal(new Total(['code' => 'one']), 'last'); + $this->block->addTotal(new Total(['code' => 'two']), 'last'); + $this->block->addTotal(new Total(['code' => 'three']), 'last'); + $this->block->applySortOrder( + [ + 'one' => 10, + 'two' => 30, + 'three' => 20, + ] + ); + $this->assertEqualsSorted( + [ + 'one' => new Total(['code' => 'one']), + 'three' => new Total(['code' => 'three']), + 'two' => new Total(['code' => 'two']), + ], + $this->block->getTotals() + ); + } + + private function assertEqualsSorted(array $expected, array $actual) + { + $this->assertEquals($expected, $actual, 'Array contents should be equal.'); + $this->assertEquals(array_keys($expected), array_keys($actual), 'Array sort order should be equal.'); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassCancelTest.php b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassCancelTest.php index 569a210d993f0..756bade3c83c9 100644 --- a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassCancelTest.php +++ b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassCancelTest.php @@ -85,6 +85,11 @@ class MassCancelTest extends \PHPUnit\Framework\TestCase */ protected $filterMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $orderManagementMock; + protected function setUp() { $objectManagerHelper = new ObjectManagerHelper($this); @@ -145,12 +150,15 @@ protected function setUp() $this->orderCollectionFactoryMock->expects($this->once()) ->method('create') ->willReturn($this->orderCollectionMock); + $this->orderManagementMock = $this->createMock(\Magento\Sales\Api\OrderManagementInterface::class); + $this->massAction = $objectManagerHelper->getObject( \Magento\Sales\Controller\Adminhtml\Order\MassCancel::class, [ 'context' => $this->contextMock, 'filter' => $this->filterMock, - 'collectionFactory' => $this->orderCollectionFactoryMock + 'collectionFactory' => $this->orderCollectionFactoryMock, + 'orderManagement' => $this->orderManagementMock ] ); } @@ -161,6 +169,9 @@ protected function setUp() */ public function testExecuteCanCancelOneOrder() { + $order1id = 100; + $order2id = 200; + $order1 = $this->getMockBuilder(\Magento\Sales\Model\Order::class) ->disableOriginalConstructor() ->getMock(); @@ -175,20 +186,19 @@ public function testExecuteCanCancelOneOrder() ->willReturn($orders); $order1->expects($this->once()) - ->method('canCancel') - ->willReturn(true); - $order1->expects($this->once()) - ->method('cancel'); - $order1->expects($this->once()) - ->method('save'); + ->method('getEntityId') + ->willReturn($order1id); + + $order2->expects($this->once()) + ->method('getEntityId') + ->willReturn($order2id); $this->orderCollectionMock->expects($this->once()) ->method('count') ->willReturn($countOrders); - $order2->expects($this->once()) - ->method('canCancel') - ->willReturn(false); + $this->orderManagementMock->expects($this->at(0))->method('cancel')->with($order1id)->willReturn(true); + $this->orderManagementMock->expects($this->at(1))->method('cancel')->with($order2id)->willReturn(false); $this->messageManagerMock->expects($this->once()) ->method('addError') @@ -222,21 +232,23 @@ public function testExcludedCannotCancelOrders() $orders = [$order1, $order2]; $countOrders = count($orders); + $order1->expects($this->once()) + ->method('getEntityId') + ->willReturn(100); + + $order2->expects($this->once()) + ->method('getEntityId') + ->willReturn(200); + $this->orderCollectionMock->expects($this->any()) ->method('getItems') ->willReturn([$order1, $order2]); - $order1->expects($this->once()) - ->method('canCancel') - ->willReturn(false); - $this->orderCollectionMock->expects($this->once()) ->method('count') ->willReturn($countOrders); - $order2->expects($this->once()) - ->method('canCancel') - ->willReturn(false); + $this->orderManagementMock->expects($this->atLeastOnce())->method('cancel')->willReturn(false); $this->messageManagerMock->expects($this->once()) ->method('addError') @@ -265,11 +277,10 @@ public function testException() ->willReturn([$order1]); $order1->expects($this->once()) - ->method('canCancel') - ->willReturn(true); - $order1->expects($this->once()) - ->method('cancel') - ->willThrowException($exception); + ->method('getEntityId') + ->willReturn(100); + + $this->orderManagementMock->expects($this->atLeastOnce())->method('cancel')->willThrowException($exception); $this->messageManagerMock->expects($this->once()) ->method('addError') diff --git a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassUnholdTest.php b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassUnholdTest.php index 7003d445e1b50..cddb503925987 100644 --- a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassUnholdTest.php +++ b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/MassUnholdTest.php @@ -85,6 +85,11 @@ class MassUnholdTest extends \PHPUnit\Framework\TestCase */ protected $filterMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $orderManagementMock; + protected function setUp() { $objectManagerHelper = new ObjectManagerHelper($this); @@ -146,12 +151,15 @@ protected function setUp() ->method('create') ->willReturn($this->orderCollectionMock); + $this->orderManagementMock = $this->createMock(\Magento\Sales\Api\OrderManagementInterface::class); + $this->massAction = $objectManagerHelper->getObject( \Magento\Sales\Controller\Adminhtml\Order\MassUnhold::class, [ 'context' => $this->contextMock, 'filter' => $this->filterMock, - 'collectionFactory' => $this->orderCollectionFactoryMock + 'collectionFactory' => $this->orderCollectionFactoryMock, + 'orderManagement' => $this->orderManagementMock ] ); } @@ -175,9 +183,7 @@ public function testExecuteOneOrdersReleasedFromHold() ->method('canUnhold') ->willReturn(true); $order1->expects($this->once()) - ->method('unhold'); - $order1->expects($this->once()) - ->method('save'); + ->method('getEntityId'); $this->orderCollectionMock->expects($this->once()) ->method('count') @@ -187,6 +193,8 @@ public function testExecuteOneOrdersReleasedFromHold() ->method('canUnhold') ->willReturn(false); + $this->orderManagementMock->expects($this->atLeastOnce())->method('unHold')->willReturn(true); + $this->messageManagerMock->expects($this->once()) ->method('addError') ->with('1 order(s) were not released from on hold status.'); diff --git a/app/code/Magento/Sales/Test/Unit/CustomerData/LastOrderedItemsTest.php b/app/code/Magento/Sales/Test/Unit/CustomerData/LastOrderedItemsTest.php index 0802bafd5ac34..035d6ff2727d8 100644 --- a/app/code/Magento/Sales/Test/Unit/CustomerData/LastOrderedItemsTest.php +++ b/app/code/Magento/Sales/Test/Unit/CustomerData/LastOrderedItemsTest.php @@ -48,11 +48,21 @@ class LastOrderedItemsTest extends \PHPUnit\Framework\TestCase */ private $orderMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $productRepositoryMock; + /** * @var \Magento\Sales\CustomerData\LastOrderedItems */ private $section; + /** + * @var \Psr\Log\LoggerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $loggerMock; + protected function setUp() { $this->objectManagerHelper = new ObjectManagerHelper($this); @@ -74,62 +84,97 @@ protected function setUp() $this->orderMock = $this->getMockBuilder(\Magento\Sales\Model\Order::class) ->disableOriginalConstructor() ->getMock(); + $this->productRepositoryMock = $this->getMockBuilder(\Magento\Catalog\Api\ProductRepositoryInterface::class) + ->getMockForAbstractClass(); + $this->loggerMock = $this->getMockBuilder(\Psr\Log\LoggerInterface::class) + ->getMockForAbstractClass(); + $this->section = new \Magento\Sales\CustomerData\LastOrderedItems( $this->orderCollectionFactoryMock, $this->orderConfigMock, $this->customerSessionMock, $this->stockRegistryMock, - $this->storeManagerMock + $this->storeManagerMock, + $this->productRepositoryMock, + $this->loggerMock ); } public function testGetSectionData() { + $storeId = 1; $websiteId = 4; - $expectedItem = [ + $expectedItem1 = [ 'id' => 1, - 'name' => 'Product Name', + 'name' => 'Product Name 1', 'url' => 'http://example.com', 'is_saleable' => true, ]; - $productId = 10; + $expectedItem2 = [ + 'id' => 2, + 'name' => 'Product Name 2', + 'url' => null, + 'is_saleable' => true, + ]; + $productIdVisible = 1; + $productIdNotVisible = 2; $stockItemMock = $this->getMockBuilder(\Magento\CatalogInventory\Api\Data\StockItemInterface::class) ->getMockForAbstractClass(); - $itemWithProductMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + $itemWithVisibleProduct = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + ->disableOriginalConstructor() + ->getMock(); + $itemWithNotVisibleProduct = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) ->disableOriginalConstructor() ->getMock(); - $itemWithoutProductMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + $productVisible = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) ->disableOriginalConstructor() ->getMock(); - $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) + $productNotVisible = $this->getMockBuilder(\Magento\Catalog\Model\Product::class) ->disableOriginalConstructor() ->getMock(); - $items = [$itemWithoutProductMock, $itemWithProductMock]; + $items = [$itemWithVisibleProduct, $itemWithNotVisibleProduct]; $this->getLastOrderMock(); $storeMock = $this->getMockBuilder(\Magento\Store\Api\Data\StoreInterface::class)->getMockForAbstractClass(); - $this->storeManagerMock->expects($this->once())->method('getStore')->willReturn($storeMock); + $this->storeManagerMock->expects($this->any())->method('getStore')->willReturn($storeMock); $storeMock->expects($this->any())->method('getWebsiteId')->willReturn($websiteId); + $storeMock->expects($this->any())->method('getId')->willReturn($storeId); $this->orderMock->expects($this->once()) ->method('getParentItemsRandomCollection') ->with(\Magento\Sales\CustomerData\LastOrderedItems::SIDEBAR_ORDER_LIMIT) ->willReturn($items); - $itemWithProductMock->expects($this->once())->method('hasData')->with('product')->willReturn(true); - $itemWithProductMock->expects($this->any())->method('getProduct')->willReturn($productMock); - $productMock->expects($this->once())->method('getWebsiteIds')->willReturn([1, 4]); - $itemWithProductMock->expects($this->once())->method('getId')->willReturn($expectedItem['id']); - $itemWithProductMock->expects($this->once())->method('getName')->willReturn($expectedItem['name']); - $productMock->expects($this->once())->method('getProductUrl')->willReturn($expectedItem['url']); - $this->stockRegistryMock->expects($this->once())->method('getStockItem')->willReturn($stockItemMock); - $productMock->expects($this->once())->method('getId')->willReturn($productId); - $itemWithProductMock->expects($this->once())->method('getStore')->willReturn($storeMock); + $productVisible->expects($this->once())->method('isVisibleInSiteVisibility')->willReturn(true); + $productVisible->expects($this->once())->method('getProductUrl')->willReturn($expectedItem1['url']); + $productVisible->expects($this->once())->method('getWebsiteIds')->willReturn([1, 4]); + $productVisible->expects($this->once())->method('getId')->willReturn($productIdVisible); + $productNotVisible->expects($this->once())->method('isVisibleInSiteVisibility')->willReturn(false); + $productNotVisible->expects($this->never())->method('getProductUrl'); + $productNotVisible->expects($this->once())->method('getWebsiteIds')->willReturn([1, 4]); + $productNotVisible->expects($this->once())->method('getId')->willReturn($productIdNotVisible); + $itemWithVisibleProduct->expects($this->once())->method('getProductId')->willReturn($productIdVisible); + $itemWithVisibleProduct->expects($this->once())->method('getProduct')->willReturn($productVisible); + $itemWithVisibleProduct->expects($this->once())->method('getId')->willReturn($expectedItem1['id']); + $itemWithVisibleProduct->expects($this->once())->method('getName')->willReturn($expectedItem1['name']); + $itemWithVisibleProduct->expects($this->once())->method('getStore')->willReturn($storeMock); + $itemWithNotVisibleProduct->expects($this->once())->method('getProductId')->willReturn($productIdNotVisible); + $itemWithNotVisibleProduct->expects($this->once())->method('getProduct')->willReturn($productNotVisible); + $itemWithNotVisibleProduct->expects($this->once())->method('getId')->willReturn($expectedItem2['id']); + $itemWithNotVisibleProduct->expects($this->once())->method('getName')->willReturn($expectedItem2['name']); + $itemWithNotVisibleProduct->expects($this->once())->method('getStore')->willReturn($storeMock); + $this->productRepositoryMock->expects($this->any()) + ->method('getById') + ->willReturnMap([ + [$productIdVisible, false, $storeId, false, $productVisible], + [$productIdNotVisible, false, $storeId, false, $productNotVisible], + ]); $this->stockRegistryMock - ->expects($this->once()) + ->expects($this->any()) ->method('getStockItem') - ->with($productId, $websiteId) - ->willReturn($stockItemMock); - $stockItemMock->expects($this->once())->method('getIsInStock')->willReturn($expectedItem['is_saleable']); - $itemWithoutProductMock->expects($this->once())->method('hasData')->with('product')->willReturn(false); - $this->assertEquals(['items' => [$expectedItem]], $this->section->getSectionData()); + ->willReturnMap([ + [$productIdVisible, $websiteId, $stockItemMock], + [$productIdNotVisible, $websiteId, $stockItemMock], + ]); + $stockItemMock->expects($this->exactly(2))->method('getIsInStock')->willReturn($expectedItem1['is_saleable']); + $this->assertEquals(['items' => [$expectedItem1, $expectedItem2]], $this->section->getSectionData()); } private function getLastOrderMock() @@ -160,4 +205,34 @@ private function getLastOrderMock() ->willReturnSelf(); return $this->orderMock; } + + public function testGetSectionDataWithNotExistingProduct() + { + $storeId = 1; + $websiteId = 4; + $productId = 1; + $exception = new \Magento\Framework\Exception\NoSuchEntityException(__("Product doesn't exist")); + $orderItemMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) + ->disableOriginalConstructor() + ->setMethods(['getProductId']) + ->getMock(); + $storeMock = $this->getMockBuilder(\Magento\Store\Api\Data\StoreInterface::class)->getMockForAbstractClass(); + + $this->getLastOrderMock(); + $this->storeManagerMock->expects($this->exactly(2))->method('getStore')->willReturn($storeMock); + $storeMock->expects($this->once())->method('getWebsiteId')->willReturn($websiteId); + $storeMock->expects($this->once())->method('getId')->willReturn($storeId); + $this->orderMock->expects($this->once()) + ->method('getParentItemsRandomCollection') + ->with(\Magento\Sales\CustomerData\LastOrderedItems::SIDEBAR_ORDER_LIMIT) + ->willReturn([$orderItemMock]); + $orderItemMock->expects($this->once())->method('getProductId')->willReturn($productId); + $this->productRepositoryMock->expects($this->once()) + ->method('getById') + ->with($productId, false, $storeId) + ->willThrowException($exception); + $this->loggerMock->expects($this->once())->method('critical')->with($exception); + + $this->assertEquals(['items' => []], $this->section->getSectionData()); + } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/AdminOrder/CreateTest.php b/app/code/Magento/Sales/Test/Unit/Model/AdminOrder/CreateTest.php index 3dab9178739b8..91da34c2b8b26 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/AdminOrder/CreateTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/AdminOrder/CreateTest.php @@ -18,12 +18,14 @@ use Magento\Framework\Api\DataObjectHelper; use Magento\Framework\App\RequestInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Model\Quote; use Magento\Quote\Model\Quote\Address; use Magento\Quote\Model\Quote\Item; use Magento\Quote\Model\Quote\Item\Updater; use Magento\Sales\Model\AdminOrder\Create; use Magento\Sales\Model\AdminOrder\Product; +use Magento\Quote\Model\QuoteFactory; use PHPUnit_Framework_MockObject_MockObject as MockObject; /** @@ -39,6 +41,16 @@ class CreateTest extends \PHPUnit\Framework\TestCase */ private $adminOrderCreate; + /** + * @var CartRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $quoteRepository; + + /** + * @var QuoteFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $quoteFactory; + /** * @var SessionQuote|MockObject */ @@ -76,12 +88,22 @@ class CreateTest extends \PHPUnit\Framework\TestCase protected function setUp() { - $this->sessionQuote = $this->createMock(SessionQuote::class); $this->formFactory = $this->createPartialMock(FormFactory::class, ['create']); + $this->quoteFactory = $this->createPartialMock(QuoteFactory::class, ['create']); $this->customerFactory = $this->createPartialMock(CustomerInterfaceFactory::class, ['create']); $this->itemUpdater = $this->createMock(Updater::class); + $this->quoteRepository = $this->getMockBuilder(CartRepositoryInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getForCustomer']) + ->getMockForAbstractClass(); + + $this->sessionQuote = $this->getMockBuilder(\Magento\Backend\Model\Session\Quote::class) + ->disableOriginalConstructor() + ->setMethods(['getQuote', 'getStoreId', 'getCustomerId']) + ->getMock(); + $this->customerMapper = $this->getMockBuilder(Mapper::class) ->setMethods(['toFlatArray']) ->disableOriginalConstructor() @@ -103,6 +125,8 @@ protected function setUp() 'quoteItemUpdater' => $this->itemUpdater, 'customerMapper' => $this->customerMapper, 'dataObjectHelper' => $this->dataObjectHelper, + 'quoteRepository' => $this->quoteRepository, + 'quoteFactory' => $this->quoteFactory, ] ); } @@ -264,4 +288,28 @@ public function testApplyCoupon() $object = $this->adminOrderCreate->applyCoupon($couponCode); self::assertEquals($this->adminOrderCreate, $object); } + + public function testGetCustomerCart() + { + $storeId = 2; + $customerId = 2; + $cartResult = [ + 'cart' => true, + ]; + + $this->quoteFactory->expects($this->once()) + ->method('create'); + $this->sessionQuote->expects($this->once()) + ->method('getStoreId') + ->willReturn($storeId); + $this->sessionQuote->expects($this->once()) + ->method('getCustomerId') + ->willReturn($customerId); + $this->quoteRepository->expects($this->once()) + ->method('getForCustomer') + ->with($customerId, [$storeId]) + ->willReturn($cartResult); + + $this->assertEquals($cartResult, $this->adminOrderCreate->getCustomerCart()); + } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/CommentRepositoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/CommentRepositoryTest.php new file mode 100644 index 0000000000000..649e8dc0600cf --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/CommentRepositoryTest.php @@ -0,0 +1,189 @@ +commentResource = $this->getMockBuilder(CreditmemoCommentResourceInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->commentFactory = $this->getMockBuilder(CreditmemoCommentInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->searchResultFactory = $this->getMockBuilder(CreditmemoCommentSearchResultInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->collectionProcessor = $this->getMockBuilder(CollectionProcessorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->creditmemoRepositoryMock = $this->getMockBuilder(CreditmemoRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->creditmemoCommentSender = $this->getMockBuilder(CreditmemoCommentSender::class) + ->disableOriginalConstructor() + ->getMock(); + $this->loggerMock = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); + + $this->creditmemoMock = $this->getMockBuilder(Creditmemo::class)->disableOriginalConstructor()->getMock(); + $this->commentMock = $this->getMockBuilder(Comment::class)->disableOriginalConstructor()->getMock(); + + $this->commentRepository = new CommentRepository( + $this->commentResource, + $this->commentFactory, + $this->searchResultFactory, + $this->collectionProcessor, + $this->creditmemoCommentSender, + $this->creditmemoRepositoryMock, + $this->loggerMock + ); + } + + public function testSave() + { + $comment = "Comment text"; + $creditmemoId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($creditmemoId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->creditmemoRepositoryMock->expects($this->once()) + ->method('get') + ->with($creditmemoId) + ->willReturn($this->creditmemoMock); + $this->creditmemoCommentSender->expects($this->once()) + ->method('send') + ->with($this->creditmemoMock, true, $comment) + ->willReturn(true); + $this->commentRepository->save($this->commentMock); + } + + /** + * @expectedException \Magento\Framework\Exception\CouldNotSaveException + * @expectedExceptionMessage Could not save the creditmemo comment. + */ + public function testSaveWithException() + { + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willThrowException( + new \Magento\Framework\Exception\CouldNotSaveException(__('Could not save the creditmemo comment.')) + ); + + $this->commentRepository->save($this->commentMock); + } + + public function testSaveSendCatchException() + { + $comment = "Comment text"; + $creditmemoId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($creditmemoId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->creditmemoRepositoryMock->expects($this->once()) + ->method('get') + ->with($creditmemoId) + ->willReturn($this->creditmemoMock); + $this->creditmemoCommentSender->expects($this->once()) + ->method('send') + ->willThrowException(new \Exception()); + $this->loggerMock->expects($this->once()) + ->method('critical'); + + $this->commentRepository->save($this->commentMock); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/CustomerManagementTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/CustomerManagementTest.php deleted file mode 100644 index 2794860793ed6..0000000000000 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/CustomerManagementTest.php +++ /dev/null @@ -1,185 +0,0 @@ -objectCopyService = $this->createMock(\Magento\Framework\DataObject\Copy::class); - $this->accountManagement = $this->createMock(\Magento\Customer\Api\AccountManagementInterface::class); - $this->customerFactory = $this->createPartialMock( - \Magento\Customer\Api\Data\CustomerInterfaceFactory::class, - ['create'] - ); - $this->addressFactory = $this->createPartialMock( - \Magento\Customer\Api\Data\AddressInterfaceFactory::class, - ['create'] - ); - $this->regionFactory = $this->createPartialMock( - \Magento\Customer\Api\Data\RegionInterfaceFactory::class, - ['create'] - ); - $this->orderRepository = $this->createMock(\Magento\Sales\Api\OrderRepositoryInterface::class); - $this->quoteAddressFactory = $this->createMock(\Magento\Quote\Model\Quote\AddressFactory::class); - - $this->service = new \Magento\Sales\Model\Order\CustomerManagement( - $this->objectCopyService, - $this->accountManagement, - $this->customerFactory, - $this->addressFactory, - $this->regionFactory, - $this->orderRepository, - $this->quoteAddressFactory - ); - } - - /** - * @expectedException \Magento\Framework\Exception\AlreadyExistsException - */ - public function testCreateThrowsExceptionIfCustomerAlreadyExists() - { - $orderMock = $this->createMock(\Magento\Sales\Api\Data\OrderInterface::class); - $orderMock->expects($this->once())->method('getCustomerId')->will($this->returnValue('customer_id')); - $this->orderRepository->expects($this->once())->method('get')->with(1)->will($this->returnValue($orderMock)); - $this->service->create(1); - } - - public function testCreateCreatesCustomerBasedonGuestOrder() - { - $orderMock = $this->createMock(\Magento\Sales\Model\Order::class); - $orderMock->expects($this->once())->method('getCustomerId')->will($this->returnValue(null)); - $orderMock->expects($this->any())->method('getBillingAddress')->will($this->returnValue('billing_address')); - - $orderBillingAddress = $this->createPartialMockForAbstractClass(OrderAddressInterface::class, ['getData']); - $orderBillingAddress->expects($this->once()) - ->method('getAddressType') - ->willReturn(Address::ADDRESS_TYPE_BILLING); - - $orderShippingAddress = $this->createPartialMockForAbstractClass(OrderAddressInterface::class, ['getData']); - $orderShippingAddress->expects($this->once()) - ->method('getAddressType') - ->willReturn(Address::ADDRESS_TYPE_SHIPPING); - - $orderMock->expects($this->any()) - ->method('getAddresses') - ->will($this->returnValue([$orderBillingAddress, $orderShippingAddress])); - - $billingQuoteAddress = $this->createMock(\Magento\Quote\Model\Quote\Address::class); - $billingQuoteAddress->expects($this->once())->method('load')->willReturn($billingQuoteAddress); - $billingQuoteAddress->expects($this->once())->method('getId')->willReturn(4); - $billingQuoteAddress->expects($this->once())->method('getData')->with('save_in_address_book')->willReturn(1); - - $shippingQuoteAddress = $this->createMock(\Magento\Quote\Model\Quote\Address::class); - $shippingQuoteAddress->expects($this->once())->method('load')->willReturn($shippingQuoteAddress); - $shippingQuoteAddress->expects($this->once())->method('getId')->willReturn(5); - $shippingQuoteAddress->expects($this->once())->method('getData')->with('save_in_address_book')->willReturn(1); - $this->quoteAddressFactory->expects($this->exactly(2))->method('create') - ->willReturnOnConsecutiveCalls($billingQuoteAddress, $shippingQuoteAddress); - $this->orderRepository->expects($this->once())->method('get')->with(1)->will($this->returnValue($orderMock)); - $this->objectCopyService->expects($this->any())->method('copyFieldsetToTarget')->will($this->returnValueMap( - [ - ['order_address', 'to_customer', 'billing_address', [], 'global', ['customer_data' => []]], - ['order_address', 'to_customer_address', $orderBillingAddress, [], 'global', 'address_data'], - ['order_address', 'to_customer_address', $orderShippingAddress, [], 'global', 'address_data'], - ] - )); - - $addressMock = $this->createMock(\Magento\Customer\Api\Data\AddressInterface::class); - $addressMock->expects($this->any()) - ->method('setIsDefaultBilling') - ->with(true) - ->willReturnSelf(); - $addressMock->expects($this->any()) - ->method('setIsDefaultShipping') - ->with(true) - ->willReturnSelf(); - - $this->addressFactory->expects($this->any())->method('create')->with(['data' => 'address_data'])->will( - $this->returnValue($addressMock) - ); - $customerMock = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); - $customerMock->expects($this->any())->method('getId')->will($this->returnValue('customer_id')); - $this->customerFactory->expects($this->once())->method('create')->with( - ['data' => ['customer_data' => [], 'addresses' => [$addressMock, $addressMock]]] - )->will($this->returnValue($customerMock)); - $this->accountManagement->expects($this->once())->method('createAccount')->with($customerMock)->will( - $this->returnValue($customerMock) - ); - $orderMock->expects($this->once())->method('setCustomerId')->with('customer_id'); - $this->orderRepository->expects($this->once())->method('save')->with($orderMock); - $this->assertEquals($customerMock, $this->service->create(1)); - } - - /** - * Get mock for abstract class with methods. - * - * @param string $className - * @param array $methods - * - * @return \PHPUnit_Framework_MockObject_MockObject - */ - private function createPartialMockForAbstractClass($className, $methods = []) - { - return $this->getMockForAbstractClass( - $className, - [], - '', - true, - true, - true, - $methods - ); - } -} diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/OrderSenderTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/OrderSenderTest.php index 411dd9e1433d7..46c44c03b1514 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/OrderSenderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Email/Sender/OrderSenderTest.php @@ -53,10 +53,11 @@ protected function setUp() * @param int $configValue * @param bool|null $forceSyncMode * @param bool|null $emailSendingResult - * @dataProvider sendDataProvider + * @param $senderSendException * @return void + * @dataProvider sendDataProvider */ - public function testSend($configValue, $forceSyncMode, $emailSendingResult) + public function testSend($configValue, $forceSyncMode, $emailSendingResult, $senderSendException) { $address = 'address_test'; $configPath = 'sales_email/general/async_sending'; @@ -110,19 +111,23 @@ public function testSend($configValue, $forceSyncMode, $emailSendingResult) $this->senderMock->expects($this->once())->method('send'); - $this->senderMock->expects($this->once())->method('sendCopyTo'); + if ($senderSendException) { + $this->checkSenderSendExceptionCase(); + } else { + $this->senderMock->expects($this->once())->method('sendCopyTo'); - $this->orderMock->expects($this->once()) - ->method('setEmailSent') - ->with(true); + $this->orderMock->expects($this->once()) + ->method('setEmailSent') + ->with(true); - $this->orderResourceMock->expects($this->once()) - ->method('saveAttribute') - ->with($this->orderMock, ['send_email', 'email_sent']); + $this->orderResourceMock->expects($this->once()) + ->method('saveAttribute') + ->with($this->orderMock, ['send_email', 'email_sent']); - $this->assertTrue( - $this->sender->send($this->orderMock) - ); + $this->assertTrue( + $this->sender->send($this->orderMock) + ); + } } else { $this->orderResourceMock->expects($this->once()) ->method('saveAttribute') @@ -146,19 +151,42 @@ public function testSend($configValue, $forceSyncMode, $emailSendingResult) } } + /** + * Methods check case when method "send" in "senderMock" throw exception. + * + * @return void + */ + protected function checkSenderSendExceptionCase() + { + $this->senderMock->expects($this->once()) + ->method('send') + ->willThrowException(new \Exception('exception')); + + $this->orderResourceMock->expects($this->once()) + ->method('saveAttribute') + ->with($this->orderMock, 'send_email'); + + $this->assertFalse( + $this->sender->send($this->orderMock) + ); + } + /** * @return array */ public function sendDataProvider() { return [ - [0, 0, true], - [0, 0, true], - [0, 0, false], - [0, 0, false], - [0, 1, true], - [0, 1, true], - [1, null, null, null] + [0, 0, true, false], + [0, 0, true, false], + [0, 0, true, true], + [0, 0, false, false], + [0, 0, false, false], + [0, 0, false, true], + [0, 1, true, false], + [0, 1, true, false], + [0, 1, true, false], + [1, null, null, false] ]; } diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/CommentRepositoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/CommentRepositoryTest.php new file mode 100644 index 0000000000000..984554b289901 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Invoice/CommentRepositoryTest.php @@ -0,0 +1,189 @@ +commentResource = $this->getMockBuilder(InvoiceCommentResourceInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->commentFactory = $this->getMockBuilder(InvoiceCommentInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->searchResultFactory = $this->getMockBuilder(InvoiceCommentSearchResultInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->collectionProcessor = $this->getMockBuilder(CollectionProcessorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->invoiceRepositoryMock = $this->getMockBuilder(InvoiceRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->invoiceCommentSender = $this->getMockBuilder(InvoiceCommentSender::class) + ->disableOriginalConstructor() + ->getMock(); + $this->loggerMock = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); + + $this->invoiceMock = $this->getMockBuilder(Invoice::class)->disableOriginalConstructor()->getMock(); + $this->commentMock = $this->getMockBuilder(Comment::class)->disableOriginalConstructor()->getMock(); + + $this->commentRepository = new CommentRepository( + $this->commentResource, + $this->commentFactory, + $this->searchResultFactory, + $this->collectionProcessor, + $this->invoiceCommentSender, + $this->invoiceRepositoryMock, + $this->loggerMock + ); + } + + public function testSave() + { + $comment = "Comment text"; + $invoiceId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($invoiceId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->invoiceRepositoryMock->expects($this->once()) + ->method('get') + ->with($invoiceId) + ->willReturn($this->invoiceMock); + $this->invoiceCommentSender->expects($this->once()) + ->method('send') + ->with($this->invoiceMock, true, $comment) + ->willReturn(true); + $this->commentRepository->save($this->commentMock); + } + + /** + * @expectedException \Magento\Framework\Exception\CouldNotSaveException + * @expectedExceptionMessage Could not save the invoice comment. + */ + public function testSaveWithException() + { + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willThrowException( + new \Magento\Framework\Exception\CouldNotSaveException(__('Could not save the invoice comment.')) + ); + + $this->commentRepository->save($this->commentMock); + } + + public function testSaveSendCatchException() + { + $comment = "Comment text"; + $creditmemoId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($creditmemoId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->invoiceRepositoryMock->expects($this->once()) + ->method('get') + ->with($creditmemoId) + ->willReturn($this->invoiceMock); + $this->invoiceCommentSender->expects($this->once()) + ->method('send') + ->willThrowException(new \Exception()); + $this->loggerMock->expects($this->once()) + ->method('critical'); + + $this->commentRepository->save($this->commentMock); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/ItemTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/ItemTest.php index 03a388410f335..39fffa23dc1ec 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/ItemTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/ItemTest.php @@ -235,4 +235,114 @@ public function getProductOptionsDataProvider() ] ]; } + + /** + * Test different combinations of item qty setups + * + * @param array $options + * @param float $expectedResult + * + * @dataProvider getItemQtyVariants + */ + public function testGetSimpleQtyToMethods(array $options, $expectedResult) + { + $this->model->setData($options); + $this->assertSame($this->model->getSimpleQtyToShip(), $expectedResult['to_ship']); + $this->assertSame($this->model->getQtyToInvoice(), $expectedResult['to_invoice']); + } + + /** + * Provides different combinations of qty options for an item and the + * expected qtys pending shipment and invoice + * + * @return array + */ + public function getItemQtyVariants() + { + return [ + 'empty_item' => [ + 'options' => [ + 'qty_ordered' => 0, 'qty_invoiced' => 0, 'qty_refunded' => 0, 'qty_shipped' => 0, + 'qty_canceled' => 0 + ], + 'expectedResult' => ['to_ship' => 0.0, 'to_invoice' => 0.0] + ], + 'ordered_item' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 0, 'qty_refunded' => 0, 'qty_shipped' => 0, + 'qty_canceled' => 0 + ], + 'expectedResult' => ['to_ship' => 12.0, 'to_invoice' => 12.0] + ], + 'partially_invoiced' => [ + 'options' => ['qty_ordered' => 12, 'qty_invoiced' => 4, 'qty_refunded' => 0, 'qty_shipped' => 0, + 'qty_canceled' => 0, + ], + 'expectedResult' => ['to_ship' => 12.0, 'to_invoice' => 8.0] + ], + 'completely_invoiced' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 12, 'qty_refunded' => 0, 'qty_shipped' => 0, + 'qty_canceled' => 0, + ], + 'expectedResult' => ['to_ship' => 12.0, 'to_invoice' => 0.0] + ], + 'partially_invoiced_refunded' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 5, 'qty_refunded' => 5, 'qty_shipped' => 0, + 'qty_canceled' => 0, + ], + 'expectedResult' => ['to_ship' => 7.0, 'to_invoice' => 7.0] + ], + 'partially_refunded' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 12, 'qty_refunded' => 5, 'qty_shipped' => 0, + 'qty_canceled' => 0, + ], + 'expectedResult' => ['to_ship' => 7.0, 'to_invoice' => 0.0] + ], + 'partially_shipped' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 0, 'qty_refunded' => 0, 'qty_shipped' => 4, + 'qty_canceled' => 0 + ], + 'expectedResult' => ['to_ship' => 8.0, 'to_invoice' => 12.0] + ], + 'partially_refunded_partially_shipped' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 12, 'qty_refunded' => 5, 'qty_shipped' => 4, + 'qty_canceled' => 0 + ], + 'expectedResult' => ['to_ship' => 3.0, 'to_invoice' => 0.0] + ], + 'complete' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 12, 'qty_refunded' => 0, 'qty_shipped' => 12, + 'qty_canceled' => 0 + ], + 'expectedResult' => ['to_ship' => 0.0, 'to_invoice' => 0.0] + ], + 'canceled' => [ + 'options' => [ + 'qty_ordered' => 12, 'qty_invoiced' => 0, 'qty_refunded' => 0, 'qty_shipped' => 0, + 'qty_canceled' => 12 + ], + 'expectedResult' => ['to_ship' => 0.0, 'to_invoice' => 0.0] + ], + 'completely_shipped_using_decimals' => [ + 'options' => [ + 'qty_ordered' => 4.4, 'qty_invoiced' => 0.4, 'qty_refunded' => 0.4, 'qty_shipped' => 4, + 'qty_canceled' => 0, + ], + 'expectedResult' => ['to_ship' => 0.0, 'to_invoice' => 4.0] + ], + 'completely_invoiced_using_decimals' => [ + 'options' => [ + 'qty_ordered' => 4.4, 'qty_invoiced' => 4, 'qty_refunded' => 0, 'qty_shipped' => 4, + 'qty_canceled' => 0.4 + ], + 'expectedResult' => ['to_ship' => 0.0, 'to_invoice' => 0.0] + ] + ]; + } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/CommentRepositoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/CommentRepositoryTest.php new file mode 100644 index 0000000000000..5152b359e4b6a --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/CommentRepositoryTest.php @@ -0,0 +1,188 @@ +commentResource = $this->getMockBuilder(ShipmentCommentResourceInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->commentFactory = $this->getMockBuilder(ShipmentCommentInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->searchResultFactory = $this->getMockBuilder(ShipmentCommentSearchResultInterfaceFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->collectionProcessor = $this->getMockBuilder(CollectionProcessorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->shipmentRepositoryMock = $this->getMockBuilder(ShipmentRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->shipmentCommentSender = $this->getMockBuilder(ShipmentCommentSender::class) + ->disableOriginalConstructor() + ->getMock(); + $this->loggerMock = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); + + $this->shipmentMock = $this->getMockBuilder(Shipment::class)->disableOriginalConstructor()->getMock(); + $this->commentMock = $this->getMockBuilder(Comment::class)->disableOriginalConstructor()->getMock(); + + $this->commentRepository = new CommentRepository( + $this->commentResource, + $this->commentFactory, + $this->searchResultFactory, + $this->collectionProcessor, + $this->shipmentCommentSender, + $this->shipmentRepositoryMock, + $this->loggerMock + ); + } + + public function testSave() + { + $comment = "Comment text"; + $shipmentId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($shipmentId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->shipmentRepositoryMock->expects($this->once()) + ->method('get') + ->with($shipmentId) + ->willReturn($this->shipmentMock); + $this->shipmentCommentSender->expects($this->once()) + ->method('send') + ->with($this->shipmentMock, true, $comment); + $this->assertEquals($this->commentMock, $this->commentRepository->save($this->commentMock)); + } + + /** + * @expectedException \Magento\Framework\Exception\CouldNotSaveException + * @expectedExceptionMessage Could not save the shipment comment. + */ + public function testSaveWithException() + { + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willThrowException( + new \Magento\Framework\Exception\CouldNotSaveException(__('Could not save the shipment comment.')) + ); + + $this->commentRepository->save($this->commentMock); + } + + public function testSaveSendCatchException() + { + $comment = "Comment text"; + $creditmemoId = 123; + $this->commentResource->expects($this->once()) + ->method('save') + ->with($this->commentMock) + ->willReturnSelf(); + $this->commentMock->expects($this->once()) + ->method('getIsCustomerNotified') + ->willReturn(1); + $this->commentMock->expects($this->once()) + ->method('getParentId') + ->willReturn($creditmemoId); + $this->commentMock->expects($this->once()) + ->method('getComment') + ->willReturn($comment); + + $this->shipmentRepositoryMock->expects($this->once()) + ->method('get') + ->with($creditmemoId) + ->willReturn($this->shipmentMock); + $this->shipmentCommentSender->expects($this->once()) + ->method('send') + ->willThrowException(new \Exception()); + $this->loggerMock->expects($this->once()) + ->method('critical'); + + $this->commentRepository->save($this->commentMock); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/OrderRegistrarTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/OrderRegistrarTest.php index ecc37a2cd427d..9eb6be5f6d66e 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/OrderRegistrarTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Shipment/OrderRegistrarTest.php @@ -37,23 +37,20 @@ protected function setUp() public function testRegister() { $item1 = $this->getShipmentItemMock(); - $item1->expects($this->once()) - ->method('getQty') - ->willReturn(0); - $item1->expects($this->never()) - ->method('register'); + $item1->expects($this->once())->method('getQty')->willReturn(0); + $item1->expects($this->never())->method('register'); + $item1->expects($this->never())->method('getOrderItem'); $item2 = $this->getShipmentItemMock(); - $item2->expects($this->once()) - ->method('getQty') - ->willReturn(0.5); - $item2->expects($this->once()) - ->method('register'); + $item2->expects($this->atLeastOnce())->method('getQty')->willReturn(0.5); + $item2->expects($this->once())->method('register'); + + $orderItemMock = $this->createMock(\Magento\Sales\Model\Order\Item::class); + $orderItemMock->expects($this->once())->method('isDummy')->with(true)->willReturn(false); + $item2->expects($this->once())->method('getOrderItem')->willReturn($orderItemMock); $items = [$item1, $item2]; - $this->shipmentMock->expects($this->once()) - ->method('getItems') - ->willReturn($items); + $this->shipmentMock->expects($this->once())->method('getItems')->willReturn($items); $this->assertEquals( $this->orderMock, $this->model->register($this->orderMock, $this->shipmentMock) @@ -67,7 +64,7 @@ private function getShipmentItemMock() { return $this->getMockBuilder(\Magento\Sales\Api\Data\ShipmentItemInterface::class) ->disableOriginalConstructor() - ->setMethods(['register']) + ->setMethods(['register', 'getOrderItem']) ->getMockForAbstractClass(); } } diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/ShipmentDocumentFactoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/ShipmentDocumentFactoryTest.php index e51d912d3f420..bf9b3a67f9640 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/ShipmentDocumentFactoryTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/ShipmentDocumentFactoryTest.php @@ -19,6 +19,7 @@ /** * Class ShipmentDocumentFactoryTest + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class ShipmentDocumentFactoryTest extends \PHPUnit\Framework\TestCase { @@ -128,6 +129,8 @@ public function testCreate() $packages = []; $items = [1 => 10]; + $this->itemMock->expects($this->once())->method('getOrderItemId')->willReturn(1); + $this->itemMock->expects($this->once())->method('getQty')->willReturn(10); $this->itemMock->expects($this->once()) ->method('getOrderItemId') ->willReturn(1); diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/ShipmentFactoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/ShipmentFactoryTest.php index b4fb645c02f8b..e65b1b8330b93 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/ShipmentFactoryTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/ShipmentFactoryTest.php @@ -71,18 +71,31 @@ protected function setUp() */ public function testCreate($tracks) { - $orderItem = $this->createPartialMock(\Magento\Sales\Model\Order\Item::class, ['getId', 'getQtyOrdered']); + $orderItem = $this->createPartialMock( + \Magento\Sales\Model\Order\Item::class, + ['getId', 'getQtyOrdered', 'getParentItemId', 'getIsVirtual'] + ); $orderItem->expects($this->any()) ->method('getId') ->willReturn(1); $orderItem->expects($this->any()) ->method('getQtyOrdered') ->willReturn(5); + $orderItem->expects($this->any())->method('getParentItemId')->willReturn(false); + $orderItem->expects($this->any())->method('getIsVirtual')->willReturn(false); - $shipmentItem = $this->createPartialMock(\Magento\Sales\Model\Order\Shipment\Item::class, ['setQty']); + $shipmentItem = $this->createPartialMock( + \Magento\Sales\Model\Order\Shipment\Item::class, + ['setQty', 'getOrderItem', 'getQty'] + ); $shipmentItem->expects($this->once()) ->method('setQty') ->with(5); + $shipmentItem->expects($this->once()) + ->method('getQty') + ->willReturn(5); + + $shipmentItem->expects($this->atLeastOnce())->method('getOrderItem')->willReturn($orderItem); $order = $this->createPartialMock(\Magento\Sales\Model\Order::class, ['getAllItems']); $order->expects($this->any()) diff --git a/app/code/Magento/Sales/Test/Unit/Model/OrderIncrementIdCheckerTest.php b/app/code/Magento/Sales/Test/Unit/Model/OrderIncrementIdCheckerTest.php new file mode 100644 index 0000000000000..e53cb7bfdf8c6 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/OrderIncrementIdCheckerTest.php @@ -0,0 +1,81 @@ +selectMock = $this->createMock(\Magento\Framework\DB\Select::class); + $this->selectMock->expects($this->any())->method('from')->will($this->returnSelf()); + $this->selectMock->expects($this->any())->method('where'); + + $this->adapterMock = $this->createMock(\Magento\Framework\DB\Adapter\Pdo\Mysql::class); + $this->adapterMock->expects($this->any())->method('select')->will($this->returnValue($this->selectMock)); + + $this->resourceMock = $this->createMock(\Magento\Sales\Model\ResourceModel\Order::class); + $this->resourceMock->expects($this->any())->method('getConnection')->willReturn($this->adapterMock); + + $this->model = $objectManagerHelper->getObject( + \Magento\Sales\Model\OrderIncrementIdChecker::class, + [ + 'resourceModel' => $this->resourceMock, + ] + ); + } + + /** + * Unit test to verify if isOrderIncrementIdUsed method works with different types increment ids. + * + * @param string|int $value + * @return void + * @dataProvider isOrderIncrementIdUsedDataProvider + */ + public function testIsIncrementIdUsed($value): void + { + $expectedBind = [':increment_id' => $value]; + $this->adapterMock->expects($this->once())->method('fetchOne')->with($this->selectMock, $expectedBind); + $this->model->isIncrementIdUsed($value); + } + + /** + * @return array + */ + public function isOrderIncrementIdUsedDataProvider(): array + { + return [[100000001], ['10000000001'], ['M10000000001']]; + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/Provider/NotSyncedDataProviderTest.php b/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/Provider/NotSyncedDataProviderTest.php index 8e248d239a501..87e9f201eb758 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/Provider/NotSyncedDataProviderTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/Provider/NotSyncedDataProviderTest.php @@ -5,14 +5,13 @@ */ namespace Magento\Sales\Test\Unit\Model\ResourceModel\Provider; -use Magento\Framework\ObjectManager\TMap; use Magento\Framework\ObjectManager\TMapFactory; use Magento\Sales\Model\ResourceModel\Provider\NotSyncedDataProvider; use Magento\Sales\Model\ResourceModel\Provider\NotSyncedDataProviderInterface; use PHPUnit_Framework_MockObject_MockObject as MockObject; /** - * Class NotSyncedDataProviderTest + * Class for testing not synchronized DataProvider. */ class NotSyncedDataProviderTest extends \PHPUnit\Framework\TestCase { @@ -23,30 +22,14 @@ public function testGetIdsEmpty() ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $tMap = $this->getMockBuilder(TMap::class) - ->disableOriginalConstructor() - ->getMock(); - $tMapFactory->expects(static::once()) - ->method('create') - ->with( - [ - 'array' => [], - 'type' => NotSyncedDataProviderInterface::class - ] - ) - ->willReturn($tMap); - $tMap->expects(static::once()) - ->method('getIterator') - ->willReturn(new \ArrayIterator([])); + $tMapFactory->method('create') + ->willReturn([]); - $provider = new NotSyncedDataProvider($tMapFactory, []); - static::assertEquals([], $provider->getIds('main_table', 'grid_table')); + $provider = new NotSyncedDataProvider($tMapFactory); + self::assertEquals([], $provider->getIds('main_table', 'grid_table')); } - /** - * @covers \Magento\Sales\Model\ResourceModel\Provider\NotSyncedDataProvider::getIds - */ public function testGetIds() { /** @var TMapFactory|MockObject $tMapFactory */ @@ -54,46 +37,31 @@ public function testGetIds() ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $tMap = $this->getMockBuilder(TMap::class) - ->disableOriginalConstructor() - ->getMock(); $provider1 = $this->getMockBuilder(NotSyncedDataProviderInterface::class) ->getMockForAbstractClass(); - $provider1->expects(static::once()) - ->method('getIds') + $provider1->method('getIds') ->willReturn([1, 2]); $provider2 = $this->getMockBuilder(NotSyncedDataProviderInterface::class) ->getMockForAbstractClass(); - $provider2->expects(static::once()) - ->method('getIds') + $provider2->method('getIds') ->willReturn([2, 3, 4]); - $tMapFactory->expects(static::once()) - ->method('create') - ->with( + $tMapFactory->method('create') + ->with(self::equalTo( [ - 'array' => [ - 'provider1' => NotSyncedDataProviderInterface::class, - 'provider2' => NotSyncedDataProviderInterface::class - ], + 'array' => [$provider1, $provider2], 'type' => NotSyncedDataProviderInterface::class ] - ) - ->willReturn($tMap); - $tMap->expects(static::once()) - ->method('getIterator') - ->willReturn(new \ArrayIterator([$provider1, $provider2])); + )) + ->willReturn([$provider1, $provider2]); - $provider = new NotSyncedDataProvider( - $tMapFactory, - [ - 'provider1' => NotSyncedDataProviderInterface::class, - 'provider2' => NotSyncedDataProviderInterface::class, - ] - ); + $provider = new NotSyncedDataProvider($tMapFactory, [$provider1, $provider2]); - static::assertEquals([1, 2, 3, 4], array_values($provider->getIds('main_table', 'grid_table'))); + self::assertEquals( + [1, 2, 3, 4], + array_values($provider->getIds('main_table', 'grid_table')) + ); } } diff --git a/app/code/Magento/Sales/Ui/Component/Listing/Column/CustomerGroup.php b/app/code/Magento/Sales/Ui/Component/Listing/Column/CustomerGroup.php index 1144321ac0b56..5d95c7b94d8fa 100644 --- a/app/code/Magento/Sales/Ui/Component/Listing/Column/CustomerGroup.php +++ b/app/code/Magento/Sales/Ui/Component/Listing/Column/CustomerGroup.php @@ -5,7 +5,6 @@ */ namespace Magento\Sales\Ui\Component\Listing\Column; -use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Ui\Component\Listing\Columns\Column; use Magento\Framework\View\Element\UiComponent\ContextInterface; diff --git a/app/code/Magento/Sales/composer.json b/app/code/Magento/Sales/composer.json index 1797a3122c32e..4286fbb506a53 100644 --- a/app/code/Magento/Sales/composer.json +++ b/app/code/Magento/Sales/composer.json @@ -5,37 +5,37 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-authorization": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-checkout": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-gift-message": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-payment": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-reports": "100.3.*", - "magento/module-sales-rule": "100.3.*", - "magento/module-sales-sequence": "100.3.*", - "magento/module-shipping": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*", - "magento/module-theme": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-widget": "100.3.*", - "magento/module-wishlist": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-authorization": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-bundle": "*", + "magento/module-catalog-inventory": "*", + "magento/module-checkout": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-gift-message": "*", + "magento/module-media-storage": "*", + "magento/module-payment": "*", + "magento/module-quote": "*", + "magento/module-reports": "*", + "magento/module-sales-rule": "*", + "magento/module-sales-sequence": "*", + "magento/module-shipping": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-theme": "*", + "magento/module-ui": "*", + "magento/module-widget": "*", + "magento/module-wishlist": "*" }, "suggest": { - "magento/module-sales-sample-data": "Sample Data version:100.3.*" + "magento/module-sales-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Sales/etc/adminhtml/system.xml b/app/code/Magento/Sales/etc/adminhtml/system.xml index 8f004d9ad5968..9d6d11d56c81f 100644 --- a/app/code/Magento/Sales/etc/adminhtml/system.xml +++ b/app/code/Magento/Sales/etc/adminhtml/system.xml @@ -106,15 +106,15 @@ We'll use the default error above if you leave this empty. - + - + Magento\Config\Model\Config\Source\Yesno Improves dashboard performance but provides non-realtime data. - + diff --git a/app/code/Magento/Sales/etc/db_schema.xml b/app/code/Magento/Sales/etc/db_schema.xml index c084a5b87b109..4b716e761094c 100644 --- a/app/code/Magento/Sales/etc/db_schema.xml +++ b/app/code/Magento/Sales/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    @@ -216,7 +216,7 @@ - + diff --git a/app/code/Magento/Sales/etc/di.xml b/app/code/Magento/Sales/etc/di.xml index 9de3f238d6a39..ce2948983edbe 100644 --- a/app/code/Magento/Sales/etc/di.xml +++ b/app/code/Magento/Sales/etc/di.xml @@ -470,6 +470,14 @@ CreditmemoRelationsComposite + + + + Magento\Sales\Model\ResourceModel\Provider\UpdatedIdListProvider + Magento\Sales\Model\ResourceModel\Provider\UpdatedAtListProvider + + + sales_order @@ -520,6 +528,7 @@ sales_order_payment.method sales_order.total_refunded + Magento\Sales\Model\ResourceModel\Provider\NotSyncedOrderDataProvider @@ -676,8 +685,8 @@ BillingAddressAggregator ShippingAddressAggregator sales_order.shipping_description - sales_order.base_subtotal - sales_order.base_shipping_amount + sales_invoice.base_subtotal + sales_invoice.base_shipping_amount sales_invoice.base_grand_total sales_invoice.grand_total sales_invoice.created_at @@ -982,4 +991,7 @@ + diff --git a/app/code/Magento/Sales/etc/events.xml b/app/code/Magento/Sales/etc/events.xml index 9ec983acab5bd..b3a7a4ab99577 100644 --- a/app/code/Magento/Sales/etc/events.xml +++ b/app/code/Magento/Sales/etc/events.xml @@ -51,4 +51,9 @@ + + + diff --git a/app/code/Magento/Sales/view/adminhtml/templates/items/column/name.phtml b/app/code/Magento/Sales/view/adminhtml/templates/items/column/name.phtml index 27b8ca86b1681..30037a918a10c 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/items/column/name.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/items/column/name.phtml @@ -32,7 +32,7 @@ getCustomizedOptionValue($_option) ?> getFormattedOption($_option['value']); ?> - ... + escapeHtml($_option['value']) ?> ... diff --git a/app/code/Magento/Sales/view/adminhtml/templates/order/view/info.phtml b/app/code/Magento/Sales/view/adminhtml/templates/order/view/info.phtml index 56c1a99e66ade..5384a00dc894d 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/order/view/info.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/order/view/info.phtml @@ -24,6 +24,8 @@ $orderStoreDate = $block->formatDate( true, $block->getTimezoneForStore($order->getStore()) ); + +$customerUrl = $block->getCustomerViewUrl(); ?>
    escapeHtml(__('Customer Name')) ?> - getCustomerViewUrl()): ?> + escapeHtml($order->getCustomerName()) ?> diff --git a/app/code/Magento/Sales/view/adminhtml/templates/transactions/detail.phtml b/app/code/Magento/Sales/view/adminhtml/templates/transactions/detail.phtml index b3708edf1d098..c46ff775f6f9a 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/transactions/detail.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/transactions/detail.phtml @@ -58,7 +58,7 @@
    - +
    getChildHtml('child_grid') ?> diff --git a/app/code/Magento/Sales/view/frontend/templates/order/creditmemo/items/renderer/default.phtml b/app/code/Magento/Sales/view/frontend/templates/order/creditmemo/items/renderer/default.phtml index 5542439da17fd..2dbef1f485945 100644 --- a/app/code/Magento/Sales/view/frontend/templates/order/creditmemo/items/renderer/default.phtml +++ b/app/code/Magento/Sales/view/frontend/templates/order/creditmemo/items/renderer/default.phtml @@ -20,12 +20,12 @@ getPrintStatus()): ?> getFormatedOptionValue($_option) ?> class="tooltip wrapper"> - + escapeHtml($_formatedOptionValue['value']) ?>
    escapeHtml($_option['label']) ?>
    -
    +
    escapeHtml($_formatedOptionValue['full_view']) ?>
    diff --git a/app/code/Magento/Sales/view/frontend/templates/order/invoice/items/renderer/default.phtml b/app/code/Magento/Sales/view/frontend/templates/order/invoice/items/renderer/default.phtml index 4f95f3d93e2c2..c17c46d5f945a 100644 --- a/app/code/Magento/Sales/view/frontend/templates/order/invoice/items/renderer/default.phtml +++ b/app/code/Magento/Sales/view/frontend/templates/order/invoice/items/renderer/default.phtml @@ -20,12 +20,12 @@ getPrintStatus()): ?> getFormatedOptionValue($_option) ?> class="tooltip wrapper"> - + escapeHtml($_formatedOptionValue['value']) ?>
    escapeHtml($_option['label']) ?>
    -
    +
    escapeHtml($_formatedOptionValue['full_view']) ?>
    diff --git a/app/code/Magento/Sales/view/frontend/templates/order/items/renderer/default.phtml b/app/code/Magento/Sales/view/frontend/templates/order/items/renderer/default.phtml index d4550dd4f01c3..227866b8e1c3d 100644 --- a/app/code/Magento/Sales/view/frontend/templates/order/items/renderer/default.phtml +++ b/app/code/Magento/Sales/view/frontend/templates/order/items/renderer/default.phtml @@ -20,9 +20,9 @@ $_item = $block->getItem(); getFormatedOptionValue($_option) ?>
    - + escapeHtml($_formatedOptionValue['full_view']) ?> - + escapeHtml($_formatedOptionValue['value']) ?>
    diff --git a/app/code/Magento/Sales/view/frontend/templates/order/recent.phtml b/app/code/Magento/Sales/view/frontend/templates/order/recent.phtml index df7e6bf334d9a..bb354e920c529 100644 --- a/app/code/Magento/Sales/view/frontend/templates/order/recent.phtml +++ b/app/code/Magento/Sales/view/frontend/templates/order/recent.phtml @@ -8,10 +8,13 @@ ?>
    -getOrders(); ?> +getOrders(); + $count = count($_orders); +?>
    - getItems()) > 0): ?> + 0): ?> @@ -19,7 +22,7 @@
    getChildHtml() ?> - getItems()) > 0): ?> + 0): ?>
    diff --git a/app/code/Magento/Sales/view/frontend/templates/order/shipment/items/renderer/default.phtml b/app/code/Magento/Sales/view/frontend/templates/order/shipment/items/renderer/default.phtml index b46e8d115480e..3f916b7a8da67 100644 --- a/app/code/Magento/Sales/view/frontend/templates/order/shipment/items/renderer/default.phtml +++ b/app/code/Magento/Sales/view/frontend/templates/order/shipment/items/renderer/default.phtml @@ -19,12 +19,12 @@ getPrintStatus()): ?> getFormatedOptionValue($_option) ?> class="tooltip wrapper"> - + escapeHtml($_formatedOptionValue['value']) ?>
    escapeHtml($_option['label']) ?>
    -
    +
    escapeHtml($_formatedOptionValue['full_view']) ?>
    diff --git a/app/code/Magento/SalesAnalytics/composer.json b/app/code/Magento/SalesAnalytics/composer.json index e9d1eb7084925..64424c8f5bc61 100644 --- a/app/code/Magento/SalesAnalytics/composer.json +++ b/app/code/Magento/SalesAnalytics/composer.json @@ -2,12 +2,11 @@ "name": "magento/module-sales-analytics", "description": "N/A", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-sales": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-sales": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SalesInventory/Model/Order/ReturnProcessor.php b/app/code/Magento/SalesInventory/Model/Order/ReturnProcessor.php index ed95ecd7e4380..3f2ba38fa5a55 100644 --- a/app/code/Magento/SalesInventory/Model/Order/ReturnProcessor.php +++ b/app/code/Magento/SalesInventory/Model/Order/ReturnProcessor.php @@ -10,6 +10,8 @@ /** * Class ReturnProcessor + * + * @api */ class ReturnProcessor { diff --git a/app/code/Magento/SalesInventory/composer.json b/app/code/Magento/SalesInventory/composer.json index 2150f0773e717..d8a48bed9169a 100644 --- a/app/code/Magento/SalesInventory/composer.json +++ b/app/code/Magento/SalesInventory/composer.json @@ -5,15 +5,14 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-sales": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Quote.php b/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Quote.php index 15f87931886c8..55e1d319cc776 100644 --- a/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Quote.php +++ b/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Quote.php @@ -74,7 +74,7 @@ protected function _initRule() /** * Initiate action * - * @return this + * @return $this */ protected function _initAction() { diff --git a/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php b/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php new file mode 100644 index 0000000000000..f413a7d047d62 --- /dev/null +++ b/app/code/Magento/SalesRule/Controller/Adminhtml/Promo/Widget/CategoriesJson.php @@ -0,0 +1,26 @@ +getActualLength(); $code = ''; for ($i = 0, $indexMax = strlen($alphabet) - 1; $i < $length; ++$i) { - $code .= substr($alphabet, mt_rand(0, $indexMax), 1); + $code .= substr($alphabet, random_int(0, $indexMax), 1); } return $code; @@ -54,7 +54,7 @@ protected function getActualLength() $lengthMin = $this->getLengthMin() ? $this->getLengthMin() : static::DEFAULT_LENGTH_MIN; $lengthMax = $this->getLengthMax() ? $this->getLengthMax() : static::DEFAULT_LENGTH_MAX; - return $this->getLength() ? $this->getLength() : mt_rand($lengthMin, $lengthMax); + return $this->getLength() ? $this->getLength() : random_int($lengthMin, $lengthMax); } /** diff --git a/app/code/Magento/SalesRule/Model/DeltaPriceRound.php b/app/code/Magento/SalesRule/Model/DeltaPriceRound.php new file mode 100644 index 0000000000000..b080a93ee4c9f --- /dev/null +++ b/app/code/Magento/SalesRule/Model/DeltaPriceRound.php @@ -0,0 +1,78 @@ +priceCurrency = $priceCurrency; + } + + /** + * Round price based on previous rounding operation delta. + * + * @param float $price + * @param string $type + * @return float + */ + public function round(float $price, string $type): float + { + if ($price) { + // initialize the delta to a small number to avoid non-deterministic behavior with rounding of 0.5 + $delta = isset($this->roundingDeltas[$type]) ? $this->roundingDeltas[$type] : 0.000001; + $price += $delta; + $roundPrice = $this->priceCurrency->round($price); + $this->roundingDeltas[$type] = $price - $roundPrice; + $price = $roundPrice; + } + + return $price; + } + + /** + * Reset all deltas. + * + * @return void + */ + public function resetAll(): void + { + $this->roundingDeltas = []; + } + + /** + * Reset deltas by type. + * + * @param string $type + * @return void + */ + public function reset(string $type): void + { + if (isset($this->roundingDeltas[$type])) { + unset($this->roundingDeltas[$type]); + } + } +} diff --git a/app/code/Magento/SalesRule/Model/Quote/Discount.php b/app/code/Magento/SalesRule/Model/Quote/Discount.php index bee007b41181f..693a61b272f66 100644 --- a/app/code/Magento/SalesRule/Model/Quote/Discount.php +++ b/app/code/Magento/SalesRule/Model/Quote/Discount.php @@ -179,7 +179,7 @@ protected function distributeDiscount(\Magento\Quote\Model\Quote\Item\AbstractIt $roundingDelta[$key] = 0.0000001; } foreach ($item->getChildren() as $child) { - $ratio = $child->getBaseRowTotal() / $parentBaseRowTotal; + $ratio = $parentBaseRowTotal != 0 ? $child->getBaseRowTotal() / $parentBaseRowTotal : 0; foreach ($keys as $key) { if (!$item->hasData($key)) { continue; diff --git a/app/code/Magento/SalesRule/Model/ResourceModel/Coupon.php b/app/code/Magento/SalesRule/Model/ResourceModel/Coupon.php index 5068f53f23bc9..3907261cad1b9 100644 --- a/app/code/Magento/SalesRule/Model/ResourceModel/Coupon.php +++ b/app/code/Magento/SalesRule/Model/ResourceModel/Coupon.php @@ -106,7 +106,7 @@ public function exists($code) } /** - * Update auto generated Specific Coupon if it's rule changed + * Update auto generated Specific Coupon if its rule changed * * @param \Magento\SalesRule\Model\Rule $rule * @return $this diff --git a/app/code/Magento/SalesRule/Model/Rule.php b/app/code/Magento/SalesRule/Model/Rule.php index f4469213bd96e..59efdf5eb3f6d 100644 --- a/app/code/Magento/SalesRule/Model/Rule.php +++ b/app/code/Magento/SalesRule/Model/Rule.php @@ -521,7 +521,7 @@ public function acquireCoupon($saveNewlyCreated = true, $saveAttemptCount = 10) $coupon->setCode( $couponCode . self::getCouponCodeGenerator()->getDelimiter() . sprintf( '%04u', - rand(0, 9999) + random_int(0, 9999) ) ); continue; diff --git a/app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php b/app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php index caa938322617d..3cd776fe99f5d 100644 --- a/app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php +++ b/app/code/Magento/SalesRule/Model/Rule/Action/Discount/CartFixed.php @@ -5,6 +5,14 @@ */ namespace Magento\SalesRule\Model\Rule\Action\Discount; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\SalesRule\Model\DeltaPriceRound; +use Magento\SalesRule\Model\Validator; + +/** + * Calculates discount for cart item if fixed discount applied on whole cart. + */ class CartFixed extends AbstractDiscount { /** @@ -14,6 +22,33 @@ class CartFixed extends AbstractDiscount */ protected $_cartFixedRuleUsedForAddress = []; + /** + * @var DeltaPriceRound + */ + private $deltaPriceRound; + + /** + * @var string + */ + private static $discountType = 'CartFixed'; + + /** + * @param Validator $validator + * @param DataFactory $discountDataFactory + * @param PriceCurrencyInterface $priceCurrency + * @param DeltaPriceRound $deltaPriceRound + */ + public function __construct( + Validator $validator, + DataFactory $discountDataFactory, + PriceCurrencyInterface $priceCurrency, + DeltaPriceRound $deltaPriceRound + ) { + $this->deltaPriceRound = $deltaPriceRound; + + parent::__construct($validator, $discountDataFactory, $priceCurrency); + } + /** * @param \Magento\SalesRule\Model\Rule $rule * @param \Magento\Quote\Model\Quote\Item\AbstractItem $item @@ -51,14 +86,22 @@ public function calculate($rule, $item, $qty) $cartRules[$rule->getId()] = $rule->getDiscountAmount(); } - if ($cartRules[$rule->getId()] > 0) { + $availableDiscountAmount = (float)$cartRules[$rule->getId()]; + $discountType = self::$discountType . $rule->getId(); + + if ($availableDiscountAmount > 0) { $store = $quote->getStore(); if ($ruleTotals['items_count'] <= 1) { - $quoteAmount = $this->priceCurrency->convert($cartRules[$rule->getId()], $store); - $baseDiscountAmount = min($baseItemPrice * $qty, $cartRules[$rule->getId()]); + $quoteAmount = $this->priceCurrency->convert($availableDiscountAmount, $store); + $baseDiscountAmount = min($baseItemPrice * $qty, $availableDiscountAmount); + $this->deltaPriceRound->reset($discountType); } else { - $discountRate = $baseItemPrice * $qty / $ruleTotals['base_items_price']; - $maximumItemDiscount = $rule->getDiscountAmount() * $discountRate; + $ratio = $baseItemPrice * $qty / $ruleTotals['base_items_price']; + $maximumItemDiscount = $this->deltaPriceRound->round( + $rule->getDiscountAmount() * $ratio, + $discountType + ); + $quoteAmount = $this->priceCurrency->convert($maximumItemDiscount, $store); $baseDiscountAmount = min($baseItemPrice * $qty, $maximumItemDiscount); @@ -67,7 +110,11 @@ public function calculate($rule, $item, $qty) $baseDiscountAmount = $this->priceCurrency->round($baseDiscountAmount); - $cartRules[$rule->getId()] -= $baseDiscountAmount; + $availableDiscountAmount -= $baseDiscountAmount; + $cartRules[$rule->getId()] = $availableDiscountAmount; + if ($availableDiscountAmount <= 0) { + $this->deltaPriceRound->reset($discountType); + } $discountData->setAmount($this->priceCurrency->round(min($itemPrice * $qty, $quoteAmount))); $discountData->setBaseAmount($baseDiscountAmount); diff --git a/app/code/Magento/SalesRule/Setup/Patch/Data/ConvertSerializedDataToJson.php b/app/code/Magento/SalesRule/Setup/Patch/Data/ConvertSerializedDataToJson.php index 807ca7ea4a794..8a8c51e9d349a 100644 --- a/app/code/Magento/SalesRule/Setup/Patch/Data/ConvertSerializedDataToJson.php +++ b/app/code/Magento/SalesRule/Setup/Patch/Data/ConvertSerializedDataToJson.php @@ -7,8 +7,8 @@ namespace Magento\SalesRule\Setup\Patch\Data; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ConvertSerializedDataToJson diff --git a/app/code/Magento/SalesRule/Setup/Patch/Data/FillSalesRuleProductAttributeTable.php b/app/code/Magento/SalesRule/Setup/Patch/Data/FillSalesRuleProductAttributeTable.php index d3605431543bf..625d5769fddf5 100644 --- a/app/code/Magento/SalesRule/Setup/Patch/Data/FillSalesRuleProductAttributeTable.php +++ b/app/code/Magento/SalesRule/Setup/Patch/Data/FillSalesRuleProductAttributeTable.php @@ -8,8 +8,8 @@ use Magento\Framework\App\ResourceConnection; use Magento\Framework\App\State; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class FillSalesRuleProductAttributeTable diff --git a/app/code/Magento/SalesRule/Setup/Patch/Data/PrepareRuleModelSerializedData.php b/app/code/Magento/SalesRule/Setup/Patch/Data/PrepareRuleModelSerializedData.php index 4a68a2d42c8e2..2387f5f1ed714 100644 --- a/app/code/Magento/SalesRule/Setup/Patch/Data/PrepareRuleModelSerializedData.php +++ b/app/code/Magento/SalesRule/Setup/Patch/Data/PrepareRuleModelSerializedData.php @@ -8,8 +8,8 @@ use Magento\Framework\Setup\ModuleDataSetupInterface; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class PrepareRuleModelSerializedData diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/CouponRepositoryTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/CouponRepositoryTest.php index ebdc10830f33f..31536e1be3d2e 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/CouponRepositoryTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/CouponRepositoryTest.php @@ -150,7 +150,8 @@ public function testSaveWithExceptions($exceptionObject, $exceptionName, $except $this->resource->expects($this->once())->method('save')->with($coupon) ->willThrowException($exceptionObject); } - $this->expectException($exceptionName, $exceptionMessage); + $this->expectException($exceptionName); + $this->expectExceptionMessage($exceptionMessage); $this->model->save($coupon); } diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/DeltaPriceRoundTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/DeltaPriceRoundTest.php new file mode 100644 index 0000000000000..d67dab5baf63b --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Unit/Model/DeltaPriceRoundTest.php @@ -0,0 +1,102 @@ +priceCurrency = $this->getMockForAbstractClass(PriceCurrencyInterface::class); + $this->priceCurrency->method('round') + ->willReturnCallback( + function ($amount) { + return round($amount, 2); + } + ); + + $this->model = new DeltaPriceRound($this->priceCurrency); + } + + /** + * Tests rounded price based on previous rounding operation delta. + * + * @param array $prices + * @param array $roundedPrices + * @return void + * @dataProvider roundDataProvider + */ + public function testRound(array $prices, array $roundedPrices): void + { + foreach ($prices as $key => $price) { + $roundedPrice = $this->model->round($price, 'test'); + $this->assertEquals($roundedPrices[$key], $roundedPrice); + } + + $this->model->reset('test'); + } + + /** + * @return array + */ + public function roundDataProvider(): array + { + return [ + [ + 'prices' => [1.004, 1.004], + 'rounded prices' => [1.00, 1.01], + ], + [ + 'prices' => [1.005, 1.005], + 'rounded prices' => [1.01, 1.0], + ], + ]; + } + + /** + * @return void + */ + public function testReset(): void + { + $this->assertEquals(1.44, $this->model->round(1.444, 'test')); + $this->model->reset('test'); + $this->assertEquals(1.44, $this->model->round(1.444, 'test')); + } + + /** + * @return void + */ + public function testResetAll(): void + { + $this->assertEquals(1.44, $this->model->round(1.444, 'test1')); + $this->assertEquals(1.44, $this->model->round(1.444, 'test2')); + + $this->model->resetAll(); + + $this->assertEquals(1.44, $this->model->round(1.444, 'test1')); + $this->assertEquals(1.44, $this->model->round(1.444, 'test2')); + } +} diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/Quote/DiscountTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/Quote/DiscountTest.php index a1c325f39a947..671f20a27a460 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/Quote/DiscountTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/Quote/DiscountTest.php @@ -229,7 +229,30 @@ public function collectItemHasChildrenDataProvider() { $data = [ // 3 items, each $100, testing that discount are distributed to item correctly - 'three_items' => [ + [ + 'child_item_data' => [ + 'item1' => [ + 'base_row_total' => 0, + ] + ], + 'parent_item_data' => [ + 'discount_amount' => 20, + 'base_discount_amount' => 10, + 'original_discount_amount' => 40, + 'base_original_discount_amount' => 20, + 'base_row_total' => 0, + ], + 'expected_child_item_data' => [ + 'item1' => [ + 'discount_amount' => 0, + 'base_discount_amount' => 0, + 'original_discount_amount' => 0, + 'base_original_discount_amount' => 0, + ] + ], + ], + [ + // 3 items, each $100, testing that discount are distributed to item correctly 'child_item_data' => [ 'item1' => [ 'base_row_total' => 100, diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php index 2f1bee9fc686a..13f26124c464d 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/Rule/Action/Discount/CartFixedTest.php @@ -5,48 +5,56 @@ */ namespace Magento\SalesRule\Test\Unit\Model\Rule\Action\Discount; +use PHPUnit_Framework_MockObject_MockObject as MockObject; + +/** + * Tests for Magento\SalesRule\Model\Rule\Action\Discount\CartFixed. + */ class CartFixedTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\SalesRule\Model\Rule|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\SalesRule\Model\Rule|MockObject */ protected $rule; /** - * @var \Magento\Quote\Model\Quote\Item\AbstractItem|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Quote\Model\Quote\Item\AbstractItem|MockObject */ protected $item; /** - * @var \Magento\SalesRule\Model\Validator|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\SalesRule\Model\Validator|MockObject */ protected $validator; /** - * @var \Magento\SalesRule\Model\Rule\Action\Discount\Data|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\SalesRule\Model\Rule\Action\Discount\Data|MockObject */ protected $data; /** - * @var \Magento\Quote\Model\Quote|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Quote\Model\Quote|MockObject */ protected $quote; /** - * @var \Magento\Quote\Model\Quote\Address|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Quote\Model\Quote\Address|MockObject */ protected $address; /** - * @var CartFixed + * @var \Magento\SalesRule\Model\Rule\Action\Discount\CartFixed */ protected $model; /** - * @var \Magento\Framework\Pricing\PriceCurrencyInterface|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\Pricing\PriceCurrencyInterface|MockObject */ protected $priceCurrency; + /** + * @inheritdoc + */ protected function setUp() { $this->rule = $this->getMockBuilder(\Magento\Framework\DataObject::class) @@ -66,18 +74,23 @@ protected function setUp() $this->item->expects($this->any())->method('getAddress')->will($this->returnValue($this->address)); $this->validator = $this->createMock(\Magento\SalesRule\Model\Validator::class); + /** @var \Magento\SalesRule\Model\Rule\Action\Discount\DataFactory|MockObject $dataFactory */ $dataFactory = $this->createPartialMock( \Magento\SalesRule\Model\Rule\Action\Discount\DataFactory::class, ['create'] ); $dataFactory->expects($this->any())->method('create')->will($this->returnValue($this->data)); - $this->priceCurrency = $this->getMockBuilder( - \Magento\Framework\Pricing\PriceCurrencyInterface::class - )->getMock(); + $this->priceCurrency = $this->getMockBuilder(\Magento\Framework\Pricing\PriceCurrencyInterface::class) + ->getMock(); + $deltaPriceRound = $this->getMockBuilder(\Magento\SalesRule\Model\DeltaPriceRound::class) + ->disableOriginalConstructor() + ->getMock(); + $this->model = new \Magento\SalesRule\Model\Rule\Action\Discount\CartFixed( $this->validator, $dataFactory, - $this->priceCurrency + $this->priceCurrency, + $deltaPriceRound ); } diff --git a/app/code/Magento/SalesRule/composer.json b/app/code/Magento/SalesRule/composer.json index 44f5a78954618..a2e7dc8835ae7 100644 --- a/app/code/Magento/SalesRule/composer.json +++ b/app/code/Magento/SalesRule/composer.json @@ -5,30 +5,29 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-rule": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-payment": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-reports": "100.3.*", - "magento/module-rule": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-shipping": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-widget": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-rule": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-payment": "*", + "magento/module-quote": "*", + "magento/module-reports": "*", + "magento/module-rule": "*", + "magento/module-sales": "*", + "magento/module-shipping": "*", + "magento/module-store": "*", + "magento/module-ui": "*", + "magento/module-widget": "*" }, "suggest": { - "magento/module-sales-rule-sample-data": "Sample Data version:100.3.*" + "magento/module-sales-rule-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SalesRule/etc/db_schema.xml b/app/code/Magento/SalesRule/etc/db_schema.xml index ed205d88d9a2d..3d882ee2eae67 100644 --- a/app/code/Magento/SalesRule/etc/db_schema.xml +++ b/app/code/Magento/SalesRule/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/SalesRule/view/frontend/layout/checkout_cart_index.xml b/app/code/Magento/SalesRule/view/frontend/layout/checkout_cart_index.xml index 022403579b237..a5d0be503baad 100644 --- a/app/code/Magento/SalesRule/view/frontend/layout/checkout_cart_index.xml +++ b/app/code/Magento/SalesRule/view/frontend/layout/checkout_cart_index.xml @@ -16,7 +16,7 @@ - Magento_SalesRule/js/view/cart/totals/discount + Magento_SalesRule/js/view/cart/totals/discount Discount diff --git a/app/code/Magento/SalesRule/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/SalesRule/view/frontend/layout/checkout_index_index.xml index e285530b25d9f..f75525576f16b 100644 --- a/app/code/Magento/SalesRule/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/SalesRule/view/frontend/layout/checkout_index_index.xml @@ -47,7 +47,7 @@ - Magento_SalesRule/js/view/summary/discount + Magento_SalesRule/js/view/summary/discount Discount diff --git a/app/code/Magento/SalesSequence/Setup/Patch/Schema/CreateSequence.php b/app/code/Magento/SalesSequence/Setup/Patch/Schema/CreateSequence.php deleted file mode 100644 index 7295408f98fd6..0000000000000 --- a/app/code/Magento/SalesSequence/Setup/Patch/Schema/CreateSequence.php +++ /dev/null @@ -1,68 +0,0 @@ -sequenceCreator = $sequenceCreator; - } - - /** - * {@inheritdoc} - */ - public function apply() - { - $this->sequenceCreator->create(); - } - - /** - * {@inheritdoc} - */ - public static function getDependencies() - { - return [ - \Magento\Store\Setup\Patch\Schema\InitializeStoresAndWebsites::class - ]; - } - - /** - * {@inheritdoc} - */ - public static function getVersion() - { - return '2.0.0'; - } - - /** - * {@inheritdoc} - */ - public function getAliases() - { - return []; - } -} diff --git a/app/code/Magento/SalesSequence/Setup/Recurring.php b/app/code/Magento/SalesSequence/Setup/Recurring.php new file mode 100644 index 0000000000000..2beff94bfab67 --- /dev/null +++ b/app/code/Magento/SalesSequence/Setup/Recurring.php @@ -0,0 +1,39 @@ +sequenceCreator = $sequenceCreator; + } + + /** + * {@inheritdoc} + */ + public function install(SchemaSetupInterface $setup, ModuleContextInterface $context) + { + $this->sequenceCreator->create(); + } +} diff --git a/app/code/Magento/SalesSequence/composer.json b/app/code/Magento/SalesSequence/composer.json index 1a3c00373a775..3865d9569c529 100644 --- a/app/code/Magento/SalesSequence/composer.json +++ b/app/code/Magento/SalesSequence/composer.json @@ -5,12 +5,10 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SalesSequence/etc/db_schema.xml b/app/code/Magento/SalesSequence/etc/db_schema.xml index 1f63c1c93a18a..530a9a33b0cfe 100644 --- a/app/code/Magento/SalesSequence/etc/db_schema.xml +++ b/app/code/Magento/SalesSequence/etc/db_schema.xml @@ -6,8 +6,8 @@ */ --> -
    + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> +
    - +
    diff --git a/app/code/Magento/SalesSequence/etc/module.xml b/app/code/Magento/SalesSequence/etc/module.xml index e200d84a3145e..dc6918830dbfa 100644 --- a/app/code/Magento/SalesSequence/etc/module.xml +++ b/app/code/Magento/SalesSequence/etc/module.xml @@ -6,9 +6,5 @@ */ --> - - - - - + diff --git a/app/code/Magento/SampleData/Console/Command/SampleDataDeployCommand.php b/app/code/Magento/SampleData/Console/Command/SampleDataDeployCommand.php index 88df47283133a..57a61fecae5ca 100644 --- a/app/code/Magento/SampleData/Console/Command/SampleDataDeployCommand.php +++ b/app/code/Magento/SampleData/Console/Command/SampleDataDeployCommand.php @@ -68,7 +68,7 @@ public function __construct( protected function configure() { $this->setName('sampledata:deploy') - ->setDescription('Deploy sample data modules'); + ->setDescription('Deploy sample data modules for composer-based Magento installations'); $this->addOption( self::OPTION_NO_UPDATE, null, @@ -83,6 +83,12 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { + $rootJson = json_decode($this->filesystem->getDirectoryRead(DirectoryList::ROOT)->readFile("composer.json")); + if (!isset($rootJson->version)) { + // @codingStandardsIgnoreLine + $output->writeln('' . 'Git installations must deploy sample data from GitHub; see https://devdocs.magento.com/guides/v2.3/install-gde/install/sample-data-after-clone.html for more information.' . ''); + return; + } $this->updateMemoryLimit(); $this->createAuthFile(); $sampleDataPackages = $this->sampleDataDependency->getSampleDataPackages(); diff --git a/app/code/Magento/SampleData/Model/Dependency.php b/app/code/Magento/SampleData/Model/Dependency.php index a475560dd6089..d08f2d9833e86 100644 --- a/app/code/Magento/SampleData/Model/Dependency.php +++ b/app/code/Magento/SampleData/Model/Dependency.php @@ -81,7 +81,7 @@ public function getSampleDataPackages() $suggests = array_merge($suggests, $this->getSuggestsFromModules()); foreach ($suggests as $name => $version) { if (strpos($version, self::SAMPLE_DATA_SUGGEST) === 0) { - $installExtensions[$name] = substr($version, strlen(self::SAMPLE_DATA_SUGGEST)); + $installExtensions[$name] = trim(substr($version, strlen(self::SAMPLE_DATA_SUGGEST))); } } return $installExtensions; diff --git a/app/code/Magento/SampleData/Setup/Patch/Data/ClearSampleDataState.php b/app/code/Magento/SampleData/Setup/Patch/Data/ClearSampleDataState.php index f0f154b477a7a..2725573b1ab99 100644 --- a/app/code/Magento/SampleData/Setup/Patch/Data/ClearSampleDataState.php +++ b/app/code/Magento/SampleData/Setup/Patch/Data/ClearSampleDataState.php @@ -7,8 +7,8 @@ namespace Magento\SampleData\Setup\Patch\Data; use Magento\Framework\Setup; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ClearSampleDataState diff --git a/app/code/Magento/SampleData/Test/Unit/Console/Command/AbstractSampleDataCommandTest.php b/app/code/Magento/SampleData/Test/Unit/Console/Command/AbstractSampleDataCommandTest.php index 090bb4256f807..a5790d40f782c 100644 --- a/app/code/Magento/SampleData/Test/Unit/Console/Command/AbstractSampleDataCommandTest.php +++ b/app/code/Magento/SampleData/Test/Unit/Console/Command/AbstractSampleDataCommandTest.php @@ -83,6 +83,9 @@ protected function setupMocks( $additionalComposerArgs = [] ) { $this->directoryReadMock->expects($this->any())->method('getAbsolutePath')->willReturn($pathToComposerJson); + $this->directoryReadMock->expects($this->any())->method('readFile')->with('composer.json')->willReturn( + '{"version": "0.0.1"}' + ); $this->filesystemMock->expects($this->any())->method('getDirectoryRead')->with(DirectoryList::ROOT)->willReturn( $this->directoryReadMock ); diff --git a/app/code/Magento/SampleData/Test/Unit/Console/Command/SampleDataDeployCommandTest.php b/app/code/Magento/SampleData/Test/Unit/Console/Command/SampleDataDeployCommandTest.php index 450b2d8798f52..7ca27a7d746c7 100644 --- a/app/code/Magento/SampleData/Test/Unit/Console/Command/SampleDataDeployCommandTest.php +++ b/app/code/Magento/SampleData/Test/Unit/Console/Command/SampleDataDeployCommandTest.php @@ -115,6 +115,15 @@ public function processDataProvider() */ public function testExecuteWithException() { + $this->directoryReadMock->expects($this->once()) + ->method('readFile') + ->with('composer.json') + ->willReturn('{"version": "0.0.1"}'); + $this->filesystemMock->expects($this->once()) + ->method('getDirectoryRead') + ->with(DirectoryList::ROOT) + ->willReturn($this->directoryReadMock); + $this->directoryWriteMock->expects($this->once()) ->method('isExist') ->with(PackagesAuth::PATH_TO_AUTH_FILE) diff --git a/app/code/Magento/SampleData/Test/Unit/Model/DependencyTest.php b/app/code/Magento/SampleData/Test/Unit/Model/DependencyTest.php index adf5a9eb6ebe4..5343b4f867adb 100644 --- a/app/code/Magento/SampleData/Test/Unit/Model/DependencyTest.php +++ b/app/code/Magento/SampleData/Test/Unit/Model/DependencyTest.php @@ -152,8 +152,8 @@ public static function dataPackagesFromComposerSuggest() ]; }, 'suggestions' => [ - 'magento/foo-sample-data' => Dependency::SAMPLE_DATA_SUGGEST . '100.0.0', - 'thirdparty/bar-sample-data' => Dependency::SAMPLE_DATA_SUGGEST . '1.2.3', + 'magento/foo-sample-data' => Dependency::SAMPLE_DATA_SUGGEST . ' 100.0.0', + 'thirdparty/bar-sample-data' => Dependency::SAMPLE_DATA_SUGGEST . ' 1.2.3', 'thirdparty/something-else' => 'Just a suggested package', ], 'expectedPackages' => [ diff --git a/app/code/Magento/SampleData/composer.json b/app/code/Magento/SampleData/composer.json index 0109bab4d9240..17bb4d03dd55e 100644 --- a/app/code/Magento/SampleData/composer.json +++ b/app/code/Magento/SampleData/composer.json @@ -5,14 +5,13 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*" }, "suggest": { - "magento/sample-data-media": "Sample Data version:100.3.*" + "magento/sample-data-media": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Search/Model/SynonymAnalyzer.php b/app/code/Magento/Search/Model/SynonymAnalyzer.php index 6d5daf6115c0d..a63c10da169d7 100644 --- a/app/code/Magento/Search/Model/SynonymAnalyzer.php +++ b/app/code/Magento/Search/Model/SynonymAnalyzer.php @@ -3,10 +3,15 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); namespace Magento\Search\Model; use Magento\Search\Api\SynonymAnalyzerInterface; +/** + * SynonymAnalyzer responsible for search of synonyms matching a word or a phrase. + */ class SynonymAnalyzer implements SynonymAnalyzerInterface { /** @@ -39,58 +44,125 @@ public function __construct(SynonymReader $synReader) * ] * @param string $phrase * @return array + * @throws \Magento\Framework\Exception\LocalizedException */ public function getSynonymsForPhrase($phrase) { - $synGroups = []; + $result = []; - if (empty($phrase)) { - return $synGroups; + if (empty(trim($phrase))) { + return $result; } - $rows = $this->synReaderModel->loadByPhrase($phrase)->getData(); - $synonyms = []; - foreach ($rows as $row) { - $synonyms [] = $row['synonyms']; - } + $synonymGroups = $this->getSynonymGroupsByPhrase($phrase); + + // Replace multiple spaces in a row with the only one space + $phrase = preg_replace("/ {2,}/", " ", $phrase); // Go through every returned record looking for presence of the actual phrase. If there were no matching // records found in DB then create a new entry for it in the returned array $words = explode(' ', $phrase); - foreach ($words as $w) { - $position = $this->findInArray($w, $synonyms); - if ($position !== false) { - $synGroups[] = explode(',', $synonyms[$position]); - } else { - // No synonyms were found. Return the original word in this position - $synGroups[] = [$w]; + + foreach ($words as $offset => $word) { + $synonyms = [$word]; + + if ($synonymGroups) { + $pattern = $this->getSearchPattern(\array_slice($words, $offset)); + $position = $this->findInArray($pattern, $synonymGroups); + if ($position !== null) { + $synonyms = explode(',', $synonymGroups[$position]); + } } + + $result[] = $synonyms; } - return $synGroups; + + return $result; } /** - * Helper method to find the presence of $word in $wordsArray. If found, the particular array index is returned. + * Helper method to find the matching of $pattern to $synonymGroupsToExamine. + * If matches, the particular array index is returned. * Otherwise false will be returned. * - * @param string $word - * @param $array $wordsArray - * @return boolean | int + * @param string $pattern + * @param array $synonymGroupsToExamine + * @return int|null */ - private function findInArray($word, $wordsArray) + private function findInArray(string $pattern, array $synonymGroupsToExamine) { - if (empty($wordsArray)) { - return false; - } $position = 0; - foreach ($wordsArray as $wordsLine) { - $pattern = '/^' . $word . ',|,' . $word . ',|,' . $word . '$/'; - $rv = preg_match($pattern, $wordsLine); - if ($rv != 0) { + foreach ($synonymGroupsToExamine as $synonymGroup) { + $matchingResultCode = preg_match($pattern, $synonymGroup); + if ($matchingResultCode === 1) { return $position; } $position++; } - return false; + return null; + } + + /** + * Returns a regular expression to search for synonyms of the phrase represented as the list of words. + * + * Returned pattern contains expression to search for a part of the phrase from the beginning. + * + * For example, in the phrase "Elizabeth is the English queen" with subset from the very first word, + * the method will build an expression which looking for synonyms for all these patterns: + * - Elizabeth is the English queen + * - Elizabeth is the English + * - Elizabeth is the + * - Elizabeth is + * - Elizabeth + * + * For the same phrase on the second iteration with the first word "is" it will match for these synonyms: + * - is the English queen + * - is the English + * - is the + * - is + * + * The pattern looking for exact match and will not find these phrases as synonyms: + * - Is there anybody in the room? + * - Is the English is most popular language? + * - Is the English queen Elizabeth? + * + * Take into account that returned pattern expects that data will be represented as comma-separated value. + * + * @param array $words + * @return string + */ + private function getSearchPattern(array $words): string + { + $patterns = []; + for ($lastItem = count($words); $lastItem > 0; $lastItem--) { + $phrase = implode("\s+", \array_slice($words, 0, $lastItem)); + $patterns[] = '^' . $phrase . ','; + $patterns[] = ',' . $phrase . ','; + $patterns[] = ',' . $phrase . '$'; + } + + $pattern = '/' . implode('|', $patterns) . '/i'; + return $pattern; + } + + /** + * Get all synonym groups for the phrase + * + * Returns an array of synonyms which are represented as comma-separated value for each item in the list + * + * @param string $phrase + * @return string[] + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getSynonymGroupsByPhrase(string $phrase): array + { + $result = []; + + /** @var array $synonymGroups */ + $synonymGroups = $this->synReaderModel->loadByPhrase($phrase)->getData(); + foreach ($synonymGroups as $row) { + $result[] = $row['synonyms']; + } + return $result; } } diff --git a/app/code/Magento/Search/Model/SynonymReader.php b/app/code/Magento/Search/Model/SynonymReader.php index 202931665f493..078a3eb178cbe 100644 --- a/app/code/Magento/Search/Model/SynonymReader.php +++ b/app/code/Magento/Search/Model/SynonymReader.php @@ -78,6 +78,7 @@ protected function _construct() * * @param string $phrase * @return $this + * @throws \Magento\Framework\Exception\LocalizedException * @since 100.1.0 */ public function loadByPhrase($phrase) diff --git a/app/code/Magento/Search/composer.json b/app/code/Magento/Search/composer.json index 192e2c843a7b8..067d1bf2f7dfd 100644 --- a/app/code/Magento/Search/composer.json +++ b/app/code/Magento/Search/composer.json @@ -5,16 +5,15 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog-search": "100.3.*", - "magento/module-reports": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-ui": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog-search": "*", + "magento/module-reports": "*", + "magento/module-store": "*", + "magento/module-ui": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Search/etc/db_schema.xml b/app/code/Magento/Search/etc/db_schema.xml index 54fac0778d469..9b2dfb493dbb8 100644 --- a/app/code/Magento/Search/etc/db_schema.xml +++ b/app/code/Magento/Search/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Search/view/frontend/web/form-mini.js b/app/code/Magento/Search/view/frontend/web/form-mini.js index e8598f46eb5be..de16305bbbe8d 100644 --- a/app/code/Magento/Search/view/frontend/web/form-mini.js +++ b/app/code/Magento/Search/view/frontend/web/form-mini.js @@ -98,14 +98,16 @@ define([ }, this), 250); }, this)); - this.element.trigger('blur'); + if (this.element.get(0) === document.activeElement) { + this.setActiveState(true); + } this.element.on('focus', this.setActiveState.bind(this, true)); this.element.on('keydown', this._onKeyDown); this.element.on('input propertychange', this._onPropertyChange); - this.searchForm.on('submit', $.proxy(function () { - this._onSubmit(); + this.searchForm.on('submit', $.proxy(function (e) { + this._onSubmit(e); this._updateAriaHasPopup(false); }, this)); }, @@ -204,13 +206,17 @@ define([ switch (keyCode) { case $.ui.keyCode.HOME: - this._getFirstVisibleElement().addClass(this.options.selectClass); - this.responseList.selected = this._getFirstVisibleElement(); + if (this._getFirstVisibleElement()) { + this._getFirstVisibleElement().addClass(this.options.selectClass); + this.responseList.selected = this._getFirstVisibleElement(); + } break; case $.ui.keyCode.END: - this._getLastElement().addClass(this.options.selectClass); - this.responseList.selected = this._getLastElement(); + if (this._getLastElement()) { + this._getLastElement().addClass(this.options.selectClass); + this.responseList.selected = this._getLastElement(); + } break; case $.ui.keyCode.ESCAPE: diff --git a/app/code/Magento/Security/Model/AdminSessionInfo.php b/app/code/Magento/Security/Model/AdminSessionInfo.php index 1aeb1b671c5cf..77d864965baca 100644 --- a/app/code/Magento/Security/Model/AdminSessionInfo.php +++ b/app/code/Magento/Security/Model/AdminSessionInfo.php @@ -164,7 +164,7 @@ public function isOtherSessionsTerminated() * Setter for isOtherSessionsTerminated * * @param bool $isOtherSessionsTerminated - * @return this + * @return $this * @since 100.1.0 */ public function setIsOtherSessionsTerminated($isOtherSessionsTerminated) diff --git a/app/code/Magento/Security/Test/Unit/Model/SecurityManagerTest.php b/app/code/Magento/Security/Test/Unit/Model/SecurityManagerTest.php index 8d98d8145f17e..c5326da45005e 100644 --- a/app/code/Magento/Security/Test/Unit/Model/SecurityManagerTest.php +++ b/app/code/Magento/Security/Test/Unit/Model/SecurityManagerTest.php @@ -126,9 +126,9 @@ public function testConstructorException() { $securityChecker = $this->createMock(\Magento\Framework\Message\ManagerInterface::class); - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - __('Incorrect Security Checker class. It has to implement SecurityCheckerInterface') + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage( + (string)__('Incorrect Security Checker class. It has to implement SecurityCheckerInterface') ); $this->model->__construct( diff --git a/app/code/Magento/Security/composer.json b/app/code/Magento/Security/composer.json index e9b646cc5ca76..405b5b518097d 100644 --- a/app/code/Magento/Security/composer.json +++ b/app/code/Magento/Security/composer.json @@ -5,17 +5,16 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-user": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-store": "*", + "magento/module-user": "*" }, "suggest": { - "magento/module-customer": "100.3.*" + "magento/module-customer": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Security/etc/adminhtml/di.xml b/app/code/Magento/Security/etc/adminhtml/di.xml index c1188c2d405cf..79477e9443097 100644 --- a/app/code/Magento/Security/etc/adminhtml/di.xml +++ b/app/code/Magento/Security/etc/adminhtml/di.xml @@ -24,7 +24,7 @@ Magento\Security\Model\SecurityChecker\Frequency - Magento\Security\Model\SecurityChecker\Quantity + Magento\Security\Model\SecurityChecker\Quantity diff --git a/app/code/Magento/Security/etc/db_schema.xml b/app/code/Magento/Security/etc/db_schema.xml index 017cdd551dc8a..f14e6de79e894 100644 --- a/app/code/Magento/Security/etc/db_schema.xml +++ b/app/code/Magento/Security/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/SendFriend/composer.json b/app/code/Magento/SendFriend/composer.json index ab01ba4360e8d..30aecf13c3588 100644 --- a/app/code/Magento/SendFriend/composer.json +++ b/app/code/Magento/SendFriend/composer.json @@ -5,14 +5,13 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-customer": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-catalog": "*", + "magento/module-customer": "*", + "magento/module-store": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SendFriend/etc/db_schema.xml b/app/code/Magento/SendFriend/etc/db_schema.xml index f3c0fb8d06e8f..7537f67cf552a 100644 --- a/app/code/Magento/SendFriend/etc/db_schema.xml +++ b/app/code/Magento/SendFriend/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Shipping/Controller/Adminhtml/Order/Shipment/NewAction.php b/app/code/Magento/Shipping/Controller/Adminhtml/Order/Shipment/NewAction.php index 500fe0dd3b289..be0555fbcda40 100644 --- a/app/code/Magento/Shipping/Controller/Adminhtml/Order/Shipment/NewAction.php +++ b/app/code/Magento/Shipping/Controller/Adminhtml/Order/Shipment/NewAction.php @@ -7,6 +7,7 @@ namespace Magento\Shipping\Controller\Adminhtml\Order\Shipment; use Magento\Backend\App\Action; +use Magento\Framework\App\ObjectManager; class NewAction extends \Magento\Backend\App\Action { @@ -22,15 +23,24 @@ class NewAction extends \Magento\Backend\App\Action */ protected $shipmentLoader; + /** + * @var \Magento\Shipping\Model\ShipmentProviderInterface + */ + private $shipmentProvider; + /** * @param Action\Context $context * @param \Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader $shipmentLoader + * @param \Magento\Shipping\Model\ShipmentProviderInterface $shipmentProvider */ public function __construct( Action\Context $context, - \Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader $shipmentLoader + \Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader $shipmentLoader, + \Magento\Shipping\Model\ShipmentProviderInterface $shipmentProvider = null ) { $this->shipmentLoader = $shipmentLoader; + $this->shipmentProvider = $shipmentProvider ?: ObjectManager::getInstance() + ->get(\Magento\Shipping\Model\ShipmentProviderInterface::class); parent::__construct($context); } @@ -43,7 +53,7 @@ public function execute() { $this->shipmentLoader->setOrderId($this->getRequest()->getParam('order_id')); $this->shipmentLoader->setShipmentId($this->getRequest()->getParam('shipment_id')); - $this->shipmentLoader->setShipment($this->getRequest()->getParam('shipment')); + $this->shipmentLoader->setShipment($this->shipmentProvider->getShipmentData()); $this->shipmentLoader->setTracking($this->getRequest()->getParam('tracking')); $shipment = $this->shipmentLoader->load(); if ($shipment) { diff --git a/app/code/Magento/Shipping/Controller/Adminhtml/Order/ShipmentLoader.php b/app/code/Magento/Shipping/Controller/Adminhtml/Order/ShipmentLoader.php index 0c6be6fd57756..c4094a63ec527 100644 --- a/app/code/Magento/Shipping/Controller/Adminhtml/Order/ShipmentLoader.php +++ b/app/code/Magento/Shipping/Controller/Adminhtml/Order/ShipmentLoader.php @@ -6,6 +6,16 @@ namespace Magento\Shipping\Controller\Adminhtml\Order; use Magento\Framework\DataObject; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Message\ManagerInterface; +use Magento\Framework\Registry; +use Magento\Sales\Api\Data\ShipmentTrackCreationInterface; +use Magento\Sales\Api\Data\ShipmentTrackCreationInterfaceFactory; +use Magento\Sales\Api\Data\ShipmentItemCreationInterfaceFactory; +use Magento\Sales\Api\ShipmentRepositoryInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order\ShipmentDocumentFactory; +use Magento\Sales\Api\Data\ShipmentItemCreationInterface; /** * Class ShipmentLoader @@ -17,80 +27,77 @@ * @method ShipmentLoader setTracking($tracking) * @method int getOrderId() * @method int getShipmentId() - * @method array getShipment() * @method array getTracking() */ class ShipmentLoader extends DataObject { + const SHIPMENT = 'shipment'; + /** - * @var \Magento\Framework\Message\ManagerInterface + * @var ManagerInterface */ - protected $messageManager; + private $messageManager; /** - * @var \Magento\Framework\Registry + * @var Registry */ - protected $registry; + private $registry; /** - * @var \Magento\Sales\Api\ShipmentRepositoryInterface + * @var ShipmentRepositoryInterface */ - protected $shipmentRepository; + private $shipmentRepository; /** - * @var \Magento\Sales\Model\Order\ShipmentFactory + * @var OrderRepositoryInterface */ - protected $shipmentFactory; + private $orderRepository; /** - * @var \Magento\Sales\Model\Order\Shipment\TrackFactory + * @var ShipmentDocumentFactory */ - protected $trackFactory; + private $documentFactory; /** - * @var \Magento\Sales\Api\OrderRepositoryInterface + * @var ShipmentTrackCreationInterfaceFactory */ - protected $orderRepository; + private $trackFactory; /** - * @param \Magento\Framework\Message\ManagerInterface $messageManager - * @param \Magento\Framework\Registry $registry - * @param \Magento\Sales\Api\ShipmentRepositoryInterface $shipmentRepository - * @param \Magento\Sales\Model\Order\ShipmentFactory $shipmentFactory - * @param \Magento\Sales\Model\Order\Shipment\TrackFactory $trackFactory - * @param \Magento\Sales\Api\OrderRepositoryInterface $orderRepository + * @var ShipmentItemCreationInterfaceFactory + */ + private $itemFactory; + + /** + * @param ManagerInterface $messageManager + * @param Registry $registry + * @param ShipmentRepositoryInterface $shipmentRepository + * @param OrderRepositoryInterface $orderRepository + * @param ShipmentDocumentFactory $documentFactory + * @param ShipmentTrackCreationInterfaceFactory $trackFactory + * @param ShipmentItemCreationInterfaceFactory $itemFactory * @param array $data */ public function __construct( - \Magento\Framework\Message\ManagerInterface $messageManager, - \Magento\Framework\Registry $registry, - \Magento\Sales\Api\ShipmentRepositoryInterface $shipmentRepository, - \Magento\Sales\Model\Order\ShipmentFactory $shipmentFactory, - \Magento\Sales\Model\Order\Shipment\TrackFactory $trackFactory, - \Magento\Sales\Api\OrderRepositoryInterface $orderRepository, + ManagerInterface $messageManager, + Registry $registry, + ShipmentRepositoryInterface $shipmentRepository, + OrderRepositoryInterface $orderRepository, + ShipmentDocumentFactory $documentFactory, + ShipmentTrackCreationInterfaceFactory $trackFactory, + ShipmentItemCreationInterfaceFactory $itemFactory, array $data = [] ) { $this->messageManager = $messageManager; $this->registry = $registry; $this->shipmentRepository = $shipmentRepository; - $this->shipmentFactory = $shipmentFactory; - $this->trackFactory = $trackFactory; $this->orderRepository = $orderRepository; + $this->documentFactory = $documentFactory; + $this->trackFactory = $trackFactory; + $this->itemFactory = $itemFactory; parent::__construct($data); } - /** - * Initialize shipment items QTY - * - * @return array - */ - protected function getItemQtys() - { - $data = $this->getShipment(); - - return isset($data['items']) ? $data['items'] : []; - } - /** * Initialize shipment model instance * @@ -129,14 +136,73 @@ public function load() return false; } - $shipment = $this->shipmentFactory->create( + $shipmentItems = $this->getShipmentItems($this->getShipment()); + + $shipment = $this->documentFactory->create( $order, - $this->getItemQtys(), - $this->getTracking() + $shipmentItems, + $this->getTrackingArray() ); } $this->registry->register('current_shipment', $shipment); return $shipment; } + + /** + * Convert UI-generated tracking array to Data Object array + * + * @return ShipmentTrackCreationInterface[] + * @throws LocalizedException + */ + private function getTrackingArray() + { + $tracks = $this->getTracking() ?: []; + $trackingCreation = []; + foreach ($tracks as $track) { + if (!isset($track['number']) || !isset($track['title']) || !isset($track['carrier_code'])) { + throw new LocalizedException( + __('Tracking information must contain title, carrier code, and tracking number') + ); + } + /** @var ShipmentTrackCreationInterface $trackCreation */ + $trackCreation = $this->trackFactory->create(); + $trackCreation->setTrackNumber($track['number']); + $trackCreation->setTitle($track['title']); + $trackCreation->setCarrierCode($track['carrier_code']); + $trackingCreation[] = $trackCreation; + } + + return $trackingCreation; + } + + /** + * Extract product id => product quantity array from shipment data. + * + * @param array $shipmentData + * @return int[] + */ + private function getShipmentItems(array $shipmentData) + { + $shipmentItems = []; + $itemQty = isset($shipmentData['items']) ? $shipmentData['items'] : []; + foreach ($itemQty as $itemId => $quantity) { + /** @var ShipmentItemCreationInterface $item */ + $item = $this->itemFactory->create(); + $item->setOrderItemId($itemId); + $item->setQty($quantity); + $shipmentItems[] = $item; + } + return $shipmentItems; + } + + /** + * Retrieve shipment + * + * @return array + */ + public function getShipment() + { + return $this->getData(self::SHIPMENT) ?: []; + } } diff --git a/app/code/Magento/Shipping/Model/Info.php b/app/code/Magento/Shipping/Model/Info.php index 31d39fb80410b..ed4c1c3f6d127 100644 --- a/app/code/Magento/Shipping/Model/Info.php +++ b/app/code/Magento/Shipping/Model/Info.php @@ -114,7 +114,7 @@ protected function _initOrder() /** @var \Magento\Sales\Model\Order $order */ $order = $this->_orderFactory->create()->load($this->getOrderId()); - if (!$order->getId() || $this->getProtectCode() != $order->getProtectCode()) { + if (!$order->getId() || $this->getProtectCode() !== $order->getProtectCode()) { return false; } @@ -130,7 +130,7 @@ protected function _initShipment() { /* @var $model Shipment */ $ship = $this->shipmentRepository->get($this->getShipId()); - if (!$ship->getEntityId() || $this->getProtectCode() != $ship->getProtectCode()) { + if (!$ship->getEntityId() || $this->getProtectCode() !== $ship->getProtectCode()) { return false; } @@ -195,7 +195,7 @@ public function getTrackingInfoByTrackId() { /** @var \Magento\Shipping\Model\Order\Track $track */ $track = $this->_trackFactory->create()->load($this->getTrackId()); - if ($track->getId() && $this->getProtectCode() == $track->getProtectCode()) { + if ($track->getId() && $this->getProtectCode() === $track->getProtectCode()) { $this->_trackingInfo = [[$track->getNumberDetail()]]; } return $this->_trackingInfo; diff --git a/app/code/Magento/Shipping/Model/ShipmentProvider.php b/app/code/Magento/Shipping/Model/ShipmentProvider.php new file mode 100644 index 0000000000000..917203af72460 --- /dev/null +++ b/app/code/Magento/Shipping/Model/ShipmentProvider.php @@ -0,0 +1,37 @@ +request = $request; + } + + /** + * @inheritdoc + */ + public function getShipmentData(): array + { + return $this->request->getParam('shipment', []); + } +} diff --git a/app/code/Magento/Shipping/Model/ShipmentProviderInterface.php b/app/code/Magento/Shipping/Model/ShipmentProviderInterface.php new file mode 100644 index 0000000000000..4ff9ba0008340 --- /dev/null +++ b/app/code/Magento/Shipping/Model/ShipmentProviderInterface.php @@ -0,0 +1,23 @@ +shipmentProviderMock = $this->getMockBuilder(\Magento\Shipping\Model\ShipmentProviderInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getShipmentData']) + ->getMockForAbstractClass(); $this->actionFlag = $this->createPartialMock(\Magento\Framework\App\ActionFlag::class, ['get']); $this->helper = $this->createPartialMock(\Magento\Backend\Helper\Data::class, ['getUrl']); $this->view = $this->createMock(\Magento\Framework\App\ViewInterface::class); @@ -166,7 +175,7 @@ protected function setUp() \Magento\Shipping\Controller\Adminhtml\Order\Shipment\NewAction::class, [ 'context' => $this->context, 'shipmentLoader' => $this->shipmentLoader, 'request' => $this->request, - 'response' => $this->response, 'view' => $this->view + 'response' => $this->response, 'view' => $this->view, 'shipmentProvider' => $this->shipmentProviderMock ] ); } @@ -188,7 +197,6 @@ public function testExecute() [ ['order_id', null, $orderId], ['shipment_id', null, $shipmentId], - ['shipment', null, $shipmentData], ['tracking', null, $tracking], ] ) @@ -261,6 +269,9 @@ public function testExecute() ->method('getBlock') ->with('menu') ->will($this->returnValue($menuBlock)); + $this->shipmentProviderMock->expects($this->once()) + ->method('getShipmentData') + ->willReturn($shipmentData); $this->assertNull($this->newAction->execute()); } diff --git a/app/code/Magento/Shipping/Test/Unit/Controller/Adminhtml/Order/ShipmentLoaderTest.php b/app/code/Magento/Shipping/Test/Unit/Controller/Adminhtml/Order/ShipmentLoaderTest.php index 39c960e933d45..83dd9595cc43b 100644 --- a/app/code/Magento/Shipping/Test/Unit/Controller/Adminhtml/Order/ShipmentLoaderTest.php +++ b/app/code/Magento/Shipping/Test/Unit/Controller/Adminhtml/Order/ShipmentLoaderTest.php @@ -5,55 +5,67 @@ */ namespace Magento\Shipping\Test\Unit\Controller\Adminhtml\Order; +use Magento\Sales\Api\Data\ShipmentItemCreationInterface; +use Magento\Sales\Api\Data\ShipmentTrackCreationInterface; +use Magento\Sales\Api\Data\ShipmentTrackCreationInterfaceFactory; +use Magento\Sales\Api\Data\ShipmentItemCreationInterfaceFactory; +use Magento\Sales\Model\Order\ShipmentDocumentFactory; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader; + /** - * Class ShipmentLoaderTest - * - * @package Magento\Shipping\Controller\Adminhtml\Order + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class ShipmentLoaderTest extends \PHPUnit\Framework\TestCase { /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ObjectManager */ - protected $objectManagerMock; + private $objectManagerMock; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $registryMock; + private $registryMock; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $messageManagerMock; + private $messageManagerMock; /** * @var \Magento\Sales\Model\Order\ShipmentRepository|\PHPUnit_Framework_MockObject_MockObject */ - protected $shipmentRepositoryMock; + private $shipmentRepositoryMock; /** - * @var \Magento\Sales\Model\Order\ShipmentFactory|\PHPUnit_Framework_MockObject_MockObject + * @var ShipmentDocumentFactory|\PHPUnit_Framework_MockObject_MockObject */ - protected $shipmentFactory; + private $documentFactoryMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ShipmentTrackCreationInterfaceFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $trackFactoryMock; + + /** + * @var ShipmentItemCreationInterfaceFactory|\PHPUnit_Framework_MockObject_MockObject */ - protected $trackFactoryMock; + private $itemFactoryMock; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $orderRepository; + private $orderRepositoryMock; /** * @var \Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader */ - protected $loader; + private $loader; protected function setUp() { + $this->objectManagerMock = new ObjectManager($this); $this->shipmentRepositoryMock = $this->getMockBuilder(\Magento\Sales\Model\Order\ShipmentRepository::class) ->disableOriginalConstructor() ->setMethods(['get']) @@ -62,19 +74,23 @@ protected function setUp() ->disableOriginalConstructor() ->setMethods([]) ->getMock(); - $this->shipmentFactory = $this->getMockBuilder(\Magento\Sales\Model\Order\ShipmentFactory::class) + $this->trackFactoryMock = $this->getMockBuilder(ShipmentTrackCreationInterfaceFactory::class) ->disableOriginalConstructor() ->setMethods(['create']) ->getMock(); - $this->trackFactoryMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Shipment\TrackFactory::class) + $this->messageManagerMock = $this->getMockBuilder(\Magento\Framework\Message\Manager::class) ->disableOriginalConstructor() - ->setMethods(['create']) + ->setMethods([]) ->getMock(); - $this->messageManagerMock = $this->getMockBuilder(\Magento\Framework\Message\Manager::class) + $this->orderRepositoryMock = $this->getMockBuilder(\Magento\Sales\Api\OrderRepositoryInterface::class) ->disableOriginalConstructor() ->setMethods([]) ->getMock(); - $this->orderRepository = $this->getMockBuilder(\Magento\Sales\Api\OrderRepositoryInterface::class) + $this->itemFactoryMock = $this->getMockBuilder(ShipmentItemCreationInterfaceFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->documentFactoryMock = $this->getMockBuilder(ShipmentDocumentFactory::class) ->disableOriginalConstructor() ->setMethods([]) ->getMock(); @@ -84,19 +100,23 @@ protected function setUp() 'shipment_id' => 1000065, 'shipment' => ['items' => [1 => 1, 2 => 2]], 'tracking' => [ - ['number' => 'jds0395'], - ['number' => 'lsk984g'], + ['number' => 'jds0395', 'title' => 'DHL', 'carrier_code' => 'dhl'], + ['number' => 'lsk984g', 'title' => 'UPS', 'carrier_code' => 'ups'], ], ]; - $this->loader = new \Magento\Shipping\Controller\Adminhtml\Order\ShipmentLoader( - $this->messageManagerMock, - $this->registryMock, - $this->shipmentRepositoryMock, - $this->shipmentFactory, - $this->trackFactoryMock, - $this->orderRepository, - $data + $this->loader = $this->objectManagerMock->getObject( + ShipmentLoader::class, + [ + 'messageManager' => $this->messageManagerMock, + 'registry' => $this->registryMock, + 'shipmentRepository' => $this->shipmentRepositoryMock, + 'orderRepository' => $this->orderRepositoryMock, + 'documentFactory' => $this->documentFactoryMock, + 'trackFactory' => $this->trackFactoryMock, + 'itemFactory' => $this->itemFactoryMock, + 'data' => $data + ] ); } @@ -123,7 +143,7 @@ public function testLoadOrderId() ->disableOriginalConstructor() ->setMethods(['getForcedShipmentWithInvoice', 'getId', 'canShip']) ->getMock(); - $this->orderRepository->expects($this->once()) + $this->orderRepositoryMock->expects($this->once()) ->method('get') ->will($this->returnValue($orderMock)); $orderMock->expects($this->once()) @@ -139,27 +159,13 @@ public function testLoadOrderId() ->disableOriginalConstructor() ->setMethods([]) ->getMock(); - $this->shipmentFactory->expects($this->once()) - ->method('create') - ->with($orderMock, $this->loader->getShipment()['items']) - ->willReturn($shipmentModelMock); - $trackMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Shipment\Track::class) + $trackMock = $this->getMockBuilder(ShipmentTrackCreationInterface::class) ->disableOriginalConstructor() - ->setMethods([]) - ->getMock(); + ->setMethods(['setCarrierCode', 'setTrackNumber', 'setTitle']) + ->getMockForAbstractClass(); $this->trackFactoryMock->expects($this->any()) ->method('create') ->will($this->returnValue($trackMock)); - $trackMock->expects($this->any()) - ->method('addData') - ->will( - $this->returnValueMap( - [ - [$this->loader->getTracking()[0], $trackMock], - [$this->loader->getTracking()[1], $trackMock], - ] - ) - ); $shipmentModelMock->expects($this->any()) ->method('addTrack') ->with($this->equalTo($trackMock)) @@ -167,6 +173,13 @@ public function testLoadOrderId() $this->registryMock->expects($this->once()) ->method('register') ->with('current_shipment', $shipmentModelMock); + $itemMock = $this->getMockBuilder(ShipmentItemCreationInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->itemFactoryMock->expects($this->any()) + ->method('create') + ->will($this->returnValue($itemMock)); + $this->documentFactoryMock->expects($this->once())->method('create')->willReturn($shipmentModelMock); $this->assertEquals($shipmentModelMock, $this->loader->load()); } diff --git a/app/code/Magento/Shipping/Test/Unit/Model/InfoTest.php b/app/code/Magento/Shipping/Test/Unit/Model/InfoTest.php new file mode 100644 index 0000000000000..6bc95993bfde6 --- /dev/null +++ b/app/code/Magento/Shipping/Test/Unit/Model/InfoTest.php @@ -0,0 +1,312 @@ +helper = $this->getMockBuilder(\Magento\Shipping\Helper\Data::class) + ->disableOriginalConstructor() + ->getMock(); + $this->orderFactory = $this->getMockBuilder(\Magento\Sales\Model\OrderFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->shipmentRepository = $this->getMockBuilder(\Magento\Sales\Api\ShipmentRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->trackFactory = $this->getMockBuilder(\Magento\Shipping\Model\Order\TrackFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->trackCollectionFactory = $this->getMockBuilder(CollectionFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + + $objectManagerHelper = new ObjectManager($this); + $this->info = $objectManagerHelper->getObject( + Info::class, + [ + 'shippingData' => $this->helper, + 'orderFactory' => $this->orderFactory, + 'shipmentRepository' => $this->shipmentRepository, + 'trackFactory' => $this->trackFactory, + 'trackCollectionFactory' => $this->trackCollectionFactory, + ] + ); + } + + public function testLoadByHashWithOrderId() + { + $hash = strtr(base64_encode('order_id:1:protected_code'), '+/=', '-_,'); + $decodedHash = [ + 'key' => 'order_id', + 'id' => 1, + 'hash' => 'protected_code', + ]; + $shipmentId = 1; + $shipmentIncrementId = 3; + $trackDetails = 'track_details'; + + $this->helper->expects($this->atLeastOnce()) + ->method('decodeTrackingHash') + ->with($hash) + ->willReturn($decodedHash); + $shipmentCollection = $this->getMockBuilder(\Magento\Sales\Model\ResourceModel\Order\Shipment\Collection::class) + ->disableOriginalConstructor() + ->setMethods(['getIterator']) + ->getMock(); + + $order = $this->getMockBuilder(\Magento\Sales\Model\Order::class) + ->disableOriginalConstructor() + ->setMethods(['load', 'getId', 'getProtectCode', 'getShipmentsCollection']) + ->getMock(); + $order->expects($this->atLeastOnce())->method('load')->with($decodedHash['id'])->willReturnSelf(); + $order->expects($this->atLeastOnce())->method('getId')->willReturn($decodedHash['id']); + $order->expects($this->atLeastOnce())->method('getProtectCode')->willReturn($decodedHash['hash']); + $order->expects($this->atLeastOnce())->method('getShipmentsCollection')->willReturn($shipmentCollection); + $this->orderFactory->expects($this->atLeastOnce())->method('create')->willReturn($order); + + $shipment = $this->getMockBuilder(\Magento\Sales\Model\Order\Shipment::class) + ->disableOriginalConstructor() + ->setMethods(['getIncrementId', 'getId']) + ->getMock(); + $shipment->expects($this->atLeastOnce())->method('getIncrementId')->willReturn($shipmentIncrementId); + $shipment->expects($this->atLeastOnce())->method('getId')->willReturn($shipmentId); + $shipmentCollection->expects($this->any())->method('getIterator')->willReturn(new \ArrayIterator([$shipment])); + + $track = $this->getMockBuilder(\Magento\Sales\Model\Order\Shipment\Track::class) + ->disableOriginalConstructor() + ->setMethods(['setShipment', 'getNumberDetail']) + ->getMock(); + $track->expects($this->atLeastOnce())->method('setShipment')->with($shipment)->willReturnSelf(); + $track->expects($this->atLeastOnce())->method('getNumberDetail')->willReturn($trackDetails); + $trackCollection = $this->getMockBuilder(\Magento\Shipping\Model\ResourceModel\Order\Track\Collection::class) + ->disableOriginalConstructor() + ->setMethods(['getIterator', 'setShipmentFilter']) + ->getMock(); + $trackCollection->expects($this->atLeastOnce()) + ->method('setShipmentFilter') + ->with($shipmentId) + ->willReturnSelf(); + $trackCollection->expects($this->atLeastOnce()) + ->method('getIterator') + ->willReturn(new \ArrayIterator([$track])); + + $this->trackCollectionFactory->expects($this->atLeastOnce())->method('create')->willReturn($trackCollection); + $this->info->loadByHash($hash); + + $this->assertEquals([$shipmentIncrementId => [$trackDetails]], $this->info->getTrackingInfo()); + } + + public function testLoadByHashWithOrderIdWrongCode() + { + $hash = strtr(base64_encode('order_id:1:0'), '+/=', '-_,'); + $decodedHash = [ + 'key' => 'order_id', + 'id' => 1, + 'hash' => '0', + ]; + $this->helper->expects($this->atLeastOnce()) + ->method('decodeTrackingHash') + ->with($hash) + ->willReturn($decodedHash); + $order = $this->getMockBuilder(\Magento\Sales\Model\Order::class) + ->disableOriginalConstructor() + ->setMethods(['load', 'getId', 'getProtectCode']) + ->getMock(); + $order->expects($this->atLeastOnce())->method('load')->with($decodedHash['id'])->willReturnSelf(); + $order->expects($this->atLeastOnce())->method('getId')->willReturn($decodedHash['id']); + $order->expects($this->atLeastOnce())->method('getProtectCode')->willReturn('0e123123123'); + $this->orderFactory->expects($this->atLeastOnce())->method('create')->willReturn($order); + $this->info->loadByHash($hash); + + $this->assertEmpty($this->info->getTrackingInfo()); + } + + public function testLoadByHashWithShipmentId() + { + $hash = strtr(base64_encode('ship_id:1:protected_code'), '+/=', '-_,'); + $decodedHash = [ + 'key' => 'ship_id', + 'id' => 1, + 'hash' => 'protected_code', + ]; + $shipmentIncrementId = 3; + $trackDetails = 'track_details'; + + $this->helper->expects($this->atLeastOnce()) + ->method('decodeTrackingHash') + ->with($hash) + ->willReturn($decodedHash); + $shipment = $this->getMockBuilder(\Magento\Sales\Model\Order\Shipment::class) + ->disableOriginalConstructor() + ->setMethods(['getEntityId', 'getProtectCode', 'getIncrementId', 'getId']) + ->getMock(); + $shipment->expects($this->atLeastOnce())->method('getIncrementId')->willReturn($shipmentIncrementId); + $shipment->expects($this->atLeastOnce())->method('getId')->willReturn($decodedHash['id']); + $shipment->expects($this->atLeastOnce())->method('getEntityId')->willReturn(3); + $shipment->expects($this->atLeastOnce())->method('getProtectCode')->willReturn($decodedHash['hash']); + $this->shipmentRepository->expects($this->atLeastOnce()) + ->method('get') + ->with($decodedHash['id']) + ->willReturn($shipment); + $track = $this->getMockBuilder(\Magento\Sales\Model\Order\Shipment\Track::class) + ->disableOriginalConstructor() + ->setMethods(['setShipment', 'getNumberDetail']) + ->getMock(); + $track->expects($this->atLeastOnce())->method('setShipment')->with($shipment)->willReturnSelf(); + $track->expects($this->atLeastOnce())->method('getNumberDetail')->willReturn($trackDetails); + $trackCollection = $this->getMockBuilder(\Magento\Shipping\Model\ResourceModel\Order\Track\Collection::class) + ->disableOriginalConstructor() + ->setMethods(['getIterator', 'setShipmentFilter']) + ->getMock(); + $trackCollection->expects($this->atLeastOnce()) + ->method('setShipmentFilter') + ->with($decodedHash['id']) + ->willReturnSelf(); + $trackCollection->expects($this->atLeastOnce()) + ->method('getIterator') + ->willReturn(new \ArrayIterator([$track])); + $this->trackCollectionFactory->expects($this->atLeastOnce())->method('create')->willReturn($trackCollection); + + $this->info->loadByHash($hash); + + $this->assertEquals([$shipmentIncrementId => [$trackDetails]], $this->info->getTrackingInfo()); + } + + public function testLoadByHashWithShipmentIdWrongCode() + { + $hash = strtr(base64_encode('ship_id:1:0'), '+/=', '-_,'); + $decodedHash = [ + 'key' => 'ship_id', + 'id' => 1, + 'hash' => '0', + ]; + $this->helper->expects($this->atLeastOnce()) + ->method('decodeTrackingHash') + ->with($hash) + ->willReturn($decodedHash); + $shipment = $this->getMockBuilder(\Magento\Sales\Model\Order\Shipment::class) + ->disableOriginalConstructor() + ->setMethods(['getEntityId', 'getProtectCode']) + ->getMock(); + $shipment->expects($this->atLeastOnce())->method('getEntityId')->willReturn(3); + $shipment->expects($this->atLeastOnce())->method('getProtectCode')->willReturn('0e123123123'); + $this->shipmentRepository->expects($this->atLeastOnce()) + ->method('get') + ->with($decodedHash['id']) + ->willReturn($shipment); + + $this->info->loadByHash($hash); + + $this->assertEmpty($this->info->getTrackingInfo()); + } + + /** + * @dataProvider loadByHashWithTrackIdDataProvider + * @param string $protectCodeHash + * @param string $protectCode + * @param string $numberDetail + * @param array $trackDetails + * @return void + */ + public function testLoadByHashWithTrackId( + string $protectCodeHash, + string $protectCode, + string $numberDetail, + array $trackDetails + ) { + $hash = base64_encode('hash'); + $decodedHash = [ + 'key' => 'track_id', + 'id' => 1, + 'hash' => $protectCodeHash, + ]; + $this->helper->expects($this->atLeastOnce()) + ->method('decodeTrackingHash') + ->with($hash) + ->willReturn($decodedHash); + $track = $this->getMockBuilder(\Magento\Sales\Model\Order\Shipment\Track::class) + ->disableOriginalConstructor() + ->setMethods(['load', 'getId', 'getProtectCode', 'getNumberDetail']) + ->getMock(); + $track->expects($this->atLeastOnce())->method('load')->with($decodedHash['id'])->willReturnSelf(); + $track->expects($this->atLeastOnce())->method('getId')->willReturn($decodedHash['id']); + $track->expects($this->atLeastOnce())->method('getProtectCode')->willReturn($protectCode); + $track->expects($this->any())->method('getNumberDetail')->willReturn($numberDetail); + + $this->trackFactory->expects($this->atLeastOnce())->method('create')->willReturn($track); + $this->info->loadByHash($hash); + + $this->assertEquals($trackDetails, $this->info->getTrackingInfo()); + } + + /** + * @return array + */ + public function loadByHashWithTrackIdDataProvider() + { + return [ + [ + 'hash' => 'protected_code', + 'protect_code' => 'protected_code', + 'number_detail' => 'track_details', + 'track_details' => [['track_details']], + ], + [ + 'hash' => '0', + 'protect_code' => '0e6640', + 'number_detail' => '', + 'track_details' => [], + ], + ]; + } +} diff --git a/app/code/Magento/Shipping/composer.json b/app/code/Magento/Shipping/composer.json index 7d33f54eee037..b29a2fd537e96 100644 --- a/app/code/Magento/Shipping/composer.json +++ b/app/code/Magento/Shipping/composer.json @@ -5,30 +5,29 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", + "php": "~7.1.3||~7.2.0", "ext-gd": "*", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-catalog-inventory": "100.3.*", - "magento/module-contact": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-payment": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-user": "100.3.*" + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-catalog-inventory": "*", + "magento/module-contact": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-payment": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-store": "*", + "magento/module-tax": "*", + "magento/module-ui": "*", + "magento/module-user": "*" }, "suggest": { - "magento/module-fedex": "100.3.*", - "magento/module-ups": "100.3.*", - "magento/module-config": "100.3.*" + "magento/module-fedex": "*", + "magento/module-ups": "*", + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Shipping/etc/adminhtml/di.xml b/app/code/Magento/Shipping/etc/adminhtml/di.xml index 36bd1ae9d3505..33a41318dd08b 100644 --- a/app/code/Magento/Shipping/etc/adminhtml/di.xml +++ b/app/code/Magento/Shipping/etc/adminhtml/di.xml @@ -7,6 +7,7 @@ --> + diff --git a/app/code/Magento/Shipping/etc/di.xml b/app/code/Magento/Shipping/etc/di.xml index 5834678157058..51c77c2cecd2d 100644 --- a/app/code/Magento/Shipping/etc/di.xml +++ b/app/code/Magento/Shipping/etc/di.xml @@ -9,4 +9,22 @@ + + + + /var/log/shipping.log + + + + + + Magento\Shipping\Model\Carrier\VirtualDebug + + + + + + Magento\Shipping\Model\Method\VirtualLogger + + diff --git a/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_new.xml b/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_new.xml index 2894bf2596783..0853aead3b8bb 100644 --- a/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_new.xml +++ b/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_new.xml @@ -15,6 +15,7 @@ + diff --git a/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_view.xml b/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_view.xml index 677102cc77d1d..e0ef730a6942b 100644 --- a/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_view.xml +++ b/app/code/Magento/Shipping/view/adminhtml/layout/adminhtml_order_shipment_view.xml @@ -15,6 +15,7 @@ + diff --git a/app/code/Magento/Shipping/view/adminhtml/templates/create/form.phtml b/app/code/Magento/Shipping/view/adminhtml/templates/create/form.phtml index 0b40bf40e97a3..be4069ccdc186 100644 --- a/app/code/Magento/Shipping/view/adminhtml/templates/create/form.phtml +++ b/app/code/Magento/Shipping/view/adminhtml/templates/create/form.phtml @@ -52,6 +52,7 @@ +
    getChildHtml('extra_shipment_info') ?>
    getItemsHtml() ?>
    diff --git a/app/code/Magento/Shipping/view/adminhtml/templates/create/items.phtml b/app/code/Magento/Shipping/view/adminhtml/templates/create/items.phtml index 89cc760bb8290..35e36aa5584c9 100644 --- a/app/code/Magento/Shipping/view/adminhtml/templates/create/items.phtml +++ b/app/code/Magento/Shipping/view/adminhtml/templates/create/items.phtml @@ -27,7 +27,7 @@ getShipment()->getAllItems() ?> - getOrderItem()->getIsVirtual() || $_item->getOrderItem()->getParentItem()): continue; endif; $_i++ ?> + getOrderItem()->getParentItem()): continue; endif; $_i++ ?>
    getItemHtml($_item) ?> getItemExtraInfoHtml($_item->getOrderItem()) ?> diff --git a/app/code/Magento/Shipping/view/adminhtml/templates/view/form.phtml b/app/code/Magento/Shipping/view/adminhtml/templates/view/form.phtml index 55c782eb0fc82..32805ec0a3495 100644 --- a/app/code/Magento/Shipping/view/adminhtml/templates/view/form.phtml +++ b/app/code/Magento/Shipping/view/adminhtml/templates/view/form.phtml @@ -5,71 +5,76 @@ */ // @codingStandardsIgnoreFile - +/** + * @var \Magento\Shipping\Block\Adminhtml\View\Form $block + */ +$order = $block->getShipment()->getOrder(); ?> -getShipment()->getOrder() ?> -getChildHtml('order_info') ?> +getChildHtml('order_info'); ?>
    - + escapeHtml(__('Payment & Shipping Method')); ?>
    - -
    - + escapeHtml(__('Payment Information')); ?>
    getChildHtml('order_payment') ?>
    -
    getOrderCurrencyCode()) ?>
    +
    + escapeHtml(__('The order was placed using %1.', $order->getOrderCurrencyCode())); ?> +
    -
    - + escapeHtml(__('Shipping and Tracking Information')); ?>
    getShipment()->getTracksCollection()->count()): ?>

    - + + escapeHtml(__('Track this shipment')); ?> +

    - escapeHtml($_order->getShippingDescription()) ?> + escapeHtml($order->getShippingDescription()); ?>
    - : + escapeHtml(__('Total Shipping Charges')); ?>: - helper('Magento\Tax\Helper\Data')->displayShippingPriceIncludingTax()): ?> - displayShippingPriceInclTax($_order); ?> + helper(\Magento\Tax\Helper\Data::class)->displayShippingPriceIncludingTax()): ?> + displayShippingPriceInclTax($order); ?> - displayPriceAttribute('shipping_amount', false, ' '); ?> + displayPriceAttribute('shipping_amount', false, ' '); ?> - displayShippingPriceInclTax($_order); ?> + displayShippingPriceInclTax($order); ?> - - helper('Magento\Tax\Helper\Data')->displayShippingBothPrices() && $_incl != $_excl): ?> - ( ) + + helper(\Magento\Tax\Helper\Data::class)->displayShippingBothPrices() && $incl != $excl): ?> + (escapeHtml(__('Incl. Tax')); ?> )
    - canCreateShippingLabel()): ?> +

    - getCreateLabelButton() ?> + canCreateShippingLabel()): ?> + getCreateLabelButton(); ?> + getShipment()->getShippingLabel()): ?> - getPrintLabelButton() ?> + getPrintLabelButton(); ?> getShipment()->getPackages()): ?> - getShowPackagesButton() ?> + getShowPackagesButton(); ?>

    - - getChildHtml('shipment_tracking') ?> + getChildHtml('shipment_tracking'); ?> - getChildHtml('shipment_packaging') ?> + getChildHtml('shipment_packaging'); ?> + + \ No newline at end of file diff --git a/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js b/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js index a24c72c473fe9..3fea6b7dddf14 100644 --- a/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js +++ b/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js @@ -268,8 +268,11 @@ define([ // tier prise selectors start tierPriceTemplateSelector: '#tier-prices-template', tierPriceBlockSelector: '[data-role="tier-price-block"]', - tierPriceTemplate: '' + tierPriceTemplate: '', // tier prise selectors end + + // A price label selector + normalPriceLabelSelector: '.normal-price .price-label' }, /** @@ -312,7 +315,7 @@ define([ */ _sortAttributes: function () { this.options.jsonConfig.attributes = _.sortBy(this.options.jsonConfig.attributes, function (attribute) { - return attribute.position; + return parseInt(attribute.position, 10); }); }, @@ -924,6 +927,22 @@ define([ } else { $(this.options.tierPriceBlockSelector).hide(); } + + $(this.options.normalPriceLabelSelector).hide(); + + _.each($('.' + this.options.classes.attributeOptionsWrapper), function (attribute) { + if ($(attribute).find('.' + this.options.classes.optionClass + '.selected').length === 0) { + if ($(attribute).find('.' + this.options.classes.selectClass).length > 0) { + _.each($(attribute).find('.' + this.options.classes.selectClass), function (dropdown) { + if ($(dropdown).val() === '0') { + $(this.options.normalPriceLabelSelector).show(); + } + }.bind(this)); + } else { + $(this.options.normalPriceLabelSelector).show(); + } + } + }.bind(this)); }, /** diff --git a/app/code/Magento/SwatchesGraphQl/Model/Resolver/SwatchLayerFilterItemResolver.php b/app/code/Magento/SwatchesGraphQl/Model/Resolver/SwatchLayerFilterItemResolver.php new file mode 100644 index 0000000000000..2fca92509eaf0 --- /dev/null +++ b/app/code/Magento/SwatchesGraphQl/Model/Resolver/SwatchLayerFilterItemResolver.php @@ -0,0 +1,27 @@ +filtersProvider = $filtersProvider; + $this->swatchHelper = $swatchHelper; + $this->renderLayered = $renderLayered; + } + + /** + * Using around as layout type has to be passed. + * + * @param Filters $subject + * @param \Closure $proceed + * @param string $layerType + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function aroundGetData(Filters $subject, \Closure $proceed, string $layerType) : array + { + $swatchFilters = []; + /** @var AbstractFilter $filter */ + foreach ($this->filtersProvider->getFilters($layerType) as $filter) { + if ($filter->hasAttributeModel()) { + if ($this->swatchHelper->isSwatchAttribute($filter->getAttributeModel())) { + $swatchFilters[] = $filter; + } + } + } + + $filtersData = $proceed($layerType); + + foreach ($filtersData as $groupKey => $filterGroup) { + /** @var AbstractFilter $swatchFilter */ + foreach ($swatchFilters as $swatchFilter) { + if ($filterGroup['request_var'] === $swatchFilter->getRequestVar()) { + $swatchData = $this->renderLayered->setSwatchFilter($swatchFilter)->getSwatchData(); + foreach ($filterGroup['filter_items'] as $itemKey => $filterItem) { + foreach ((array)$swatchData['swatches'] as $swatchKey => $swatchDataItem) { + if ($filterItem['value_string'] == $swatchKey) { + $filtersData[$groupKey]['filter_items'][$itemKey]['swatch_data'] = [ + 'type' => $swatchDataItem['type'], + 'value' => $swatchDataItem['value'] + ]; + } + } + } + } + } + } + + return $filtersData; + } +} diff --git a/app/code/Magento/SwatchesGraphQl/composer.json b/app/code/Magento/SwatchesGraphQl/composer.json index 4efdc678f3239..01ef35b1ccb6d 100644 --- a/app/code/Magento/SwatchesGraphQl/composer.json +++ b/app/code/Magento/SwatchesGraphQl/composer.json @@ -2,14 +2,14 @@ "name": "magento/module-swatches-graph-ql", "description": "N/A", "type": "magento2-module", - "version": "100.0.0-dev", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-swatches": "*", + "magento/module-catalog": "*" }, "suggest": { - "magento/module-swatches": "100.3.*", - "magento/module-catalog-graph-ql": "100.0.*" + "magento/module-catalog-graph-ql": "*" }, "license": [ "OSL-3.0", diff --git a/app/code/Magento/SwatchesGraphQl/etc/graphql.xml b/app/code/Magento/SwatchesGraphQl/etc/graphql.xml deleted file mode 100644 index 1e5e9ac1fb7be..0000000000000 --- a/app/code/Magento/SwatchesGraphQl/etc/graphql.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/code/Magento/SwatchesGraphQl/etc/graphql/di.xml b/app/code/Magento/SwatchesGraphQl/etc/graphql/di.xml new file mode 100644 index 0000000000000..34f65d8e30e57 --- /dev/null +++ b/app/code/Magento/SwatchesGraphQl/etc/graphql/di.xml @@ -0,0 +1,19 @@ + + + + + + + + + + Magento\SwatchesGraphQl\Model\Resolver\SwatchLayerFilterItemResolver + + + + \ No newline at end of file diff --git a/app/code/Magento/SwatchesGraphQl/etc/module.xml b/app/code/Magento/SwatchesGraphQl/etc/module.xml index de2baeee94c57..6689f13db754e 100644 --- a/app/code/Magento/SwatchesGraphQl/etc/module.xml +++ b/app/code/Magento/SwatchesGraphQl/etc/module.xml @@ -6,5 +6,10 @@ */ --> - + + + + + + diff --git a/app/code/Magento/SwatchesGraphQl/etc/schema.graphqls b/app/code/Magento/SwatchesGraphQl/etc/schema.graphqls new file mode 100644 index 0000000000000..bdd2631e7aa10 --- /dev/null +++ b/app/code/Magento/SwatchesGraphQl/etc/schema.graphqls @@ -0,0 +1,29 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +interface ProductInterface { + swatch_image: String @doc(description: "The file name of a swatch image") +} + +input ProductFilterInput { + swatch_image: FilterTypeInput @doc(description: "The file name of a swatch image") +} + +input ProductSortInput { + swatch_image: SortEnum @doc(description: "The file name of a swatch image") +} + +interface SwatchLayerFilterItemInterface @typeResolver(class: "Magento\\SwatchesGraphQl\\Model\\Resolver\\SwatchLayerFilterItemResolver") +{ + swatch_data: SwatchData @doc(description: "Data required to render swatch filter item") +} + +type SwatchLayerFilterItem implements LayerFilterItemInterface, SwatchLayerFilterItemInterface +{ + +} + +type SwatchData { + type: String @doc(description: "Type of swatch filter item: 1 - text, 2 - image") + value: String @doc(description: "Value for swatch item (text or image link)") +} \ No newline at end of file diff --git a/app/code/Magento/SwatchesLayeredNavigation/composer.json b/app/code/Magento/SwatchesLayeredNavigation/composer.json index 7bc4053df84f7..e11c3d9c4d423 100644 --- a/app/code/Magento/SwatchesLayeredNavigation/composer.json +++ b/app/code/Magento/SwatchesLayeredNavigation/composer.json @@ -5,12 +5,11 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", "magento/magento-composer-installer": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Tax/Block/Adminhtml/Rate/Toolbar/Add.php b/app/code/Magento/Tax/Block/Adminhtml/Rate/Toolbar/Add.php index 80cc9673a0d38..9cf96bc21e962 100644 --- a/app/code/Magento/Tax/Block/Adminhtml/Rate/Toolbar/Add.php +++ b/app/code/Magento/Tax/Block/Adminhtml/Rate/Toolbar/Add.php @@ -11,8 +11,6 @@ */ namespace Magento\Tax\Block\Adminhtml\Rate\Toolbar; -use Magento\Framework\View\Element\Template; - /** * @api * @since 100.0.2 diff --git a/app/code/Magento/Tax/Block/Grid/Renderer/Codes.php b/app/code/Magento/Tax/Block/Grid/Renderer/Codes.php index c4ed80c522c6e..6c4e754f7e316 100644 --- a/app/code/Magento/Tax/Block/Grid/Renderer/Codes.php +++ b/app/code/Magento/Tax/Block/Grid/Renderer/Codes.php @@ -20,6 +20,6 @@ public function render(\Magento\Framework\DataObject $row) { $ratesCodes = $row->getTaxRatesCodes(); - return is_array($ratesCodes) ? implode(', ', $ratesCodes) : ''; + return $ratesCodes && is_array($ratesCodes) ? $this->escapeHtml(implode(', ', $ratesCodes)) : ''; } } diff --git a/app/code/Magento/Tax/Helper/Data.php b/app/code/Magento/Tax/Helper/Data.php index 6d79540464ea1..1a531858797ac 100644 --- a/app/code/Magento/Tax/Helper/Data.php +++ b/app/code/Magento/Tax/Helper/Data.php @@ -9,7 +9,6 @@ use Magento\Store\Model\Store; use Magento\Customer\Model\Address; use Magento\Tax\Model\Config; -use Magento\Customer\Model\Session as CustomerSession; use Magento\Tax\Api\OrderTaxManagementInterface; use Magento\Sales\Model\Order\Invoice; use Magento\Sales\Model\Order\Creditmemo; diff --git a/app/code/Magento/Tax/Model/Calculation/Rule/Validator.php b/app/code/Magento/Tax/Model/Calculation/Rule/Validator.php index 2149a0c3c3ca2..29601b53c7765 100644 --- a/app/code/Magento/Tax/Model/Calculation/Rule/Validator.php +++ b/app/code/Magento/Tax/Model/Calculation/Rule/Validator.php @@ -6,7 +6,6 @@ namespace Magento\Tax\Model\Calculation\Rule; -use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Tax\Model\ClassModel as TaxClassModel; use Magento\Tax\Model\ClassModelRegistry; diff --git a/app/code/Magento/Tax/Model/Config.php b/app/code/Magento/Tax/Model/Config.php index b30e5afb85142..09212ce90bf58 100644 --- a/app/code/Magento/Tax/Model/Config.php +++ b/app/code/Magento/Tax/Model/Config.php @@ -832,12 +832,12 @@ public function getInfoUrl($store = null) * If it necessary will be returned conversion type (minus or plus) * * @param null|int|string|Store $store - * @return bool + * @return bool|int * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ public function needPriceConversion($store = null) { - $res = false; + $res = 0; $priceIncludesTax = $this->priceIncludesTax($store) || $this->getNeedUseShippingExcludeTax(); if ($priceIncludesTax) { switch ($this->getPriceDisplayType($store)) { @@ -845,7 +845,7 @@ public function needPriceConversion($store = null) case self::DISPLAY_TYPE_BOTH: return self::PRICE_CONVERSION_MINUS; case self::DISPLAY_TYPE_INCLUDING_TAX: - $res = true; + $res = false; break; default: break; diff --git a/app/code/Magento/Tax/Model/Sales/Order/TaxManagement.php b/app/code/Magento/Tax/Model/Sales/Order/TaxManagement.php index c563ee1b3084e..2e6e74a573f99 100644 --- a/app/code/Magento/Tax/Model/Sales/Order/TaxManagement.php +++ b/app/code/Magento/Tax/Model/Sales/Order/TaxManagement.php @@ -10,7 +10,6 @@ use Magento\Framework\Exception\NoSuchEntityException; use Magento\Tax\Api\Data\OrderTaxDetailsAppliedTaxInterfaceFactory as TaxDetailsDataObjectFactory; use Magento\Tax\Api\Data\OrderTaxDetailsAppliedTaxInterface as AppliedTax; -use Magento\Tax\Model\Sales\Order\Tax; use Magento\Sales\Model\Order\Tax\Item; class TaxManagement implements \Magento\Tax\Api\OrderTaxManagementInterface diff --git a/app/code/Magento/Tax/Model/TaxRuleCollection.php b/app/code/Magento/Tax/Model/TaxRuleCollection.php index 33aaf3b22b6d6..5c6f3b1f14455 100644 --- a/app/code/Magento/Tax/Model/TaxRuleCollection.php +++ b/app/code/Magento/Tax/Model/TaxRuleCollection.php @@ -27,14 +27,14 @@ class TaxRuleCollection extends AbstractServiceCollection /** * Initialize dependencies. * - * @param \Magento\Framework\Data\Collection\EntityFactory $entityFactory + * @param EntityFactory $entityFactory * @param FilterBuilder $filterBuilder * @param SearchCriteriaBuilder $searchCriteriaBuilder * @param SortOrderBuilder $sortOrderBuilder * @param TaxRuleRepositoryInterface $ruleService */ public function __construct( - \Magento\Framework\Data\Collection\EntityFactory $entityFactory, + EntityFactory $entityFactory, FilterBuilder $filterBuilder, SearchCriteriaBuilder $searchCriteriaBuilder, SortOrderBuilder $sortOrderBuilder, diff --git a/app/code/Magento/Tax/Setup/Patch/Data/AddTaxAttributeAndTaxClasses.php b/app/code/Magento/Tax/Setup/Patch/Data/AddTaxAttributeAndTaxClasses.php index d6cf3bf6451f1..8b7b9df936009 100644 --- a/app/code/Magento/Tax/Setup/Patch/Data/AddTaxAttributeAndTaxClasses.php +++ b/app/code/Magento/Tax/Setup/Patch/Data/AddTaxAttributeAndTaxClasses.php @@ -8,8 +8,8 @@ use Magento\Directory\Model\RegionFactory; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Tax\Setup\TaxSetup; use Magento\Tax\Setup\TaxSetupFactory; diff --git a/app/code/Magento/Tax/Setup/Patch/Data/UpdateTaxClassAttributeVisibility.php b/app/code/Magento/Tax/Setup/Patch/Data/UpdateTaxClassAttributeVisibility.php index 840afb270cb02..24d21d513ca23 100644 --- a/app/code/Magento/Tax/Setup/Patch/Data/UpdateTaxClassAttributeVisibility.php +++ b/app/code/Magento/Tax/Setup/Patch/Data/UpdateTaxClassAttributeVisibility.php @@ -7,8 +7,8 @@ namespace Magento\Tax\Setup\Patch\Data; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Tax\Setup\TaxSetup; use Magento\Tax\Setup\TaxSetupFactory; diff --git a/app/code/Magento/Tax/Setup/Patch/Data/UpdateTaxRegionId.php b/app/code/Magento/Tax/Setup/Patch/Data/UpdateTaxRegionId.php index efe7b04dfb8aa..d2f5aac442609 100644 --- a/app/code/Magento/Tax/Setup/Patch/Data/UpdateTaxRegionId.php +++ b/app/code/Magento/Tax/Setup/Patch/Data/UpdateTaxRegionId.php @@ -10,8 +10,8 @@ use Magento\Framework\Api\Search\SearchCriteriaFactory; use Magento\Tax\Api\TaxRateRepositoryInterface; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; use Magento\Tax\Setup\TaxSetupFactory; class UpdateTaxRegionId implements DataPatchInterface, PatchVersionInterface diff --git a/app/code/Magento/Tax/Test/Unit/Block/Grid/Renderer/CodesTest.php b/app/code/Magento/Tax/Test/Unit/Block/Grid/Renderer/CodesTest.php index afbf65b67b3a8..dd4b3df842de6 100644 --- a/app/code/Magento/Tax/Test/Unit/Block/Grid/Renderer/CodesTest.php +++ b/app/code/Magento/Tax/Test/Unit/Block/Grid/Renderer/CodesTest.php @@ -5,7 +5,9 @@ */ namespace Magento\Tax\Test\Unit\Block\Grid\Renderer; +use Magento\Backend\Block\Context; use Magento\Framework\DataObject; +use Magento\Framework\Escaper; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Tax\Block\Grid\Renderer\Codes; @@ -24,7 +26,26 @@ class CodesTest extends \PHPUnit\Framework\TestCase protected function setUp() { $objectManager = new ObjectManager($this); - $this->codes = $objectManager->getObject(Codes::class); + $escaper = $this->getMockBuilder(Escaper::class) + ->disableOriginalConstructor() + ->getMock(); + $escaper->expects($this->any()) + ->method('escapeHtml') + ->willReturnCallback( + function ($str) { + return 'ESCAPED:' .$str; + } + ); + $context = $this->getMockBuilder(Context::class) + ->disableOriginalConstructor() + ->getMock(); + $context->expects($this->any()) + ->method('getEscaper') + ->willReturn($escaper); + $this->codes = $objectManager->getObject( + Codes::class, + ['context' => $context] + ); } /** @@ -50,10 +71,10 @@ public function testRenderCodes($ratesCodes, $expected) public function ratesCodesDataProvider() { return [ - [['some_code'], 'some_code'], - [['some_code', 'some_code2'], 'some_code, some_code2'], + [['some_code'], 'ESCAPED:some_code'], + [['some_code', 'some_code2'], 'ESCAPED:some_code, some_code2'], [[], ''], - [null, ''] + [null, ''], ]; } } diff --git a/app/code/Magento/Tax/Test/Unit/Model/Calculation/RateRepositoryTest.php b/app/code/Magento/Tax/Test/Unit/Model/Calculation/RateRepositoryTest.php index 82a1f1803e903..e12edf0c683e9 100644 --- a/app/code/Magento/Tax/Test/Unit/Model/Calculation/RateRepositoryTest.php +++ b/app/code/Magento/Tax/Test/Unit/Model/Calculation/RateRepositoryTest.php @@ -307,7 +307,8 @@ public function testSaveThrowsExceptionIfCannotSaveTitles($expectedException, $e ->with($rateTitles) ->willThrowException($expectedException); $this->rateRegistryMock->expects($this->never())->method('registerTaxRate')->with($rateMock); - $this->expectException($exceptionType, $exceptionMessage); + $this->expectException($exceptionType); + $this->expectExceptionMessage($exceptionMessage); $this->model->save($rateMock); } diff --git a/app/code/Magento/Tax/Test/Unit/Model/Calculation/RateTest.php b/app/code/Magento/Tax/Test/Unit/Model/Calculation/RateTest.php index 4edb0328b73c2..7284eb46ea9d8 100644 --- a/app/code/Magento/Tax/Test/Unit/Model/Calculation/RateTest.php +++ b/app/code/Magento/Tax/Test/Unit/Model/Calculation/RateTest.php @@ -47,7 +47,8 @@ protected function setUp() */ public function testExceptionOfValidation($exceptionMessage, $data) { - $this->expectException(\Magento\Framework\Exception\LocalizedException::class, $exceptionMessage); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage($exceptionMessage); $rate = $this->objectHelper->getObject( \Magento\Tax\Model\Calculation\Rate::class, ['resource' => $this->resourceMock] diff --git a/app/code/Magento/Tax/Test/Unit/Model/TaxClass/FactoryTest.php b/app/code/Magento/Tax/Test/Unit/Model/TaxClass/FactoryTest.php index eb107c248880b..ee611b5320a8a 100644 --- a/app/code/Magento/Tax/Test/Unit/Model/TaxClass/FactoryTest.php +++ b/app/code/Magento/Tax/Test/Unit/Model/TaxClass/FactoryTest.php @@ -70,10 +70,8 @@ public function testCreateWithWrongClassType() $taxClassFactory = new \Magento\Tax\Model\TaxClass\Factory($objectManager); - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - sprintf('Invalid type of tax class "%s"', $wrongClassType) - ); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage(sprintf('Invalid type of tax class "%s"', $wrongClassType)); $taxClassFactory->create($classMock); } } diff --git a/app/code/Magento/Tax/Test/Unit/Model/TaxRuleRepositoryTest.php b/app/code/Magento/Tax/Test/Unit/Model/TaxRuleRepositoryTest.php index 182e1b43d786c..f4151cd18ba66 100644 --- a/app/code/Magento/Tax/Test/Unit/Model/TaxRuleRepositoryTest.php +++ b/app/code/Magento/Tax/Test/Unit/Model/TaxRuleRepositoryTest.php @@ -163,7 +163,8 @@ public function testSaveWithExceptions($exceptionObject, $exceptionName, $except ->willThrowException($exceptionObject); $this->taxRuleRegistry->expects($this->never())->method('registerTaxRule'); - $this->expectException($exceptionName, $exceptionMessage); + $this->expectException($exceptionName); + $this->expectExceptionMessage($exceptionMessage); $this->model->save($rule); } diff --git a/app/code/Magento/Tax/Test/Unit/Observer/UpdateProductOptionsObserverTest.php b/app/code/Magento/Tax/Test/Unit/Observer/UpdateProductOptionsObserverTest.php index aaaf72b986240..97fb5472a280d 100644 --- a/app/code/Magento/Tax/Test/Unit/Observer/UpdateProductOptionsObserverTest.php +++ b/app/code/Magento/Tax/Test/Unit/Observer/UpdateProductOptionsObserverTest.php @@ -64,18 +64,18 @@ public function testUpdateProductOptions( ->method('getEvent') ->will($this->returnValue($eventObject)); - $objectManager = new ObjectManager($this); - $taxObserverObject = $objectManager->getObject( - \Magento\Tax\Observer\UpdateProductOptionsObserver::class, - [ - 'taxData' => $taxData, - 'registry' => $registry, - ] - ); - - $taxObserverObject->execute($observerObject); - - $this->assertEquals($expected, $frameworkObject->getAdditionalOptions()); + $objectManager = new ObjectManager($this); + $taxObserverObject = $objectManager->getObject( + \Magento\Tax\Observer\UpdateProductOptionsObserver::class, + [ + 'taxData' => $taxData, + 'registry' => $registry, + ] + ); + + $taxObserverObject->execute($observerObject); + + $this->assertEquals($expected, $frameworkObject->getAdditionalOptions()); } /** diff --git a/app/code/Magento/Tax/composer.json b/app/code/Magento/Tax/composer.json index 944823ef600ab..a09ebbd53750f 100644 --- a/app/code/Magento/Tax/composer.json +++ b/app/code/Magento/Tax/composer.json @@ -5,27 +5,26 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-catalog": "101.2.*", - "magento/module-checkout": "100.3.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-page-cache": "100.3.*", - "magento/module-quote": "100.3.*", - "magento/module-reports": "100.3.*", - "magento/module-sales": "100.3.*", - "magento/module-shipping": "100.3.*", - "magento/module-store": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-catalog": "*", + "magento/module-checkout": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-directory": "*", + "magento/module-eav": "*", + "magento/module-page-cache": "*", + "magento/module-quote": "*", + "magento/module-reports": "*", + "magento/module-sales": "*", + "magento/module-shipping": "*", + "magento/module-store": "*" }, "suggest": { - "magento/module-tax-sample-data": "Sample Data version:100.3.*" + "magento/module-tax-sample-data": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Tax/etc/db_schema.xml b/app/code/Magento/Tax/etc/db_schema.xml index bde6879c073d6..6cc4041f75a6d 100644 --- a/app/code/Magento/Tax/etc/db_schema.xml +++ b/app/code/Magento/Tax/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Tax/etc/di.xml b/app/code/Magento/Tax/etc/di.xml index 66b23f24e1095..096f8359fadd3 100644 --- a/app/code/Magento/Tax/etc/di.xml +++ b/app/code/Magento/Tax/etc/di.xml @@ -116,6 +116,7 @@ main_table.zip_is_range main_table.zip_from main_table.zip_to + region_table.code diff --git a/app/code/Magento/Tax/i18n/en_US.csv b/app/code/Magento/Tax/i18n/en_US.csv index 2314f27b92928..e6d89deb7696c 100644 --- a/app/code/Magento/Tax/i18n/en_US.csv +++ b/app/code/Magento/Tax/i18n/en_US.csv @@ -176,4 +176,5 @@ Rate,Rate "Order Total Incl. Tax","Order Total Incl. Tax" "Order Total","Order Total" "Your credit card will be charged for","Your credit card will be charged for" -"An error occurred while loading tax rates.","An error occurred while loading tax rates." \ No newline at end of file +"An error occurred while loading tax rates.","An error occurred while loading tax rates." +"You will be charged for","You will be charged for" diff --git a/app/code/Magento/Tax/view/adminhtml/templates/class/page/edit.phtml b/app/code/Magento/Tax/view/adminhtml/templates/class/page/edit.phtml index 8930a4de28fe5..18e86549a1ff9 100644 --- a/app/code/Magento/Tax/view/adminhtml/templates/class/page/edit.phtml +++ b/app/code/Magento/Tax/view/adminhtml/templates/class/page/edit.phtml @@ -11,10 +11,10 @@ getSaveButtonHtml() ?> getRenameFormHtml() ?> - diff --git a/app/code/Magento/Tax/view/adminhtml/web/js/page/validate.js b/app/code/Magento/Tax/view/adminhtml/web/js/page/validate.js new file mode 100644 index 0000000000000..a49f199ba56b6 --- /dev/null +++ b/app/code/Magento/Tax/view/adminhtml/web/js/page/validate.js @@ -0,0 +1,15 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'mage/mage' +], function (jQuery) { + 'use strict'; + + return function (data, element) { + jQuery(element).mage('form').mage('validation'); + }; +}); diff --git a/app/code/Magento/Tax/view/frontend/layout/checkout_cart_index.xml b/app/code/Magento/Tax/view/frontend/layout/checkout_cart_index.xml index 7041ab3793a07..ac0f0852c6c1c 100644 --- a/app/code/Magento/Tax/view/frontend/layout/checkout_cart_index.xml +++ b/app/code/Magento/Tax/view/frontend/layout/checkout_cart_index.xml @@ -34,7 +34,7 @@ - Magento_Tax/js/view/checkout/summary/subtotal + Magento_Tax/js/view/checkout/summary/subtotal Magento_Tax/checkout/summary/subtotal (Excl. Tax) @@ -42,7 +42,7 @@ - Magento_Tax/js/view/checkout/cart/totals/shipping + Magento_Tax/js/view/checkout/cart/totals/shipping 20 Magento_Tax/checkout/cart/totals/shipping @@ -51,21 +51,21 @@ - uiComponent + uiComponent 30 - Magento_Tax/js/view/checkout/cart/totals/tax + Magento_Tax/js/view/checkout/cart/totals/tax Magento_Tax/checkout/cart/totals/tax Tax - Magento_Tax/js/view/checkout/cart/totals/grand-total + Magento_Tax/js/view/checkout/cart/totals/grand-total Magento_Tax/checkout/cart/totals/grand-total Order Total Excl. Tax diff --git a/app/code/Magento/Tax/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/Tax/view/frontend/layout/checkout_index_index.xml index 6d867fcb71f2e..8f60ba15d8a1a 100644 --- a/app/code/Magento/Tax/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/Tax/view/frontend/layout/checkout_index_index.xml @@ -38,14 +38,14 @@ - Magento_Tax/js/view/checkout/summary/subtotal + Magento_Tax/js/view/checkout/summary/subtotal Excl. Tax Incl. Tax - Magento_Tax/js/view/checkout/summary/shipping + Magento_Tax/js/view/checkout/summary/shipping 20 Excl. Tax @@ -53,24 +53,24 @@ - uiComponent + uiComponent 30 - Magento_Tax/js/view/checkout/summary/tax + Magento_Tax/js/view/checkout/summary/tax Tax - Magento_Tax/js/view/checkout/summary/grand-total + Magento_Tax/js/view/checkout/summary/grand-total Order Total Excl. Tax Order Total Incl. Tax - Your credit card will be charged for + You will be charged for Order Total diff --git a/app/code/Magento/Tax/view/frontend/templates/checkout/discount.phtml b/app/code/Magento/Tax/view/frontend/templates/checkout/discount.phtml deleted file mode 100644 index 75f04eae82159..0000000000000 --- a/app/code/Magento/Tax/view/frontend/templates/checkout/discount.phtml +++ /dev/null @@ -1,5 +0,0 @@ - helper('Magento\Tax\Helper\Data')->displayFullSummary() && $_value != 0): ?> - getTotal()->getFullInfo() as $info): ?> - diff --git a/app/code/Magento/TaxGraphQl/composer.json b/app/code/Magento/TaxGraphQl/composer.json index ca65d0fa3cde4..33f87dd35d40f 100644 --- a/app/code/Magento/TaxGraphQl/composer.json +++ b/app/code/Magento/TaxGraphQl/composer.json @@ -2,14 +2,13 @@ "name": "magento/module-tax-graph-ql", "description": "N/A", "type": "magento2-module", - "version": "100.0.0-dev", "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*" }, "suggest": { - "magento/module-tax": "100.3.*", - "magento/module-catalog-graph-ql": "100.0.*" + "magento/module-tax": "*", + "magento/module-catalog-graph-ql": "*" }, "license": [ "OSL-3.0", diff --git a/app/code/Magento/TaxGraphQl/etc/graphql.xml b/app/code/Magento/TaxGraphQl/etc/graphql.xml deleted file mode 100644 index 4f8dc3aba3fd9..0000000000000 --- a/app/code/Magento/TaxGraphQl/etc/graphql.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - TAX - - diff --git a/app/code/Magento/TaxGraphQl/etc/schema.graphqls b/app/code/Magento/TaxGraphQl/etc/schema.graphqls new file mode 100644 index 0000000000000..b39673f5431f1 --- /dev/null +++ b/app/code/Magento/TaxGraphQl/etc/schema.graphqls @@ -0,0 +1,18 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +input ProductFilterInput { + tax_class_id: FilterTypeInput +} + +interface ProductInterface { + tax_class_id: Int +} + +input ProductSortInput { + tax_class_id: SortEnum +} + +enum PriceAdjustmentCodesEnum { + TAX +} diff --git a/app/code/Magento/TaxImportExport/composer.json b/app/code/Magento/TaxImportExport/composer.json index 69d6cc4162f8d..f234cc9bc249b 100644 --- a/app/code/Magento/TaxImportExport/composer.json +++ b/app/code/Magento/TaxImportExport/composer.json @@ -5,15 +5,14 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-directory": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-tax": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-directory": "*", + "magento/module-store": "*", + "magento/module-tax": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Theme/Block/Html/Topmenu.php b/app/code/Magento/Theme/Block/Html/Topmenu.php index ed3445e117331..0dca0f8606a8c 100644 --- a/app/code/Magento/Theme/Block/Html/Topmenu.php +++ b/app/code/Magento/Theme/Block/Html/Topmenu.php @@ -177,7 +177,7 @@ protected function _addSubMenu($child, $childLevel, $childrenWrapClass, $limit) return $html; } - $colStops = null; + $colStops = []; if ($childLevel == 0 && $limit) { $colStops = $this->_columnBrake($child->getChildren(), $limit); } @@ -205,7 +205,7 @@ protected function _getHtml( \Magento\Framework\Data\Tree\Node $menuTree, $childrenWrapClass, $limit, - $colBrakes = [] + array $colBrakes = [] ) { $html = ''; @@ -244,7 +244,7 @@ protected function _getHtml( } } - if (count($colBrakes) && $colBrakes[$counter]['colbrake']) { + if (is_array($colBrakes) && count($colBrakes) && $colBrakes[$counter]['colbrake']) { $html .= '
    • '; } @@ -261,7 +261,7 @@ protected function _getHtml( $counter++; } - if (count($colBrakes) && $limit) { + if (is_array($colBrakes) && count($colBrakes) && $limit) { $html = '
      • ' . $html . '
    • '; } @@ -309,6 +309,10 @@ protected function _getMenuItemClasses(\Magento\Framework\Data\Tree\Node $item) $classes[] = 'level' . $item->getLevel(); $classes[] = $item->getPositionClass(); + if ($item->getIsCategory()) { + $classes[] = 'category-item'; + } + if ($item->getIsFirst()) { $classes[] = 'first'; } diff --git a/app/code/Magento/Theme/Model/Config/Processor/DesignTheme.php b/app/code/Magento/Theme/Model/Config/Processor/DesignTheme.php index 258be67979ed2..d9d2c0e041e99 100644 --- a/app/code/Magento/Theme/Model/Config/Processor/DesignTheme.php +++ b/app/code/Magento/Theme/Model/Config/Processor/DesignTheme.php @@ -72,8 +72,8 @@ public function process(array $config) private function changeThemeFullPathToIdentifier($configItems) { $theme = null; - if ($this->arrayManager->exists(DesignInterface::XML_PATH_THEME_ID, $configItems)) { - $themeIdentifier = $this->arrayManager->get(DesignInterface::XML_PATH_THEME_ID, $configItems); + $themeIdentifier = $this->arrayManager->get(DesignInterface::XML_PATH_THEME_ID, $configItems); + if (!empty($themeIdentifier)) { if (!is_numeric($themeIdentifier)) { // workaround for case when db is not available try { diff --git a/app/code/Magento/Theme/Model/Design/Backend/File.php b/app/code/Magento/Theme/Model/Design/Backend/File.php index 1e32cb36b4aea..b37628e54aa30 100644 --- a/app/code/Magento/Theme/Model/Design/Backend/File.php +++ b/app/code/Magento/Theme/Model/Design/Backend/File.php @@ -89,9 +89,9 @@ public function beforeSave() $values = $this->getValue(); $value = reset($values) ?: []; if (!isset($value['file'])) { - throw new LocalizedException( - __('%1 does not contain field \'file\'', $this->getData('field_config/field')) - ); + throw new LocalizedException( + __('%1 does not contain field \'file\'', $this->getData('field_config/field')) + ); } if (isset($value['exists'])) { $this->setValue($value['file']); diff --git a/app/code/Magento/Theme/Setup/Patch/Data/ConvertSerializedData.php b/app/code/Magento/Theme/Setup/Patch/Data/ConvertSerializedData.php index 4c3f41faedbac..0e132eb429f60 100644 --- a/app/code/Magento/Theme/Setup/Patch/Data/ConvertSerializedData.php +++ b/app/code/Magento/Theme/Setup/Patch/Data/ConvertSerializedData.php @@ -10,8 +10,8 @@ use Magento\Framework\DB\FieldDataConverterFactory; use Magento\Framework\DB\Select\QueryModifierFactory; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class ConvertSerializedData diff --git a/app/code/Magento/Theme/Setup/Patch/Data/RegisterThemes.php b/app/code/Magento/Theme/Setup/Patch/Data/RegisterThemes.php index 6c75e0b224bd8..31b6c6b47773e 100644 --- a/app/code/Magento/Theme/Setup/Patch/Data/RegisterThemes.php +++ b/app/code/Magento/Theme/Setup/Patch/Data/RegisterThemes.php @@ -8,8 +8,8 @@ use Magento\Theme\Model\Theme\Registration; use Magento\Framework\App\ResourceConnection; -use Magento\Setup\Model\Patch\DataPatchInterface; -use Magento\Setup\Model\Patch\PatchVersionInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; /** * Class RegisterThemes diff --git a/app/code/Magento/Theme/Test/Unit/Model/Config/Processor/DesignThemeTest.php b/app/code/Magento/Theme/Test/Unit/Model/Config/Processor/DesignThemeTest.php index 1f3ab5642f471..db9283c54f2d2 100644 --- a/app/code/Magento/Theme/Test/Unit/Model/Config/Processor/DesignThemeTest.php +++ b/app/code/Magento/Theme/Test/Unit/Model/Config/Processor/DesignThemeTest.php @@ -5,7 +5,6 @@ */ namespace Magento\Theme\Test\Unit\Model\Config\Processor; -use Magento\Config\App\Config\Source\DumpConfigSourceAggregated; use Magento\Framework\Stdlib\ArrayManager; use Magento\Framework\View\Design\Theme\ListInterface; use Magento\Theme\Model\Config\Processor\DesignTheme; @@ -78,6 +77,7 @@ private function prepareThemeMock() /** * @return array + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function getDumpConfigDataProvider() { @@ -162,6 +162,22 @@ public function getDumpConfigDataProvider() ], ], ], + [ + [ + 'websites' => [ + 'base' => [ + 'design' => ['theme' => ['theme_id' => '']], + ], + ], + ], + [ + 'websites' => [ + 'base' => [ + 'design' => ['theme' => ['theme_id' => '']], + ], + ], + ], + ], ], ]; } diff --git a/app/code/Magento/Theme/Test/Unit/Observer/CleanThemeRelatedContentObserverTest.php b/app/code/Magento/Theme/Test/Unit/Observer/CleanThemeRelatedContentObserverTest.php index 0eaa509685616..f1f4664c8541d 100644 --- a/app/code/Magento/Theme/Test/Unit/Observer/CleanThemeRelatedContentObserverTest.php +++ b/app/code/Magento/Theme/Test/Unit/Observer/CleanThemeRelatedContentObserverTest.php @@ -105,7 +105,8 @@ public function testCleanThemeRelatedContentException() $this->themeConfig->expects($this->any())->method('isThemeAssignedToStore')->with($themeMock)->willReturn(true); - $this->expectException(\Magento\Framework\Exception\LocalizedException::class, 'Theme isn\'t deletable.'); + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage('Theme isn\'t deletable.'); $this->themeObserver->execute($observerMock); } diff --git a/app/code/Magento/Theme/composer.json b/app/code/Magento/Theme/composer.json index 89c543f7f3c15..0c2b092b8d7d0 100644 --- a/app/code/Magento/Theme/composer.json +++ b/app/code/Magento/Theme/composer.json @@ -5,27 +5,26 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-cms": "101.2.*", - "magento/module-config": "100.3.*", - "magento/module-customer": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-media-storage": "100.3.*", - "magento/module-require-js": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-ui": "100.3.*", - "magento/module-widget": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-cms": "*", + "magento/module-config": "*", + "magento/module-customer": "*", + "magento/module-eav": "*", + "magento/module-media-storage": "*", + "magento/module-require-js": "*", + "magento/module-store": "*", + "magento/module-ui": "*", + "magento/module-widget": "*" }, "suggest": { - "magento/module-translation": "100.3.*", - "magento/module-theme-sample-data": "Sample Data version:100.3.*", - "magento/module-deploy": "100.3.*", - "magento/module-directory": "100.3.*" + "magento/module-translation": "*", + "magento/module-theme-sample-data": "*", + "magento/module-deploy": "*", + "magento/module-directory": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Theme/etc/config.xml b/app/code/Magento/Theme/etc/config.xml index 8b740bc8d09ae..a6984b449d944 100644 --- a/app/code/Magento/Theme/etc/config.xml +++ b/app/code/Magento/Theme/etc/config.xml @@ -45,7 +45,7 @@ Disallow: /*SID= Default welcome msg!
      - Copyright © 2013-2018 Magento, Inc. All rights reserved. + Copyright © 2013-present Magento, Inc. All rights reserved.
      diff --git a/app/code/Magento/Theme/etc/db_schema.xml b/app/code/Magento/Theme/etc/db_schema.xml index 62f7135d7b94d..ee1185e6e576d 100644 --- a/app/code/Magento/Theme/etc/db_schema.xml +++ b/app/code/Magento/Theme/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
  • diff --git a/app/code/Magento/Theme/i18n/en_US.csv b/app/code/Magento/Theme/i18n/en_US.csv index daa5c27e75fcc..53b890635fdc1 100644 --- a/app/code/Magento/Theme/i18n/en_US.csv +++ b/app/code/Magento/Theme/i18n/en_US.csv @@ -107,7 +107,7 @@ Remove,Remove "For the best experience on our site, be sure to turn on Javascript in your browser.","For the best experience on our site, be sure to turn on Javascript in your browser." "Local Storage seems to be disabled in your browser.","Local Storage seems to be disabled in your browser." "For the best experience on our site, be sure to turn on Local Storage in your browser.","For the best experience on our site, be sure to turn on Local Storage in your browser." -"This is demo store. No orders will be fulfilled.","This is demo store. No orders will be fulfilled." +"This is a demo store. No orders will be fulfilled.","This is a demo store. No orders will be fulfilled." "Items %1 to %2 of %3 total","Items %1 to %2 of %3 total" "%1 Item","%1 Item" "%1 Item(s)","%1 Item(s)" @@ -142,7 +142,7 @@ Empty,Empty "1 column","1 column" Configuration,Configuration "Default welcome msg!","Default welcome msg!" -"Copyright © 2013-2018 Magento, Inc. All rights reserved.","Copyright © 2013-2018 Magento, Inc. All rights reserved." +"Copyright © 2013-present Magento, Inc. All rights reserved.","Copyright © 2013-present Magento, Inc. All rights reserved." "Design Config Grid","Design Config Grid" "Rebuild design config grid index","Rebuild design config grid index" "Admin empty","Admin empty" diff --git a/app/code/Magento/Theme/view/base/requirejs-config.js b/app/code/Magento/Theme/view/base/requirejs-config.js index bd72a3d74fad1..fdc08b9adf668 100644 --- a/app/code/Magento/Theme/view/base/requirejs-config.js +++ b/app/code/Magento/Theme/view/base/requirejs-config.js @@ -15,7 +15,6 @@ var config = { }, 'shim': { 'jquery/jquery-migrate': ['jquery'], - 'jquery/jquery.hashchange': ['jquery', 'jquery/jquery-migrate'], 'jquery/jstree/jquery.hotkeys': ['jquery'], 'jquery/hover-intent': ['jquery'], 'mage/adminhtml/backup': ['prototype'], @@ -39,11 +38,12 @@ var config = { 'jquery/validate': 'jquery/jquery.validate', 'jquery/hover-intent': 'jquery/jquery.hoverIntent', 'jquery/file-uploader': 'jquery/fileUploader/jquery.fileupload-fp', - 'jquery/jquery.hashchange': 'jquery/jquery.ba-hashchange.min', 'prototype': 'legacy-build.min', 'jquery/jquery-storageapi': 'jquery/jquery.storageapi.min', 'text': 'mage/requirejs/text', - 'domReady': 'requirejs/domReady' + 'domReady': 'requirejs/domReady', + 'spectrum': 'jquery/spectrum/spectrum', + 'tinycolor': 'jquery/spectrum/tinycolor' }, 'deps': [ 'jquery/jquery-migrate' diff --git a/app/code/Magento/Theme/view/frontend/layout/default_head_blocks.xml b/app/code/Magento/Theme/view/frontend/layout/default_head_blocks.xml index 38ab9c29402e9..38cfe25c16f8e 100644 --- a/app/code/Magento/Theme/view/frontend/layout/default_head_blocks.xml +++ b/app/code/Magento/Theme/view/frontend/layout/default_head_blocks.xml @@ -10,10 +10,10 @@ diff --git a/app/code/Magento/Theme/view/frontend/templates/page/js/require_js.phtml b/app/code/Magento/Theme/view/frontend/templates/page/js/require_js.phtml index 67265de90da77..2a4b07ee6396f 100644 --- a/app/code/Magento/Theme/view/frontend/templates/page/js/require_js.phtml +++ b/app/code/Magento/Theme/view/frontend/templates/page/js/require_js.phtml @@ -5,6 +5,7 @@ */ ?> diff --git a/app/code/Magento/Ui/Block/Wysiwyg/ActiveEditor.php b/app/code/Magento/Ui/Block/Wysiwyg/ActiveEditor.php index ac0af00f9682d..b6077b7b1625d 100644 --- a/app/code/Magento/Ui/Block/Wysiwyg/ActiveEditor.php +++ b/app/code/Magento/Ui/Block/Wysiwyg/ActiveEditor.php @@ -16,20 +16,34 @@ */ class ActiveEditor extends \Magento\Framework\View\Element\Template { + const DEFAULT_EDITOR_PATH = 'mage/adminhtml/wysiwyg/tiny_mce/tinymce4Adapter'; + /** * @var ScopeConfigInterface */ private $scopeConfig; /** + * @var array + */ + private $availableAdapterPaths; + + /** + * ActiveEditor constructor. * @param Context $context * @param ScopeConfigInterface $scopeConfig + * @param array $availableAdapterPaths * @param array $data */ - public function __construct(Context $context, ScopeConfigInterface $scopeConfig, array $data = []) - { + public function __construct( + Context $context, + ScopeConfigInterface $scopeConfig, + $availableAdapterPaths = [], + array $data = [] + ) { parent::__construct($context, $data); $this->scopeConfig = $scopeConfig; + $this->availableAdapterPaths = $availableAdapterPaths; } /** @@ -40,6 +54,9 @@ public function __construct(Context $context, ScopeConfigInterface $scopeConfig, public function getWysiwygAdapterPath() { $adapterPath = $this->scopeConfig->getValue(Model\Config::WYSIWYG_EDITOR_CONFIG_PATH); + if ($adapterPath !== self::DEFAULT_EDITOR_PATH && !isset($this->availableAdapterPaths[$adapterPath])) { + $adapterPath = self::DEFAULT_EDITOR_PATH; + } return $this->escapeHtml($adapterPath); } } diff --git a/app/code/Magento/Ui/Component/Bookmark.php b/app/code/Magento/Ui/Component/Bookmark.php index aa1d7a9fb5c0a..db824f11bd4b1 100644 --- a/app/code/Magento/Ui/Component/Bookmark.php +++ b/app/code/Magento/Ui/Component/Bookmark.php @@ -82,11 +82,11 @@ public function prepare() } } - $this->setData('config', array_replace_recursive($config, $this->getConfiguration($this))); + $this->setData('config', array_replace_recursive($config, $this->getConfiguration())); parent::prepare(); - $jsConfig = $this->getConfiguration($this); + $jsConfig = $this->getConfiguration(); $this->getContext()->addComponentDefinition($this->getComponentName(), $jsConfig); } } diff --git a/app/code/Magento/Ui/Component/Filters.php b/app/code/Magento/Ui/Component/Filters.php index 3085485521cd3..fe02c23af9c8a 100644 --- a/app/code/Magento/Ui/Component/Filters.php +++ b/app/code/Magento/Ui/Component/Filters.php @@ -82,7 +82,7 @@ public function update(UiComponentInterface $component) return; } - if (isset($this->filterMap[$filterType])) { + if (isset($this->filterMap[$filterType]) && !isset($this->columnFilters[$component->getName()])) { $filterComponent = $this->uiComponentFactory->create( $component->getName(), $this->filterMap[$filterType], diff --git a/app/code/Magento/Ui/Component/Filters/Type/Input.php b/app/code/Magento/Ui/Component/Filters/Type/Input.php index cbcb33ffcca04..9cc060ae58172 100644 --- a/app/code/Magento/Ui/Component/Filters/Type/Input.php +++ b/app/code/Magento/Ui/Component/Filters/Type/Input.php @@ -65,7 +65,7 @@ public function prepare() protected function applyFilter() { if (isset($this->filterData[$this->getName()])) { - $value = $this->filterData[$this->getName()]; + $value = str_replace(['%', '_'], ['\%', '\_'], $this->filterData[$this->getName()]); if (!empty($value)) { $filter = $this->filterBuilder->setConditionType('like') diff --git a/app/code/Magento/Ui/Component/Form/Element/ColorPicker.php b/app/code/Magento/Ui/Component/Form/Element/ColorPicker.php new file mode 100644 index 0000000000000..b1925b4641d0b --- /dev/null +++ b/app/code/Magento/Ui/Component/Form/Element/ColorPicker.php @@ -0,0 +1,79 @@ +modesProvider = $modesProvider; + parent::__construct($context, $components, $data); + } + + /** + * Get component name + * + * @return string + */ + public function getComponentName(): string + { + return static::NAME; + } + + /** + * Prepare component configuration + * + * @return void + */ + public function prepare() : void + { + $modes = $this->modesProvider->getModes(); + $colorPickerModeSetting = $this->getData('config/colorPickerMode'); + $colorFormatSetting = $this->getData('config/colorFormat'); + $colorPickerMode = $modes[$colorPickerModeSetting] ?? $modes[self::DEFAULT_MODE]; + $colorPickerMode['preferredFormat'] = $colorFormatSetting; + $this->_data['config']['colorPickerConfig'] = $colorPickerMode; + + parent::prepare(); + } +} diff --git a/app/code/Magento/Ui/Component/Form/Element/DataType/Media/Image.php b/app/code/Magento/Ui/Component/Form/Element/DataType/Media/Image.php index 7b923bdbf84bf..aee81f65775bc 100644 --- a/app/code/Magento/Ui/Component/Form/Element/DataType/Media/Image.php +++ b/app/code/Magento/Ui/Component/Form/Element/DataType/Media/Image.php @@ -3,6 +3,9 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Ui\Component\Form\Element\DataType\Media; use Magento\Store\Model\StoreManagerInterface; @@ -61,7 +64,10 @@ public function getComponentName() public function prepare() { // dynamically set max file size based on php ini config if not present in XML - $maxFileSize = $this->getConfiguration()['maxFileSize'] ?? $this->fileSize->getMaxFileSize(); + $maxFileSize = min(array_filter([ + $this->getConfiguration()['maxFileSize'] ?? 0, + $this->fileSize->getMaxFileSize() + ])); $data = array_replace_recursive( $this->getData(), @@ -69,8 +75,9 @@ public function prepare() 'config' => [ 'maxFileSize' => $maxFileSize, 'mediaGallery' => [ - 'openDialogUrl' => $this->getContext()->getUrl('cms/wysiwyg_images/index'), + 'openDialogUrl' => $this->getContext()->getUrl('cms/wysiwyg_images/index', ['_secure' => true]), 'openDialogTitle' => $this->getConfiguration()['openDialogTitle'] ?? __('Insert Images...'), + 'initialOpenSubpath' => $this->getConfiguration()['initialMediaGalleryOpenSubpath'], 'storeId' => $this->storeManager->getStore()->getId(), ], ], diff --git a/app/code/Magento/Ui/Component/Form/Element/UrlInput.php b/app/code/Magento/Ui/Component/Form/Element/UrlInput.php new file mode 100644 index 0000000000000..0e0e25d02ffed --- /dev/null +++ b/app/code/Magento/Ui/Component/Form/Element/UrlInput.php @@ -0,0 +1,42 @@ +getData('config'); + //process urlTypes + if (isset($config['urlTypes'])) { + $links = $config['urlTypes']->getConfig(); + $config['urlTypes'] = $links; + } + $this->setData('config', (array)$config); + parent::prepare(); + } +} diff --git a/app/code/Magento/Ui/Component/Form/Field/DefaultValue.php b/app/code/Magento/Ui/Component/Form/Field/DefaultValue.php new file mode 100644 index 0000000000000..99c124e88787f --- /dev/null +++ b/app/code/Magento/Ui/Component/Form/Field/DefaultValue.php @@ -0,0 +1,70 @@ +scopeConfig = $scopeConfig; + $this->storeManager = $storeManager; + $this->path = $path; + } + + /** + * {@inheritdoc} + */ + public function prepare() + { + parent::prepare(); + $store = $this->storeManager->getStore(); + $this->_data['config']['default'] = $this->scopeConfig->getValue( + $this->path, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + $store + ); + } +} diff --git a/app/code/Magento/Ui/Component/Layout/Tabs.php b/app/code/Magento/Ui/Component/Layout/Tabs.php index 6449e10bf8d91..8ceac716ae218 100644 --- a/app/code/Magento/Ui/Component/Layout/Tabs.php +++ b/app/code/Magento/Ui/Component/Layout/Tabs.php @@ -350,15 +350,21 @@ protected function initAreas() protected function addNavigationBlock() { $pageLayout = $this->component->getContext()->getPageLayout(); + + $navName = 'tabs_nav'; + if ($pageLayout->hasElement($navName)) { + $navName = $this->component->getName() . '_tabs_nav'; + } + /** @var \Magento\Ui\Component\Layout\Tabs\Nav $navBlock */ if (isset($this->navContainerName)) { $navBlock = $pageLayout->addBlock( \Magento\Ui\Component\Layout\Tabs\Nav::class, - 'tabs_nav', + $navName, $this->navContainerName ); } else { - $navBlock = $pageLayout->addBlock(\Magento\Ui\Component\Layout\Tabs\Nav::class, 'tabs_nav', 'content'); + $navBlock = $pageLayout->addBlock(\Magento\Ui\Component\Layout\Tabs\Nav::class, $navName, 'content'); } $navBlock->setTemplate('Magento_Ui::layout/tabs/nav/default.phtml'); $navBlock->setData('data_scope', $this->namespace); diff --git a/app/code/Magento/Ui/Controller/Adminhtml/Export/GridToCsv.php b/app/code/Magento/Ui/Controller/Adminhtml/Export/GridToCsv.php index 13aed8fe300e5..cb0fae3c18e59 100644 --- a/app/code/Magento/Ui/Controller/Adminhtml/Export/GridToCsv.php +++ b/app/code/Magento/Ui/Controller/Adminhtml/Export/GridToCsv.php @@ -9,6 +9,9 @@ use Magento\Backend\App\Action\Context; use Magento\Ui\Model\Export\ConvertToCsv; use Magento\Framework\App\Response\Http\FileFactory; +use Magento\Framework\App\ObjectManager; +use Magento\Ui\Component\MassAction\Filter; +use Psr\Log\LoggerInterface; /** * Class Render @@ -25,19 +28,35 @@ class GridToCsv extends Action */ protected $fileFactory; + /** + * @var Filter + */ + private $filter; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param Context $context * @param ConvertToCsv $converter * @param FileFactory $fileFactory + * @param Filter|null $filter + * @param LoggerInterface|null $logger */ public function __construct( Context $context, ConvertToCsv $converter, - FileFactory $fileFactory + FileFactory $fileFactory, + Filter $filter = null, + LoggerInterface $logger = null ) { parent::__construct($context); $this->converter = $converter; $this->fileFactory = $fileFactory; + $this->filter = $filter ?: ObjectManager::getInstance()->get(Filter::class); + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -50,4 +69,32 @@ public function execute() { return $this->fileFactory->create('export.csv', $this->converter->getCsvFile(), 'var'); } + + /** + * Checking if the user has access to requested component. + * + * @inheritDoc + */ + protected function _isAllowed() + { + if ($this->_request->getParam('namespace')) { + try { + $component = $this->filter->getComponent(); + $dataProviderConfig = $component->getContext() + ->getDataProvider() + ->getConfigData(); + if (isset($dataProviderConfig['aclResource'])) { + return $this->_authorization->isAllowed( + $dataProviderConfig['aclResource'] + ); + } + } catch (\Throwable $exception) { + $this->logger->critical($exception); + + return false; + } + } + + return true; + } } diff --git a/app/code/Magento/Ui/Controller/Adminhtml/Export/GridToXml.php b/app/code/Magento/Ui/Controller/Adminhtml/Export/GridToXml.php index f00825a3e0bb9..130e89bb246b9 100644 --- a/app/code/Magento/Ui/Controller/Adminhtml/Export/GridToXml.php +++ b/app/code/Magento/Ui/Controller/Adminhtml/Export/GridToXml.php @@ -9,6 +9,9 @@ use Magento\Backend\App\Action\Context; use Magento\Ui\Model\Export\ConvertToXml; use Magento\Framework\App\Response\Http\FileFactory; +use Magento\Framework\App\ObjectManager; +use Magento\Ui\Component\MassAction\Filter; +use Psr\Log\LoggerInterface; /** * Class Render @@ -25,19 +28,35 @@ class GridToXml extends Action */ protected $fileFactory; + /** + * @var Filter + */ + private $filter; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param Context $context * @param ConvertToXml $converter * @param FileFactory $fileFactory + * @param Filter|null $filter + * @param LoggerInterface|null $logger */ public function __construct( Context $context, ConvertToXml $converter, - FileFactory $fileFactory + FileFactory $fileFactory, + Filter $filter = null, + LoggerInterface $logger = null ) { parent::__construct($context); $this->converter = $converter; $this->fileFactory = $fileFactory; + $this->filter = $filter ?: ObjectManager::getInstance()->get(Filter::class); + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -50,4 +69,32 @@ public function execute() { return $this->fileFactory->create('export.xml', $this->converter->getXmlFile(), 'var'); } + + /** + * Checking if the user has access to requested component. + * + * @inheritDoc + */ + protected function _isAllowed() + { + if ($this->_request->getParam('namespace')) { + try { + $component = $this->filter->getComponent(); + $dataProviderConfig = $component->getContext() + ->getDataProvider() + ->getConfigData(); + if (isset($dataProviderConfig['aclResource'])) { + return $this->_authorization->isAllowed( + $dataProviderConfig['aclResource'] + ); + } + } catch (\Throwable $exception) { + $this->logger->critical($exception); + + return false; + } + } + + return true; + } } diff --git a/app/code/Magento/Ui/Controller/Adminhtml/Index/Render.php b/app/code/Magento/Ui/Controller/Adminhtml/Index/Render.php index 4df9198c0b95d..fb99cef8e53cc 100644 --- a/app/code/Magento/Ui/Controller/Adminhtml/Index/Render.php +++ b/app/code/Magento/Ui/Controller/Adminhtml/Index/Render.php @@ -10,6 +10,9 @@ use Magento\Framework\View\Element\UiComponentFactory; use Magento\Framework\View\Element\UiComponentInterface; use Magento\Ui\Model\UiComponentTypeResolver; +use Psr\Log\LoggerInterface; +use Magento\Framework\Escaper; +use Magento\Framework\Controller\Result\JsonFactory; class Render extends AbstractAction { @@ -18,39 +21,97 @@ class Render extends AbstractAction */ private $contentTypeResolver; + /** + * @var JsonFactory + */ + private $resultJsonFactory; + + /** + * @var Escaper + */ + private $escaper; + + /** + * @var LoggerInterface + */ + private $logger; + /** * @param Context $context * @param UiComponentFactory $factory * @param UiComponentTypeResolver $contentTypeResolver + * @param JsonFactory|null $resultJsonFactory + * @param Escaper|null $escaper + * @param LoggerInterface|null $logger */ public function __construct( Context $context, UiComponentFactory $factory, - UiComponentTypeResolver $contentTypeResolver + UiComponentTypeResolver $contentTypeResolver, + JsonFactory $resultJsonFactory = null, + Escaper $escaper = null, + LoggerInterface $logger = null ) { parent::__construct($context, $factory); $this->contentTypeResolver = $contentTypeResolver; + $this->resultJsonFactory = $resultJsonFactory ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Framework\Controller\Result\JsonFactory::class); + $this->escaper = $escaper ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Framework\Escaper::class); + $this->logger = $logger ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Psr\Log\LoggerInterface::class); } /** - * Action for AJAX request - * - * @return void + * @inheritdoc */ public function execute() { if ($this->_request->getParam('namespace') === null) { $this->_redirect('admin/noroute'); + return; } - $component = $this->factory->create($this->getRequest()->getParam('namespace')); - if ($this->validateAclResource($component->getContext()->getDataProvider()->getConfigData())) { - $this->prepareComponent($component); - $this->getResponse()->appendBody((string) $component->render()); + try { + $component = $this->factory->create($this->getRequest()->getParam('namespace')); + if ($this->validateAclResource($component->getContext()->getDataProvider()->getConfigData())) { + $this->prepareComponent($component); + $this->getResponse()->appendBody((string)$component->render()); + + $contentType = $this->contentTypeResolver->resolve($component->getContext()); + $this->getResponse()->setHeader('Content-Type', $contentType, true); + } + } catch (\Magento\Framework\Exception\LocalizedException $e) { + $this->logger->critical($e); + $result = [ + 'error' => $this->escaper->escapeHtml($e->getMessage()), + 'errorcode' => $this->escaper->escapeHtml($e->getCode()) + ]; + /** @var \Magento\Framework\Controller\Result\Json $resultJson */ + $resultJson = $this->resultJsonFactory->create(); + $resultJson->setStatusHeader( + \Zend\Http\Response::STATUS_CODE_400, + \Zend\Http\AbstractMessage::VERSION_11, + 'Bad Request' + ); + + return $resultJson->setData($result); + } catch (\Exception $e) { + $this->logger->critical($e); + $result = [ + 'error' => __('UI component could not be rendered because of system exception'), + 'errorcode' => $this->escaper->escapeHtml($e->getCode()) + ]; + /** @var \Magento\Framework\Controller\Result\Json $resultJson */ + $resultJson = $this->resultJsonFactory->create(); + $resultJson->setStatusHeader( + \Zend\Http\Response::STATUS_CODE_400, + \Zend\Http\AbstractMessage::VERSION_11, + 'Bad Request' + ); - $contentType = $this->contentTypeResolver->resolve($component->getContext()); - $this->getResponse()->setHeader('Content-Type', $contentType, true); + return $resultJson->setData($result); } } diff --git a/app/code/Magento/Ui/DataProvider/AbstractDataProvider.php b/app/code/Magento/Ui/DataProvider/AbstractDataProvider.php index cff6171395ec9..971da30d1009a 100644 --- a/app/code/Magento/Ui/DataProvider/AbstractDataProvider.php +++ b/app/code/Magento/Ui/DataProvider/AbstractDataProvider.php @@ -12,7 +12,7 @@ * @api * @since 100.0.2 */ -abstract class AbstractDataProvider implements DataProviderInterface +abstract class AbstractDataProvider implements DataProviderInterface, \Countable { /** * Data Provider name diff --git a/app/code/Magento/Ui/Model/ColorPicker/ColorModesProvider.php b/app/code/Magento/Ui/Model/ColorPicker/ColorModesProvider.php new file mode 100644 index 0000000000000..768cc39cdb45e --- /dev/null +++ b/app/code/Magento/Ui/Model/ColorPicker/ColorModesProvider.php @@ -0,0 +1,67 @@ +colorModes = $colorModesPool; + $this->objectManager = $objectManager; + } + + /** + * Return all available modes and their configuration + * + * @return array + */ + public function getModes(): array + { + $config = []; + foreach ($this->colorModes as $modeName => $className) { + $config[$modeName] = $this->createModeProvider($className)->getConfig(); + } + + return $config; + } + + /** + * Create mode provider + * + * @param string $instance + * @return ModeInterface + */ + private function createModeProvider(string $instance): ModeInterface + { + return $this->objectManager->create($instance); + } +} diff --git a/app/code/Magento/Ui/Model/ColorPicker/FullMode.php b/app/code/Magento/Ui/Model/ColorPicker/FullMode.php new file mode 100644 index 0000000000000..8161e795a0398 --- /dev/null +++ b/app/code/Magento/Ui/Model/ColorPicker/FullMode.php @@ -0,0 +1,30 @@ + true, + 'showInitial' => false, + 'showPalette' => true, + 'showAlpha' => true, + 'showSelectionPalette' => true + ]; + } +} diff --git a/app/code/Magento/Ui/Model/ColorPicker/ModeInterface.php b/app/code/Magento/Ui/Model/ColorPicker/ModeInterface.php new file mode 100644 index 0000000000000..5c0504c995b99 --- /dev/null +++ b/app/code/Magento/Ui/Model/ColorPicker/ModeInterface.php @@ -0,0 +1,22 @@ + true, + 'showInitial' => false, + 'showPalette' => true, + 'showAlpha' => false, + 'showSelectionPalette' => true + ]; + } +} diff --git a/app/code/Magento/Ui/Model/ColorPicker/PaletteOnlyMode.php b/app/code/Magento/Ui/Model/ColorPicker/PaletteOnlyMode.php new file mode 100644 index 0000000000000..d90324b5daf37 --- /dev/null +++ b/app/code/Magento/Ui/Model/ColorPicker/PaletteOnlyMode.php @@ -0,0 +1,32 @@ + false, + 'showInitial' => false, + 'showPalette' => true, + 'showPaletteOnly' => true, + 'showAlpha' => false, + 'showSelectionPalette' => false + ]; + } +} diff --git a/app/code/Magento/Ui/Model/ColorPicker/SimpleMode.php b/app/code/Magento/Ui/Model/ColorPicker/SimpleMode.php new file mode 100644 index 0000000000000..c5753839be359 --- /dev/null +++ b/app/code/Magento/Ui/Model/ColorPicker/SimpleMode.php @@ -0,0 +1,31 @@ + false, + 'showInitial' => false, + 'showPalette' => false, + 'showAlpha' => false, + 'showSelectionPalette' => true + ]; + } +} diff --git a/app/code/Magento/Ui/Model/Export/ConvertToCsv.php b/app/code/Magento/Ui/Model/Export/ConvertToCsv.php index 9eba829982533..e8136c7520054 100644 --- a/app/code/Magento/Ui/Model/Export/ConvertToCsv.php +++ b/app/code/Magento/Ui/Model/Export/ConvertToCsv.php @@ -8,7 +8,6 @@ use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Filesystem; -use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Ui\Component\MassAction\Filter; /** @@ -17,7 +16,7 @@ class ConvertToCsv { /** - * @var WriteInterface + * @var DirectoryList */ protected $directory; diff --git a/app/code/Magento/Ui/Model/Export/ConvertToXml.php b/app/code/Magento/Ui/Model/Export/ConvertToXml.php index b707742063dbd..fca5f10126765 100644 --- a/app/code/Magento/Ui/Model/Export/ConvertToXml.php +++ b/app/code/Magento/Ui/Model/Export/ConvertToXml.php @@ -12,7 +12,6 @@ use Magento\Framework\Convert\ExcelFactory; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Filesystem; -use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Ui\Component\MassAction\Filter; /** @@ -21,7 +20,7 @@ class ConvertToXml { /** - * @var WriteInterface + * @var DirectoryList */ protected $directory; diff --git a/app/code/Magento/Ui/Model/UrlInput/ConfigInterface.php b/app/code/Magento/Ui/Model/UrlInput/ConfigInterface.php new file mode 100644 index 0000000000000..8cf7a9dd8b229 --- /dev/null +++ b/app/code/Magento/Ui/Model/UrlInput/ConfigInterface.php @@ -0,0 +1,21 @@ +linksConfiguration = $linksConfiguration; + $this->objectManager = $objectManager; + } + + /** + * {@inheritdoc} + */ + public function getConfig(): array + { + $config = []; + foreach ($this->linksConfiguration as $linkName => $className) { + $config[$linkName] = $this->createConfigProvider($className)->getConfig(); + } + return $config; + } + + /** + * Create config provider + * + * @param string $instance + * @return ConfigInterface + */ + private function createConfigProvider($instance): ConfigInterface + { + return $this->objectManager->create($instance); + } +} diff --git a/app/code/Magento/Ui/Model/UrlInput/Url.php b/app/code/Magento/Ui/Model/UrlInput/Url.php new file mode 100644 index 0000000000000..268bce512ed19 --- /dev/null +++ b/app/code/Magento/Ui/Model/UrlInput/Url.php @@ -0,0 +1,27 @@ + __('URL'), + 'component' => 'Magento_Ui/js/form/element/abstract', + 'template' => 'ui/form/element/input', + 'sortOrder' => 20, + ]; + } +} diff --git a/app/code/Magento/Ui/TemplateEngine/Xhtml/Result.php b/app/code/Magento/Ui/TemplateEngine/Xhtml/Result.php index cda3106a14f49..e249a64861d43 100644 --- a/app/code/Magento/Ui/TemplateEngine/Xhtml/Result.php +++ b/app/code/Magento/Ui/TemplateEngine/Xhtml/Result.php @@ -80,7 +80,9 @@ public function getDocumentElement() */ public function appendLayoutConfiguration() { - $layoutConfiguration = $this->wrapContent(json_encode($this->structure->generate($this->component))); + $layoutConfiguration = $this->wrapContent( + json_encode($this->structure->generate($this->component), JSON_HEX_TAG) + ); $this->template->append($layoutConfiguration); } diff --git a/app/code/Magento/Ui/Test/Unit/Component/Control/ActionPoolTest.php b/app/code/Magento/Ui/Test/Unit/Component/Control/ActionPoolTest.php index e18155cd08c53..85226b780aac3 100644 --- a/app/code/Magento/Ui/Test/Unit/Component/Control/ActionPoolTest.php +++ b/app/code/Magento/Ui/Test/Unit/Component/Control/ActionPoolTest.php @@ -83,8 +83,7 @@ protected function setUp() $this->items[$this->key] = $this->createPartialMock(\Magento\Ui\Component\Control\Item::class, ['setData']); $this->actionPool = new ActionPool( $this->contextMock, - $this->itemFactoryMock, - $this->toolbarBlockMock + $this->itemFactoryMock ); } diff --git a/app/code/Magento/Ui/Test/Unit/Component/Filters/Type/InputTest.php b/app/code/Magento/Ui/Test/Unit/Component/Filters/Type/InputTest.php index ed571f44dd6e9..d814fdcd153da 100644 --- a/app/code/Magento/Ui/Test/Unit/Component/Filters/Type/InputTest.php +++ b/app/code/Magento/Ui/Test/Unit/Component/Filters/Type/InputTest.php @@ -111,8 +111,7 @@ public function testPrepare($name, $filterData, $expectedCondition) ->method('addComponentDefinition') ->with(Input::NAME, ['extends' => Input::NAME]); $this->contextMock->expects($this->any()) - ->method('getRequestParam') - ->with(UiContext::FILTER_VAR) + ->method('getFiltersParams') ->willReturn($filterData); $dataProvider = $this->getMockForAbstractClass( \Magento\Framework\View\Element\UiComponent\DataProvider\DataProviderInterface::class, @@ -120,20 +119,41 @@ public function testPrepare($name, $filterData, $expectedCondition) '', false ); + $this->contextMock->expects($this->any()) ->method('getDataProvider') ->willReturn($dataProvider); - if ($expectedCondition !== null) { - $dataProvider->expects($this->any()) - ->method('addFilter') - ->with($expectedCondition, $name); - } $this->uiComponentFactory->expects($this->any()) ->method('create') ->with($name, Input::COMPONENT, ['context' => $this->contextMock]) ->willReturn($uiComponent); + if ($expectedCondition !== null) { + $this->filterBuilderMock->expects($this->once()) + ->method('setConditionType') + ->with('like') + ->willReturnSelf(); + + $this->filterBuilderMock->expects($this->once()) + ->method('setField') + ->with($name) + ->willReturnSelf(); + + $this->filterBuilderMock->expects($this->once()) + ->method('setValue') + ->with($expectedCondition['like']) + ->willReturnSelf(); + + $filterMock = $this->getMockBuilder(\Magento\Framework\Api\Filter::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->filterBuilderMock->expects($this->once()) + ->method('create') + ->willReturn($filterMock); + } + $date = new Input( $this->contextMock, $this->uiComponentFactory, @@ -160,7 +180,12 @@ public function getPrepareDataProvider() [ 'test_date', ['test_date' => 'some_value'], - ['like' => '%some_value%'], + ['like' => '%some\_value%'], + ], + [ + 'test_date', + ['test_date' => '%'], + ['like' => '%\%%'], ], ]; } diff --git a/app/code/Magento/Ui/Test/Unit/Component/FiltersTest.php b/app/code/Magento/Ui/Test/Unit/Component/FiltersTest.php new file mode 100644 index 0000000000000..402fd30bf4d5b --- /dev/null +++ b/app/code/Magento/Ui/Test/Unit/Component/FiltersTest.php @@ -0,0 +1,81 @@ +uiComponentInterface = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponentInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->uiComponentFactory = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponentFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->context = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponent\ContextInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->filters = $objectManager->getObject( + Filters::class, + [ + 'columnFilters' => ['select' => $this->uiComponentInterface], + 'uiComponentFactory' => $this->uiComponentFactory, + 'context' => $this->context, + ] + ); + } + + public function testUpdate() + { + $componentName = 'component_name'; + $componentConfig = [0, 1, 2]; + $columnInterface = $this->getMockBuilder(\Magento\Ui\Component\Listing\Columns\ColumnInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getData', 'getName', 'getConfiguration']) + ->getMockForAbstractClass(); + $columnInterface->expects($this->atLeastOnce())->method('getData')->with('config/filter')->willReturn('text'); + $columnInterface->expects($this->atLeastOnce())->method('getName')->willReturn($componentName); + $columnInterface->expects($this->once())->method('getConfiguration')->willReturn($componentConfig); + $filterComponent = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponentInterface::class) + ->disableOriginalConstructor() + ->setMethods(['setData', 'prepare']) + ->getMockForAbstractClass(); + $filterComponent->expects($this->once())->method('setData')->with('config', $componentConfig) + ->willReturnSelf(); + $filterComponent->expects($this->once())->method('prepare')->willReturnSelf(); + $this->uiComponentFactory->expects($this->once())->method('create') + ->with($componentName, 'filterInput', ['context' => $this->context]) + ->willReturn($filterComponent); + + $this->filters->update($columnInterface); + /** Verify that filter is already set and it wouldn't be set again */ + $this->filters->update($columnInterface); + } +} diff --git a/app/code/Magento/Ui/Test/Unit/Component/Form/Element/DataType/Media/ImageTest.php b/app/code/Magento/Ui/Test/Unit/Component/Form/Element/DataType/Media/ImageTest.php new file mode 100644 index 0000000000000..ebe4d10475cc9 --- /dev/null +++ b/app/code/Magento/Ui/Test/Unit/Component/Form/Element/DataType/Media/ImageTest.php @@ -0,0 +1,127 @@ +processor = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponent\Processor::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->context->expects($this->atLeastOnce())->method('getProcessor')->willReturn($this->processor); + + $this->storeManager = $this->getMockForAbstractClass(StoreManagerInterface::class); + + $this->store = $this->getMockBuilder(StoreInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->store->expects($this->any())->method('getId')->willReturn(0); + + $this->storeManager->expects($this->any())->method('getStore')->willReturn($this->store); + + $this->fileSize = $this->getMockBuilder(Size::class)->getMock(); + + $this->objectManager = new ObjectManager($this); + + $this->image = $this->objectManager->getObject(Image::class, [ + 'context' => $this->context, + 'storeManager' => $this->storeManager, + 'fileSize' => $this->fileSize + ]); + + $this->image->setData([ + 'config' => [ + 'initialMediaGalleryOpenSubpath' => 'open/sesame', + ], + ]); + } + + /** + * @dataProvider prepareDataProvider + */ + public function testPrepare() + { + $this->assertExpectedPreparedConfiguration(...func_get_args()); + } + + /** + * Data provider for testPrepare + * @return array + */ + public function prepareDataProvider(): array + { + return [ + [['maxFileSize' => 10], 10, ['maxFileSize' => 10]], + [['maxFileSize' => null], 10, ['maxFileSize' => 10]], + [['maxFileSize' => 10], 5, ['maxFileSize' => 5]], + [['maxFileSize' => 10], 20, ['maxFileSize' => 10]], + [['maxFileSize' => 0], 10, ['maxFileSize' => 10]], + ]; + } + + /** + * @param array $initialConfig + * @param int $maxFileSizeSupported + * @param array $expectedPreparedConfig + */ + private function assertExpectedPreparedConfiguration( + array $initialConfig, + int $maxFileSizeSupported, + array $expectedPreparedConfig + ) { + $this->image->setData(array_merge_recursive(['config' => $initialConfig], $this->image->getData())); + + $this->fileSize->expects($this->any())->method('getMaxFileSize')->willReturn($maxFileSizeSupported); + + $this->image->prepare(); + + $actualRelevantPreparedConfig = array_intersect_key($this->image->getConfiguration(), $initialConfig); + + $this->assertEquals( + $expectedPreparedConfig, + $actualRelevantPreparedConfig + ); + } +} diff --git a/app/code/Magento/Ui/Test/Unit/Controller/Adminhtml/Index/RenderTest.php b/app/code/Magento/Ui/Test/Unit/Controller/Adminhtml/Index/RenderTest.php index d41c90bfa760a..05b35fb017b4b 100644 --- a/app/code/Magento/Ui/Test/Unit/Controller/Adminhtml/Index/RenderTest.php +++ b/app/code/Magento/Ui/Test/Unit/Controller/Adminhtml/Index/RenderTest.php @@ -8,9 +8,11 @@ use Magento\Ui\Controller\Adminhtml\Index\Render; use Magento\Ui\Model\UiComponentTypeResolver; use Magento\Framework\View\Element\UiComponent\ContextInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyFields) */ class RenderTest extends \PHPUnit\Framework\TestCase { @@ -19,6 +21,11 @@ class RenderTest extends \PHPUnit\Framework\TestCase */ private $render; + /** + * @var ObjectManagerHelper + */ + private $objectManagerHelper; + /** * @var \PHPUnit_Framework_MockObject_MockObject */ @@ -80,6 +87,16 @@ class RenderTest extends \PHPUnit\Framework\TestCase */ private $uiComponentTypeResolverMock; + /** + * @var \Magento\Framework\Controller\Result\JsonFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $resultJsonFactoryMock; + + /** + * @var \Psr\Log\LoggerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $loggerMock; + protected function setUp() { $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\Request\Http::class) @@ -121,6 +138,14 @@ protected function setUp() ['render'] ); + $this->resultJsonFactoryMock = $this->getMockBuilder( + \Magento\Framework\Controller\Result\JsonFactory::class + ) + ->disableOriginalConstructor() + ->getMock(); + + $this->loggerMock = $this->getMockForAbstractClass(\Psr\Log\LoggerInterface::class); + $this->contextMock->expects($this->any()) ->method('getRequest') ->willReturn($this->requestMock); @@ -146,7 +171,71 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); - $this->render = new Render($this->contextMock, $this->uiFactoryMock, $this->uiComponentTypeResolverMock); + $this->objectManagerHelper = new ObjectManagerHelper($this); + + $this->render = $this->objectManagerHelper->getObject( + \Magento\Ui\Controller\Adminhtml\Index\Render::class, + [ + 'context' => $this->contextMock, + 'factory' => $this->uiFactoryMock, + 'contentTypeResolver' => $this->uiComponentTypeResolverMock, + 'resultJsonFactory' => $this->resultJsonFactoryMock, + 'logger' => $this->loggerMock, + ] + ); + } + + public function testExecuteAjaxRequestException() + { + $name = 'test-name'; + $renderedData = 'data'; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->with('namespace') + ->willReturn($name); + $this->requestMock->expects($this->any()) + ->method('getParams') + ->willReturn([]); + $this->responseMock->expects($this->once()) + ->method('appendBody') + ->willThrowException(new \Exception('exception')); + + $jsonResultMock = $this->getMockBuilder(\Magento\Framework\Controller\Result\Json::class) + ->disableOriginalConstructor() + ->setMethods(['setData']) + ->getMock(); + + $this->resultJsonFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($jsonResultMock); + + $jsonResultMock->expects($this->once()) + ->method('setData') + ->willReturnSelf(); + + $this->loggerMock->expects($this->once()) + ->method('critical') + ->willReturnSelf(); + + $this->dataProviderMock->expects($this->once()) + ->method('getConfigData') + ->willReturn([]); + + $this->uiComponentMock->expects($this->once()) + ->method('render') + ->willReturn($renderedData); + $this->uiComponentMock->expects($this->once()) + ->method('getChildComponents') + ->willReturn([]); + $this->uiComponentMock->expects($this->once()) + ->method('getContext') + ->willReturn($this->uiComponentContextMock); + $this->uiFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->uiComponentMock); + + $this->render->executeAjaxRequest(); } public function testExecuteAjaxRequest() diff --git a/app/code/Magento/Ui/Test/Unit/Model/ManagerTest.php b/app/code/Magento/Ui/Test/Unit/Model/ManagerTest.php index 414f52037542d..e569688eaa8c6 100644 --- a/app/code/Magento/Ui/Test/Unit/Model/ManagerTest.php +++ b/app/code/Magento/Ui/Test/Unit/Model/ManagerTest.php @@ -152,9 +152,9 @@ public function testGetReader() public function testPrepareDataWithoutName() { - $this->expectException( - \Magento\Framework\Exception\LocalizedException::class, - __('The "" UI component element name is invalid. Verify the name and try again.') + $this->expectException(\Magento\Framework\Exception\LocalizedException::class); + $this->expectExceptionMessage( + (string)__('The "" UI component element name is invalid. Verify the name and try again.') ); $this->manager->prepareData(null); } diff --git a/app/code/Magento/Ui/Test/Unit/Model/ResourceModel/BookmarkRepositoryTest.php b/app/code/Magento/Ui/Test/Unit/Model/ResourceModel/BookmarkRepositoryTest.php index 5ba14d15f6b06..be3c1c60da5bc 100644 --- a/app/code/Magento/Ui/Test/Unit/Model/ResourceModel/BookmarkRepositoryTest.php +++ b/app/code/Magento/Ui/Test/Unit/Model/ResourceModel/BookmarkRepositoryTest.php @@ -94,7 +94,8 @@ public function testSaveWithException() ->method('save') ->with($this->bookmarkMock) ->willThrowException(new \Exception($exceptionMessage)); - $this->expectException(\Magento\Framework\Exception\CouldNotSaveException::class, __($exceptionMessage)); + $this->expectException(\Magento\Framework\Exception\CouldNotSaveException::class); + $this->expectExceptionMessage($exceptionMessage); $this->bookmarkRepository->save($this->bookmarkMock); } @@ -121,10 +122,12 @@ public function testGetByIdWithException() ->method('load') ->with($this->bookmarkMock, $notExistsBookmarkId) ->willReturn($this->bookmarkMock); - $this->expectException( - \Magento\Framework\Exception\NoSuchEntityException::class, - __('The bookmark with "%1" ID doesn\'t exist. Verify your information and try again.', $notExistsBookmarkId) + $this->expectException(\Magento\Framework\Exception\NoSuchEntityException::class); + $exceptionMessage = (string)__( + 'The bookmark with "%1" ID doesn\'t exist. Verify your information and try again.', + $notExistsBookmarkId ); + $this->expectExceptionMessage($exceptionMessage); $this->bookmarkRepository->getById($notExistsBookmarkId); } @@ -143,7 +146,8 @@ public function testDeleteWithException() ->method('delete') ->with($this->bookmarkMock) ->willThrowException(new \Exception($exceptionMessage)); - $this->expectException(\Magento\Framework\Exception\CouldNotDeleteException::class, __($exceptionMessage)); + $this->expectException(\Magento\Framework\Exception\CouldNotDeleteException::class); + $this->expectExceptionMessage($exceptionMessage); $this->assertTrue($this->bookmarkRepository->delete($this->bookmarkMock)); } diff --git a/app/code/Magento/Ui/composer.json b/app/code/Magento/Ui/composer.json index 2edcc3507970d..296332e383509 100644 --- a/app/code/Magento/Ui/composer.json +++ b/app/code/Magento/Ui/composer.json @@ -5,19 +5,18 @@ "sort-packages": true }, "require": { - "php": "7.0.2|7.0.4|~7.0.6|~7.1.0", - "magento/framework": "100.3.*", - "magento/module-authorization": "100.3.*", - "magento/module-backend": "100.3.*", - "magento/module-eav": "100.3.*", - "magento/module-store": "100.3.*", - "magento/module-user": "100.3.*" + "php": "~7.1.3||~7.2.0", + "magento/framework": "*", + "magento/module-authorization": "*", + "magento/module-backend": "*", + "magento/module-eav": "*", + "magento/module-store": "*", + "magento/module-user": "*" }, "suggest": { - "magento/module-config": "100.3.*" + "magento/module-config": "*" }, "type": "magento2-module", - "version": "100.3.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Ui/etc/adminhtml/di.xml b/app/code/Magento/Ui/etc/adminhtml/di.xml index 416d7a6916f88..e220581c42c24 100644 --- a/app/code/Magento/Ui/etc/adminhtml/di.xml +++ b/app/code/Magento/Ui/etc/adminhtml/di.xml @@ -44,4 +44,21 @@ + + + + Magento\Ui\Model\ColorPicker\FullMode + Magento\Ui\Model\ColorPicker\SimpleMode + Magento\Ui\Model\ColorPicker\NoAlphaMode + Magento\Ui\Model\ColorPicker\PaletteOnlyMode + + + + + + + Magento\Ui\Model\UrlInput\Url + + + diff --git a/app/code/Magento/Ui/etc/db_schema.xml b/app/code/Magento/Ui/etc/db_schema.xml index c5e5392277490..d07329df9eb21 100644 --- a/app/code/Magento/Ui/etc/db_schema.xml +++ b/app/code/Magento/Ui/etc/db_schema.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    diff --git a/app/code/Magento/Ui/etc/di.xml b/app/code/Magento/Ui/etc/di.xml index 09a17f8b2af3f..c029e18addf73 100644 --- a/app/code/Magento/Ui/etc/di.xml +++ b/app/code/Magento/Ui/etc/di.xml @@ -252,6 +252,7 @@ + Magento\Framework\Data\Argument\Interpreter\Constant configurableObjectArgumentInterpreterProxy configurableObjectArgumentInterpreterProxy arrayArgumentInterpreterProxy @@ -416,6 +417,7 @@ Magento\Ui\Config\Converter\AdditionalClasses Magento\Ui\Config\Converter\Options Magento\Ui\Config\Converter\Actions\Proxy + Magento\Ui\Config\Converter\Actions\Proxy type diff --git a/app/code/Magento/Ui/etc/ui_components.xsd b/app/code/Magento/Ui/etc/ui_components.xsd index 3d3c0d11bb454..eb8cc08f904fd 100644 --- a/app/code/Magento/Ui/etc/ui_components.xsd +++ b/app/code/Magento/Ui/etc/ui_components.xsd @@ -17,6 +17,7 @@ + @@ -28,6 +29,7 @@ + diff --git a/app/code/Magento/Ui/etc/ui_configuration.xsd b/app/code/Magento/Ui/etc/ui_configuration.xsd index 5783323b53188..b839593a38f47 100644 --- a/app/code/Magento/Ui/etc/ui_configuration.xsd +++ b/app/code/Magento/Ui/etc/ui_configuration.xsd @@ -19,6 +19,7 @@ + @@ -54,6 +55,7 @@ + @@ -65,6 +67,7 @@ + @@ -157,6 +160,7 @@ + @@ -205,6 +209,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -283,7 +311,7 @@ - + @@ -448,6 +476,14 @@ + + + + The ColorPicker component uses the Spectrum and tinycolor .js libraries to make it easier to choose and + implement color values. + + + @@ -780,4 +816,12 @@ + + + + The Url Input component allows to insert External URL and relative URL into the content. Add abilities + to define custom url link types. + + + diff --git a/app/code/Magento/Ui/etc/ui_definition.xsd b/app/code/Magento/Ui/etc/ui_definition.xsd index d1787309d051e..e86e2654ff629 100644 --- a/app/code/Magento/Ui/etc/ui_definition.xsd +++ b/app/code/Magento/Ui/etc/ui_definition.xsd @@ -28,6 +28,7 @@ + @@ -75,6 +76,7 @@ + diff --git a/app/code/Magento/Ui/i18n/en_US.csv b/app/code/Magento/Ui/i18n/en_US.csv index 225d83387563b..ed4386f6000c3 100644 --- a/app/code/Magento/Ui/i18n/en_US.csv +++ b/app/code/Magento/Ui/i18n/en_US.csv @@ -201,3 +201,4 @@ CSV,CSV "Please enter at least {0} characters.","Please enter at least {0} characters." "Please enter a value between {0} and {1} characters long.","Please enter a value between {0} and {1} characters long." "Please enter a value between {0} and {1}.","Please enter a value between {0} and {1}." +"was not uploaded","was not uploaded" \ No newline at end of file diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition.map.xml b/app/code/Magento/Ui/view/base/ui_component/etc/definition.map.xml index 713cf2217d168..94272f723ec77 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition.map.xml +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition.map.xml @@ -103,6 +103,16 @@ + + + + + settings/colorPickerMode + settings/colorFormat + + + + @@ -287,6 +297,12 @@ @formElement + + formElements/*[name(.)=../../@formElement]/settings/colorPickerMode + + + formElements/*[name(.)=../../@formElement]/settings/colorFormat + formElements/*[name(.)=../../@formElement]/settings/wysiwyg @@ -362,6 +378,9 @@ formElements/*[name(.)=../../@formElement]/settings/openDialogTitle + + formElements/*[name(.)=../../@formElement]/settings/initialMediaGalleryOpenSubpath + formElements/*[name(.)=../../@formElement]/settings/customEntry @@ -407,6 +426,22 @@ + + + + + + + settings/urlTypes + settings/isDisplayAdditionalSettings + settings/settingLabel + settings/typeSelectorTemplate + settings/settingTemplate + settings/settingValue + + + + @@ -427,6 +462,7 @@ settings/openDialogTitle + settings/initialMediaGalleryOpenSubpath diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition.xml b/app/code/Magento/Ui/view/base/ui_component/etc/definition.xml index 88997f4e4cba7..75374a82f1da3 100755 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition.xml +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition.xml @@ -144,7 +144,25 @@ - + + + wysiwyg + + + + + ui/form/element/color-picker + rgb + full + + + + + ui/form/element/urlInput/setting + ui/form/element/urlInput/typeSelector + true + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/boolean.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/boolean.xsd index 7cc33cf4351df..21ca445a4177b 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/boolean.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/boolean.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/button.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/button.xsd index f4b4dd403509f..3c9866e6845e7 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/button.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/button.xsd @@ -23,6 +23,19 @@ + + + + + + + + + + + + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/checkbox.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/checkbox.xsd index 13bc629606470..4dfb074498e5b 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/checkbox.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/checkbox.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/checkboxset.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/checkboxset.xsd index b0dea52cb4bd9..8fa509fa594d6 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/checkboxset.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/checkboxset.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/colorPicker.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/colorPicker.xsd new file mode 100644 index 0000000000000..dea1f7ce059e9 --- /dev/null +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/colorPicker.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Defines the color format that is displayed in selection tool as well as in input field. + Valid formats: hex, rgb, hsl, hsv, name, none + + + + + + + Defines the mode that affects available color picker functionality + Valid modes: simple, full, noalpha, palette + + + + + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/date.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/date.xsd index b24ba626a9ea1..a2a8fbbe103c1 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/date.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/date.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/email.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/email.xsd index cd1147b560034..648eaf9db5b18 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/email.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/email.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/file.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/file.xsd index eac284ddc92d8..1536f752a0aaf 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/file.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/file.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/fileUploader.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/fileUploader.xsd index 3181a7ab673cd..664a2d0e8d904 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/fileUploader.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/fileUploader.xsd @@ -23,6 +23,19 @@ + + + + + + + + + + + + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/hidden.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/hidden.xsd index 82282dd61673f..0e7a6f4ed5fe0 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/hidden.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/hidden.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/imageUploader.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/imageUploader.xsd index b78defa668a0e..f32d5c2c0fe8f 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/imageUploader.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/imageUploader.xsd @@ -23,6 +23,19 @@ + + + + + + + + + + + + + @@ -33,6 +46,13 @@ + + + + Defines the initial subpath relative to root that will be open when opening media gallery + + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/input.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/input.xsd index fc3ea2d92576a..047212f3973e4 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/input.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/input.xsd @@ -15,6 +15,7 @@ + @@ -23,6 +24,18 @@ + + + + + + + + + + + + @@ -73,7 +86,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/multiselect.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/multiselect.xsd index 28d9d24868820..aefb0328c8800 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/multiselect.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/multiselect.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/price.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/price.xsd index 5a3053e59be95..69b936c3e5f8a 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/price.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/price.xsd @@ -12,7 +12,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/radioset.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/radioset.xsd index c53ad0333d6d7..f2db9a57c5a1c 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/radioset.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/radioset.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/select.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/select.xsd index 7e0d2dfbfeb18..76d37de771a4c 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/select.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/select.xsd @@ -15,6 +15,7 @@ + @@ -29,9 +30,21 @@ + + + + + + + + + + + + + - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/text.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/text.xsd index e382af84111c7..813ad7870bfe9 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/text.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/text.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/textarea.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/textarea.xsd index ad7fda6200a79..67ee63a3f889f 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/textarea.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/textarea.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/ui_settings.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/ui_settings.xsd index 9f9d1f519f45e..cbf69e6046943 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/ui_settings.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/ui_settings.xsd @@ -219,6 +219,15 @@ + + + + The array of the configuration for urls types to be displayed in the list for selection. + + + + + @@ -601,18 +610,33 @@ - + - + + + + + The array of the configuration for urls types to be displayed in the list for selection. + + + + + + Path to the PHP class that provides configuration. + + + + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/urlInput.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/urlInput.xsd new file mode 100644 index 0000000000000..98918d92944e9 --- /dev/null +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/urlInput.xsd @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Options for "urlInput" element + + + + + + + The path to the custom url setting ".html" template. + + + + + + + The path to the custom url types selector ".html" template. + + + + + + + The label for custom url setting. + + + + + + + Allows to specify if display additional settings + + + + + + + Allows to specify default value for setting + + + + + + diff --git a/app/code/Magento/Ui/view/base/ui_component/etc/definition/wysiwyg.xsd b/app/code/Magento/Ui/view/base/ui_component/etc/definition/wysiwyg.xsd index 62c81234aba33..3e7bbb3a59f8c 100644 --- a/app/code/Magento/Ui/view/base/ui_component/etc/definition/wysiwyg.xsd +++ b/app/code/Magento/Ui/view/base/ui_component/etc/definition/wysiwyg.xsd @@ -12,6 +12,20 @@ + + + + + + + + + + + + + + @@ -25,7 +39,6 @@ - diff --git a/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dynamic-rows-grid.js b/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dynamic-rows-grid.js index c707c504e2384..dc6f8d930a144 100644 --- a/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dynamic-rows-grid.js +++ b/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dynamic-rows-grid.js @@ -109,9 +109,8 @@ define([ * @param {String|Number} recordId */ deleteRecord: function (index, recordId) { - this._super(); - this.updateInsertData(recordId); + this._super(); }, /** @@ -124,7 +123,7 @@ define([ prop = this.map[this.identificationDRProperty]; this.insertData(_.reject(this.source.get(this.dataProvider), function (recordData) { - return ~~recordData[prop] === ~~data[prop]; + return recordData[prop].toString() === data[prop].toString(); }, this)); }, diff --git a/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dynamic-rows.js b/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dynamic-rows.js index 01fa03d1b4b67..7537107560cb4 100644 --- a/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dynamic-rows.js +++ b/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dynamic-rows.js @@ -224,6 +224,14 @@ define([ return this; }, + /** @inheritdoc */ + destroy: function () { + if (this.dnd()) { + this.dnd().destroy(); + } + this._super(); + }, + /** * Calls 'initObservable' of parent * @@ -547,7 +555,6 @@ define([ columnsHeaderClasses: cell.config.columnsHeaderClasses, sortOrder: cell.config.sortOrder }); - labels.push(data); }, this); this.labels(_.sortBy(labels, 'sortOrder')); @@ -714,6 +721,8 @@ define([ * @param {Number} page - current page */ changePage: function (page) { + this.clear(); + if (page === 1 && !this.recordData().length) { return false; } @@ -755,7 +764,6 @@ define([ * Change page to next */ nextPage: function () { - this.clear(); this.currentPage(this.currentPage() + 1); }, @@ -763,7 +771,6 @@ define([ * Change page to previous */ previousPage: function () { - this.clear(); this.currentPage(this.currentPage() - 1); }, @@ -903,7 +910,7 @@ define([ prop = prop || this.identificationProperty; return _.reject(this.getChildItems(), function (recordData) { - return ~~recordData[prop] === ~~id; + return recordData[prop].toString() === id.toString(); }, this); }, diff --git a/app/code/Magento/Ui/view/base/web/js/form/components/html.js b/app/code/Magento/Ui/view/base/web/js/form/components/html.js index 82e51aff40287..c8bb849361af1 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/components/html.js +++ b/app/code/Magento/Ui/view/base/web/js/form/components/html.js @@ -20,7 +20,10 @@ define([ loading: false, visible: true, template: 'ui/content/content', - additionalClasses: {} + additionalClasses: {}, + ignoreTmpls: { + content: true + } }, /** diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/abstract.js b/app/code/Magento/Ui/view/base/web/js/form/element/abstract.js index b6979121a1891..5177b4a378d69 100755 --- a/app/code/Magento/Ui/view/base/web/js/form/element/abstract.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/abstract.js @@ -220,7 +220,7 @@ define([ }, /** - * Sets 'value' as 'hidden' propertie's value, triggers 'toggle' event, + * Sets 'value' as 'hidden' property's value, triggers 'toggle' event, * sets instance's hidden identifier in params storage based on * 'value'. * diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/color-picker-palette.js b/app/code/Magento/Ui/view/base/web/js/form/element/color-picker-palette.js new file mode 100644 index 0000000000000..ffa8700f3f9e9 --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/js/form/element/color-picker-palette.js @@ -0,0 +1,45 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +/** + * @api + */ +define([], function () { + 'use strict'; + + return [ + [ + 'rgb(0,0,0)', 'rgb(52,52,52)', 'rgb(83,83,83)', 'rgb(135,135,135)', 'rgb(193,193,193)', + 'rgb(234,234,234)', 'rgb(240,240,240)', 'rgb(255,255,255)' + ], + [ + 'rgb(252,0,9)', 'rgb(253,135,10)', 'rgb(255,255,13)', 'rgb(35,255,9)', 'rgb(33,255,255)', + 'rgb(0,0,254)', 'rgb(132,0,254)', 'rgb(251,0,255)' + ], + [ + 'rgb(240,192,194)', 'rgb(251,223,194)', 'rgb(255,241,193)', 'rgb(210,230,201)', + 'rgb(199,217,220)', 'rgb(197,219,240)', 'rgb(208,200,227)', 'rgb(229,199,212)' + ], + [ + 'rgb(228,133,135)', 'rgb(246,193,139)', 'rgb(254,225,136)', 'rgb(168,208,152)', + 'rgb(146,184,190)', 'rgb(143,184,227)', 'rgb(165,148,204)', 'rgb(202,147,175)' + ], + [ + 'rgb(214,78,83)', 'rgb(243,163,88)', 'rgb(254,211,83)', 'rgb(130,187,106)', + 'rgb(99,149,159)', 'rgb(93,150,211)', 'rgb(123,100,182)', 'rgb(180,100,142)' + ], + [ + 'rgb(190,0,5)', 'rgb(222,126,44)', 'rgb(236,183,39)', 'rgb(89,155,61)', 'rgb(55,110,123)', + 'rgb(49,112,185)', 'rgb(83,55,150)', 'rgb(147,55,101)' + ], + [ + 'rgb(133,0,3)', 'rgb(163,74,10)', 'rgb(177,127,7)', 'rgb(45,101,23)', 'rgb(18,62,74)', + 'rgb(14,62,129)', 'rgb(40,15,97)', 'rgb(95,16,55)' + ], + [ + 'rgb(81,0,1)', 'rgb(100,48,7)', 'rgb(107,78,3)', 'rgb(31,63,16)', + 'rgb(13,39,46)', 'rgb(10,40,79)', 'rgb(24,12,59)', 'rgb(59,10,36)' + ] + ]; +}); diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/color-picker.js b/app/code/Magento/Ui/view/base/web/js/form/element/color-picker.js new file mode 100644 index 0000000000000..c08fbd98908e5 --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/js/form/element/color-picker.js @@ -0,0 +1,42 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/** + * @api + */ +define([ + 'mage/translate', + 'Magento_Ui/js/form/element/abstract', + 'Magento_Ui/js/form/element/color-picker-palette' +], function ($t, Abstract, palette) { + 'use strict'; + + return Abstract.extend({ + + defaults: { + colorPickerConfig: { + chooseText: $t('Apply'), + cancelText: $t('Cancel'), + maxSelectionSize: 8, + clickoutFiresChange: true, + allowEmpty: true, + localStorageKey: 'magento.spectrum', + palette: palette + } + }, + + /** + * Invokes initialize method of parent class, + * contains initialization logic + */ + initialize: function () { + this._super(); + + this.colorPickerConfig.value = this.value; + + return this; + } + }); +}); diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/country.js b/app/code/Magento/Ui/view/base/web/js/form/element/country.js index 49083374767ec..f64a80bf535ec 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/country.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/country.js @@ -28,7 +28,7 @@ define([ * @param {String} field */ filter: function (value, field) { - var result; + var result, defaultCountry, defaultValue; if (!field) { //validate field, if we are on update field = this.filterBy.field; @@ -46,6 +46,17 @@ define([ this.setOptions(result); this.reset(); + + if (!this.value()) { + defaultCountry = _.filter(result, function (item) { + return item['is_default'] && item['is_default'].includes(value); + }); + + if (defaultCountry.length) { + defaultValue = defaultCountry.shift(); + this.value(defaultValue.value); + } + } } }); }); diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js b/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js index bbac27141ba22..b583d0be69f34 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js @@ -13,13 +13,16 @@ define([ 'Magento_Ui/js/modal/alert', 'Magento_Ui/js/lib/validation/validator', 'Magento_Ui/js/form/element/abstract', + 'mage/backend/notification', + 'mage/translate', 'jquery/file-uploader' -], function ($, _, utils, uiAlert, validator, Element) { +], function ($, _, utils, uiAlert, validator, Element, notification, $t) { 'use strict'; return Element.extend({ defaults: { value: [], + aggregatedErrors: [], maxFileSize: false, isMultipleFiles: false, placeholderType: 'document', // 'image', 'video' @@ -278,9 +281,15 @@ define([ * @returns {FileUploader} Chainable. */ notifyError: function (msg) { - uiAlert({ + var data = { content: msg - }); + }; + + if (this.isMultipleFiles) { + data.modalClass = '_image-box'; + } + + uiAlert(data); return this; }, @@ -309,13 +318,19 @@ define([ }, /** - * Abstract handler which is invoked when files are choosed for upload. + * Handler which is invoked when files are choosed for upload. * May be used for implementation of aditional validation rules, * e.g. total files and a total size rules. * - * @abstract + * @param {Event} e - Event object. + * @param {Object} data - File data that will be uploaded. */ - onFilesChoosed: function () {}, + onFilesChoosed: function (e, data) { + // no option exists in fileuploader for restricting upload chains to single files; this enforces that policy + if (!this.isMultipleFiles) { + data.files.splice(1); + } + }, /** * Handler which is invoked prior to the start of a file upload. @@ -337,10 +352,28 @@ define([ data.submit(); }); } else { - this.notifyError(allowed.message); + this.aggregateError(file.name, allowed.message); + + // if all files in upload chain are invalid, stop callback is never called; this resolves promise + if (this.aggregatedErrors.length === data.originalFiles.length) { + this.uploaderConfig.stop(); + } } }, + /** + * Add error message associated with filename for display when upload chain is complete + * + * @param {String} filename + * @param {String} message + */ + aggregateError: function (filename, message) { + this.aggregatedErrors.push({ + filename: filename, + message: message + }); + }, + /** * Handler of the file upload complete event. * @@ -348,11 +381,12 @@ define([ * @param {Object} data */ onFileUploaded: function (e, data) { - var file = data.result, + var uploadedFilename = data.files[0].name, + file = data.result, error = file.error; error ? - this.notifyError(error) : + this.aggregateError(uploadedFilename, error) : this.addFile(file); }, @@ -367,7 +401,45 @@ define([ * Load stop event handler. */ onLoadingStop: function () { + var aggregatedErrorMessages = []; + this.isLoading = false; + + if (!this.aggregatedErrors.length) { + return; + } + + if (!this.isMultipleFiles) { // only single file upload occurred; use first file's error message + aggregatedErrorMessages.push(this.aggregatedErrors[0].message); + } else { // construct message from all aggregatedErrors + _.each(this.aggregatedErrors, function (error) { + notification().add({ + error: true, + message: '%s' + error.message, // %s to be used as placeholder for html injection + + /** + * Adds constructed error notification to aggregatedErrorMessages + * + * @param {String} constructedMessage + */ + insertMethod: function (constructedMessage) { + var errorMsgBodyHtml = '%s %s.
    ' + .replace('%s', error.filename) + .replace('%s', $t('was not uploaded')); + + // html is escaped in message body for notification widget; prepend unescaped html here + constructedMessage = constructedMessage.replace('%s', errorMsgBodyHtml); + + aggregatedErrorMessages.push(constructedMessage); + } + }); + }); + } + + this.notifyError(aggregatedErrorMessages.join('')); + + // clear out aggregatedErrors array for this completed upload chain + this.aggregatedErrors = []; }, /** diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/image-uploader.js b/app/code/Magento/Ui/view/base/web/js/form/element/image-uploader.js index dabb9783abefd..69c9fb74cbce1 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/image-uploader.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/image-uploader.js @@ -3,6 +3,7 @@ * See COPYING.txt for license details. */ +/* global Base64 */ define([ 'jquery', 'underscore', @@ -10,7 +11,8 @@ define([ 'Magento_Ui/js/modal/alert', 'Magento_Ui/js/lib/validation/validator', 'Magento_Ui/js/form/element/file-uploader', - 'mage/adminhtml/browser' + 'mage/adminhtml/browser', + 'mage/adminhtml/tools' ], function ($, _, utils, uiAlert, validator, Element, browser) { 'use strict'; @@ -55,7 +57,7 @@ define([ }, /** - * Open the media browser dialog using the + * Open the media browser dialog * * @param {ImageUploader} imageUploader - UI Class * @param {Event} e @@ -65,7 +67,11 @@ define([ openDialogUrl = this.mediaGallery.openDialogUrl + 'target_element_id/' + $buttonEl.attr('id') + '/store/' + this.mediaGallery.storeId + - '/type/image/use_storage_root/1?isAjax=true'; + '/type/image/?isAjax=true'; + + if (this.mediaGallery.initialOpenSubpath) { + openDialogUrl += '¤t_tree_path=' + Base64.mageEncode(this.mediaGallery.initialOpenSubpath); + } browser.openDialog(openDialogUrl, null, null, this.mediaGallery.openDialogTitle); }, diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/ui-select.js b/app/code/Magento/Ui/view/base/web/js/form/element/ui-select.js index 867527ae1aaf5..dba0992c5ba52 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/ui-select.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/ui-select.js @@ -165,6 +165,21 @@ define([ lotPlaceholders: $t('Selected') }, separator: 'optgroup', + searchOptions: false, + loading: false, + searchUrl: false, + lastSearchKey: '', + lastSearchPage: 1, + filterPlaceholder: '', + emptyOptionsHtml: '', + cachedSearchResults: {}, + pageLimit: 50, + deviation: 30, + validationLoading: false, + isRemoveSelectedIcon: false, + debounce: 300, + missingValuePlaceholder: $t('Entity with ID: %s doesn\'t exist'), + isDisplayMissingValuePlaceholder: false, listens: { listVisible: 'cleanHoveredElement', filterInputValue: 'filterOptionsList', @@ -308,7 +323,10 @@ define([ 'options', 'itemsQuantity', 'filterInputValue', - 'filterOptionsFocus' + 'filterOptionsFocus', + 'loading', + 'validationLoading', + 'isDisplayMissingValuePlaceholder' ]); this.filterInputValue.extend({ @@ -441,6 +459,10 @@ define([ return false; } + if (this.searchOptions) { + return _.debounce(this.loadOptions.bind(this, value), this.debounce)(); + } + this.cleanHoveredElement(); if (!value) { @@ -574,6 +596,20 @@ define([ return this.multiple ? _.contains(this.value(), value) : this.value() === value; }, + /** + * Check selected option + * + * @param {Object} option - option value + * @return {Boolean} + */ + isSelectedValue: function (option) { + if (_.isUndefined(option)) { + return false; + } + + return this.isSelected(option.value); + }, + /** * Check optgroup label * @@ -600,6 +636,10 @@ define([ elementData = ko.dataFor(this.hoveredElement); + if (_.isUndefined(elementData)) { + return false; + } + return data.value === elementData.value; }, @@ -921,7 +961,7 @@ define([ * Set caption */ setCaption: function () { - var length; + var length, caption = ''; if (!_.isArray(this.value()) && this.value()) { length = 1; @@ -931,6 +971,16 @@ define([ this.value([]); length = 0; } + this.warn(caption); + + //check if option was removed + if (this.isDisplayMissingValuePlaceholder && length && !this.getSelected().length) { + caption = this.missingValuePlaceholder.replace('%s', this.value()); + this.placeholder(caption); + this.warn(caption); + + return this.placeholder(); + } if (length > 1) { this.placeholder(length + ' ' + this.selectedPlaceholders.lotPlaceholders); @@ -1083,6 +1133,156 @@ define([ targetSelector, this.onDelegatedMouseMouve.bind(this) ); + }, + + /** + * Returns options from cache or send request + * + * @param {String} searchKey + */ + loadOptions: function (searchKey) { + var currentPage = searchKey === this.lastSearchKey ? this.lastSearchPage + 1 : 1, + cachedSearchResult; + + this.renderPath = !!this.showPath; + + if (this.isSearchKeyCached(searchKey)) { + cachedSearchResult = this.getCachedSearchResults(searchKey); + this.options(cachedSearchResult.options); + this.afterLoadOptions(searchKey, cachedSearchResult.lastPage, cachedSearchResult.total); + + return; + } + + if (searchKey !== this.lastSearchKey) { + this.options([]); + } + this.processRequest(searchKey, currentPage); + }, + + /** + * Load more options on scroll down + * @param {Object} data + * @param {Event} event + */ + onScrollDown: function (data, event) { + var clientHight = event.target.scrollTop + event.target.clientHeight, + scrollHeight = event.target.scrollHeight; + + if (!this.searchOptions) { + return; + } + + if (clientHight > scrollHeight - this.deviation && !this.isSearchKeyCached(data.filterInputValue())) { + this.loadOptions(data.filterInputValue()); + } + }, + + /** + * Returns cached search result by search key + * + * @param {String} searchKey + * @return {Object} + */ + getCachedSearchResults: function (searchKey) { + if (this.cachedSearchResults.hasOwnProperty(searchKey)) { + return this.cachedSearchResults[searchKey]; + } + + return { + options: [], + lastPage: 1, + total: 0 + }; + }, + + /** + * Cache loaded data + * + * @param {String} searchKey + * @param {Array} optionsArray + * @param {Number} page + * @param {Number} total + */ + setCachedSearchResults: function (searchKey, optionsArray, page, total) { + var cachedData = {}; + + cachedData.options = optionsArray; + cachedData.lastPage = page; + cachedData.total = total; + this.cachedSearchResults[searchKey] = cachedData; + }, + + /** + * Check if search key cached + * + * @param {String} searchKey + * @return {Boolean} + */ + isSearchKeyCached: function (searchKey) { + var totalCached = this.cachedSearchResults.hasOwnProperty(searchKey) ? + this.deviation * this.cachedSearchResults[searchKey].lastPage : + 0; + + return totalCached > 0 && totalCached >= this.cachedSearchResults[searchKey].total; + }, + + /** + * Submit request to load data + * + * @param {String} searchKey + * @param {Number} page + */ + processRequest: function (searchKey, page) { + var total = 0, + existingOptions = this.options(); + + this.loading(true); + $.ajax({ + url: this.searchUrl, + type: 'post', + dataType: 'json', + context: this, + data: { + searchKey: searchKey, + page: page, + limit: this.pageLimit + }, + + /** @param {Object} response */ + success: function (response) { + _.each(response.options, function (opt) { + existingOptions.push(opt); + }); + total = response.total; + this.options(existingOptions); + }, + + /** set empty array if error occurs */ + error: function () { + this.options([]); + }, + + /** cache options and stop loading*/ + complete: function () { + this.setCachedSearchResults(searchKey, this.options(), page, total); + this.afterLoadOptions(searchKey, page, total); + } + }); + }, + + /** + * Stop loading and update data after options were updated + * + * @param {String} searchKey + * @param {Number} page + * @param {Number} total + */ + afterLoadOptions: function (searchKey, page, total) { + this._setItemsQuantity(total); + this.lastSearchPage = page; + this.lastSearchKey = searchKey; + this.loading(false); } }); }); diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/url-input.js b/app/code/Magento/Ui/view/base/web/js/form/element/url-input.js new file mode 100644 index 0000000000000..2bfce304b9adc --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/js/form/element/url-input.js @@ -0,0 +1,164 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/** + * @api + */ +define([ + 'underscore', + 'uiLayout', + 'mage/translate', + 'Magento_Ui/js/form/element/abstract' +], function (_, layout, $t, Abstract) { + 'use strict'; + + return Abstract.extend({ + defaults: { + linkedElement: {}, + settingTemplate: 'ui/form/element/urlInput/setting', + typeSelectorTemplate: 'ui/form/element/urlInput/typeSelector', + options: [], + linkedElementInstances: {}, + //checkbox + isDisplayAdditionalSettings: true, + settingValue: false, + settingLabel: $t('Open in new tab'), + tracks: { + linkedElement: true + }, + baseLinkSetting: { + namePrefix: '${$.name}.', + dataScopePrefix: '${$.dataScope}.', + provider: '${$.provider}' + }, + urlTypes: {}, + listens: { + settingValue: 'checked', + disabled: 'hideLinkedElement', + linkType: 'createChildUrlInputComponent' + }, + links: { + linkType: '${$.provider}:${$.dataScope}.type', + settingValue: '${$.provider}:${$.dataScope}.setting' + } + }, + + /** @inheritdoc */ + initConfig: function (config) { + var processedLinkTypes = {}, + baseLinkType = this.constructor.defaults.baseLinkSetting; + + _.each(config.urlTypes, function (linkSettingsArray, linkName) { + //add link name by link type + linkSettingsArray.name = baseLinkType.namePrefix + linkName; + linkSettingsArray.dataScope = baseLinkType.dataScopePrefix + linkName; + linkSettingsArray.type = linkName; + linkSettingsArray.disabled = config.disabled; + linkSettingsArray.visible = config.visible; + processedLinkTypes[linkName] = {}; + _.extend(processedLinkTypes[linkName], baseLinkType, linkSettingsArray); + }); + _.extend(this.constructor.defaults.urlTypes, processedLinkTypes); + + this._super(); + }, + + /** + * Initializes observable properties of instance + * + * @returns {Abstract} Chainable. + */ + initObservable: function () { + this._super() + .observe('componentTemplate options value linkType settingValue checked isDisplayAdditionalSettings') + .setOptions(); + + return this; + }, + + /** + * Set options to select based on link types configuration + * + * @return {Object} + */ + setOptions: function () { + var result = []; + + _.each(this.urlTypes, function (option, key) { + result.push({ + value: key, + label: option.label, + sortOrder: option.sortOrder || 0 + }); + }); + + //sort options by sortOrder + result.sort(function (a, b) { + return a.sortOrder > b.sortOrder ? 1 : -1; + }); + + this.options(result); + + return this; + }, + + /** @inheritdoc */ + setPreview: function (visible) { + this.linkedElement().visible(visible); + }, + + /** + * Initializes observable properties of instance + * + * @param {Boolean} disabled + */ + hideLinkedElement: function (disabled) { + this.linkedElement().disabled(disabled); + }, + + /** @inheritdoc */ + destroy: function () { + _.each(this.linkedElementInstances, function (value) { + value().destroy(); + }); + this._super(); + }, + + /** + * Create child component by value + * + * @param {String} value + * @return void + */ + createChildUrlInputComponent: function (value) { + var elementConfig; + + if (!_.isEmpty(value) && _.isUndefined(this.linkedElementInstances[value])) { + elementConfig = this.urlTypes[value]; + layout([elementConfig]); + this.linkedElementInstances[value] = this.requestModule(elementConfig.name); + } + this.linkedElement = this.linkedElementInstances[value]; + + }, + + /** + * Returns linked element to display related field in template + * @return String + */ + getLinkedElementName: function () { + return this.linkedElement; + }, + + /** + * Add ability to choose check box by clicking on label + */ + checkboxClick: function () { + if (!this.disabled()) { + this.settingValue(!this.settingValue()); + } + } + }); +}); diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/wysiwyg.js b/app/code/Magento/Ui/view/base/web/js/form/element/wysiwyg.js index dc5c2389ba8e5..de899fd8f2eff 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/wysiwyg.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/wysiwyg.js @@ -23,12 +23,10 @@ define([ value: '', $wysiwygEditorButton: '', links: { - value: '${ $.provider }:${ $.dataScope }', - stageActive: false + value: '${ $.provider }:${ $.dataScope }' }, template: 'ui/form/field', elementTmpl: 'ui/form/element/wysiwyg', - stageActive: false, content: '', showSpinner: false, loading: false, @@ -49,7 +47,8 @@ define([ component: this, selector: 'button' }, function (element) { - this.$wysiwygEditorButton = $(element); + this.$wysiwygEditorButton = this.$wysiwygEditorButton ? + this.$wysiwygEditorButton.add($(element)) : $(element); }.bind(this)); // disable editor completely after initialization is field is disabled @@ -110,7 +109,6 @@ define([ /* eslint-disable no-undef */ if (typeof wysiwyg !== 'undefined' && wysiwyg.activeEditor()) { - if (wysiwyg && disabled) { wysiwyg.setEnabledStatus(false); wysiwyg.getPluginButtons().prop('disabled', 'disabled'); diff --git a/app/code/Magento/Ui/view/base/web/js/form/provider.js b/app/code/Magento/Ui/view/base/web/js/form/provider.js index f43c03e046f44..d070e23c708bb 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/provider.js +++ b/app/code/Magento/Ui/view/base/web/js/form/provider.js @@ -21,6 +21,9 @@ define([ save: '${ $.submit_url }', beforeSave: '${ $.validate_url }' } + }, + ignoreTmpls: { + data: true } }, diff --git a/app/code/Magento/Ui/view/base/web/js/grid/filters/filters.js b/app/code/Magento/Ui/view/base/web/js/grid/filters/filters.js index 22ba19657168d..ecc4ec1902d87 100644 --- a/app/code/Magento/Ui/view/base/web/js/grid/filters/filters.js +++ b/app/code/Magento/Ui/view/base/web/js/grid/filters/filters.js @@ -10,8 +10,10 @@ define([ 'underscore', 'mageUtils', 'uiLayout', - 'uiCollection' -], function (_, utils, layout, Collection) { + 'uiCollection', + 'mage/translate', + 'jquery' +], function (_, utils, layout, Collection, $t, $) { 'use strict'; /** @@ -48,6 +50,7 @@ define([ stickyTmpl: 'ui/grid/sticky/filters', _processed: [], columnsProvider: 'ns = ${ $.ns }, componentType = columns', + bookmarksProvider: 'ns = ${ $.ns }, componentType = bookmark', applied: { placeholder: true }, @@ -102,7 +105,9 @@ define([ applied: '${ $.provider }:params.filters' }, imports: { - 'onColumnsUpdate': '${ $.columnsProvider }:elems' + onColumnsUpdate: '${ $.columnsProvider }:elems', + onBackendError: '${ $.provider }:lastError', + bookmarksActiveIndex: '${ $.bookmarksProvider }:activeIndex' }, modules: { columns: '${ $.columnsProvider }', @@ -371,6 +376,37 @@ define([ */ onColumnsUpdate: function (columns) { columns.forEach(this.addFilter, this); + }, + + /** + * Provider ajax error listener. + * + * @param {bool} isError - Selected index of the filter. + */ + onBackendError: function (isError) { + var defaultMessage = 'Something went wrong with processing the default view and we have restored the ' + + 'filter to its original state.', + customMessage = 'Something went wrong with processing current custom view and filters have been ' + + 'reset to its original state. Please edit filters then click apply.'; + + if (isError) { + this.clear(); + + $('body').notification('clear') + .notification('add', { + error: true, + message: $.mage.__(this.bookmarksActiveIndex !== 'default' ? customMessage : defaultMessage), + + /** + * @param {String} message + */ + insertMethod: function (message) { + var $wrapper = $('
    ').html(message); + + $('.page-main-actions').after($wrapper); + } + }); + } } }); }); diff --git a/app/code/Magento/Ui/view/base/web/js/grid/filters/range.js b/app/code/Magento/Ui/view/base/web/js/grid/filters/range.js index 438d6ccd58a55..ccfba8e98b6f4 100644 --- a/app/code/Magento/Ui/view/base/web/js/grid/filters/range.js +++ b/app/code/Magento/Ui/view/base/web/js/grid/filters/range.js @@ -27,7 +27,8 @@ define([ }, date: { component: 'Magento_Ui/js/form/element/date', - dateFormat: 'MM/dd/YYYY' + dateFormat: 'MM/dd/YYYY', + shiftedValue: 'filter' }, text: { component: 'Magento_Ui/js/form/element/abstract' diff --git a/app/code/Magento/Ui/view/base/web/js/grid/provider.js b/app/code/Magento/Ui/view/base/web/js/grid/provider.js index 14a64216ef597..2ba8bd73af910 100644 --- a/app/code/Magento/Ui/view/base/web/js/grid/provider.js +++ b/app/code/Magento/Ui/view/base/web/js/grid/provider.js @@ -21,6 +21,7 @@ define([ return Element.extend({ defaults: { firstLoad: true, + lastError: false, storageConfig: { component: 'Magento_Ui/js/grid/data-storage', provider: '${ $.storageConfig.name }', @@ -30,6 +31,9 @@ define([ listens: { params: 'onParamsChange', requestConfig: 'updateRequestConfig' + }, + ignoreTmpls: { + data: true } }, @@ -120,7 +124,7 @@ define([ request .done(this.onReload) - .fail(this.onError); + .fail(this.onError.bind(this)); return request; }, @@ -144,6 +148,10 @@ define([ return; } + this.set('lastError', true); + + this.firstLoad = false; + alert({ content: $t('Something went wrong.') }); @@ -157,6 +165,8 @@ define([ onReload: function (data) { this.firstLoad = false; + this.set('lastError', false); + this.setData(data) .trigger('reloaded'); }, diff --git a/app/code/Magento/Ui/view/base/web/js/lib/key-codes.js b/app/code/Magento/Ui/view/base/web/js/lib/key-codes.js index 2303aeceb9dc8..1f5a4210793ba 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/key-codes.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/key-codes.js @@ -20,6 +20,9 @@ define([], function () { 39: 'pageRightKey', 17: 'ctrlKey', 18: 'altKey', - 16: 'shiftKey' + 16: 'shiftKey', + 66: 'bKey', + 73: 'iKey', + 85: 'uKey' }; }); diff --git a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js index b29d10a143117..4518db598b4d3 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js @@ -37,6 +37,7 @@ define(function (require) { bindHtml: require('./bind-html'), tooltip: require('./tooltip'), repeat: require('knockoutjs/knockout-repeat'), - fastForEach: require('knockoutjs/knockout-fast-foreach') + fastForEach: require('knockoutjs/knockout-fast-foreach'), + colorPicker: require('./color-picker') }; }); diff --git a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/color-picker.js b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/color-picker.js new file mode 100644 index 0000000000000..c678b85276093 --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/color-picker.js @@ -0,0 +1,87 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +define([ + 'ko', + 'jquery', + '../template/renderer', + 'spectrum', + 'tinycolor' +], function (ko, $, renderer, spectrum, tinycolor) { + 'use strict'; + + /** + * Change color picker status to be enabled or disabled + * + * @param {HTMLElement} element - Element to apply colorpicker enable/disable status to. + * @param {Object} viewModel - Object, which represents view model binded to el. + */ + function changeColorPickerStateBasedOnViewModel(element, viewModel) { + $(element).spectrum(viewModel.disabled() ? 'disable' : 'enable'); + } + + ko.bindingHandlers.colorPicker = { + /** + * Binding init callback. + * + * @param {*} element + * @param {Function} valueAccessor + * @param {Function} allBindings + * @param {Object} viewModel + */ + init: function (element, valueAccessor, allBindings, viewModel) { + var config = valueAccessor(), + + /** change value */ + changeValue = function (value) { + if (value == null) { + value = ''; + } + config.value(value.toString()); + }; + + config.change = changeValue; + + config.hide = changeValue; + + /** show value */ + config.show = function () { + if (!viewModel.focused()) { + viewModel.focused(true); + } + + return true; + }; + + $(element).spectrum(config); + + changeColorPickerStateBasedOnViewModel(element, viewModel); + }, + + /** + * Reads params passed to binding, parses component declarations. + * Fetches for those found and attaches them to the new context. + * + * @param {HTMLElement} element - Element to apply bindings to. + * @param {Function} valueAccessor - Function that returns value, passed to binding. + * @param {Object} allBindings - Object, which represents all bindings applied to element. + * @param {Object} viewModel - Object, which represents view model binded to element. + */ + update: function (element, valueAccessor, allBindings, viewModel) { + var config = valueAccessor(); + + if (tinycolor(config.value()).isValid() || config.value() === '') { + $(element).spectrum('set', config.value()); + + if (config.value() !== '') { + config.value($(element).spectrum('get').toString()); + } + } + + changeColorPickerStateBasedOnViewModel(element, viewModel); + } + }; + + renderer.addAttribute('colorPicker'); +}); diff --git a/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js b/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js index 0ffd65c47d4cf..41e2703f4ed1e 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js @@ -11,10 +11,11 @@ define([ 'underscore', './utils', 'moment', + 'tinycolor', 'jquery/validate', 'jquery/ui', 'mage/translate' -], function ($, _, utils, moment) { +], function ($, _, utils, moment, tinycolor) { 'use strict'; /** @@ -909,8 +910,10 @@ define([ ], 'validate-item-quantity': [ function (value, params) { - // obtain values for validation - var qty = utils.parseNumber(value), + var validator = this, + result = false, + // obtain values for validation + qty = utils.parseNumber(value), isMinAllowedValid = typeof params.minAllowed === 'undefined' || qty >= utils.parseNumber(params.minAllowed), isMaxAllowedValid = typeof params.maxAllowed === 'undefined' || @@ -918,9 +921,42 @@ define([ isQtyIncrementsValid = typeof params.qtyIncrements === 'undefined' || qty % utils.parseNumber(params.qtyIncrements) === 0; - return isMaxAllowedValid && isMinAllowedValid && isQtyIncrementsValid && qty > 0; - }, - '' + result = qty > 0; + + if (result === false) { + validator.itemQtyErrorMessage = $.mage.__('Please enter a quantity greater than 0.');//eslint-disable-line max-len + + return result; + } + + result = isMinAllowedValid; + + if (result === false) { + validator.itemQtyErrorMessage = $.mage.__('The fewest you may purchase is %1.').replace('%1', params.minAllowed);//eslint-disable-line max-len + + return result; + } + + result = isMaxAllowedValid; + + if (result === false) { + validator.itemQtyErrorMessage = $.mage.__('The maximum you may purchase is %1.').replace('%1', params.maxAllowed);//eslint-disable-line max-len + + return result; + } + + result = isQtyIncrementsValid; + + if (result === false) { + validator.itemQtyErrorMessage = $.mage.__('You can buy this product only in quantities of %1 at a time.').replace('%1', params.qtyIncrements);//eslint-disable-line max-len + + return result; + } + + return result; + }, function () { + return this.itemQtyErrorMessage; + } ], 'equalTo': [ function (value, param) { @@ -963,6 +999,22 @@ define([ return moment.utc(value, params.dateFormat).unix() <= maxValue; }, $.mage.__('The date is not within the specified range.') + ], + 'validate-color': [ + function (value) { + if (value === '') { + return true; + } + + return tinycolor(value).isValid(); + }, + $.mage.__('Wrong color format. Please specify color in HEX, RGBa, HSVa, HSLa or use color name.') + ], + 'blacklist-url': [ + function (value, param) { + return new RegExp(param).test(value); + }, + $.mage.__('This link is not allowed.') ] }, function (data) { return { diff --git a/app/code/Magento/Ui/view/base/web/js/lib/view/utils/raf.js b/app/code/Magento/Ui/view/base/web/js/lib/view/utils/raf.js index 1cbbfb3ecee48..3ec0996543c7d 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/view/utils/raf.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/view/utils/raf.js @@ -19,6 +19,9 @@ define([ window.onRequestAnimationFrame || window.msRequestAnimationFrame || function (callback) { + if (typeof callback != 'function') { + throw new Error('raf argument "callback" must be of type function'); + } window.setTimeout(callback, 1000 / 60); }; diff --git a/app/code/Magento/Ui/view/base/web/templates/dynamic-rows/templates/grid.html b/app/code/Magento/Ui/view/base/web/templates/dynamic-rows/templates/grid.html index 25917bcbdb59a..d0b12549bd66d 100644 --- a/app/code/Magento/Ui/view/base/web/templates/dynamic-rows/templates/grid.html +++ b/app/code/Magento/Ui/view/base/web/templates/dynamic-rows/templates/grid.html @@ -8,14 +8,14 @@
    -
    - +
    @@ -69,7 +69,7 @@
    -
    +