diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 084aa8d8b65e..cb58dcad8dbe 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -19,11 +19,11 @@ For more detailed information on contribution please read our [beginners guide]( 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 [Mage-OS Magento Pull Request Template](https://github.com/mage-os/mageos-magento2/blob/HEAD/.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 -* Proposed [documentation](https://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/mage-os/mageos-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. +5. PRs which include new logic or new features must be submitted along with: + * Unit/integration test coverage + * Proposed [documentation](https://developer.adobe.com/commerce) updates. Use feedback buttons __Edit in GitHub__ and __Log an issue__ at the top of a relevant topic. +6. For larger features or changes, please [open an issue](https://github.com/mage-os/mageos-magento2/issues) to discuss the proposed changes prior to development. This may prevent duplicate or unnecessary effort and allow other contributors to provide input. +7. All automated tests must pass. ## Contribution process diff --git a/README.md b/README.md index a02a955a9ebb..7628b7b1afa5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ However, for those who need a full-featured eCommerce solution, we recommend [Ad ## Contribute -Our [Community](https://opensource.magento.com/) is large and diverse, and our project is enormous. As a contributor, you have countless opportunities to impact product development and delivery by introducing new features or improving existing ones, enhancing test coverage, updating documentation for [developers](https://developer.adobe.com/commerce/docs/) and [end-users](https://docs.magento.com/user-guide/), catching and fixing code bugs, suggesting points for optimization, and sharing your great ideas. +Our [Community](https://opensource.magento.com/) is large and diverse, and our project is enormous. As a contributor, you have countless opportunities to impact product development and delivery by introducing new features or improving existing ones, enhancing test coverage, updating documentation for [developers](https://developer.adobe.com/commerce/docs/) and [end-users](https://experienceleague.adobe.com/docs/commerce-admin/user-guides/home.html), catching and fixing code bugs, suggesting points for optimization, and sharing your great ideas. - [Contribute to the code](https://developer.adobe.com/commerce/contributor/guides/code-contributions/) - [Report an issue](https://developer.adobe.com/commerce/contributor/guides/code-contributions/#report) diff --git a/app/code/Magento/Analytics/etc/config.xml b/app/code/Magento/Analytics/etc/config.xml index d3066e186b25..20096c7e005f 100644 --- a/app/code/Magento/Analytics/etc/config.xml +++ b/app/code/Magento/Analytics/etc/config.xml @@ -15,7 +15,7 @@ https://advancedreporting.rjmetrics.com/otp https://advancedreporting.rjmetrics.com/report https://advancedreporting.rjmetrics.com/report - https://docs.magento.com/user-guide/reports/advanced-reporting.html + https://experienceleague.adobe.com/docs/commerce-admin/start/reporting/business-intelligence.html#advanced-reporting Magento Analytics user diff --git a/app/code/Magento/ApplicationPerformanceMonitor/LICENSE.txt b/app/code/Magento/ApplicationPerformanceMonitor/LICENSE.txt new file mode 100644 index 000000000000..49525fd99da9 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/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/ApplicationPerformanceMonitor/LICENSE_AFL.txt b/app/code/Magento/ApplicationPerformanceMonitor/LICENSE_AFL.txt new file mode 100644 index 000000000000..f39d641b18a1 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/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/ApplicationPerformanceMonitor/Plugin/ApplicationPerformanceMonitor.php b/app/code/Magento/ApplicationPerformanceMonitor/Plugin/ApplicationPerformanceMonitor.php new file mode 100644 index 000000000000..d99035fd9795 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/Plugin/ApplicationPerformanceMonitor.php @@ -0,0 +1,49 @@ +profiler->isEnabled()) { + return $proceed(); + } + $returnValue = null; + $this->profiler->doProfile( + function () use ($proceed, &$returnValue) { + $returnValue = $proceed(); + }, + $subject + ); + return $returnValue; + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/Profiler/Input/GeneralInput.php b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/Input/GeneralInput.php new file mode 100644 index 000000000000..28f27197decc --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/Input/GeneralInput.php @@ -0,0 +1,25 @@ + get_class($application)]; + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/Profiler/InputInterface.php b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/InputInterface.php new file mode 100644 index 000000000000..e120d3b82d0b --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/InputInterface.php @@ -0,0 +1,24 @@ +type; + } + + /** + * Gets a name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets a value + * + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * Checks if verbose + * + * @return bool + */ + public function isVerbose(): bool + { + return $this->verbose; + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricType.php b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricType.php new file mode 100644 index 000000000000..dd8a19d3a04c --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricType.php @@ -0,0 +1,19 @@ +peakMemoryUsage; + } + + /** + * Gets memory usage + * + * @return int + */ + public function getMemoryUsage() : int + { + return $this->memoryUsage; + } + + /** + * Gets fusage + * + * @return array + */ + public function getRusage() : array + { + return $this->rusage; + } + + /** + * Gets microtime + * + * @return float + */ + public function getMicrotime() : float + { + return $this->microtime; + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricsComparator.php b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricsComparator.php new file mode 100644 index 000000000000..b1fe19381f88 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricsComparator.php @@ -0,0 +1,138 @@ +metricFactory->create([ + 'type' => MetricType::MEMORY_SIZE_INT, + 'name' => 'memoryUsageBefore', + 'value' => $beforeMetrics->getMemoryUsage(), + 'verbose' => true, + ]); + $metrics['memoryUsageAfter'] = $this->metricFactory->create([ + 'type' => MetricType::MEMORY_SIZE_INT, + 'name' => 'memoryUsageAfter', + 'value' => $afterMetrics->getMemoryUsage(), + 'verbose' => false, + ]); + if ($previousAfterMetrics) { + $metrics['memoryUsageAfterComparedToPrevious'] = $this->metricFactory->create([ + 'type' => MetricType::MEMORY_SIZE_INT, + 'name' => 'memoryUsageAfterComparedToPrevious', + 'value' => $afterMetrics->getMemoryUsage() - $previousAfterMetrics->getMemoryUsage(), + 'verbose' => false, + ]); + } + $metrics['memoryUsageDelta'] = $this->metricFactory->create([ + 'type' => MetricType::MEMORY_SIZE_INT, + 'name' => 'memoryUsageDelta', + 'value' => $afterMetrics->getMemoryUsage() - $beforeMetrics->getMemoryUsage(), + 'verbose' => false, + ]); + $metrics['peakMemoryUsageBefore'] = $this->metricFactory->create([ + 'type' => MetricType::MEMORY_SIZE_INT, + 'name' => 'peakMemoryUsageBefore', + 'value' => $beforeMetrics->getPeakMemoryUsage(), + 'verbose' => true, + ]); + $metrics['peakMemoryUsageAfter'] = $this->metricFactory->create([ + 'type' => MetricType::MEMORY_SIZE_INT, + 'name' => 'peakMemoryUsageAfter', + 'value' => $afterMetrics->getPeakMemoryUsage(), + 'verbose' => false, + ]); + $metrics['peakMemoryUsageDelta'] = $this->metricFactory->create([ + 'type' => MetricType::MEMORY_SIZE_INT, + 'name' => 'peakMemoryUsageDelta', + 'value' => $afterMetrics->getPeakMemoryUsage() - $beforeMetrics->getPeakMemoryUsage(), + 'verbose' => false, + ]); + $metrics['wallTimeBefore'] = $this->metricFactory->create([ + 'type' => MetricType::UNIX_TIMESTAMP_FLOAT, + 'name' => 'wallTimeBefore', + 'value' => $beforeMetrics->getMicrotime(), + 'verbose' => true, + ]); + $metrics['wallTimeAfter'] = $this->metricFactory->create([ + 'type' => MetricType::UNIX_TIMESTAMP_FLOAT, + 'name' => 'wallTimeAfter', + 'value' => $afterMetrics->getMicrotime(), + 'verbose' => true, + ]); + $metrics['wallTimeElapsed'] = $this->metricFactory->create([ + 'type' => MetricType::SECONDS_ELAPSED_FLOAT, + 'name' => 'wallTimeElapsed', + 'value' => $afterMetrics->getMicrotime() - $beforeMetrics->getMicrotime(), + 'verbose' => false, + ]); + $metrics['userTimeBefore'] = $this->metricFactory->create([ + 'type' => MetricType::SECONDS_ELAPSED_FLOAT, + 'name' => 'userTimeBefore', + 'value' => $beforeMetrics->getRusage()['ru_utime.tv_sec'] + + 0.000001 * $beforeMetrics->getRusage()['ru_utime.tv_usec'], + 'verbose' => true, + ]); + $metrics['userTimeAfter'] = $this->metricFactory->create([ + 'type' => MetricType::SECONDS_ELAPSED_FLOAT, + 'name' => 'userTimeAfter', + 'value' => $afterMetrics->getRusage()['ru_utime.tv_sec'] + + 0.000001 * $afterMetrics->getRusage()['ru_utime.tv_usec'], + 'verbose' => true, + ]); + $metrics['userTimeElapsed'] = $this->metricFactory->create([ + 'type' => MetricType::SECONDS_ELAPSED_FLOAT, + 'name' => 'userTimeElapsed', + 'value' => $metrics['userTimeAfter']->getValue() - $metrics['userTimeBefore']->getValue(), + 'verbose' => true, + ]); + $metrics['systemTimeBefore'] = $this->metricFactory->create([ + 'type' => MetricType::SECONDS_ELAPSED_FLOAT, + 'name' => 'systemTimeBefore', + 'value' => $beforeMetrics->getRusage()['ru_stime.tv_sec'] + + 0.000001 * $beforeMetrics->getRusage()['ru_stime.tv_usec'], + 'verbose' => true, + ]); + $metrics['systemTimeAfter'] = $this->metricFactory->create([ + 'type' => MetricType::SECONDS_ELAPSED_FLOAT, + 'name' => 'systemTimeAfter', + 'value' => $afterMetrics->getRusage()['ru_stime.tv_sec'] + + 0.000001 * $afterMetrics->getRusage()['ru_stime.tv_usec'], + 'verbose' => true, + ]); + $metrics['systemTimeElapsed'] = $this->metricFactory->create([ + 'type' => MetricType::SECONDS_ELAPSED_FLOAT, + 'name' => 'systemTimeElapsed', + 'value' => $metrics['systemTimeAfter']->getValue() - $metrics['systemTimeBefore']->getValue(), + 'verbose' => true, + ]); + return $metrics; + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricsGatherer.php b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricsGatherer.php new file mode 100644 index 000000000000..d950dbdd8487 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/MetricsGatherer.php @@ -0,0 +1,36 @@ +metricsFactory->create([ + 'memoryUsage' => \memory_get_usage(), + 'peakMemoryUsage' => \memory_get_peak_usage(), + 'rusage' => \getrusage(), + 'microtime' => \microtime(true), + ]); + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/Profiler/Output/LoggerOutput.php b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/Output/LoggerOutput.php new file mode 100644 index 000000000000..2910c07a50bf --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/Output/LoggerOutput.php @@ -0,0 +1,174 @@ +deploymentConfig->get(static::CONFIG_ENABLE_KEY)) { + 1, "1", "true", true => true, + default => false, + }; + } + + /** + * @inheritDoc + */ + public function doOutput(array $metrics, array $information) : void + { + if (!$this->isEnabled()) { + return; + } + if (!empty($information['subject'])) { + $subject = __('Profile information for %1', $information['subject']); + unset($information['subject']); + } else { + $subject = __('Profile information'); + } + if (!empty($information['requestContentLength'])) { + $information['requestContentLength'] = $this->prettyMemorySize($information['requestContentLength']); + } + $verbose = $this->isVerbose(); + $prettyMetrics = $this->doOutputMetrics($metrics, $verbose); + $message = sprintf("\"%s\": {\n", $subject); + foreach ($information as $key => $value) { + $message .= sprintf("\t\"%s\":\t\"%s\",\n", (string)$key, (string)$value); + } + foreach ($prettyMetrics as $key => $value) { + $message .= sprintf("\t\"%s\":\t\"%s\",\n", (string)$key, (string)$value); + } + $message = \rtrim($message, ",\n"); + $message .= sprintf("\n}\n"); + $this->logger->debug($message); + } + + /** + * Make the metrics pretty and checks verbosity + * + * @param array $metrics + * @param bool $verbose + * @return array + */ + private function doOutputMetrics(array $metrics, bool $verbose) + { + $prettyMetrics = []; + /** @var Metric $metric */ + foreach ($metrics as $metric) { + if (!$verbose && $metric->isVerbose()) { + continue; + } + switch ($metric->getType()) { + case MetricType::SECONDS_ELAPSED_FLOAT: + $prettyMetrics[$metric->getName()] = $this->prettyElapsedTime($metric->getValue()); + break; + case MetricType::UNIX_TIMESTAMP_FLOAT: + $prettyMetrics[$metric->getName()] = $this->prettyUnixTime($metric->getValue()); + break; + case MetricType::MEMORY_SIZE_INT: + $prettyMetrics[$metric->getName()] = $this->prettyMemorySize($metric->getValue()); + break; + default: + $prettyMetrics[$metric->getName()] = $metric->getValue(); + break; + } + } + return $prettyMetrics; + } + + /** + * Returns a string format of memory with units. + * + * @param int $size + * @return string + */ + private function prettyMemorySize(int $size): string + { + if (!$this->isVerbose()) { + $absSize = abs($size); + if ($absSize > 1000000000) { + return sprintf("%.3g GB", $size / 1000000000.0); + } + if ($absSize > 1000000) { + return sprintf("%.3g MB", $size / 1000000.0); + } + if ($absSize > 1000) { + return sprintf("%.3g KB", $size / 1000.0); + } + } + return ((string)($size)) . ' B'; + } + + /** + * Returns a string format of elapsed time with units. + * + * @param string $time + * @return string + */ + private function prettyElapsedTime(float $time): string + { + if ($this->isVerbose()) { + return ((string)($time)) . ' s'; + } + $time = (int) $time; + if ($time > 60) { + return sprintf("%.3g m", $time / 60.0); + } + return ((string)($time)) . ' s'; + } + + /** + * Returns a string format of unix time with units. + * + * @param string $time + * @return string + */ + private function prettyUnixTime(float $time): string + { + $timeAsString = sprintf("%.1f", $time); + return \DateTime::createFromFormat('U.u', $timeAsString)->format('Y-m-d\TH:i:s.u'); + } + + /** + * Returns true when verbose is enabled in configuration. + * + * @return bool + */ + private function isVerbose(): bool + { + return match ($this->deploymentConfig->get(static::CONFIG_VERBOSE_KEY)) { + 1, "1", "true", true => true, + default => false, + }; + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/Profiler/OutputInterface.php b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/OutputInterface.php new file mode 100644 index 000000000000..4a5f9849fcf8 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/Profiler/OutputInterface.php @@ -0,0 +1,31 @@ +previousAfterMetrics; + $previousRequestCount = $this->previousRequestCount; + $this->previousRequestCount++; + $this->previousAfterMetrics = null; + if (!$this->isEnabled()) { + $functionBeingProfiled(); + return; + } + $beforeMetrics = $this->metricsGatherer->gatherMetrics(); + $functionBeingProfiled(); + $afterMetrics = $this->metricsGatherer->gatherMetrics(); + $this->previousAfterMetrics = $afterMetrics; + $information = []; + foreach ($this->inputs as $input) { + $information[] = $input->doInput($application); + } + $information = array_merge(...$information); + $information['threadPreviousRequestCount'] = $previousRequestCount; + $this->doOutput($beforeMetrics, $afterMetrics, $previousAfterMetrics, $information); + } + + /** + * Outputs the results of profiling to all enabled outputs. + * + * @param Metrics $beforeMetrics + * @param Metrics $afterMetrics + * @param Metrics|null $previousAfterMetrics + * @param array $information extra information that we send to output + * @return void + */ + private function doOutput( + Metrics $beforeMetrics, + Metrics $afterMetrics, + ?Metrics $previousAfterMetrics, + array $information + ) : void { + if (!$this->isEnabled()) { + return; + } + $metrics = $this->metricsComparator->compareMetrics($beforeMetrics, $afterMetrics, $previousAfterMetrics); + foreach ($this->outputs as $output) { + $output->doOutput($metrics, $information); + } + } + + /** + * Returns true if any of our outputs are enabled. + * + * @return bool + */ + public function isEnabled() : bool + { + foreach ($this->outputs as $output) { + if ($output->isEnabled()) { + return true; + } + } + return false; + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/README.md b/app/code/Magento/ApplicationPerformanceMonitor/README.md new file mode 100644 index 000000000000..746a4bace160 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/README.md @@ -0,0 +1,64 @@ +**ApplicationPerformanceMonitor** + +Monitors the Performance of the Application + +To configure, edit app/etc/env.php +Add these lines. + +``` +'application' => [ + 'performance_monitor' => [ + 'logger_output_enable' => 1, + 'logger_output_verbose' => 0, + ] +] +``` + +Use 0 or 1 as the value to enable or disable. +Both `logger_output_enable` and `logger_output_verbose` default to 0. + +The option `logger_output_enable` enables outputting performance metrics to the logger using `debug` method of logger. +The option `logger_output_verbose` adds additional metrics. + +Example output in log file without verbose: +``` +[2023-10-04T20:48:23.727037+00:00] report.ERROR: "Profile information": { + "applicationClass": "Magento\ApplicationServer\App\Application\Interceptor", + "applicationServer": "1", + "threadPreviousRequestCount": "73", + "memoryUsageAfter": "240 MB", + "memoryUsageAfterComparedToPrevious": "0 B", + "memoryUsageDelta": "118 KB", + "peakMemoryUsageAfter": "243 MB", + "peakMemoryUsageDelta": "0 B", + "wallTimeElapsed": "0 s" +} +``` + +Example output in log file with verbose: +``` +[2023-10-04T20:55:31.174304+00:00] report.ERROR: "Profile information": { + "applicationClass": "Magento\ApplicationServer\App\Application\Interceptor", + "applicationServer": "1", + "threadPreviousRequestCount": "42", + "memoryUsageBefore": "239568640 B", + "memoryUsageAfter": "239686808 B", + "memoryUsageAfterComparedToPrevious": "0 B", + "memoryUsageDelta": "118168 B", + "peakMemoryUsageBefore": "243053632 B", + "peakMemoryUsageAfter": "243053632 B", + "peakMemoryUsageDelta": "0 B", + "wallTimeBefore": "2023-10-04T20:55:31.170300", + "wallTimeAfter": "2023-10-04T20:55:31.174200", + "wallTimeElapsed": "0.0038700103759766 s", + "userTimeBefore": "3.771626 s", + "userTimeAfter": "3.771626 s", + "userTimeElapsed": "0 s", + "systemTimeBefore": "0.095585 s", + "systemTimeAfter": "0.099126 s", + "systemTimeElapsed": "0.003541 s" +} +``` + +The additional options `newrelic_output_enable` and `newrelic_output_verbose` are only used if ApplicationPerformanceMonitorNewRelic module is also installed and enabled. +See README.md in ApplicationPerformanceMonitorNewRelic for more details on that. diff --git a/app/code/Magento/ApplicationPerformanceMonitor/composer.json b/app/code/Magento/ApplicationPerformanceMonitor/composer.json new file mode 100644 index 000000000000..2ce9ebb9db30 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/composer.json @@ -0,0 +1,18 @@ +{ + "name": "magento/module-application-performance-monitor", + "license": "OSL-3.0", + "type": "magento2-module", + "description": "Performance Monitor for Application", + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\ApplicationPerformanceMonitor\\": "" + } + }, + "require": { + "php": "~8.1.0||~8.2.0", + "magento/framework": "*" + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitor/etc/di.xml b/app/code/Magento/ApplicationPerformanceMonitor/etc/di.xml new file mode 100644 index 000000000000..7a4f96e76702 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/etc/di.xml @@ -0,0 +1,22 @@ + + + + + + + + + + Magento\ApplicationPerformanceMonitor\Profiler\Input\GeneralInput + + + Magento\ApplicationPerformanceMonitor\Profiler\Output\LoggerOutput + + + + diff --git a/app/code/Magento/ApplicationPerformanceMonitor/etc/module.xml b/app/code/Magento/ApplicationPerformanceMonitor/etc/module.xml new file mode 100644 index 000000000000..6da033a1c426 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/etc/module.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/code/Magento/ApplicationPerformanceMonitor/registration.php b/app/code/Magento/ApplicationPerformanceMonitor/registration.php new file mode 100644 index 000000000000..5396f8d8c90d --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitor/registration.php @@ -0,0 +1,12 @@ +" 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/ApplicationPerformanceMonitorNewRelic/LICENSE_AFL.txt b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/LICENSE_AFL.txt new file mode 100644 index 000000000000..f39d641b18a1 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/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/ApplicationPerformanceMonitorNewRelic/Profiler/Output/NewRelicOutput.php b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/Profiler/Output/NewRelicOutput.php new file mode 100644 index 000000000000..19916612bb1e --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/Profiler/Output/NewRelicOutput.php @@ -0,0 +1,79 @@ +newRelicWrapper->isExtensionInstalled()) { + return false; + } + return match ($this->deploymentConfig->get(static::CONFIG_ENABLE_KEY)) { + 0, "0", "false", false => false, + default => true, + }; + } + + /** + * @inheritDoc + */ + public function doOutput(array $metrics, array $information) : void + { + if (!$this->isEnabled()) { + return; + } + foreach ($information as $key => $value) { + $this->newRelicWrapper->addCustomParameter($key, $value); + } + $verbose = $this->isVerbose(); + foreach ($metrics as $metric) { + if (!$verbose && $metric->isVerbose()) { + continue; + } + $this->newRelicWrapper->addCustomParameter($metric->getName(), $metric->getValue()); + } + } + + /** + * Is configured to output verbose + * + * @return bool + */ + private function isVerbose(): bool + { + return match ($this->deploymentConfig->get(static::CONFIG_VERBOSE_KEY)) { + 1, "1", "true", true => true, + default => false, + }; + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitorNewRelic/README.md b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/README.md new file mode 100644 index 000000000000..72227cfa7868 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/README.md @@ -0,0 +1,25 @@ +**ApplicationPerformanceMonitorNewRelic** + +Monitors the Performance of the Application in New Relic + +To use this module, it requires a New Relic account and the environment already by configured to use that account. +For general New Relic PHP configuration information, see https://docs.newrelic.com/docs/apm/agents/php-agent/configuration/php-agent-configuration/ . + +To configure this module, edit app/etc/env.php +Add these lines. + +``` +'application' => [ + 'performance_monitor' => [ + 'newrelic_output_enable' => 1, + 'newrelic_output_verbose' => 0, + ] +] +``` +Use 0 or 1 as the value to enable or disable. +`newrelic_output_enable` defaults to 1, and `newrelic_output_verbose` defaults to 0. + +The option `newrelic_output_enable` enables outputting performance metrics to New Relic. +The option `newrelic_output_verbose` adds additional metrics + +See README.md in ApplicationPerformanceMonitor for details about what metrics are in each. diff --git a/app/code/Magento/ApplicationPerformanceMonitorNewRelic/composer.json b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/composer.json new file mode 100644 index 000000000000..c44965dea68a --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/composer.json @@ -0,0 +1,23 @@ +{ + "name": "magento/module-application-performance-monitor-new-relic", + "license": "OSL-3.0", + "type": "magento2-module", + "description": "Performance data about Application into New Relic", + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\ApplicationPerformanceMonitorNewRelic\\": "" + } + }, + "require": { + "php": "~8.1.0||~8.2.0", + "magento/framework": "*", + "magento/module-application-performance-monitor": "*", + "magento/module-new-relic-reporting": "*" + }, + "suggest": { + "ext-newrelic": "This module requires New Relic's PHP extension in order to function." + } +} diff --git a/app/code/Magento/ApplicationPerformanceMonitorNewRelic/etc/di.xml b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/etc/di.xml new file mode 100644 index 000000000000..f11ca0821087 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/etc/di.xml @@ -0,0 +1,16 @@ + + + + + + + Magento\ApplicationPerformanceMonitorNewRelic\Profiler\Output\NewRelicOutput + + + + diff --git a/app/code/Magento/ApplicationPerformanceMonitorNewRelic/etc/module.xml b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/etc/module.xml new file mode 100644 index 000000000000..65d98d238f63 --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/etc/module.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/code/Magento/ApplicationPerformanceMonitorNewRelic/registration.php b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/registration.php new file mode 100644 index 000000000000..2e3c2f49ac0c --- /dev/null +++ b/app/code/Magento/ApplicationPerformanceMonitorNewRelic/registration.php @@ -0,0 +1,12 @@ +bundleSelection->create(); + $selectionModel->load($linkedProduct->getId()); $selectionModel = $this->mapProductLinkToBundleSelectionModel( $selectionModel, $linkedProduct, diff --git a/app/code/Magento/Bundle/Model/Selection.php b/app/code/Magento/Bundle/Model/Selection.php index 3137f723e57c..ea115c343d43 100644 --- a/app/code/Magento/Bundle/Model/Selection.php +++ b/app/code/Magento/Bundle/Model/Selection.php @@ -5,6 +5,8 @@ */ namespace Magento\Bundle\Model; +use Magento\Framework\App\ObjectManager; + /** * Bundle Selection Model * @@ -36,8 +38,6 @@ class Selection extends \Magento\Framework\Model\AbstractModel { /** - * Catalog data - * * @var \Magento\Catalog\Helper\Data */ protected $_catalogData; @@ -82,7 +82,9 @@ public function beforeSave() { if (!$this->_catalogData->isPriceGlobal() && $this->getWebsiteId()) { $this->setData('tmp_selection_price_value', $this->getSelectionPriceValue()); + $this->setData('tmp_selection_price_type', $this->getSelectionPriceType()); $this->setSelectionPriceValue($this->getOrigData('selection_price_value')); + $this->setSelectionPriceType($this->getOrigData('selection_price_type')); } parent::beforeSave(); } @@ -98,6 +100,9 @@ public function afterSave() if (null !== $this->getData('tmp_selection_price_value')) { $this->setSelectionPriceValue($this->getData('tmp_selection_price_value')); } + if (null !== $this->getData('tmp_selection_price_type')) { + $this->setSelectionPriceType($this->getData('tmp_selection_price_type')); + } $this->getResource()->saveSelectionPrice($this); if (!$this->getDefaultPriceScope()) { diff --git a/app/code/Magento/Bundle/Test/Unit/Model/LinkManagementTest.php b/app/code/Magento/Bundle/Test/Unit/Model/LinkManagementTest.php index 92fc41c59fa7..464caf9cfd97 100644 --- a/app/code/Magento/Bundle/Test/Unit/Model/LinkManagementTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Model/LinkManagementTest.php @@ -621,7 +621,7 @@ public function testAddChildCouldNotSave(): void ->method('create') ->willReturn($bundle); - $selection = $this->createPartialMock(Selection::class, ['save']); + $selection = $this->createPartialMock(Selection::class, ['save', 'load']); $selection->expects($this->once())->method('save') ->willReturnCallback( static function () { @@ -696,7 +696,7 @@ public function testAddChild(): void ->willReturn($selections); $this->bundleFactoryMock->expects($this->once())->method('create')->willReturn($bundle); - $selection = $this->createPartialMock(Selection::class, ['save', 'getId']); + $selection = $this->createPartialMock(Selection::class, ['save', 'getId', 'load']); $selection->expects($this->once())->method('save'); $selection->expects($this->once())->method('getId')->willReturn(42); $this->bundleSelectionMock->expects($this->once())->method('create')->willReturn($selection); diff --git a/app/code/Magento/Catalog/Helper/Product/View.php b/app/code/Magento/Catalog/Helper/Product/View.php index 8d328d9a6229..e1b69522c6a4 100644 --- a/app/code/Magento/Catalog/Helper/Product/View.php +++ b/app/code/Magento/Catalog/Helper/Product/View.php @@ -130,14 +130,7 @@ private function preparePageMetadata(ResultPage $resultPage, $product) $pageConfig->setKeywords($product->getName()); } - $description = $product->getMetaDescription(); - if ($description) { - $pageConfig->setDescription($description); - } else { - $productDescription = is_string($product->getDescription()) ? - $this->string->substr(strip_tags($product->getDescription()), 0, 255) : ''; - $pageConfig->setDescription($productDescription); - } + $pageConfig->setDescription($product->getMetaDescription()); if ($this->_catalogProduct->canUseCanonicalTag()) { $pageConfig->addRemotePageAsset( diff --git a/app/code/Magento/Catalog/Model/Category.php b/app/code/Magento/Catalog/Model/Category.php index 959964aa665d..93753b430383 100644 --- a/app/code/Magento/Catalog/Model/Category.php +++ b/app/code/Magento/Catalog/Model/Category.php @@ -109,6 +109,7 @@ class Category extends \Magento\Catalog\Model\AbstractModel implements * * @var \Magento\UrlRewrite\Model\UrlRewrite * @deprecated 102.0.0 + * @see \Magento\UrlRewrite\Model\UrlFinderInterface */ protected $_urlRewrite; @@ -315,6 +316,7 @@ protected function getCustomAttributesCodes() * @throws \Magento\Framework\Exception\LocalizedException * @return \Magento\Catalog\Model\ResourceModel\Category * @deprecated 102.0.6 because resource models should be used directly + * @see \Magento\Catalog\Model\ResourceModel\Category * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod * @since 102.0.6 */ @@ -615,6 +617,7 @@ public function getUrl() UrlRewrite::ENTITY_ID => $this->getId(), UrlRewrite::ENTITY_TYPE => CategoryUrlRewriteGenerator::ENTITY_TYPE, UrlRewrite::STORE_ID => $this->getStoreId(), + UrlRewrite::REDIRECT_TYPE => 0 ] ); if ($rewrite) { diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCreateCatalogProductWidgetActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCreateCatalogProductWidgetActionGroup.xml index 1b9b9d60b36e..c17524c677c7 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCreateCatalogProductWidgetActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCreateCatalogProductWidgetActionGroup.xml @@ -20,8 +20,8 @@ - - + + diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertSeeProductDetailsOnStorefrontRecentlyViewedWidgetActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertSeeProductDetailsOnStorefrontRecentlyViewedWidgetActionGroup.xml index d7fc31d4607a..eb851d8731d2 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertSeeProductDetailsOnStorefrontRecentlyViewedWidgetActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AssertSeeProductDetailsOnStorefrontRecentlyViewedWidgetActionGroup.xml @@ -10,12 +10,13 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> - Goes to the home Page Recently VIewed Product and Grab the Prdouct name and Position from it. + Goes to the home Page Recently Viewed Product and Grab the Product name and Position from it. + $grabRelatedProductPosition diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection/AdminCategoryBasicFieldSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection/AdminCategoryBasicFieldSection.xml index b0792c6d3464..9b2e3f57c011 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection/AdminCategoryBasicFieldSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection/AdminCategoryBasicFieldSection.xml @@ -6,7 +6,7 @@ */ --> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd">
@@ -27,12 +27,11 @@ - - - - - - + + + + +
diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml index 034150ef4546..bf0228c89d49 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml @@ -7,7 +7,7 @@ --> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd">
@@ -19,9 +19,9 @@ - + - + diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategorySidebarTreeSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategorySidebarTreeSection.xml index 8b1a25adb267..de8b887cc265 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategorySidebarTreeSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategorySidebarTreeSection.xml @@ -14,16 +14,17 @@ - - - + + + + - - - - - + + + + +
diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetEditSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetEditSection.xml index d456e0f9afca..cf6f559480dc 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetEditSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetEditSection.xml @@ -11,17 +11,17 @@
- - - - + + + + - + - - + + diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/AdminProductAttributeSetSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/AdminProductAttributeSetSection.xml index 6790022d5fcf..2d3f4b6ae4df 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/AdminProductAttributeSetSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/AdminProductAttributeSetSection.xml @@ -12,7 +12,7 @@ - + diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/GroupSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/GroupSection.xml index 73ea6e0a86f8..69ba592afe80 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/GroupSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/GroupSection.xml @@ -8,6 +8,6 @@
- +
diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/UnassignedAttributesSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/UnassignedAttributesSection.xml index b6a29a8b60b1..dbb5077d5c7f 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/UnassignedAttributesSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeSetSection/UnassignedAttributesSection.xml @@ -8,6 +8,6 @@
- +
diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AlterAnchorCategoryTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AlterAnchorCategoryTest.xml index e1ca60cd279a..f6777eab65df 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AlterAnchorCategoryTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AlterAnchorCategoryTest.xml @@ -38,6 +38,12 @@ + + + + + + diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/CreateAnchorCategoryTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/CreateAnchorCategoryTest.xml index 5e1da0f77eb8..fae43ce42b7a 100755 --- a/app/code/Magento/Catalog/Test/Mftf/Test/CreateAnchorCategoryTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/CreateAnchorCategoryTest.xml @@ -46,6 +46,12 @@ A + + + + + + @@ -66,6 +72,12 @@ B + + + + + + @@ -97,6 +109,12 @@ C + + + + + + @@ -143,6 +161,12 @@ D + + + + + + @@ -179,6 +203,12 @@ E + + + + + + diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/ProductViewPageCustomOptionValidationErrorMessageTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/ProductViewPageCustomOptionValidationErrorMessageTest.xml new file mode 100644 index 000000000000..af8d29a365c2 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/ProductViewPageCustomOptionValidationErrorMessageTest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + <description value="Check custom option validation error message is displayed in product view page"/> + <severity value="AVERAGE"/> + <testCaseId value="AC-9978"/> + <useCaseId value="ACP2E-2404"/> + <group value="Catalog"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin" /> + <createData entity="SimpleProduct2" stepKey="createProduct"/> + <!-- open product edit page --> + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="goToProductEditPage"> + <argument name="productId" value="$$createProduct.id$$"/> + </actionGroup> + <!-- Create a custom option(radio button) with 2 values --> + <click stepKey="openCustomizableOptions" selector="{{AdminProductCustomizableOptionsSection.customizableOptions}}"/> + <waitForPageLoad stepKey="waitForCustomOptionsOpen"/> + <actionGroup ref="CreateCustomRadioOptionsActionGroup" stepKey="createCustomOption1"> + <argument name="customOptionName" value="ProductOptionRadiobutton.title"/> + <argument name="productOption" value="ProductOptionField"/> + <argument name="productOption2" value="ProductOptionField2"/> + </actionGroup> + <!-- Save the product --> + <actionGroup ref="AdminProductFormSaveActionGroup" stepKey="saveProduct"/> + <seeElement selector="{{AdminCategoryMessagesSection.SuccessMessage}}" stepKey="assertSuccess"/> + <!-- indexer reindex --> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> + <argument name="indices" value=""/> + </actionGroup> + </before> + <after> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <!-- Navigate to Product Page on StoreFront --> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="openStorefrontProductPage"> + <argument name="productUrl" value="$$createProduct.custom_attributes[url_key]$$"/> + </actionGroup> + <!-- Add Product to Cart from product detail page --> + <click selector="{{StorefrontProductActionSection.addToCart}}" stepKey="addToCart"/> + <!-- see custom option validation message --> + <see userInput="This is a required field." stepKey="seeRequiredField"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCategoryHighlightedAndProductDisplayedTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCategoryHighlightedAndProductDisplayedTest.xml index 4a28581f2284..bf4dcb90cd6d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCategoryHighlightedAndProductDisplayedTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontCategoryHighlightedAndProductDisplayedTest.xml @@ -50,24 +50,30 @@ <deleteData createDataKey="category4" stepKey="deleteCategory4"/> <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> </after> + + <!-- Reindex invalidated indices after product attribute has been created/deleted --> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexInvalidatedIndices"> + <argument name="indices" value=""/> + </actionGroup> + <!--Open Storefront home page--> <comment userInput="Open Storefront home page" stepKey="openStorefrontHomePage"/> <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="goToStorefrontHomePage"/> <!--Click on first category--> <comment userInput="Click on first category" stepKey="openFirstCategoryPage"/> - <click selector="{{AdminCategorySidebarTreeSection.categoryInTree($$category1.name$$)}}" stepKey="clickCategory1Name"/> + <click selector="{{AdminCategorySidebarTreeSection.categoryInFrontendTree($$category1.name$$)}}" stepKey="clickCategory1Name"/> <waitForPageLoad stepKey="waitForCategory1Page"/> <!--Check if current category is highlighted and the others are not--> <comment userInput="Check if current category is highlighted and the others are not" stepKey="checkCateg1NameIsHighlighted"/> <grabAttributeFrom selector="{{AdminCategorySidebarTreeSection.categoryHighlighted($$category1.name$$)}}" userInput="class" stepKey="grabCategory1Class"/> <assertStringContainsString stepKey="assertCategory1IsHighlighted"> - <actualResult type="const">$grabCategory1Class</actualResult> - <expectedResult type="string">active</expectedResult> + <actualResult type="const">$grabCategory1Class</actualResult> + <expectedResult type="string">active</expectedResult> </assertStringContainsString> <executeJS function="return document.querySelectorAll('{{AdminCategorySidebarTreeSection.categoryNotHighlighted}}').length" stepKey="highlightedAmount"/> <assertEquals stepKey="assertRestCategories1IsNotHighlighted"> - <actualResult type="const">$highlightedAmount</actualResult> - <expectedResult type="int">1</expectedResult> + <actualResult type="const">$highlightedAmount</actualResult> + <expectedResult type="int">1</expectedResult> </assertEquals> <!--See products in the category page--> <comment userInput="See products in the category page" stepKey="seeProductsInCategoryPage"/> @@ -75,19 +81,19 @@ <seeElement selector="{{StorefrontCategoryMainSection.specifiedProductItemInfo($product2.name$)}}" stepKey="seeProduct2InCategoryPage"/> <!--Click on second category--> <comment userInput="Click on second category" stepKey="openSecondCategoryPage"/> - <click selector="{{AdminCategorySidebarTreeSection.categoryInTree($$category2.name$$)}}" stepKey="clickCategory2Name"/> + <click selector="{{AdminCategorySidebarTreeSection.categoryInFrontendTree($$category2.name$$)}}" stepKey="clickCategory2Name"/> <waitForPageLoad stepKey="waitForCategory2Page"/> <!--Check if current category is highlighted and the others are not--> <comment userInput="Check if current category is highlighted and the others are not" stepKey="checkCateg2NameIsHighlighted"/> <grabAttributeFrom selector="{{AdminCategorySidebarTreeSection.categoryHighlighted($$category2.name$$)}}" userInput="class" stepKey="grabCategory2Class"/> <assertStringContainsString stepKey="assertCategory2IsHighlighted"> - <actualResult type="const">$grabCategory2Class</actualResult> - <expectedResult type="string">active</expectedResult> + <actualResult type="const">$grabCategory2Class</actualResult> + <expectedResult type="string">active</expectedResult> </assertStringContainsString> <executeJS function="return document.querySelectorAll('{{AdminCategorySidebarTreeSection.categoryNotHighlighted}}').length" stepKey="highlightedAmount2"/> <assertEquals stepKey="assertRestCategories1IsNotHighlighted2"> - <actualResult type="const">$highlightedAmount2</actualResult> - <expectedResult type="int">1</expectedResult> + <actualResult type="const">$highlightedAmount2</actualResult> + <expectedResult type="int">1</expectedResult> </assertEquals> <!--Assert products in second category page--> <comment userInput="Assert products in second category page" stepKey="commentAssertProducts"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontProductWithEmptyAttributeTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontProductWithEmptyAttributeTest.xml index f056bacfbf45..3ab42d5b206b 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontProductWithEmptyAttributeTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontProductWithEmptyAttributeTest.xml @@ -44,7 +44,7 @@ <dragAndDrop selector1="{{UnassignedAttributes.ProductAttributeName('$$createProductAttribute.attribute_code$$')}}" selector2="{{Group.FolderName('Product Details')}}" stepKey="moveProductAttributeToGroup"/> <click selector="{{AttributeSetSection.Save}}" stepKey="saveAttributeSet"/> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear" /> - <seeElement selector=".message-success" stepKey="assertSuccess"/> + <waitForElementVisible selector=".message-success" stepKey="assertSuccess"/> <actionGroup ref="FillAdminSimpleProductFormActionGroup" stepKey="fillProductFieldsInAdmin"> <argument name="category" value="$$createPreReqCategory$$"/> <argument name="simpleProduct" value="SimpleProduct"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontProductsCompareWithEmptyAttributeTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontProductsCompareWithEmptyAttributeTest.xml index 6dc5846855e4..261cfdf44ef8 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontProductsCompareWithEmptyAttributeTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontProductsCompareWithEmptyAttributeTest.xml @@ -47,7 +47,7 @@ <dragAndDrop selector1="{{UnassignedAttributes.ProductAttributeName('$$createProductAttribute.attribute_code$$')}}" selector2="{{Group.FolderName('Product Details')}}" stepKey="moveProductAttributeToGroup"/> <click selector="{{AttributeSetSection.Save}}" stepKey="saveAttributeSet"/> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear" /> - <seeElement selector=".message-success" stepKey="assertSuccess"/> + <waitForElementVisible selector=".message-success" stepKey="assertSuccess"/> <actionGroup ref="ClearCacheActionGroup" stepKey="clearCache"/> <amOnPage url="{{StorefrontProductPage.NavigationCategoryByName($$createCategory.custom_attributes[url_key]$$)}}" stepKey="goProductPageOnStorefront1"/> <!-- View Simple Product 1 --> diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/General.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/General.php index 024c62076d5b..617926c88486 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/General.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/General.php @@ -394,7 +394,7 @@ protected function customizeNameListeners(array $meta) $urlKeyConfig = [ 'tooltip' => [ - 'link' => 'https://docs.magento.com/user-guide/catalog/catalog-urls.html', + 'link' => 'https://experienceleague.adobe.com/docs/commerce-admin/catalog/catalog/catalog-urls.html', 'description' => __( 'The URL key should consist of lowercase characters with hyphens to separate words.' ), diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Websites.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Websites.php index 60ef9e83aed2..528203f3073b 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Websites.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/Websites.php @@ -165,7 +165,7 @@ protected function getFieldsForFieldset() $websitesList = $this->getWebsitesList(); $isNewProduct = !$this->locator->getProduct()->getId(); $tooltip = [ - 'link' => 'https://docs.magento.com/user-guide/configuration/scope.html', + 'link' => 'https://experienceleague.adobe.com/docs/commerce-admin/start/setup/websites-stores-views.html#scope-settings', // @codingStandardsIgnoreLine 'description' => __( 'If your Magento installation has multiple websites, ' . 'you can edit the scope to use the product on specific sites.' diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/tree.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/tree.phtml index 0cd00e88f435..b706eb671b26 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/tree.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/tree.phtml @@ -7,539 +7,371 @@ /** @var $block \Magento\Catalog\Block\Adminhtml\Category\Tree */ /** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */ ?> +<?php $root = $block->getRoot(); ?> + <div class="categories-side-col"> <div class="sidebar-actions"> - <?php if ($block->getRoot()):?> + <?php if ($root):?> <?= $block->getAddRootButtonHtml() ?><br/> <?= $block->getAddSubButtonHtml() ?> <?php endif; ?> </div> <div class="tree-actions"> - <?php if ($block->getRoot()):?> + <?php if ($root):?> <a id="colapseAll" href="#"><?= $block->escapeHtml(__('Collapse All')) ?></a> <span class="separator">|</span> <a id="expandAll" href="#"><?= $block->escapeHtml(__('Expand All')) ?></a> <?php endif; ?> </div> - <?php if ($block->getRoot()):?> + <?php if ($root):?> <?= /* @noEscape */ $secureRenderer->renderEventListenerAsTag( 'onclick', - 'tree.collapseTree(); event.preventDefault();', + 'TreeConfig.collapseAll(); event.preventDefault();', '#colapseAll' ) ?> <?= /* @noEscape */ $secureRenderer->renderEventListenerAsTag( 'onclick', - 'tree.expandTree();event.preventDefault();', + 'TreeConfig.expandAll();event.preventDefault();', '#expandAll' ) ?> - <div class="tree-holder"> - <div id="tree-div" class="tree-wrapper"></div> + <div class="tree-holder" > + <div id="tree-div" class="tree-wrapper x-tree"></div> </div> </div> - <div data-id="information-dialog-tree" class="messages"> - <div class="message message-notice"> - <div><?= $block->escapeHtml(__('This operation can take a long time')) ?></div> - </div> +<div data-id="information-dialog-tree" class="messages"> + <div class="message message-notice"> + <div><?= $block->escapeHtml(__('This operation can take a long time')) ?></div> </div> +</div> + <?= /* @noEscape */ $secureRenderer->renderStyleAsTag( 'display: none;', 'div[data-id="information-dialog-tree"]' ) ?> - <?php $scriptString = <<<script - var tree; - require([ - "jquery", - 'Magento_Ui/js/modal/modal', - "jquery/ui", - "prototype", - "extjs/ext-tree-checkbox", - "mage/adminhtml/form", - "mage/translate" - ], function (jQuery, modal) { - - var registry = { - data: [], - - set: function (key, value) { - this.data[key] = value; - }, - - get: function (key) { - return this.data[key]; - } - }; - - var treeRoot = '#tree-div'; - - /** - * 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; - }; - - Ext.tree.TreePanel.Enhanced = function (el, config) { - Ext.tree.TreePanel.Enhanced.superclass.constructor.call(this, el, config); - }; - - Ext.extend(Ext.tree.TreePanel.Enhanced, Ext.tree.TreePanel, { - - loadTree: function (config, firstLoad) { - var parameters = config['parameters']; - var data = config['data']; - - this.storeId = parameters['store_id']; + <?php $divElement = '<div id="tree-div" class="tree-wrapper x-tree"></div>'; ?> - if (this.storeId != 0 && $('add_root_category_button')) { - $('add_root_category_button').hide(); - } + <?php $scriptString = <<<script + let TreeConfig, expandAll = false; + require([ + 'jquery', + 'jquery/jstree/jquery.jstree' + ], function($) { + let registry = { + data: [], + + set: function (key, value) { + this.data[key] = value; + }, - if ((typeof parameters['root_visible']) != 'undefined') { - this.rootVisible = parameters['root_visible'] * 1; + get: function (key) { + return this.data[key]; } + }; - var root = new Ext.tree.TreeNode(parameters); - - this.nodeHash = {}; - this.setRootNode(root); - this.modal = modal; + let treeDiv = $('#tree-div'), + treeInstance, +script; + $scriptString .= 'currentNodeId = ' . (int)$block->getCategoryId() . ', + + defaultParams = { + text: ' . /* @noEscape */ json_encode(htmlentities($root->getName())) . ', + allowDrop: ' . ($root->getIsVisible() ? 'true' : 'false') . ', + id: ' . (int)$root->getId() . ', + expanded: ' . (int)$block->getIsWasExpanded() . ', + store_id: ' . (int)$block->getStore()->getId() . ', + category_id: ' . (int)$block->getCategoryId() . ', + parent: ' . (int)$block->getRequest()->getParam('parent') . ' + };' . PHP_EOL; - if (firstLoad) { - this.addListener('click', this.categoryClick); - this.addListener('beforenodedrop', categoryMove.bind(this)); + $scriptString .= <<<script + TreeConfig = function () { + return { + createTree: function () { + /** + * Initialize the jstree with tree root + */ + treeDiv.jstree({ + core: { + check_callback: function (operation, node) { + //Draggable false for root categories + return !(operation === 'move_node' && + node.original !== undefined && node.original.allowDrag === false); + } + }, + plugins: ['dnd'], + }).bind('move_node.jstree', function (e, data) { + TreeConfig.categoryMove(data); + }); + + treeInstance = treeDiv.jstree(true); + let root = treeInstance.get_node('#'); + this.buildCategoryTree(treeDiv, root, '{$block->getTreeJson()}', true); + + let catId = treeInstance.get_node(defaultParams.category_id); + if (catId) { + currentNodeId = defaultParams.category_id; + } else if (defaultParams.parent > 0 && defaultParams.category_id === 0) { + currentNodeId = defaultParams.parent; } - this.loader.buildCategoryTree(root, data); - this.el.dom.innerHTML = ''; - // render the tree - this.render(); - if (parameters['expanded']) { - this.expandAll(); + // select and open child node + if (defaultParams.expanded) { + treeInstance.open_all(); } else { - root.expand(); - } + let selectedNode = treeInstance.get_node(currentNodeId); + treeInstance.select_node(selectedNode, true); - var selectedNode = this.getNodeById(parameters['category_id']); - if (selectedNode) { - this.currentNodeId = parameters['category_id']; - } else { - if (parameters['parent'] > 0 && parameters['category_id'] === 0) { - this.currentNodeId = parameters['parent']; - } + setTimeout(function () { + treeInstance.open_node(selectedNode); + }, 15); } - this.selectCurrentNode(); - - // Temporary solution will be replaced after refactoring of tree functionality - jQuery('body').off('tabsactivate.tree').on('tabsactivate.tree', jQuery.proxy(function (e, ui) { - this.activeTab = jQuery(ui.newTab).find('a').prop('id'); - }, this)) }, - request: function (url, params) { - if (!params) { - if (this.activeTab) { - var params = {active_tab_id: this.activeTab}; - } - else { - var params = {}; - } - } - if (!params.form_key) { - params.form_key = FORM_KEY; + buildCategoryTree: function(treeDiv, parent, config, firstLoad){ + if (!config) return; + + if(firstLoad){ + config = $.parseJSON(config); } - var result = new Ajax.Request( - url + (url.match(new RegExp('\\?')) ? '&isAjax=true' : '?isAjax=true' ), - { - parameters: params, - method: 'post' - } - ); - return result; - }, + for (let i = 0; i < config.length; i++) { + let nodeConfig = config[i], + newNode = { + text: nodeConfig.text, + id: nodeConfig.id, + allowDrag: nodeConfig.allowDrag, + allowDrop: nodeConfig.allowDrop, + cls: nodeConfig.cls, + store: nodeConfig.store, + li_attr: { class: nodeConfig.cls} + }; - selectCurrentNode: function () { - var selectedNode = this.getNodeById(this.currentNodeId); + const parentTree = treeInstance.create_node(parent, newNode, 'last'); - if (selectedNode) { - if ((typeof selectedNode.attributes.path) != 'undefined') { - var path = selectedNode.attributes.path; - if (!this.storeId) { - path = '0/' + path; - } - this.selectPath(path); - } else { - this.getSelectionModel().select(selectedNode); + if (nodeConfig.children) { + this.buildCategoryTree(treeDiv, parentTree, nodeConfig.children, false); } } }, - collapseTree: function () { - this.collapseAll(); + categoryMove : function (obj){ + let data = {id: obj.node.id, form_key: FORM_KEY}; - this.selectCurrentNode(); - - if (!this.collapsed) { - this.collapsed = true; - this.loader.dataUrl = '{$block->escapeJs($block->getLoadTreeUrl(false))}'; - this.request(this.loader.dataUrl, false); + if(obj.node.parent === '#'){ + data.pid = defaultParams.id; + }else{ + data.pid = obj.node.parent; } - }, - expandTree: function () { - this.expandAll(); - if (this.collapsed) { - this.collapsed = false; - this.loader.dataUrl = '{$block->escapeJs($block->getLoadTreeUrl(true))}'; - this.request(this.loader.dataUrl, false); + data.paid = obj.old_parent; + data.aid = this.getSiblings(obj.node.parent,obj.position); + + let pd = []; + for (let key in data) { + pd.push(encodeURIComponent(key), '=', encodeURIComponent(data[key]), '&'); } - }, - categoryClick: function (node, e) { - var url = this.buildUrl(node.id); + pd.splice(pd.length - 1, 1); + registry.set('pd', pd.join('')); + + $('[data-id="information-dialog-category"]').modal({ + modalClass: 'confirm', + title: $.mage.__('Warning message'), + buttons: [{ + text: $.mage.__('Cancel'), + class: 'action-secondary', + click: function () { + TreeConfig.reRenderTree(); + this.closeModal(); + } + }, { + text: $.mage.__('Ok'), + class: 'action-primary', + click: function () { + (function ($) { + $.ajax({ + url: '{$block->escapeJs($block->getMoveUrl())}', + method: 'POST', + data: registry.get('pd'), + showLoader: true + }).done(function (response) { + if (response.error) { + TreeConfig.reRenderTree(); + } else { + treeInstance.trigger('TreeConfig.categoryMove'); + } + $('.page-main-actions').next('.messages').remove(); + $('.page-main-actions').next('#messages').remove(); + $('.page-main-actions').after(response.messages); + }).fail(function (jqXHR, textStatus) { + if (window.console) { + console.log(textStatus); + } + location.reload(); + }); + })(jQuery); + this.closeModal(); + } + }], + keyEventHandlers: { + enterKey: function (event) { + this.buttons[1].click(); + event.preventDefault(); + } + } + }).trigger('openModal'); + }, - this.currentNodeId = node.id; - if (!this.useAjax) { - setLocation(url); - return; - } - if (this.activeTab) { - var params = {active_tab_id: this.activeTab}; - } - updateContent(url, params); + getSiblings : function (parent,position) { + let parentNode = treeInstance.get_node(parent); + let sibling = treeInstance.get_node(parentNode.children[position - 1]); + return sibling.id; }, - buildUrl: function (id) { - var urlExt = (this.storeId ? 'store/' + this.storeId + '/' : '') + 'id/' + id + '/'; + reRenderTree : function(){ + $('.tree-holder').empty().append('{$divElement}'); + treeDiv = $('#tree-div'); + TreeConfig.createTree(); - return parseSidUrl(this.baseUrl, urlExt); - }, + treeDiv.on('changed.jstree', function (e, data) { + TreeConfig.categoryClick(data); + }); - getBaseUrl: function () { - return this.baseUrl; - } - }); - - function reRenderTree(switcherParams) { - // re-render tree by store switcher - if (tree && switcherParams) { - var url; - if (switcherParams.useConfirm) { - if (!confirm("{$block->escapeJs(__( - 'Please confirm site switching. All data that hasn\'t been saved will be lost.' -))}")) { - return false; - } - } + treeDiv.on("open_node.jstree", function (e, data) { + TreeConfig.handleOpenNode(data); + }); + }, - if ($('add_root_category_button')) { - if (!switcherParams.scopeId) { - $('add_root_category_button').show(); - } - else { - $('add_root_category_button').hide(); + categoryClick : function(data){ + let baseUrl = '{$block->escapeJs($block->getEditUrl())}'; + if(data.node !== undefined && data.node.original !== undefined) + { + let storeId = data.node.original.store, + id = data.node.original.id; + if (storeId !== 0) { + baseUrl = baseUrl + 'store/' + storeId + '/'; } - } - - if (tree.useAjax) { - // retain current selected category id - url = tree.switchTreeUrl + switcherParams.scopeParams + 'id/' + tree.currentNodeId + '/'; - // load from cache - // load from ajax - // add form key - var params = { - form_key: FORM_KEY - }; - new Ajax.Request(url + (url.match(new RegExp('\\?')) ? '&isAjax=true' : '?isAjax=true' ), { - parameters: params, - method: 'post', - onComplete: function (transport) { - var response; - - try { - response = JSON.parse(transport.responseText); - } catch (e) { - console.warn('An error occurred while parsing response'); - } - - if (!response || !response['parameters']) { - return false; - } - - _renderNewTree(response, switcherParams.scopeParams); - } - }); - } else { - var baseUrl = '{$block->escapeJs($block->getEditUrl())}'; - var urlExt = switcherParams.scopeParams + 'id/' + tree.currentNodeId + '/'; - url = parseSidUrl(baseUrl, urlExt); + let url = baseUrl + 'id/' + id + '/'; setLocation(url); } - } - // render default tree - else { - _renderNewTree(); - } - } - - function _renderNewTree(config, scopeParams) { - if (!config) { - var config = defaultLoadTreeParams; - } - - if (tree) { - tree.purgeListeners(); - tree.el.dom.innerHTML = ''; - } - tree = new Ext.tree.TreePanel.Enhanced('tree-div', newTreeParams); - - tree.loadTree(config, true); - - // try to select current category - var selectedNode = tree.getNodeById(config.parameters.category_id); - if (selectedNode) { - tree.currentNodeId = config.parameters.category_id; - } - tree.selectCurrentNode(); - - // update content area - var url = tree.editUrl; - if (scopeParams) { - url = url + scopeParams; - } -script; - if ($block->isClearEdit()): - $scriptString .= <<<script - if (selectedNode) { - url = url + 'id/' + config.parameters.category_id; - } -script; -endif; - $scriptString .= <<<script - //updateContent(url); //commented since ajax requests replaced with http ones to load a category - jQuery('#tree-div').find('.x-tree-node-el').first().remove(); - } + }, - jQuery(function () { - categoryLoader = new Ext.tree.TreeLoader({ - dataUrl: '{$block->escapeJs($block->getLoadTreeUrl())}' - }); + handleOpenNode : function(data){ + let parentNode = data.node; + if (parentNode && parentNode.children.length > 0) { - categoryLoader.processResponse = function (response, parent, callback) { - var config = JSON.parse(response.responseText); + parentNode.children.forEach(function(childId) { - this.buildCategoryTree(parent, config); + let childNode = data.instance.get_node(childId, false); - if (typeof callback == "function") { - callback(this, parent); - } - }; + // Check if the child node has no children (is not yet loaded) + if (childNode.children && childNode.children.length === 0 + && childNode.original && !childNode.original.lastNode) { - categoryLoader.buildCategoryTree = function (parent, config) { - if (!config) return null; - - if (parent && config && config.length) { - for (var i = 0; i < config.length; i++) { - var node; - var _node = Object.clone(config[i]); - if (_node.children && !_node.children.length) { - delete(_node.children); - node = new Ext.tree.AsyncTreeNode(_node); - } else { - node = new Ext.tree.TreeNode(config[i]); - } - parent.appendChild(node); - node.loader = node.getOwnerTree().loader; - if (_node.children) { - this.buildCategoryTree(node, _node.children); + $.ajax({ + url: '{$block->escapeJs($block->escapeUrl($block->getLoadTreeUrl()))}', + type: "POST", + data: { + id: childNode.original.id, + store: childNode.original.store, + form_key: FORM_KEY + }, + dataType: 'json', + success: function (response) { + TreeConfig.handleSuccessResponse(response, childNode, data); + }, + error: function (jqXHR, status, error) { + console.log(status + ': ' + error); + } + }); } - } + }); } - }; - - categoryLoader.buildHash = function (node) { - var hash = {}; + }, - hash = this.toArray(node.attributes); + handleSuccessResponse : function(response, childNode, data){ + if (response.length > 0) { + response.forEach(newNode => { + TreeConfig.addLastNodeFlag(newNode); - if (node.childNodes.length > 0 || (node.loaded == false && node.loading == false)) { - hash['children'] = new Array; + // Create the new node and execute node callback + data.instance.create_node(childNode, newNode, 'last'); + }); - for (var i = 0, len = node.childNodes.length; i < len; i++) { - if (!hash['children']) { - hash['children'] = new Array; - } - hash['children'].push(this.buildHash(node.childNodes[i])); + // open all node if, expand all link clicked + if(expandAll === true){ + data.instance.open_all(); } } + }, - return hash; - }; - - categoryLoader.toArray = function (attributes) { - var data = {form_key: FORM_KEY}; - for (var key in attributes) { - var value = attributes[key]; - data[key] = value; + addLastNodeFlag : function(treeData) { + if (treeData.children) { + treeData.children.forEach(child => TreeConfig.addLastNodeFlag(child)); + } else { + treeData.lastNode = true; } + }, - return data; - }; - - categoryLoader.on("beforeload", function (treeLoader, node) { - treeLoader.baseParams.id = node.attributes.id; - treeLoader.baseParams.store = node.attributes.store; - treeLoader.baseParams.form_key = FORM_KEY; - }); - - categoryLoader.on("load", function (treeLoader, node, config) { - //varienWindowOnload(); - }); - - scopeSwitcherHandler = reRenderTree; - - newTreeParams = { - animate: false, - loader: categoryLoader, - enableDD: true, - containerScroll: true, - selModel: new Ext.tree.CheckNodeMultiSelectionModel(), -script; - $scriptString .= ' - rootVisible: \'' . ($block->getRoot()->getIsVisible() ? 'true' : 'false') . '\', - useAjax: ' . $block->escapeJs($block->getUseAjax()) . ', - switchTreeUrl: \'' . $block->escapeJs($block->escapeUrl($block->getSwitchTreeUrl())) .'\', - editUrl: \'' . $block->escapeJs($block->escapeUrl($block->getEditUrl())) .'\', - currentNodeId: ' . (int)$block->getCategoryId() . ', - baseUrl: \'' . $block->escapeJs($block->escapeUrl($block->getEditUrl())) . '\' - }; + expandAll : function(){ + expandAll = true; + treeInstance.open_all(); + }, - defaultLoadTreeParams = { - parameters: { - text: ' . /* @noEscape */ json_encode(htmlentities($block->getRoot()->getName())) . ', - draggable: false, - allowDrop: ' . ($block->getRoot()->getIsVisible() ? 'true' : 'false') . ', - id: ' . (int)$block->getRoot()->getId() . ', - expanded: ' . (int)$block->getIsWasExpanded() . ', - store_id: ' . (int)$block->getStore()->getId() . ', - category_id: ' . (int)$block->getCategoryId() . ', - parent: ' . (int)$block->getRequest()->getParam('parent') . ' - }, - data: ' . /* @noEscape */ $block->getTreeJson() . ' - }; - reRenderTree(); - });' . PHP_EOL; - $scriptString .= <<<script - function addNew(url, isRoot) { - if (isRoot) { - tree.currentNodeId = tree.root.id; - } + collapseAll : function(){ + treeInstance.close_all(); + let selectedNode = treeDiv.jstree('get_selected'); + if(selectedNode.length > 0){ + let nodeObj = treeInstance.get_node(selectedNode), + parents = nodeObj.parents; + if(parents.indexOf('#') > -1){ + parents.splice(parents.indexOf('#'), 1); + } - if (/store\/\d+/.test(url)) { - url = url.replace(/store\/\d+/, "store/" + tree.storeId); - } - else { - url += "store/" + tree.storeId + "/"; + treeInstance.select_node(selectedNode, true); + treeInstance.open_node(parents); + } } + }; + }(); - url += 'parent/' + tree.currentNodeId; - location.href = url; - } + /** + * jstree changed event i.e. when a node clicked + */ + treeDiv.on('changed.jstree', function (e, data) { + TreeConfig.categoryClick(data); + }); - function categoryMove(obj) { - var data = {id: obj.dropNode.id, form_key: FORM_KEY}; - - data.point = obj.point; - switch (obj.point) { - case 'above' : - data.pid = obj.target.parentNode.id; - data.paid = obj.dropNode.parentNode.id; - if (obj.target.previousSibling) { - data.aid = obj.target.previousSibling.id; - } else { - data.aid = 0; - } - break; - case 'below' : - data.pid = obj.target.parentNode.id; - data.aid = obj.target.id; - break; - case 'append' : - data.pid = obj.target.id; - data.paid = obj.dropNode.parentNode.id; - if (obj.target.lastChild) { - data.aid = obj.target.lastChild.id; - } else { - data.aid = 0; - } - break; - default : - obj.cancel = true; - return obj; - } + /** + * jstree handle open node + */ + treeDiv.on('open_node.jstree', function (e, data) { + TreeConfig.handleOpenNode(data); + }); - var pd = []; - for (var key in data) { - pd.push(encodeURIComponent(key), "=", encodeURIComponent(data[key]), "&"); - } - pd.splice(pd.length - 1, 1); - - registry.set('pd', pd.join("")); - - jQuery('[data-id="information-dialog-category"]').modal({ - modalClass: 'confirm', - title: jQuery.mage.__('Warning message'), - buttons: [{ - text: 'Cancel', - class: 'action-secondary', - click: function () { - reRenderTree(); - this.closeModal(); - } - }, { - text: 'Ok', - class: 'action-primary', - click: function () { - (function ($) { - $.ajax({ - url: '{$block->escapeJs($block->getMoveUrl())}', - method: 'POST', - data: registry.get('pd'), - showLoader: true - }).done(function (data) { - if (data.error) { - reRenderTree(); - } else { - $(treeRoot).trigger('categoryMove.tree'); - } - $('.page-main-actions').next('.messages').remove(); - $('.page-main-actions').next('#messages').remove(); - $('.page-main-actions').after(data.messages); - }).fail(function (jqXHR, textStatus) { - if (window.console) { - console.log(textStatus); - } - location.reload(); - }); - })(jQuery); - this.closeModal(); - } - }], - keyEventHandlers: { - enterKey: function (event) { - this.buttons[1].click(); - event.preventDefault(); - } - } - }).trigger('openModal'); + /** + * create default tree + */ + TreeConfig.createTree(); + function addNew(url, isRoot) { + if (isRoot) { + currentNodeId = defaultParams.id; } - window.addNew = addNew; + if (/store\/\d+/.test(url)) { + url = url.replace(/store\/\d+/, 'store/' + defaultParams.store_id); + } + else { + url += 'store/' + defaultParams.store_id + '/'; + } - }); + url += 'parent/' + currentNodeId; + location.href = url; + } + window.addNew = addNew; + }); script; ?> <?= /* @noEscape */ $secureRenderer->renderTag('script', [], $scriptString, false); ?> diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/widget/tree.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/widget/tree.phtml index e3586551435d..7bc85087a7e0 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/widget/tree.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/widget/tree.phtml @@ -10,204 +10,176 @@ <?php $_divId = 'tree' . $block->getId() ?> <div id="<?= $block->escapeHtmlAttr($_divId) ?>" class="tree"></div> <?php -$isUseMassaction = $block->getUseMassaction() ? 1 : 0; +$isUseMassAction = $block->getUseMassaction() ? 1 : 0; $isAnchorOnly = $block->getIsAnchorOnly() ? 1 : 0; -$intCategoryId = (int)$block->getCategoryId(); -$intRootId = (int) $block->getRoot()->getId(); $scriptString = <<<script -require(['jquery', 'prototype', 'extjs/ext-tree-checkbox'], function(jQuery){ +require(['jquery', 'jquery/jstree/jquery.jstree'], function($) { -var tree{$block->escapeJs($block->getId())}; + let tree = $('#tree{$block->escapeJs($block->getId())}'); + let useMassAction = {$isUseMassAction}; + let isAnchorOnly = {$isAnchorOnly}; + let checkedNodes = []; -var useMassaction = {$isUseMassaction}; - -var isAnchorOnly = {$isAnchorOnly}; - -Ext.tree.TreePanel.Enhanced = function(el, config) -{ - Ext.tree.TreePanel.Enhanced.superclass.constructor.call(this, el, config); -}; - -Ext.extend(Ext.tree.TreePanel.Enhanced, Ext.tree.TreePanel, { - - loadTree : function(config, firstLoad) - { - var parameters = config['parameters']; - var data = config['data']; + function addLastNodeProperty(nodes) { + return nodes.map(node => { + return node.children + ? { ...node, children: addLastNodeProperty(node.children) } + : { ...node, lastNode: true }; + }); + } - if ((typeof parameters['root_visible']) != 'undefined') { - this.rootVisible = parameters['root_visible']*1; - } + function actionBasedOnIsAnchorOnly() { + tree.jstree().get_json('#', { flat: true }).each((node, value) => { + const attrId = node.a_attr.id; + const rootNode = tree.jstree().get_node("#"); + const rootId = rootNode.children[0]; - var root = new Ext.tree.TreeNode(parameters); + if (isAnchorOnly === 1 && node.id === rootId) { + tree.jstree(true).disable_node(node); + } else if (isAnchorOnly === 0 && node.id !== rootId) { + tree.jstree(true).disable_node(node); + } + }); + } - this.nodeHash = {}; - this.setRootNode(root); + function handleLoadedTree(e, data) { + const container = $(e.target).closest('div.chooser_container'); + checkedNodes = container.find('input[type="text"].entities').val().split(',').map(item => item.trim()); - if (firstLoad) { + data.instance.get_json('#', { flat: true }).forEach(nodeId => { + const node = data.instance.get_node(nodeId); -script; -if ($block->getNodeClickListener()): - $scriptString .= 'this.addListener(\'click\', ' . /* @noEscape */ $block->getNodeClickListener() . - '.createDelegate(this));' . PHP_EOL; -endif; -$scriptString .= <<<script - } + if (checkedNodes.includes(node.id)) { + tree.jstree(true).select_node(node.id); + } - this.loader.buildCategoryTree(root, data); - this.el.dom.innerHTML = ''; - // render the tree - this.render(); + actionBasedOnIsAnchorOnly(); + }); } -}); - -jQuery(function() -{ - -script; - $scriptString .= 'var emptyNodeAdded = ' . ($block->getWithEmptyNode() ? 'false' : 'true') . ';' . PHP_EOL; -$scriptString .= <<<script + function handleChange(e, data) { + if (data.action === 'ready') { + return; + } - var categoryLoader = new Ext.tree.TreeLoader({ - dataUrl: '{$block->escapeJs($block->escapeUrl($block->getLoadTreeUrl()))}' - }); + if (useMassAction) { + const clickedNodeID = data.node.id; + const currentCheckedNodes = data.instance.get_checked(); - categoryLoader.processResponse = function (response, parent, callback) { - var config = JSON.parse(response.responseText); + if (data.action === 'select_node' && !checkedNodes.includes(clickedNodeID)) { + checkedNodes = currentCheckedNodes; + } else if (data.action === 'deselect_node') { + checkedNodes = currentCheckedNodes.filter(nodeID => nodeID !== clickedNodeID); + } - this.buildCategoryTree(parent, config); + checkedNodes.sort((a, b) => a - b); - if (typeof callback == "function") { - callback(this, parent); + const container = $(e.target).closest('div.chooser_container'); + container.find('input[type="text"].entities').val(checkedNodes.join(', ')); + } else { + node = data.node; + node.attributes = node.original; + const nodeClickListener = {$block->getNodeClickListener()}; + nodeClickListener(node); } - }; + } - categoryLoader.buildCategoryTree = function(parent, config) - { - if (!config) return null; - - - if (parent && config && config.length){ - for (var i = 0; i < config.length; i++) { - var node; - if (useMassaction && config[i].is_anchor == isAnchorOnly) { - config[i].uiProvider = Ext.tree.CheckboxNodeUI; - } - var _node = Object.clone(config[i]); - - // Add empty node to reset category filter - if(!emptyNodeAdded) { - var empty = Object.clone(_node); - empty.text = '{$block->escapeJs(__('None'))}'; - empty.children = []; - empty.id = 'none'; - empty.path = '1/none'; - empty.cls = 'leaf'; - parent.appendChild(new Ext.tree.TreeNode(empty)); - emptyNodeAdded = true; - } - - if (_node.children && !_node.children.length) { - delete(_node.children); - node = new Ext.tree.AsyncTreeNode(_node); - } else { - node = new Ext.tree.TreeNode(config[i]); - } - parent.appendChild(node); - node.loader = node.getOwnerTree().loader; - node.loader = node.getOwnerTree().loader; - if (_node.children) { - this.buildCategoryTree(node, _node.children); - } + function getCheckedNodeIds(tree, node) { + if (node.children_d && node.children_d.length > 0) { + const selectChildrenNodes = node.children_d.filter(item => checkedNodes.includes(item)); + + if (selectChildrenNodes.length > 0) { + tree.jstree(true).select_node(selectChildrenNodes); } } - }; + } - categoryLoader.createNode = function(config) { - var node; - if (useMassaction && config.is_anchor == isAnchorOnly) { - config.uiProvider = Ext.tree.CheckboxNodeUI; - } - var _node = Object.clone(config); - if (config.children && !config.children.length) { - delete(config.children); - node = new Ext.tree.AsyncTreeNode(config); + function addLastNodeFlag(treeData) { + if (treeData.children) { + treeData.children.forEach(child => addLastNodeFlag(child)); } else { - node = new Ext.tree.TreeNode(config); + treeData.lastNode = true; } - return node; - }; - - categoryLoader.buildHash = function(node) - { - var hash = {}; - - hash = this.toArray(node.attributes); - - if (node.childNodes.length>0 || (node.loaded==false && node.loading==false)) { - hash['children'] = new Array; + } - for (var i = 0, len = node.childNodes.length; i < len; i++) { - if (!hash['children']) { - hash['children'] = new Array; - } - hash['children'].push(this.buildHash(node.childNodes[i])); - } + function handleSuccessResponse(response, childNode, data) { + if (response.length > 0) { + response.forEach(newNode => { + addLastNodeFlag(newNode); + + // Create the new node and execute node callback + data.instance.create_node(childNode, newNode, 'last', node => { + if (useMassAction) { + if (checkedNodes.includes(node.id)) { + tree.jstree(true).select_node(node.id); + } + getCheckedNodeIds(tree, node); + actionBasedOnIsAnchorOnly(); + } + }); + }); } + } - return hash; - }; - - categoryLoader.toArray = function(attributes) { - var data = {}; - for (var key in attributes) { - var value = attributes[key]; - data[key] = value; + function handleOpenNode(e, data) { + let parentNode = data.node; + + if (parentNode.children.length > 0) { + let childNode = data.instance.get_node(parentNode.children, false); + + // Check if the child node has no children (is not yet loaded) + if (childNode.children && childNode.children.length === 0 + && childNode.original && !childNode.original.lastNode) { + $.ajax({ + url: '{$block->escapeJs($block->escapeUrl($block->getLoadTreeUrl()))}', + data: { + id: childNode.original.id, + store: childNode.original.store, + form_key: FORM_KEY + }, + dataType: 'json', + success: function (response) { + handleSuccessResponse(response, childNode, data); + }, + error: function (jqXHR, status, error) { + console.log(status + ': ' + error + 'Response text:' + jqXHR.responseText); + } + }); + } } + } - return data; + var jstreeConfig = { + core: { + data: addLastNodeProperty({$block->getTreeJson()}), + check_callback: true + }, + plugins: [] }; - categoryLoader.on("beforeload", function(treeLoader, node) { - treeLoader.baseParams.id = node.attributes.id; - treeLoader.baseParams.store = node.attributes.store; - treeLoader.baseParams.form_key = FORM_KEY; - $('{$block->escapeJs($_divId)}').fire('category:beforeLoad', {treeLoader:treeLoader}); - }); - - tree{$block->escapeJs($block->getId())} = new Ext.tree.TreePanel.Enhanced('{$block->escapeJs($_divId)}', { - animate: false, - loader: categoryLoader, - enableDD: false, - containerScroll: true, - rootVisible: false, - useAjax: true, - currentNodeId: {$intCategoryId}, - addNodeTo: false - }); - - if (useMassaction) { - tree{$block->escapeJs($block->getId())}.on('check', function(node) { - $('{$block->escapeJs($_divId)}').fire('node:changed', {node:node}); - }, tree{$block->escapeJs($block->getId())}); + if (useMassAction) { + jstreeConfig.plugins.push('checkbox'); + jstreeConfig.checkbox = { + three_state: false + }; } - // set the root node - var parameters = { - text: 'Psw', - draggable: false, - id: {$intRootId}, - expanded: true, - category_id: {$intCategoryId} - }; + tree.jstree(jstreeConfig); - tree{$block->escapeJs($block->getId())}.loadTree({parameters:parameters, data:{$block->getTreeJson()}},true); + if (useMassAction) { + tree.on('loaded.jstree', (e, data) => handleLoadedTree(e, data)); + } + tree.on('changed.jstree', (e, data) => handleChange(e, data)); + tree.on('open_node.jstree', (e, data) => handleOpenNode(e, data)); }); -}); script; ?> +<?= /* @noEscape */ $secureRenderer->renderStyleAsTag( + 'overflow-x: auto;', + '#tree' . $block->escapeJs($block->getId()) +); +?> <?= /* @noEscape */ $secureRenderer->renderTag('script', [], $scriptString, false); ?> 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 6f021a8b0e7a..03b0a38ca97d 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 @@ -56,219 +56,276 @@ script; __('This group contains system attributes. Please move system attributes to another group and try again.') ); $scriptString = <<<script - define("tree-panel", + define('tree-panel', [ - "jquery", - "Magento_Ui/js/modal/prompt", - "Magento_Ui/js/modal/alert", - "extjs/ext-tree-checkbox", - "prototype" + 'jquery', + 'Magento_Ui/js/modal/prompt', + 'Magento_Ui/js/modal/alert', + 'jquery/jstree/jquery.jstree', + 'prototype' ], function(jQuery, prompt, alert){ //<![CDATA[ var allowDragAndDrop = {$readOnly}; var canEditGroups = {$readOnly}; + var treeDiv1 = jQuery('#tree-div1'); + var treeDiv2 = jQuery('#tree-div2'); var TreePanels = function() { - // shorthand - var Tree = Ext.tree; return { - init : function(){ - // yui-ext tree - - var tree = new Ext.tree.TreePanel('tree-div1', { - animate:false, - loader: false, - enableDD:allowDragAndDrop, - containerScroll: true, - rootVisible: false - }); + init: function() { - // set the root node - this.root = new Ext.tree.TreeNode({ + var treeRoot = [{ text: 'ROOT', - allowDrag:false, - allowDrop:true, - id:'1' - }); - - tree.setRootNode(this.root); - buildCategoryTree(this.root, {$groupTree}); - // render the tree - tree.render(); - this.root.expand(false, false); - tree.expandAll(); - - this.ge = new Ext.tree.TreeEditor(tree, { - allowBlank:false, - blankText:'{$block->escapeJs(__('A name is required.'))}', - selectOnFocus:true, - cls:'folder' + id: '#', + allowDrag: false, + allowDrop: true, + state: { opened: true, loaded: true, selected: false }, + li_attr: { class: '' } + }]; + + /** + * Initialize the jstree with tree root + */ + treeDiv1.jstree({ + core: { + animation: false, + check_callback: checkCallback, + data: treeRoot + }, + plugins : [ 'dnd' ], }); - this.root.addListener('beforeinsert', editSet.leftBeforeInsert); - this.root.addListener('beforeappend', editSet.leftBeforeInsert); - - //this.ge.addListener('beforerender', editSet.editGroup); - this.ge.addListener('beforeshow', editSet.editGroup); - this.ge.addListener('beforecomplete', editSet.beforeRenameGroup); - //this.ge.addListener('startedit', editSet.editGroup); + var root = treeDiv1.jstree(true).get_node('#'); + buildCategoryTree(treeDiv1, root, {$groupTree}); //------------------------------------------------------------- - var tree2 = new Ext.tree.TreePanel('tree-div2', { - animate:false, - loader: false, - enableDD:allowDragAndDrop, - containerScroll: true, - rootVisible: false, - lines:false + var treeRoot2 = [ + { + text: 'ROOT_2', + id: '#', + allowDrag: true, + allowDrop: true, + state: { opened: true, loaded: true } + } + ]; + + /** + * Initialize the jstree with tree root 2 + */ + treeDiv2.jstree({ + core: { + data: treeRoot2, + check_callback: checkCallback, + themes: { + dots: false + } + }, + plugins : [ 'dnd' ] }); - // set the root node - this.root2 = new Ext.tree.TreeNode({ - text: 'ROOT', - draggable:false, - id:'free' - }); - tree2.setRootNode(this.root2); - buildCategoryTree(this.root2, {$attributeTreeJson}); - - this.root2.addListener('beforeinsert', editSet.rightBeforeInsert); - this.root2.addListener('beforeappend', editSet.rightBeforeAppend); - - this.root2.addListener('append', editSet.rightAppend); - this.root2.addListener('remove', editSet.rightRemove); - // render the tree - tree2.render(); - this.root2.expand(false, false); - tree2.expandAll(); + var root2 = treeDiv2.jstree(true).get_node('#'); + buildCategoryTree(treeDiv2, root2, {$attributeTreeJson}); }, - rebuildTrees : function(){ - editSet.req.attributes = new Array(); - rootNode = TreePanels.root; - var gIterator = 0; - for( i in rootNode.childNodes ) { - if(rootNode.childNodes[i].id) { - var group = rootNode.childNodes[i]; - editSet.req.groups[gIterator] = new Array(group.id, group.attributes.text.strip(), - (gIterator+1)); - var iterator = 0 - for( j in group.childNodes ) { - iterator ++; - if( group.childNodes[j].id > 0 ) { - editSet.req.attributes[group.childNodes[j].id] = - new Array(group.childNodes[j].id, group.id, iterator, - group.childNodes[j].attributes.entity_id); - } + rebuildTrees: function () { + editSet.req.attributes = []; + rootNode = treeDiv1.jstree(true).get_node('#'); + gIterator = 0; + + rootNode.children.forEach(function (groupNodeId) { + let groupNode = treeDiv1.jstree(true).get_node(groupNodeId); + let newGroupNodeId = groupNode.id.replace(groupNode.parent+'_', ''); + editSet.req.groups[gIterator] = + [newGroupNodeId, groupNode.text.trim(), (gIterator + 1)]; + let iterator = 0; + groupNode.children.forEach(function (childNodeId) { + iterator++; + childNode = treeDiv1.jstree(true).get_node(childNodeId); + if(!childNode.id.startsWith(childNode.parent) ) { + childNode.id = childNode.parent + + childNode.id.substring(childNode.id.lastIndexOf('_')); + childNode.li_attr.id = childNode.id; + childNode.a_attr.id = childNode.id + "_anchor"; } - iterator = 0; - } - gIterator ++; - } + generatedNodeId = childNode.original.attribute_id; + if( generatedNodeId > 0 ) { + editSet.req.attributes[generatedNodeId] = new Array(generatedNodeId, + newGroupNodeId, iterator, childNode.original.entity_id); + } + }); + + iterator = 0; + gIterator++; + }); - editSet.req.not_attributes = new Array(); - rootNode = TreePanels.root2; + editSet.req.not_attributes = []; + rootNode = treeDiv2.jstree(true).get_node('#'); - var iterator = 0; - for( i in rootNode.childNodes ) { - if(rootNode.childNodes[i].id) { - if( rootNode.childNodes[i].id > 0 ) { - editSet.req.not_attributes[iterator] = - rootNode.childNodes[i].attributes.entity_id; - } - iterator ++; + let iterator = 0; + rootNode.children.forEach(function (childId) { + childNode = treeDiv2.jstree(true).get_node(childId); + generatedNodeId = childNode.original.attribute_id; + if( generatedNodeId > 0 ) { + editSet.req.not_attributes[iterator] = + childNode.original.entity_id; } - } - } + iterator ++; + }); + } }; }(); - function buildCategoryTree(parent, config){ - if (!config) return null; - if (parent && config && config.length){ - for (var i = 0; i < config.length; i++) { - var node = new Ext.tree.TreeNode(config[i]); - parent.appendChild(node); - node.addListener('click', editSet.register); - node.addListener('beforemove', editSet.groupBeforeMove); - node.addListener('beforeinsert', editSet.groupBeforeInsert); - node.addListener('beforeappend', editSet.groupBeforeInsert); - if( config[i].children ) { - for( j in config[i].children ) { - if(config[i].children[j].id) { - newNode = new Ext.tree.TreeNode(config[i].children[j]); - - if (typeof newNode.ui.onTextChange === 'function') { - newNode.ui.onTextChange = function (_3, _4, _5) { - if (this.rendered) { - this.textNode.innerText = _4; - } - } - } - } - node.appendChild(newNode); - newNode.addListener('click', editSet.unregister); - } + function buildCategoryTree(treeDiv, parent, config) { + if (!config) return; + const treeInstance = treeDiv.jstree(true); + + for (var i = 0; i < config.length; i++) { + var nodeConfig = config[i]; + + // Create a new node in the jsTree + let newNode = { + text: nodeConfig.text, + id: parent.id+'_'+nodeConfig.id, + attribute_id: nodeConfig.id, + allowDrag: nodeConfig.allowDrag, + allowDrop: nodeConfig.allowDrop, + cls: nodeConfig.cls, + entity_id: nodeConfig.entity_id, + is_user_defined: nodeConfig.is_user_defined, + li_attr: { class: nodeConfig.cls } + }; + const parentTree = treeInstance.create_node(parent, newNode, 'last'); + + if (nodeConfig.children) { + for (var j = 0; j < nodeConfig.children.length; j++) { + let newChildNode = nodeConfig.children[j]; + newChildNode.attribute_id = nodeConfig.children[j].id; + newChildNode.id = parentTree+'_'+nodeConfig.children[j].id; + newChildNode.li_attr = { + 'class': nodeConfig.children[j].cls + }; + treeInstance.create_node(parentTree, newChildNode, 'last'); } } } + + treeDiv.on('select_node.jstree', function (e, data) { + editSet.register(data.node); + }); + + treeDiv.on('copy_node.jstree', function (e, data) { + let originalNode = data.original.original; + let copiedNode = data.node.original; + + // Assign properties from the original node to the copied node + Object.assign(copiedNode, { + cls: originalNode.cls, + is_user_defined: originalNode.is_user_defined, + allowDrag: originalNode.allowDrag, + allowDrop: originalNode.allowDrop, + entity_id: originalNode.entity_id, + attribute_id: originalNode.attribute_id + }); + + treeDiv1.jstree(true).redraw(copiedNode); + treeInstance.open_node(data.node.parent); + + let treeIns = treeDiv2.jstree(true); + let rootNode = treeIns.get_node('#'); + + if (rootNode.children.length === 1) { + editSet.rightAppend(treeIns); + } else if(rootNode.children.length > 1) { + editSet.rightRemove(treeIns); + } + }); + + treeDiv.on('dblclick.jstree', function (event) { + const selectedNode = treeInstance.get_selected(true)[0]; + if (selectedNode && selectedNode.original.cls === 'folder') { + editSet.validateGroupName(selectedNode.text, selectedNode.id); + treeInstance.edit(selectedNode); + } + treeInstance.open_all(); + }); + + treeInstance.open_all(); } + function checkCallback(operation, node, parent, position) { + if (operation === 'move_node') { + if(parent.original && !parent.original.allowDrop) { + return false; + } - editSet = function () { - return { - register: function (node) { - editSet.currentNode = node; - if (typeof node.ui.onTextChange === 'function') { - node.ui.onTextChange = function (_3, _4, _5) { - if (this.rendered) { - this.textNode.innerText = _4; - } - } - } - }, + if((node.original.cls === 'system-leaf' || node.original.cls === 'leaf') + && parent.text === 'ROOT') { + return false; + } + + if (node.original.cls === 'folder' && parent.original.cls === 'folder' + && parent.id !== '#') { + return false; + } + return true; + } + if (operation === 'copy_node' && (node.original.is_unassignable == 0 + && node.original.is_user_defined == 0)) { + alert({ + content: '{$block->escapeJs( + __('You can\'t remove attributes from this attribute set.') + )}' + }); + return false; + } + if (operation === 'rename_node') { + return editSet.validateGroupName(position, node.id); + } + + return true; + } - unregister : function() { - editSet.currentNode = false; + editSet = function () { + return { + register: function (node) { + editSet.currentNode = node; }, - submit : function() { + submit: function () { var i, child, newNode; - - if( TreePanels.root.firstChild == TreePanels.root.lastChild ) { + var rootNode = treeDiv1.jstree(true).get_node('#'); + if (rootNode.children.length === 0) { return; } - if( editSet.SystemNodesExists(editSet.currentNode) ) { + if (editSet.SystemNodesExists(editSet.currentNode)) { alert({ content: '{$systemAttributeWarning}' }); return; } - if( editSet.currentNode && editSet.currentNode.attributes.cls == 'folder' ) { - TreePanels.root.removeChild(editSet.currentNode); - - for( i in editSet.currentNode.childNodes ) { - if( editSet.currentNode.childNodes[i].id ) { - child = editSet.currentNode.childNodes[i]; - newNode = new Ext.tree.TreeNode(child.attributes); - - if( child.attributes.is_user_defined == 1 ) { - TreePanels.root2.appendChild(newNode); - } + if (editSet.currentNode && editSet.currentNode.original.cls === "folder") { + let currentChild = editSet.currentNode.children; + for (i = 0; i < currentChild.length; i++) { + let child = treeDiv1.jstree(true).get_node(currentChild[i]); + if (child.original.is_user_defined == 1) { + newNode = child.original; + treeDiv2.jstree(true).create_node('#', newNode, 'last'); } } - editSet.req.removeGroups[editSet.currentNode.id] = editSet.currentNode.id; + let currentNodeId = editSet.currentNode.id.replace(editSet.currentNode.parent+'_', ''); + editSet.req.removeGroups[currentNodeId] = currentNodeId; + treeDiv1.jstree(true).delete_node(editSet.currentNode); editSet.currentNode = false; } }, - SystemNodesExists : function(currentNode) { + SystemNodesExists: function (currentNode) { if (!currentNode) { alert({ content: '{$block->escapeJs(__('Please select a node.'))}' @@ -276,21 +333,16 @@ script; return; } - for (i in currentNode.childNodes) { - if (currentNode.childNodes[i].id) { - child = editSet.currentNode.childNodes[i]; - if (child.attributes.is_unassignable != 1) { - return true; - } + let children = currentNode.children; + for (let i = 0; i < children.length; i++) { + let child = treeDiv1.jstree(true).get_node(children[i]); + if (child.original.is_unassignable != 1) { + return true; } } }, - rightAppend : function(node) { - return; - }, - - addGroup : function() { + addGroup: function () { prompt({ title: "{$block->escapeJs($block->escapeHtml(__('Add New Group')))}", content: "{$block->escapeJs($block->escapeHtml(__('Please enter a new group name.')))}", @@ -314,64 +366,45 @@ script; return; } - var newNode = new Ext.tree.TreeNode({ - text : group_name.escapeHTML(), - cls : 'folder', - allowDrop : true, - allowDrag : true - }); - - if (typeof newNode.ui.onTextChange === 'function') { - newNode.ui.onTextChange = function (_3, _4, _5) { - if (this.rendered) { - this.textNode.innerText = _4; - } - } - } + var newNodeData = { + text: group_name, + icon: 'jstree-folder', + cls: 'folder', + allowDrop: true, + allowDrag: true + }; - TreePanels.root.appendChild(newNode); - newNode.addListener('beforemove', editSet.groupBeforeMove); - newNode.addListener('beforeinsert', editSet.groupBeforeInsert); - newNode.addListener('beforeappend', editSet.groupBeforeInsert); - newNode.addListener('click', editSet.register); + let rootNode = treeDiv1.jstree(true).get_node('#'); + treeDiv1.jstree(true).create_node(rootNode, newNodeData, 'last'); } } }); }, - editGroup : function(obj) { - if( obj.editNode.attributes.cls != 'folder' || !canEditGroups) { - TreePanels.ge.cancelEdit(); - return false; - } - }, - - beforeRenameGroup : function(obj, after, before) { - return editSet.validateGroupName(after, obj.editNode.id); - }, - validateGroupName : function(name, exceptNodeId) { - name = name.strip().escapeHTML(); + name = name.trim(); var result = true; if (name === '') { result = false; } - for (var i=0; i < TreePanels.root.childNodes.length; i++) { - if (TreePanels.root.childNodes[i].text.toLowerCase() == name.toLowerCase() && - TreePanels.root.childNodes[i].id != exceptNodeId) { + var rootNode = treeDiv1.jstree(true).get_node('#'); + for (var i = 0; i < rootNode.children.length; i++) { + var childNode = treeDiv1.jstree(true).get_node(rootNode.children[i]); + if (childNode.text.toLowerCase() === name.toLowerCase() + && childNode.id !== exceptNodeId) { errorText = '{$block->escapeJs( - __('An attribute group named "/name/" already exists.') - )}'; + __('An attribute group named "/name/" already exists.') + )}'; alert({ content: errorText.replace("/name/",name) }); - result = false; + result = false; } } return result; }, - save : function() { + save: function () { var block; if ($('messages')) { @@ -388,102 +421,65 @@ script; if (!editSet.req.form_key) { editSet.req.form_key = FORM_KEY; } - var req = {data : Ext.util.JSON.encode(editSet.req)}; - var con = new Ext.lib.Ajax.request('POST', '{$block->escapeJs($block->getMoveUrl())}', - {success:editSet.success,failure:editSet.failure}, req); + + for (var key in editSet.req) { + if (Array.isArray(editSet.req[key])) { + editSet.req[key] = editSet.req[key].filter(item => item !== null); + } + } + + var reqData = { data: JSON.stringify(editSet.req) }; + jQuery.ajax({ + url: '{$block->escapeJs($block->getMoveUrl())}', + type: 'POST', + data: reqData, + success: editSet.success, + error: editSet.failure + }); }, - success : function(o) { - var response = Ext.util.JSON.decode(o.responseText); - if( response.error ) { - $('messages').update(response.message); - } else if( response.ajaxExpired && response.ajaxRedirect ){ + success: function (response) { + if (response.error || response.message) { + jQuery("#messages").html(response.message); + } else if (response.ajaxExpired && response.ajaxRedirect) { setLocation(response.ajaxRedirect); - } else if( response.url ){ + } else if (response.url) { setLocation(response.url); - } else if( response.message ) { - $('messages').update(response.message); } }, - failure : function(o) { + failure: function(o) { alert({ content: '{$block->escapeJs(__('Sorry, we\'re unable to complete this request.'))}' }); }, - groupBeforeMove : function(tree, nodeThis, oldParent, newParent) { - if( newParent.attributes.cls == 'folder' && nodeThis.attributes.cls == 'folder' ) { - return false; - } - - if( newParent == TreePanels.root && nodeThis.attributes.cls != 'folder' ) { - return false; - } - }, - - rightBeforeAppend : function(tree, nodeThis, node, newParent) { - if (node.attributes.is_user_defined == 0) { - alert({ - content: '{$block->escapeJs( - __('You can\'t remove attributes from this attribute set.') - )}' - }); - return false; - } else { - return true; - } - }, - - rightBeforeInsert : function(tree, nodeThis, node, newParent) { - var empty = TreePanels.root2.findChild('id', 'empty'); - if (empty) { - return false; - } - - if (node.attributes.is_unassignable == 0) { - alert({ - content: '{$block->escapeJs( - __('You can\'t remove attributes from this attribute set.') - )}' - }); - return false; - } else { - return true; - } + rightAppend: function(treeInstance) { + let newNode = { + text: 'Empty', + id: '#_empty', + cls: 'folder', + is_user_defined: 1, + allowDrop: false, + allowDrag: false + }; + treeInstance.create_node('#', newNode, 'last'); }, - groupBeforeInsert : function(tree, nodeThis, node, newParent) { - if( node.allowChildren ) { - return false; - } - }, + rightRemove: function(treeInstance) { + var emptyNode = treeInstance.get_node('#_empty'); - rightAppend : function(tree, nodeThis, node) { - var empty = TreePanels.root2.findChild('id', 'empty'); - if( empty && node.id != 'empty' ) { - TreePanels.root2.removeChild(empty); + if (emptyNode) { + treeInstance.delete_node(emptyNode); } }, - rightRemove : function(tree, nodeThis, node) { - if( nodeThis.firstChild == null && node.id != 'empty' ) { - var newNode = new Ext.tree.TreeNode({ - text : '{$block->escapeJs(__('Empty'))}', - id : 'empty', - cls : 'folder', - is_user_defined : 1, - allowDrop : false, - allowDrag : false - }); - TreePanels.root2.appendChild(newNode); - } + rightBeforeAppend: function(tree, nodeThis, node, newParent) { + return original(tree, nodeThis, node, newParent); }, - leftBeforeInsert : function(tree, nodeThis, node, newParent) { - if( node.allowChildren == false ) { - return false; - } + rightBeforeInsert: function(tree, nodeThis, node, newParent) { + return original(tree, nodeThis, node, newParent); } } }(); @@ -502,10 +498,9 @@ script; TreePanels.init(); jQuery("[data-role='spinner']").hide(); }); - //]]> }); - require(["tree-panel"]); + require(['tree-panel']); script; ?> <?= /* @noEscape */ $secureRenderer->renderTag('script', [], $scriptString, false) ?> 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 index 0ec404a769f4..3c7edea5c05f 100644 --- 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 @@ -1,272 +1,222 @@ -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. +/************************************************************************ + * + * Copyright 2018 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + * *********************************************************************** */ -/* global Ext, varienWindowOnload, varienElementMethods */ - define([ 'jquery', - 'prototype', - 'extjs/ext-tree-checkbox', + 'jquery/jstree/jquery.jstree', 'mage/adminhtml/form' -], function (jQuery) { +], function ($) { 'use strict'; + /** + * Recursively adds the 'lastNode' property to the nodes in the nested object. + * + * @param {Array} nodes + * @returns {Array} + */ + function addLastNodeProperty(nodes) { + return nodes.map(node => { + return node.children ? { + ...node, + children: addLastNodeProperty(node.children) + } : { + ...node, + lastNode: true + }; + }); + } + + /** + * Main function that creates the jstree + * + * @param {Object} config - Configuration object containing various options + */ 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 = {}, - key = ''; + + let 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: addLastNodeProperty(config.treeJson) + }, + checkedNodes = []; /** - * Fix ext compatibility with prototype 1.6 + * Get the jstree element by its ID */ - Ext.lib.Event.getTarget = function (e) { - var ee = e.browserEvent || e; - - return ee.target ? Event.element(ee) : null; - }; + const treeId = $('#' + options.divId); /** - * @param {Object} el - * @param {Object} nodeConfig + * Function to check child nodes based on the checkedNodes array + * + * @param {Object} node */ - 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); + function getCheckedNodeIds(node) { + if (node.children_d && node.children_d.length > 0) { + const selectChildrenNodes = node.children_d.filter(item => checkedNodes.includes(item)); - if (typeof parameters.rootVisible !== 'undefined') { - this.rootVisible = parameters.rootVisible * 1; - } - - this.nodeHash = {}; - this.setRootNode(root); - - if (firstLoad) { - this.addListener('click', this.categoryClick.createDelegate(this)); + if (selectChildrenNodes.length > 0) { + treeId.jstree(false).select_node(selectChildrenNodes); } + } + } - this.loader.buildCategoryTree(root, data); - this.el.dom.innerHTML = ''; - // render the tree - this.render(); + /** + * Initialize the jstree with configuration options + */ + treeId.jstree({ + core: { + data: options.treeJson, + check_callback: true }, - - /** - * @param {Object} node - */ - categoryClick: function (node) { - node.getUI().check(!node.getUI().checked()); + plugins: ['checkbox'], + checkbox: { + three_state: false } }); - 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; - }; + /** + * Event handler for 'loaded.jstree' event + */ + treeId.on('loaded.jstree', function () { /** - * @param {Object} parent - * @param {Object} nodeConfig - * @param {Integer} i + * Get each node in the tree */ - categoryLoader.processCategoryTree = function (parent, nodeConfig, i) { - var node, - _node = {}; + $(treeId.jstree().get_json('#', { + flat: false + })).each(function () { + let node = treeId.jstree().get_node(this.id, false); - 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]); + if (node.original.expanded) { + treeId.jstree(true).open_node(node); } - 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 i = 0; - if (!nodeConfig) { - return null; + if (options.jsFormObject.updateElement.defaultValue) { + checkedNodes = options.jsFormObject.updateElement.defaultValue.split(','); } + }); + }); - if (parent && nodeConfig && nodeConfig.length) { - for (i; i < nodeConfig.length; i++) { - categoryLoader.processCategoryTree(parent, nodeConfig, i); - } - } - }; + /** + * Event handler for 'load_node.jstree' event + */ + treeId.on('load_node.jstree', function (e, data) { + getCheckedNodeIds(data.node); + }); - /** - * - * @param {Object} hash - * @param {Object} node - * @returns {Object} - */ - categoryLoader.buildHashChildren = function (hash, node) { - var i = 0, - len; + /** + * Add lastNode property to child who doesn't have children property + * + * @param {Object} treeData + */ + function addLastNodeFlag(treeData) { + if (treeData.children) { + treeData.children.forEach((child) => addLastNodeFlag(child)); + } else { + treeData.lastNode = true; + } + } - if (node.childNodes.length > 0 || node.loaded === false && node.loading === false) { - hash.children = []; + /** + * Function to handle the 'success' callback of the AJAX request + * + * @param {Array} response + * @param {Object} childNode + * @param {Object} data + */ + function handleSuccessResponse(response, childNode, data) { + if (response.length > 0) { + response.forEach(function (newNode) { + addLastNodeFlag(newNode); + + /** + * Create the new node and execute node callback + */ + data.instance.create_node(childNode, newNode, 'last', function (node) { + if (checkedNodes.includes(node.id)) { + treeId.jstree(false).select_node(node.id); + } + getCheckedNodeIds(node); + }); + }); + } + } - for (i, len = node.childNodes.length; i < len; i++) { - hash.children = hash.children ? hash.children : []; - hash.children.push(this.buildHash(node.childNodes[i])); - } + /** + * Event handler for 'open_node.jstree' event + */ + treeId.on('open_node.jstree', function (e, data) { + let parentNode = data.node; + + if (parentNode.children.length > 0) { + let childNode = data.instance.get_node(parentNode.children, false); + + /** + * Check if the child node has no children (is not yet loaded) + */ + if (childNode.children && childNode.children.length === 0 + && childNode.original && !childNode.original.lastNode) { + $.ajax({ + url: options.dataUrl, + data: { + id: childNode.id, + selected: options.jsFormObject.updateElement.value + }, + dataType: 'json', + success: function (response) { + handleSuccessResponse(response, childNode, data); + }, + error: function (jqXHR, status, error) { + console.log(status + ': ' + error + '\nResponse text:\n' + jqXHR.responseText); + } + }); } + } + }); - return hash; - }; - - /** - * @param {Object} node - * @returns {Object} - */ - categoryLoader.buildHash = function (node) { - var hash = {}; - - hash = this.toArray(node.attributes); + /** + * Event handler for 'changed.jstree' event + */ + treeId.on('changed.jstree', function (e, data) { + if (data.action === 'ready') { + return; + } + const clickedNodeID = data.node.id, currentCheckedNodes = data.instance.get_checked(); - return categoryLoader.buildHashChildren(hash, node); - }; + if (data.action === 'select_node' && !checkedNodes.includes(clickedNodeID)) { + checkedNodes = currentCheckedNodes; + } else if (data.action === 'deselect_node') { + checkedNodes = currentCheckedNodes.filter((nodeID) => nodeID !== clickedNodeID); + } + checkedNodes.sort((a, b) => a - b); /** - * @param {Object} attributes - * @returns {Object} + * Update the value of the corresponding form element with the checked node IDs */ - 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; - treeLoader.baseParams.selected = options.jsFormObject.updateElement.value; - }); - - 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); + options.jsFormObject.updateElement.value = checkedNodes.join(', '); }); }; }); 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 ce0fa3827afe..c4c9a734c255 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 @@ -39,6 +39,7 @@ $mainImageData = $mainImage ? <?= $imageWidth ? 'width="'. $escaper->escapeHtmlAttr($imageWidth) .'"' : '' ?> <?= $imageHeight ? 'height="'. $escaper->escapeHtmlAttr($imageHeight) .'"' : '' ?> /> + <link itemprop="image" href="<?= /* @noEscape */ $mainImageData ?>"> </div> <?php // phpcs:ignore Magento2.Legacy.PhtmlTemplate ?> <script type="text/x-magento-init"> diff --git a/app/code/Magento/Catalog/view/frontend/web/js/product/provider.js b/app/code/Magento/Catalog/view/frontend/web/js/product/provider.js index c9f0f9171a2e..6880cce8bcf1 100644 --- a/app/code/Magento/Catalog/view/frontend/web/js/product/provider.js +++ b/app/code/Magento/Catalog/view/frontend/web/js/product/provider.js @@ -98,17 +98,15 @@ define([ return; } + // Filter initial ids to remove "out of scope" and "outdated" data + this.ids( + this.filterIds(this.ids()) + ); this.initIdsListener(); this.idsMerger( this.idsStorage.get(), this.prepareDataFromCustomerData(customerData.get(this.identifiersConfig.namespace)()) ); - - if (!_.isEmpty(this.productStorage.data())) { - this.dataCollectionHandler(this.productStorage.data()); - } else { - this.productStorage.setIds(this.data.currency, this.data.store, this.ids()); - } }, /** @@ -176,7 +174,7 @@ define([ if (!_.isEmpty(data)) { this.ids( - this.filterIds(_.extend(this.ids(), data)) + this.filterIds(_.extend(utils.copy(this.ids()), data)) ); } }, 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 3dc9f3e84451..ad5976eaf1cb 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 @@ -149,7 +149,7 @@ define([ if (data.items && ids.length) { //we can extend only items data = data.items; - this.data(_.extend(data, currentData)); + this.data(_.extend(currentData, data)); } }, @@ -271,13 +271,9 @@ define([ sentDataIds = _.keys(this.request.data); currentDataIds = _.keys(ids); - _.each(currentDataIds, function (id) { - if (_.lastIndexOf(sentDataIds, id) === -1) { - return false; - } + return _.every(currentDataIds, function (id) { + return _.lastIndexOf(sentDataIds, id) !== -1; }); - - return true; } return false; diff --git a/app/code/Magento/Catalog/view/frontend/web/product/view/validation.js b/app/code/Magento/Catalog/view/frontend/web/product/view/validation.js index cc7bb6bc3031..8cb037574704 100644 --- a/app/code/Magento/Catalog/view/frontend/web/product/view/validation.js +++ b/app/code/Magento/Catalog/view/frontend/web/product/view/validation.js @@ -12,78 +12,78 @@ define([ $.widget('mage.validation', $.mage.validation, { options: { - radioCheckboxClosest: 'ul, ol', + radioCheckboxClosest: 'ul, ol' + }, - /** - * @param {*} error - * @param {HTMLElement} element - */ - errorPlacement: function (error, element) { - var messageBox, - dataValidate; + /** + * @param {*} error + * @param {HTMLElement} element + */ + errorPlacement: function (error, element) { + var messageBox, + dataValidate; - if ($(element).hasClass('datetime-picker')) { - element = $(element).parent(); + if ($(element).hasClass('datetime-picker')) { + element = $(element).parent(); - if (element.parent().find('.mage-error').length) { - return; - } + if (element.parent().find('.mage-error').length) { + return; } + } - if (element.attr('data-errors-message-box')) { - messageBox = $(element.attr('data-errors-message-box')); - messageBox.html(error); + if (element.attr('data-errors-message-box')) { + messageBox = $(element.attr('data-errors-message-box')); + messageBox.html(error); - return; - } + return; + } - dataValidate = element.attr('data-validate'); + dataValidate = element.attr('data-validate'); - if (dataValidate && dataValidate.indexOf('validate-one-checkbox-required-by-name') > 0) { - error.appendTo('#links-advice-container'); - } else if (element.is(':radio, :checkbox')) { - element.closest(this.radioCheckboxClosest).after(error); - } else { - element.after(error); - } - }, + if (dataValidate && dataValidate.indexOf('validate-one-checkbox-required-by-name') > 0) { + error.appendTo('#links-advice-container'); + } else if (element.is(':radio, :checkbox')) { + element.closest(this.radioCheckboxClosest).after(error); + } else { + element.after(error); + } + }, - /** - * @param {HTMLElement} element - * @param {String} errorClass - */ - highlight: function (element, errorClass) { - var dataValidate = $(element).attr('data-validate'); + /** + * @param {HTMLElement} element + * @param {String} errorClass + */ + highlight: function (element, errorClass) { + var dataValidate = $(element).attr('data-validate'); - if (dataValidate && dataValidate.indexOf('validate-required-datetime') > 0) { - $(element).parent().find('.datetime-picker').each(function () { - $(this).removeClass(errorClass); + if (dataValidate && dataValidate.indexOf('validate-required-datetime') > 0) { + $(element).parent().find('.datetime-picker').each(function () { + $(this).removeClass(errorClass); - if ($(this).val().length === 0) { - $(this).addClass(errorClass); - } - }); - } else if ($(element).is(':radio, :checkbox')) { - $(element).closest(this.radioCheckboxClosest).addClass(errorClass); - } else { - $(element).addClass(errorClass); - } - }, + if ($(this).val().length === 0) { + $(this).addClass(errorClass); + } + }); + } else if ($(element).is(':radio, :checkbox')) { + $(element).closest(this.radioCheckboxClosest).addClass(errorClass); + } else { + $(element).addClass(errorClass); + } + }, - /** - * @param {HTMLElement} element - * @param {String} errorClass - */ - unhighlight: function (element, errorClass) { - var dataValidate = $(element).attr('data-validate'); + /** + * @param {HTMLElement} element + * @param {String} errorClass + */ + unhighlight: function (element, errorClass) { + var dataValidate = $(element).attr('data-validate'); - if (dataValidate && dataValidate.indexOf('validate-required-datetime') > 0) { - $(element).parent().find('.datetime-picker').removeClass(errorClass); - } else if ($(element).is(':radio, :checkbox')) { - $(element).closest(this.radioCheckboxClosest).removeClass(errorClass); - } else { - $(element).removeClass(errorClass); - } + if (dataValidate && dataValidate.indexOf('validate-required-datetime') > 0) { + $(element).parent().find('.datetime-picker').removeClass(errorClass); + } else if ($(element).is(':radio, :checkbox')) { + $(element).closest(this.radioCheckboxClosest).removeClass(errorClass); + } else { + $(element).removeClass(errorClass); } } }); diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/RequestDataBuilder.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/RequestDataBuilder.php new file mode 100644 index 000000000000..baca41a365e9 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/RequestDataBuilder.php @@ -0,0 +1,61 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\DataProvider\Product; + +use Magento\Framework\ObjectManager\ResetAfterRequestInterface; + +/** + * Builds request specific Product Search Query + */ +class RequestDataBuilder implements ResetAfterRequestInterface +{ + /** + * @var array + */ + private array $data; + + /** + * Constructor + * + * @return void + */ + public function __construct() + { + $this->_resetState(); + } + + /** + * Sets request data + * + * @param array $data + * @return void + */ + public function setData(array $data): void + { + $this->data = $data; + } + + /** + * Gets request data + * + * @param string $key + * @return mixed|null + */ + public function getData(string $key): mixed + { + return $this->data[$key] ?? null; + } + + /** + * @inheritDoc + */ + public function _resetState(): void + { + $this->data = []; + } +} diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php index e203d3902db7..e05a5ec1c814 100644 --- a/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php +++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php @@ -69,6 +69,11 @@ class SearchCriteriaBuilder */ private SearchConfig $searchConfig; + /** + * @var RequestDataBuilder|mixed + */ + private RequestDataBuilder $localData; + /** * @param Builder $builder * @param ScopeConfigInterface $scopeConfig @@ -78,6 +83,7 @@ class SearchCriteriaBuilder * @param SortOrderBuilder|null $sortOrderBuilder * @param Config|null $eavConfig * @param SearchConfig|null $searchConfig + * @param RequestDataBuilder|null $localData */ public function __construct( Builder $builder, @@ -87,7 +93,8 @@ public function __construct( Visibility $visibility, SortOrderBuilder $sortOrderBuilder = null, Config $eavConfig = null, - SearchConfig $searchConfig = null + SearchConfig $searchConfig = null, + RequestDataBuilder $localData = null, ) { $this->scopeConfig = $scopeConfig; $this->filterBuilder = $filterBuilder; @@ -97,6 +104,7 @@ public function __construct( $this->sortOrderBuilder = $sortOrderBuilder ?? ObjectManager::getInstance()->get(SortOrderBuilder::class); $this->eavConfig = $eavConfig ?? ObjectManager::getInstance()->get(Config::class); $this->searchConfig = $searchConfig ?? ObjectManager::getInstance()->get(SearchConfig::class); + $this->localData = $localData ?? ObjectManager::getInstance()->get(RequestDataBuilder::class); } /** @@ -169,7 +177,7 @@ private function updateMatchTypeRequestConfig(string $requestName, array $partia } } } - $this->searchConfig->merge([$requestName => $data]); + $this->localData->setData([$requestName => $data]); } /** diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Price/Provider.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Price/Provider.php index a3dd3356774e..63ba502ad646 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Price/Provider.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/Price/Provider.php @@ -9,13 +9,14 @@ use Magento\Catalog\Pricing\Price\FinalPrice; use Magento\Catalog\Pricing\Price\RegularPrice; +use Magento\Framework\ObjectManager\ResetAfterRequestInterface; use Magento\Framework\Pricing\Amount\AmountInterface; use Magento\Framework\Pricing\SaleableInterface; /** * Provides product prices */ -class Provider implements ProviderInterface +class Provider implements ProviderInterface, ResetAfterRequestInterface { /** * @var array @@ -33,6 +34,38 @@ class Provider implements ProviderInterface RegularPrice::PRICE_CODE => [] ]; + /** + * @var array|array[] + * + * phpcs:disable Magento2.Commenting.ClassPropertyPHPDocFormatting + */ + private readonly array $minimalPriceConstructed; + + /** + * @var array|array[] + * + * phpcs:disable Magento2.Commenting.ClassPropertyPHPDocFormatting + */ + private readonly array $maximalPriceConstructed; + + /** + * Constructor + */ + public function __construct() + { + $this->minimalPriceConstructed = $this->minimalPrice; + $this->maximalPriceConstructed = $this->maximalPrice; + } + + /** + * @inheritDoc + */ + public function _resetState(): void + { + $this->minimalPrice = $this->minimalPriceConstructed; + $this->maximalPrice = $this->maximalPriceConstructed; + } + /** * @inheritdoc */ diff --git a/app/code/Magento/CatalogGraphQl/Plugin/Search/RequestBuilderPlugin.php b/app/code/Magento/CatalogGraphQl/Plugin/Search/RequestBuilderPlugin.php new file mode 100644 index 000000000000..f8e0bb8a1fbb --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Plugin/Search/RequestBuilderPlugin.php @@ -0,0 +1,41 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogGraphQl\Plugin\Search; + +use Magento\CatalogGraphQl\DataProvider\Product\RequestDataBuilder; +use Magento\Framework\Search\Request\Config; + +class RequestBuilderPlugin +{ + /** + * Constructor + * + * @param RequestDataBuilder $localData + * @phpcs:disable Magento2.CodeAnalysis.EmptyBlock + */ + public function __construct(private RequestDataBuilder $localData) + { + } + + /** + * Get around + * + * @param Config $subject + * @param callable $proceed + * @param string $requestName + * @return array + */ + public function aroundGet(Config $subject, callable $proceed, string $requestName) + { + if ($this->localData->getData($requestName)) { + return $this->localData->getData($requestName); + } else { + return $proceed($requestName); + } + } +} diff --git a/app/code/Magento/CatalogGraphQl/etc/di.xml b/app/code/Magento/CatalogGraphQl/etc/di.xml index 7656a593d6ff..149f959ba32b 100644 --- a/app/code/Magento/CatalogGraphQl/etc/di.xml +++ b/app/code/Magento/CatalogGraphQl/etc/di.xml @@ -26,6 +26,14 @@ </argument> </arguments> </virtualType> + <virtualType name="Magento\Framework\GraphQl\Config\Data" type="Magento\Framework\Config\Data"> + <arguments> + <argument name="cacheTags" xsi:type="array"> + <!-- Note: Because of DynamicAttributeReaders, this cache needs to be cleaned when attributes change--> + <item name="EAV" xsi:type="string">EAV</item> + </argument> + </arguments> + </virtualType> <type name="Magento\Framework\GraphQl\Query\FieldTranslator"> <arguments> <argument name="translationMap" xsi:type="array"> @@ -93,10 +101,18 @@ </argument> </arguments> </type> - <type name="Magento\Framework\Search\Request\Config\FilesystemReader"> <plugin name="productAttributesDynamicFields" type="Magento\CatalogGraphQl\Plugin\Search\Request\ConfigReader" /> </type> + <type name="Magento\Framework\Search\Request\Config"> + <arguments> + <argument name="cacheTags" xsi:type="array"> + <!-- Note: We have to add EAV to the cache tags because productAttributesDynamicFields uses EAV --> + <item name="EAV" xsi:type="string">EAV</item> + </argument> + </arguments> + <plugin name="localRequestDataPlugin" type="Magento\CatalogGraphQl\Plugin\Search\RequestBuilderPlugin" /> + </type> <preference type="Magento\CatalogGraphQl\Model\Resolver\Product\Price\Provider" for="Magento\CatalogGraphQl\Model\Resolver\Product\Price\ProviderInterface"/> diff --git a/app/code/Magento/CatalogRule/view/adminhtml/ui_component/catalog_rule_form.xml b/app/code/Magento/CatalogRule/view/adminhtml/ui_component/catalog_rule_form.xml index 64bc5efe8e38..1eed8489f762 100644 --- a/app/code/Magento/CatalogRule/view/adminhtml/ui_component/catalog_rule_form.xml +++ b/app/code/Magento/CatalogRule/view/adminhtml/ui_component/catalog_rule_form.xml @@ -133,7 +133,7 @@ </validation> <dataType>number</dataType> <tooltip> - <link>https://docs.magento.com/user-guide/configuration/scope.html</link> + <link>https://experienceleague.adobe.com/docs/commerce-admin/start/setup/websites-stores-views.html#scope-settings</link> <description>What is this?</description> </tooltip> <label translate="true">Websites</label> diff --git a/app/code/Magento/CatalogRuleConfigurable/Plugin/CatalogRule/Model/Rule/Validation.php b/app/code/Magento/CatalogRuleConfigurable/Plugin/CatalogRule/Model/Rule/Validation.php index 87008a2f4ea2..194eb53e6017 100644 --- a/app/code/Magento/CatalogRuleConfigurable/Plugin/CatalogRule/Model/Rule/Validation.php +++ b/app/code/Magento/CatalogRuleConfigurable/Plugin/CatalogRule/Model/Rule/Validation.php @@ -49,15 +49,19 @@ public function afterValidate(Rule $rule, $validateResult, DataObject $product) { if (!$validateResult && ($configurableProducts = $this->configurable->getParentIdsByChild($product->getId()))) { foreach ($configurableProducts as $configurableProductId) { - $configurableProduct = $this->productRepository->getById( - $configurableProductId, - false, - $product->getStoreId() - ); - $validateResult = $rule->getConditions()->validate($configurableProduct); - // If any of configurable product is valid for current rule, then their sub-product must be valid too - if ($validateResult) { - break; + try { + $configurableProduct = $this->productRepository->getById( + $configurableProductId, + false, + $product->getStoreId() + ); + $validateResult = $rule->getConditions()->validate($configurableProduct); + //If any of configurable product is valid for current rule, then their sub-product must be valid too + if ($validateResult) { + break; + } + } catch (\Exception $e) { + continue; } } } diff --git a/app/code/Magento/CatalogRuleConfigurable/Test/Unit/Plugin/CatalogRule/Model/Rule/ValidationTest.php b/app/code/Magento/CatalogRuleConfigurable/Test/Unit/Plugin/CatalogRule/Model/Rule/ValidationTest.php index ec40415f7ed6..29358f23ebc3 100644 --- a/app/code/Magento/CatalogRuleConfigurable/Test/Unit/Plugin/CatalogRule/Model/Rule/ValidationTest.php +++ b/app/code/Magento/CatalogRuleConfigurable/Test/Unit/Plugin/CatalogRule/Model/Rule/ValidationTest.php @@ -66,6 +66,32 @@ protected function setUp(): void ); } + /** + * @return void + */ + public function testAfterValidateConfigurableProductException(): void + { + $validationResult = false; + $parentsIds = [2]; + $productId = 1; + + $this->productMock->expects($this->once()) + ->method('getId') + ->willReturn($productId); + $this->configurableMock->expects($this->once()) + ->method('getParentIdsByChild') + ->with($productId) + ->willReturn($parentsIds); + $this->productRepositoryMock->expects($this->once()) + ->method('getById') + ->willThrowException(new \Exception('Faulty configurable product')); + + $this->assertSame( + $validationResult, + $this->validation->afterValidate($this->ruleMock, $validationResult, $this->productMock) + ); + } + /** * @param $parentsIds * @param $validationResult diff --git a/app/code/Magento/CatalogWidget/Test/Mftf/ActionGroup/AdminFillCatalogProductsListWidgetCategoryActionGroup.xml b/app/code/Magento/CatalogWidget/Test/Mftf/ActionGroup/AdminFillCatalogProductsListWidgetCategoryActionGroup.xml index ecc5780da0e0..cb848d947a03 100644 --- a/app/code/Magento/CatalogWidget/Test/Mftf/ActionGroup/AdminFillCatalogProductsListWidgetCategoryActionGroup.xml +++ b/app/code/Magento/CatalogWidget/Test/Mftf/ActionGroup/AdminFillCatalogProductsListWidgetCategoryActionGroup.xml @@ -25,8 +25,8 @@ <click selector="{{WidgetSection.RuleParam}}" stepKey="clickToAddRuleParam"/> <click selector="{{WidgetSection.Chooser}}" stepKey="clickToSelectFromList"/> <waitForPageLoad stepKey="waitForPageLoadAfterSelectingRuleParam"/> - <waitForElementVisible selector="{{WidgetSection.PreCreateCategory(categoryName)}}" stepKey="waitForCategoryElementVisible"/> - <click selector="{{WidgetSection.PreCreateCategory(categoryName)}}" stepKey="selectCategoryFromArguments"/> + <waitForElementVisible selector="{{WidgetSection.CreateCategory(categoryName)}}" stepKey="waitForCategoryElementVisible"/> + <click selector="{{WidgetSection.CreateCategory(categoryName)}}" stepKey="selectCategoryFromArguments"/> <click selector="{{AdminNewWidgetSection.applyParameter}}" stepKey="clickApplyButton"/> <waitForElementNotVisible selector="{{InsertWidgetSection.categoryTreeWrapper}}" stepKey="waitForCategoryTreeIsNotVisible"/> </actionGroup> diff --git a/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListCheckWidgetOrderTest.xml b/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListCheckWidgetOrderTest.xml index 062c4c398e18..957af5f91645 100644 --- a/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListCheckWidgetOrderTest.xml +++ b/app/code/Magento/CatalogWidget/Test/Mftf/Test/CatalogProductListCheckWidgetOrderTest.xml @@ -68,8 +68,8 @@ <click selector="{{WidgetSection.RuleParam}}" stepKey="clickRuleParam" /> <waitForElementVisible selector="{{WidgetSection.Chooser}}" stepKey="waitForElement" /> <click selector="{{WidgetSection.Chooser}}" stepKey="clickChooser" /> - <waitForElementVisible selector="{{WidgetSection.PreCreateCategory('$simplecategory.name$')}}" stepKey="waitForCategoryVisible" /> - <click selector="{{WidgetSection.PreCreateCategory('$simplecategory.name$')}}" stepKey="selectCategory" /> + <waitForElementVisible selector="{{WidgetSection.CreateCategory('$simplecategory.name$')}}" stepKey="waitForCategoryVisible" /> + <click selector="{{WidgetSection.CreateCategory('$simplecategory.name$')}}" stepKey="selectCategory" /> <actionGroup ref="AdminClickInsertWidgetActionGroup" stepKey="clickInsertWidget"/> <!--Save cms page and go to Storefront--> <actionGroup ref="SaveCmsPageActionGroup" stepKey="saveCmsPage"/> diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminUpdateWidgetsOfCatalogCategoryLinkActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminUpdateWidgetsOfCatalogCategoryLinkActionGroup.xml index 2e8353df9e46..77afc0ab227e 100644 --- a/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminUpdateWidgetsOfCatalogCategoryLinkActionGroup.xml +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/AdminUpdateWidgetsOfCatalogCategoryLinkActionGroup.xml @@ -22,6 +22,7 @@ <click selector="{{CmsNewWidgetOptionsSection.SelectCategory}}" stepKey="clickSelectCategoryButton1"/> <waitForPageLoad stepKey="waitToLoadRootCategory"/> <click selector="{{CmsNewWidgetOptionsSection.ExpandRootCategory}}" stepKey="clickToExpandRootCat"/> + <waitForPageLoad stepKey="waitToExpandRootCategory"/> <click selector="{{CmsNewWidgetOptionsSection.SecondCategory}}" stepKey="clickSecondCategoryToSelect"/> <waitForPageLoad stepKey="waitToSelectSecondCategory"/> <click selector="{{CmsNewWidgetOptionsSection.SaveWidget}}" stepKey="clickSaveWidgetButton"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/CmsNewWidgetOptionsSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/CmsNewWidgetOptionsSection.xml index bf9f9f67d5c9..2f4d38b4a55d 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/CmsNewWidgetOptionsSection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/CmsNewWidgetOptionsSection.xml @@ -16,9 +16,9 @@ <element name="SecondProduct" type="text" selector="//div[@class='admin__data-grid-wrap admin__data-grid-wrap-static']//tbody/tr[2]"/> <element name="SaveWidget" type="button" selector="//button[@id='save']"/> <element name="FirstWidget" type="text" selector="//tbody/tr[1]/td[3]"/> - <element name="ExpandRootCategory" type="button" selector="//img[@class='x-tree-ec-icon x-tree-elbow-end-plus']"/> - <element name="FirstCategory" type="text" selector="//Span[contains(text(),'simpleCategory')]"/> - <element name="SecondCategory" type="text" selector="//Span[contains(text(),'CategoryB')]"/> + <element name="ExpandRootCategory" type="button" selector="//li[contains(@class,'jstree-node jstree-closed')]//i[contains(@class,'jstree-icon jstree-ocl')]"/> + <element name="FirstCategory" type="text" selector="//a[contains(text(),'simpleCategory')]/../..//a"/> + <element name="SecondCategory" type="text" selector="//a[contains(text(),'CategoryB')]"/> </section> </sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/CmsNewWidgetUpdateLayoutSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/CmsNewWidgetUpdateLayoutSection.xml index 5f311ec89eaa..d61a45a250ec 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/CmsNewWidgetUpdateLayoutSection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/CmsNewWidgetUpdateLayoutSection.xml @@ -14,7 +14,7 @@ <element name="Template" type="button" selector="#all_pages_0 > table > tbody > tr > td:nth-child(2) > div > div > select"/> <element name="SpecificCategories" type="button" selector="//input[@id='specific_anchor_categories_0']"/> <element name="CategoryChooserButton" type="button" selector="//*[@id='anchor_categories_ids_0']/p/a[1]/img"/> - <element name="BranchCat" type="button" selector="//a/span[contains(text(),'{{var}}')]/../..//img[@class='x-tree-ec-icon x-tree-elbow-end-plus']" parameterized="true"/> - <element name="CountAllNestedCat" type="button" selector="//*[@class='x-tree-ec-icon x-tree-elbow-end-minus' or @class='x-tree-ec-icon x-tree-elbow-end']"/> + <element name="BranchCat" type="button" selector="//a[contains(text(),'{{var}}')]/..//i[contains(@class,'jstree-ocl')]" parameterized="true"/> + <element name="CountAllNestedCat" type="button" selector="//*[@class='jstree-node jstree-last jstree-open' or @class='jstree-node jstree-leaf jstree-last']//*[@class='jstree-icon jstree-ocl']"/> </section> </sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/WidgetSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/WidgetSection.xml index c9ef757ca747..6406c0602c86 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/WidgetSection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/TinyMCESection/WidgetSection.xml @@ -25,6 +25,7 @@ <element name="CMSPage" type="text" selector="//td[contains(text(),'Home page')]"/> <element name="BlockPage" type="text" selector="//td[contains(text(),'{{var1}}')]" parameterized="true"/> <element name="PreCreateCategory" type="text" selector=" //span[contains(text(),'{{var1}}')]" parameterized="true"/> + <element name="CreateCategory" type="text" selector=" //a[contains(text(),'{{var1}}')]" parameterized="true"/> <element name="PreCreateProduct" type="text" selector="//td[contains(text(),'{{var1}}')]" parameterized="true"/> <element name="NoOfProductToDisplay" type="input" selector="input[data-ui-id='wysiwyg-widget-options-fieldset-element-text-parameters-products-count']"/> <element name="AddParam" type="button" selector=".rule-param-add"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogCategoryLinkTypeTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogCategoryLinkTypeTest.xml index de7c1145b4e6..d32e2b590c61 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogCategoryLinkTypeTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogCategoryLinkTypeTest.xml @@ -46,9 +46,9 @@ <click selector="{{WidgetSection.BtnChooser}}" stepKey="clickSelectCategoryBtn" /> <waitForPageLoad stepKey="wait3"/> <waitForElementVisible selector="{{AdminCategorySidebarTreeSection.expandCategoryByName('Default Category')}}" stepKey="waitForDefaultCategory"/> - <conditionalClick selector="{{AdminCategorySidebarTreeSection.expandCategoryByName('Default Category')}}" dependentSelector="{{WidgetSection.PreCreateCategory('$$createPreReqCategory.name$$')}}" visible="false" stepKey="expandRootCategory"/> - <waitForElementVisible selector="{{WidgetSection.PreCreateCategory('$$createPreReqCategory.name$$')}}" stepKey="expandWait" /> - <click selector="{{WidgetSection.PreCreateCategory('$$createPreReqCategory.name$$')}}" stepKey="selectPreCreateCategory" /> + <conditionalClick selector="{{AdminCategorySidebarTreeSection.expandCategoryByName('Default Category')}}" dependentSelector="{{WidgetSection.CreateCategory('$$createPreReqCategory.name$$')}}" visible="false" stepKey="expandRootCategory"/> + <waitForElementVisible selector="{{WidgetSection.CreateCategory('$$createPreReqCategory.name$$')}}" stepKey="expandWait" /> + <click selector="{{WidgetSection.CreateCategory('$$createPreReqCategory.name$$')}}" stepKey="selectPreCreateCategory" /> <waitForElementNotVisible selector="{{WidgetSection.SelectCategoryTitle}}" stepKey="waitForSlideoutCloses1" /> <actionGroup ref="AdminClickInsertWidgetActionGroup" stepKey="clickInsertWidget"/> <comment userInput="Comment is added to preserve the step key for backward compatibility" stepKey="waitForSlideOutCloses2"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogProductLinkTypeTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogProductLinkTypeTest.xml index 49173a612854..08abc6ac2b9c 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogProductLinkTypeTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogProductLinkTypeTest.xml @@ -50,9 +50,9 @@ <click selector="{{WidgetSection.BtnChooser}}" stepKey="clickSelectPageBtn" /> <waitForPageLoad stepKey="wait4"/> <waitForElementVisible selector="{{AdminCategorySidebarTreeSection.expandCategoryByName('Default Category')}}" stepKey="waitForDefaultCategory"/> - <conditionalClick selector="{{AdminCategorySidebarTreeSection.expandCategoryByName('Default Category')}}" dependentSelector="{{WidgetSection.PreCreateCategory('$$createPreReqCategory.name$$')}}" visible="false" stepKey="expandRootCategory"/> - <waitForElementVisible selector="{{WidgetSection.PreCreateCategory('$$createPreReqCategory.name$$')}}" stepKey="expandWait" /> - <click selector="{{WidgetSection.PreCreateCategory('$$createPreReqCategory.name$$')}}" stepKey="selectPreCategory" /> + <conditionalClick selector="{{AdminCategorySidebarTreeSection.expandCategoryByName('Default Category')}}" dependentSelector="{{WidgetSection.CreateCategory('$$createPreReqCategory.name$$')}}" visible="false" stepKey="expandRootCategory"/> + <waitForElementVisible selector="{{WidgetSection.CreateCategory('$$createPreReqCategory.name$$')}}" stepKey="expandWait" /> + <click selector="{{WidgetSection.CreateCategory('$$createPreReqCategory.name$$')}}" stepKey="selectPreCategory" /> <waitForLoadingMaskToDisappear stepKey="waitLoadingMask" /> <click selector="{{WidgetSection.PreCreateProduct('$$createPreReqProduct.name$$')}}" stepKey="selectPreProduct" /> <waitForElementNotVisible selector="{{WidgetSection.SelectProductTitle}}" stepKey="waitForSlideOutCloses" /> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogProductListTypeTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogProductListTypeTest.xml index 4d7a99de198a..c22f48ae4a0a 100644 --- a/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogProductListTypeTest.xml +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminAddWidgetToWYSIWYGWithCatalogProductListTypeTest.xml @@ -57,7 +57,7 @@ <waitForElementVisible selector="{{WidgetSection.Chooser}}" stepKey="waitForElement" /> <click selector="{{WidgetSection.Chooser}}" stepKey="clickChooser" /> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskDisappear4" /> - <click selector="{{WidgetSection.PreCreateCategory('$$createPreReqCategory.name$$')}}" stepKey="selectPreCategory" /> + <click selector="{{WidgetSection.CreateCategory('$$createPreReqCategory.name$$')}}" stepKey="selectPreCategory" /> <!-- Test that the "<" operand functions correctly --> <click selector="{{WidgetSection.AddParam}}" stepKey="clickAddParamBtn2" /> 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 4da2e0c8c13d..fd81373c23c8 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 @@ -125,7 +125,7 @@ </validation> <dataType>int</dataType> <tooltip> - <link>https://docs.magento.com/user-guide/configuration/scope.html</link> + <link>https://experienceleague.adobe.com/docs/commerce-admin/start/setup/websites-stores-views.html#scope-settings</link> <description>What is this?</description> </tooltip> <label translate="true">Store View</label> diff --git a/app/code/Magento/Cms/view/adminhtml/ui_component/cms_page_form.xml b/app/code/Magento/Cms/view/adminhtml/ui_component/cms_page_form.xml index 1db60883e5e2..6f531b4fda86 100644 --- a/app/code/Magento/Cms/view/adminhtml/ui_component/cms_page_form.xml +++ b/app/code/Magento/Cms/view/adminhtml/ui_component/cms_page_form.xml @@ -209,7 +209,7 @@ </validation> <dataType>int</dataType> <tooltip> - <link>https://docs.magento.com/user-guide/configuration/scope.html</link> + <link>https://experienceleague.adobe.com/docs/commerce-admin/start/setup/websites-stores-views.html#scope-settings</link> <description>What is this?</description> </tooltip> <label translate="true">Store View</label> diff --git a/app/code/Magento/Config/App/Config/ReloadConfig.php b/app/code/Magento/Config/App/Config/ReloadConfig.php new file mode 100644 index 000000000000..63a41109e3f7 --- /dev/null +++ b/app/code/Magento/Config/App/Config/ReloadConfig.php @@ -0,0 +1,33 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); +namespace Magento\Config\App\Config; + +use Magento\Config\App\Config\Type\System; +use Magento\Framework\App\State\ReloadProcessorInterface; + +/** + * Config module specific reset state + */ +class ReloadConfig implements ReloadProcessorInterface +{ + /** + * @param System $system + */ + public function __construct(private readonly System $system) + { + } + + /** + * Tells the system state to reload itself. + * + * @return void + */ + public function reloadState(): void + { + $this->system->get(); + } +} diff --git a/app/code/Magento/Config/Model/Config/Backend/Email/Sender.php b/app/code/Magento/Config/Model/Config/Backend/Email/Sender.php index 590ba269564e..10f4833b78c7 100644 --- a/app/code/Magento/Config/Model/Config/Backend/Email/Sender.php +++ b/app/code/Magento/Config/Model/Config/Backend/Email/Sender.php @@ -30,6 +30,12 @@ public function beforeSave() ); } + if (str_contains($value, ":")) { + throw new \Magento\Framework\Exception\LocalizedException( + __('The sender name "%1" is not valid. The colon character is not allowed.', $value) + ); + } + if (strlen($value) > 255) { throw new \Magento\Framework\Exception\LocalizedException( __('Maximum sender name length is 255. Please correct your settings.') diff --git a/app/code/Magento/Config/Test/Unit/Model/Config/Backend/Email/SenderTest.php b/app/code/Magento/Config/Test/Unit/Model/Config/Backend/Email/SenderTest.php index 329d1705ed6f..d5a52afab907 100644 --- a/app/code/Magento/Config/Test/Unit/Model/Config/Backend/Email/SenderTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/Config/Backend/Email/SenderTest.php @@ -51,6 +51,7 @@ public function beforeSaveDataProvider() { return [ ['Mr. Real Name', 'Mr. Real Name'], + ['No colons:', false], [str_repeat('a', 256), false], [null, false], ['', false], diff --git a/app/code/Magento/Config/etc/di.xml b/app/code/Magento/Config/etc/di.xml index aebe4230207a..4b1c743f0a5d 100644 --- a/app/code/Magento/Config/etc/di.xml +++ b/app/code/Magento/Config/etc/di.xml @@ -390,4 +390,11 @@ <type name="Magento\Framework\App\Cache\TypeList"> <plugin name="warm_config_cache" type="Magento\Config\Plugin\Framework\App\Cache\TypeList\WarmConfigCache"/> </type> + <type name="Magento\Framework\App\State\ReloadProcessorComposite"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="Magento_Config::config" xsi:type="object">Magento\Config\App\Config\ReloadConfig</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Config/i18n/en_US.csv b/app/code/Magento/Config/i18n/en_US.csv index ef3ae9fa3601..7df19a3f0ad7 100644 --- a/app/code/Magento/Config/i18n/en_US.csv +++ b/app/code/Magento/Config/i18n/en_US.csv @@ -38,6 +38,7 @@ System,System "Sorry, the default display currency you selected is not available in allowed currencies.","Sorry, the default display currency you selected is not available in allowed currencies." "The ""%1"" email address is incorrect. Verify the email address and try again.","The ""%1"" email address is incorrect. Verify the email address and try again." "The sender name ""%1"" is not valid. Please use only visible characters and spaces.","The sender name ""%1"" is not valid. Please use only visible characters and spaces." +"The sender name ""%1"" is not valid. The colon character is not allowed.","The sender name ""%1"" is not valid. The colon character is not allowed." "Maximum sender name length is 255. Please correct your settings.","Maximum sender name length is 255. Please correct your settings." "The file you're uploading exceeds the server size limit of %1 kilobytes.","The file you're uploading exceeds the server size limit of %1 kilobytes." "The base directory to upload file is not specified.","The base directory to upload file is not specified." diff --git a/app/code/Magento/ConfigurableProduct/Model/ConfigurableMaxPriceCalculator.php b/app/code/Magento/ConfigurableProduct/Model/ConfigurableMaxPriceCalculator.php new file mode 100644 index 000000000000..53496206963d --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ConfigurableMaxPriceCalculator.php @@ -0,0 +1,72 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Model; + +use Magento\Framework\Pricing\Adjustment\CalculatorInterface; +use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\Framework\App\ResourceConnection; + +class ConfigurableMaxPriceCalculator +{ + /** + * @var CalculatorInterface + */ + private $calculator; + + /** + * @var PriceCurrencyInterface + */ + private $priceCurrency; + + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param CalculatorInterface $calculator + * @param PriceCurrencyInterface $priceCurrency + * @param ResourceConnection $resourceConnection + */ + public function __construct( + CalculatorInterface $calculator, + PriceCurrencyInterface $priceCurrency, + ResourceConnection $resourceConnection + ) { + $this->calculator = $calculator; + $this->priceCurrency = $priceCurrency; + $this->resourceConnection = $resourceConnection; + } + + /** + * Get the maximum price of a configurable product. + * + * @param int $productId + * @return float + */ + public function getMaxPriceForConfigurableProduct($productId) + { + $connection = $this->resourceConnection->getConnection(); + $superLinkTable = $this->resourceConnection->getTableName('catalog_product_super_link'); + $catalogProductTable = $this->resourceConnection->getTableName('catalog_product_entity'); + $priceIndexTable = $this->resourceConnection->getTableName('catalog_product_index_price'); + $select = $connection->select() + ->from(['sl' => $superLinkTable], []) + ->join(['pe' => $catalogProductTable], 'sl.product_id = pe.entity_id', []) + ->join(['ip' => $priceIndexTable], 'pe.entity_id = ip.entity_id', ['max_price' => 'MAX(ip.final_price)']) + ->where('sl.parent_id = ?', $productId); + $result = $connection->fetchRow($select); + + if ($result && isset($result['max_price'])) { + return $result['max_price']; + } + + // Return a default value or handle the case where there's no max price + return 0.00; + } +} diff --git a/app/code/Magento/ConfigurableProduct/Pricing/Price/ConfigurableRegularPrice.php b/app/code/Magento/ConfigurableProduct/Pricing/Price/ConfigurableRegularPrice.php index 25f1a464e3b5..1ac53291e67b 100644 --- a/app/code/Magento/ConfigurableProduct/Pricing/Price/ConfigurableRegularPrice.php +++ b/app/code/Magento/ConfigurableProduct/Pricing/Price/ConfigurableRegularPrice.php @@ -7,9 +7,11 @@ namespace Magento\ConfigurableProduct\Pricing\Price; use Magento\Catalog\Model\Product; +use Magento\ConfigurableProduct\Model\ConfigurableMaxPriceCalculator; use Magento\Framework\App\ObjectManager; use Magento\Framework\ObjectManager\ResetAfterRequestInterface; use Magento\Framework\Pricing\Price\AbstractPrice; +use Magento\Framework\Pricing\SaleableInterface; /** * Class RegularPrice @@ -53,12 +55,18 @@ class ConfigurableRegularPrice extends AbstractPrice implements */ private $lowestPriceOptionsProvider; + /** + * @var ConfigurableMaxPriceCalculator + */ + private $configurableMaxPriceCalculator; + /** * @param \Magento\Framework\Pricing\SaleableInterface $saleableItem * @param float $quantity * @param \Magento\Framework\Pricing\Adjustment\CalculatorInterface $calculator * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency * @param PriceResolverInterface $priceResolver + * @param ConfigurableMaxPriceCalculator $configurableMaxPriceCalculator * @param LowestPriceOptionsProviderInterface $lowestPriceOptionsProvider */ public function __construct( @@ -67,12 +75,14 @@ public function __construct( \Magento\Framework\Pricing\Adjustment\CalculatorInterface $calculator, \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, PriceResolverInterface $priceResolver, + ConfigurableMaxPriceCalculator $configurableMaxPriceCalculator, LowestPriceOptionsProviderInterface $lowestPriceOptionsProvider = null ) { parent::__construct($saleableItem, $quantity, $calculator, $priceCurrency); $this->priceResolver = $priceResolver; $this->lowestPriceOptionsProvider = $lowestPriceOptionsProvider ?: ObjectManager::getInstance()->get(LowestPriceOptionsProviderInterface::class); + $this->configurableMaxPriceCalculator = $configurableMaxPriceCalculator; } /** @@ -184,4 +194,28 @@ public function _resetState(): void { $this->values = []; } + + /** + * Check whether Configurable Product have more than one children products + * + * @param SaleableInterface $product + * @return bool + */ + public function isChildProductsOfEqualPrices(SaleableInterface $product): bool + { + $minPrice = $this->getMinRegularAmount()->getValue(); + $final_price = $product->getFinalPrice(); + $productId = $product->getId(); + if ($final_price < $minPrice) { + return false; + } + $attributes = $product->getTypeInstance()->getConfigurableAttributes($product); + $items = $attributes->getItems(); + $options = reset($items); + $maxPrice = $this->configurableMaxPriceCalculator->getMaxPriceForConfigurableProduct($productId); + if ($maxPrice == 0) { + $maxPrice = $this->getMaxRegularAmount()->getValue(); + } + return (count($options->getOptions()) > 1) && $minPrice == $maxPrice; + } } diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/CreateConfigurableProductWithSamePriceActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/CreateConfigurableProductWithSamePriceActionGroup.xml new file mode 100644 index 000000000000..3e158ed039de --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/CreateConfigurableProductWithSamePriceActionGroup.xml @@ -0,0 +1,101 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="CreateConfigurableProductWithSamePriceActionGroup"> + <annotations> + <description>Goes to the Admin Product grid page. + Create a Configurable Product using the default Product Options with identical pricing.</description> + </annotations> + <arguments> + <argument name="product" defaultValue="_defaultProduct"/> + <argument name="category" defaultValue="_defaultCategory"/> + </arguments> + + <!-- fill in basic configurable product values --> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="amOnProductGridPage"/> + <waitForPageLoad time="30" stepKey="wait1"/> + <click selector="{{AdminProductGridActionSection.addProductToggle}}" stepKey="clickOnAddProductToggle"/> + <click selector="{{AdminProductGridActionSection.addConfigurableProduct}}" + stepKey="clickOnAddConfigurableProduct"/> + <fillField userInput="{{product.name}}" selector="{{AdminProductFormSection.productName}}" stepKey="fillName"/> + <fillField userInput="{{product.sku}}" selector="{{AdminProductFormSection.productSku}}" stepKey="fillSKU"/> + <fillField userInput="{{product.price}}" selector="{{AdminProductFormSection.productPrice}}" + stepKey="fillPrice"/> + <fillField userInput="{{product.quantity}}" selector="{{AdminProductFormSection.productQuantity}}" + stepKey="fillQuantity"/> + <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" + parameterArray="[{{category.name}}]" stepKey="fillCategory"/> + <selectOption userInput="{{product.visibility}}" selector="{{AdminProductFormSection.visibility}}" + stepKey="fillVisibility"/> + <click selector="{{AdminProductSEOSection.sectionHeader}}" stepKey="openSeoSection"/> + <fillField userInput="{{product.urlKey}}" selector="{{AdminProductSEOSection.urlKeyInput}}" + stepKey="fillUrlKey"/> + + <!-- create configurations for colors the product is available in --> + <click selector="{{AdminProductFormConfigurationsSection.createConfigurations}}" + stepKey="clickOnCreateConfigurations"/> + <click selector="{{AdminCreateProductConfigurationsPanel.createNewAttribute}}" stepKey="clickOnNewAttribute"/> + <waitForPageLoad stepKey="waitForIFrame"/> + <switchToIFrame selector="{{AdminNewAttributePanel.newAttributeIFrame}}" stepKey="switchToNewAttributeIFrame"/> + <fillField selector="{{AdminNewAttributePanel.defaultLabel}}" + userInput="{{colorProductAttribute.default_label}}" + stepKey="fillDefaultLabel"/> + <click selector="{{AdminNewAttributePanel.saveAttribute}}" stepKey="clickOnNewAttributePanel"/> + <waitForPageLoad stepKey="waitForSaveAttribute"/> + <switchToIFrame stepKey="switchOutOfIFrame"/> + <waitForPageLoad stepKey="waitForFilters"/> + <click selector="{{AdminCreateProductConfigurationsPanel.filters}}" stepKey="clickOnFilters"/> + <fillField userInput="{{colorProductAttribute.default_label}}" + selector="{{AdminCreateProductConfigurationsPanel.attributeCode}}" + stepKey="fillFilterAttributeCodeField"/> + <click selector="{{AdminCreateProductConfigurationsPanel.applyFilters}}" stepKey="clickApplyFiltersButton"/> + <click selector="{{AdminCreateProductConfigurationsPanel.firstCheckbox}}" stepKey="clickOnFirstCheckbox"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton1"/> + <waitForElementVisible selector="{{AdminCreateProductConfigurationsPanel.createNewValue}}" + stepKey="waitCreateNewValueAppears"/> + <click selector="{{AdminCreateProductConfigurationsPanel.createNewValue}}" stepKey="clickOnCreateNewValue1"/> + <fillField userInput="{{colorProductAttribute1.name}}" + selector="{{AdminCreateProductConfigurationsPanel.attributeName}}" + stepKey="fillFieldForNewAttribute1"/> + <click selector="{{AdminCreateProductConfigurationsPanel.saveAttribute}}" stepKey="clickOnSaveNewAttribute1"/> + <click selector="{{AdminCreateProductConfigurationsPanel.createNewValue}}" stepKey="clickOnCreateNewValue2"/> + <fillField userInput="{{colorProductAttribute2.name}}" + selector="{{AdminCreateProductConfigurationsPanel.attributeName}}" + stepKey="fillFieldForNewAttribute2"/> + <click selector="{{AdminCreateProductConfigurationsPanel.saveAttribute}}" stepKey="clickOnSaveNewAttribute2"/> + <click selector="{{AdminCreateProductConfigurationsPanel.createNewValue}}" stepKey="clickOnCreateNewValue3"/> + <fillField userInput="{{colorProductAttribute3.name}}" + selector="{{AdminCreateProductConfigurationsPanel.attributeName}}" + stepKey="fillFieldForNewAttribute3"/> + <click selector="{{AdminCreateProductConfigurationsPanel.saveAttribute}}" stepKey="clickOnSaveNewAttribute3"/> + <click selector="{{AdminCreateProductConfigurationsPanel.selectAll}}" stepKey="clickOnSelectAll"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton2"/> + <click selector="{{AdminCreateProductConfigurationsPanel.applyUniquePricesByAttributeToEachSku}}" + stepKey="clickOnApplyUniquePricesByAttributeToEachSku"/> + <selectOption selector="{{AdminCreateProductConfigurationsPanel.selectAttribute}}" + userInput="{{colorProductAttribute.default_label}}" stepKey="selectAttributes"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.attribute1}}" + userInput="{{colorProductAttribute1.price}}" stepKey="fillAttributePrice1"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.attribute2}}" + userInput="{{colorProductAttribute1.price}}" stepKey="fillAttributePrice2"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.attribute3}}" + userInput="{{colorProductAttribute1.price}}" stepKey="fillAttributePrice3"/> + <click selector="{{AdminCreateProductConfigurationsPanel.applySingleQuantityToEachSkus}}" + stepKey="clickOnApplySingleQuantityToEachSku"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.quantity}}" + userInput="1" stepKey="enterAttributeQuantity"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton3"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton4"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickOnSaveButton2"/> + <click selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" stepKey="clickOnConfirmInPopup"/> + <seeElement selector="{{AdminProductMessagesSection.successMessage}}" stepKey="seeSaveProductMessage"/> + <seeInTitle userInput="{{product.name}}" stepKey="seeProductNameInTitle"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductDeleteTest/AdminConfigurableProductDeleteTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductDeleteTest/AdminConfigurableProductDeleteTest.xml index 66c505c297de..8c8965c1fcda 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductDeleteTest/AdminConfigurableProductDeleteTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductDeleteTest/AdminConfigurableProductDeleteTest.xml @@ -70,6 +70,7 @@ <deleteData createDataKey="createConfigChildProduct1" stepKey="deleteConfigChildProduct1"/> <deleteData createDataKey="createConfigChildProduct2" stepKey="deleteConfigChildProduct2"/> <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteConfigProductAttribute"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> <!-- Reindex invalidated indices after product attribute has been created/deleted --> <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindexInvalidatedIndices"> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/ConfigurableProductPriceLabelVisibilityWithoutAsLowAsTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/ConfigurableProductPriceLabelVisibilityWithoutAsLowAsTest.xml new file mode 100644 index 000000000000..ef27d51239e8 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/ConfigurableProductPriceLabelVisibilityWithoutAsLowAsTest.xml @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="ConfigurableProductPriceLabelVisibilityWithoutAsLowAsTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="View configurable product details in storefront"/> + <title value="Customer should not see As low as label for Configurable Product with identical pricing"/> + <description value="Customer should not see As low as label for Configurable Product with same pricing"/> + <severity value="AVERAGE"/> + <testCaseId value="AC-7099"/> + <group value="ConfigurableProduct"/> + </annotations> + + <before> + <createData entity="ApiCategory" stepKey="createCategory"/> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <!-- Create a configurable product via the UI --> + <actionGroup ref="CreateConfigurableProductWithSamePriceActionGroup" stepKey="createProduct"> + <argument name="product" value="_defaultProduct"/> + <argument name="category" value="$$createCategory$$"/> + </actionGroup> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <actionGroup ref="DeleteProductBySkuActionGroup" stepKey="deleteProduct"> + <argument name="sku" value="{{_defaultProduct.sku}}"/> + </actionGroup> + <actionGroup ref="AdminGridFilterResetActionGroup" stepKey="clearGridFiltersVirtual"/> + <actionGroup ref="AdminGridFilterFillInputFieldActionGroup" stepKey="addSkuFilterVirtual"> + <argument name="filterInputName" value="sku"/> + <argument name="filterValue" value="{{_defaultProduct.sku}}"/> + </actionGroup> + <actionGroup ref="AdminClickSearchInGridActionGroup" stepKey="applyGridFilterVirtual"/> + <actionGroup ref="DeleteProductsIfTheyExistActionGroup" stepKey="deleteVirtualProducts"> + <argument name="sku" value="{{_defaultProduct.sku}}"/> + </actionGroup> + <actionGroup ref="ResetAdminDataGridToDefaultViewActionGroup" stepKey="clearProductsGridFilters"/> + <actionGroup ref="AdminDeleteProductAttributeByLabelActionGroup" stepKey="deleteProductAttribute"> + <argument name="productAttributeLabel" value="{{colorProductAttribute.default_label}}"/> + </actionGroup> + <!-- Reindex after deleting product attribute --> + <actionGroup ref="CliIndexerReindexActionGroup" stepKey="reindex"> + <argument name="indices" value=""/> + </actionGroup> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + </after> + + <!-- Verify configurable product details in storefront product view --> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="amOnConfigurableProductPage"> + <argument name="productUrl" value="{{_defaultProduct.urlKey}}"/> + </actionGroup> + <comment userInput="Comment is added to preserve the step key for backward compatibility" stepKey="wait"/> + <actionGroup ref="StorefrontAssertProductNameOnProductPageActionGroup" stepKey="seeProductName"> + <argument name="productName" value="{{_defaultProduct.name}}"/> + </actionGroup> + <actionGroup ref="StorefrontAssertProductSkuOnProductPageActionGroup" stepKey="seeProductSku"> + <argument name="productSku" value="{{_defaultProduct.sku}}"/> + </actionGroup> + <dontSee userInput="As low as" selector="{{StorefrontProductInfoMainSection.productPriceLabel}}" + stepKey="seeProductPriceLabel"/> + <actionGroup ref="AssertStorefrontProductStockStatusOnProductPageActionGroup" stepKey="seeProductStockStatus"> + <argument name="productStockStatus" value="In Stock"/> + </actionGroup> + <actionGroup ref="StorefrontAssertProductPriceOnProductPageActionGroup" stepKey="seeProductPrice"> + <argument name="productPrice" value="1.00"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductAttributeLabelVisibleActionGroup" stepKey="seeProductAttributeTitle"> + <argument name="productAttributeLabel" value="{{colorProductAttribute.default_label}}"/> + </actionGroup> + <comment userInput="Comment is added to preserve the step key for backward compatibility" + stepKey="seeColorAttributeName1"/> + <actionGroup ref="AssertStorefrontProductAttributeOptionVisibleActionGroup" stepKey="seeInDropDown1"> + <argument name="productAttributeOption" value="{{colorProductAttribute1.name}}"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductAttributeOptionVisibleActionGroup" stepKey="seeInDropDown2"> + <argument name="productAttributeOption" value="{{colorProductAttribute2.name}}"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductAttributeOptionVisibleActionGroup" stepKey="seeInDropDown3"> + <argument name="productAttributeOption" value="{{colorProductAttribute3.name}}"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/ConfigurableProduct/etc/di.xml b/app/code/Magento/ConfigurableProduct/etc/di.xml index 36041169514c..4f06c3ef6e3e 100644 --- a/app/code/Magento/ConfigurableProduct/etc/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/di.xml @@ -164,6 +164,7 @@ <type name="Magento\ConfigurableProduct\Pricing\Price\ConfigurableRegularPrice"> <arguments> <argument name="priceResolver" xsi:type="object">ConfigurableRegularPriceResolver</argument> + <argument name="configurableMaxPriceCalculator" xsi:type="object">Magento\ConfigurableProduct\Model\ConfigurableMaxPriceCalculator</argument> </arguments> </type> <type name="Magento\Catalog\Model\Product\Attribute\Backend\Price"> 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 a6dc6c819989..5d3381333104 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 @@ -9,21 +9,25 @@ $priceModel = $block->getPriceType('regular_price'); /** @var \Magento\Framework\Pricing\Price\PriceInterface $finalPriceModel */ $finalPriceModel = $block->getPriceType('final_price'); +/** @var Magento\ConfigurableProduct\Pricing\Price\ConfigurableRegularPriceInterface $regularPriceModel */ +$regularPriceModel = $block->getPriceType('regular_price'); $idSuffix = $block->getIdSuffix() ? $block->getIdSuffix() : ''; $schema = ($block->getZone() == 'item_view') ? true : false; +$product = $regularPriceModel->getProduct(); ?> -<span class="normal-price"> - <?= /* @noEscape */ $block->renderAmount($finalPriceModel->getAmount(), [ - 'display_label' => __('As low as'), - 'price_id' => $block->getPriceId('product-price-' . $idSuffix), - 'price_type' => 'finalPrice', - 'include_container' => true, - 'schema' => $schema, - ]); + <span class="normal-price"> +<?= /* @noEscape */ +$block->renderAmount($finalPriceModel->getAmount(), [ + 'display_label' => $regularPriceModel->isChildProductsOfEqualPrices($product) ? '' : __('As low as'), + 'price_id' => $block->getPriceId('product-price-' . $idSuffix), + 'price_type' => 'finalPrice', + 'include_container' => true, + 'schema' => $schema, +]); ?> </span> -<?php if (!$block->isProductList() && $block->hasSpecialPrice()) : ?> +<?php if (!$block->isProductList() && $block->hasSpecialPrice()): ?> <span class="old-price sly-old-price no-display"> <?= /* @noEscape */ $block->renderAmount($priceModel->getAmount(), [ 'display_label' => __('Regular Price'), @@ -35,14 +39,14 @@ $schema = ($block->getZone() == 'item_view') ? true : false; </span> <?php endif; ?> -<?php if ($block->showMinimalPrice()) : ?> - <?php if ($block->getUseLinkForAsLowAs()) :?> +<?php if ($block->showMinimalPrice()): ?> + <?php if ($block->getUseLinkForAsLowAs()):?> <a href="<?= $block->escapeUrl($block->getSaleableItem()->getProductUrl()) ?>" class="minimal-price-link"> <?= /* @noEscape */ $block->renderAmountMinimal() ?> </a> - <?php else :?> + <?php else:?> <span class="minimal-price-link"> <?= /* @noEscape */ $block->renderAmountMinimal() ?> </span> <?php endif?> -<?php endif; ?> +<?php endif; ?> \ No newline at end of file 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 9d19500bf605..9fa634edf095 100644 --- a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js +++ b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js @@ -22,6 +22,11 @@ define([ options: { superSelector: '.super-attribute-select', selectSimpleProduct: '[name="selected_configurable_option"]', + + /** + * @deprecated Not used anymore + * @see selectorProductPrice + */ priceHolderSelector: '.price-box', spConfig: {}, state: {}, @@ -86,7 +91,7 @@ define([ _initializeOptions: function () { var options = this.options, gallery = $(options.mediaGallerySelector), - priceBoxOptions = $(this.options.priceHolderSelector).priceBox('option').priceConfig || null; + priceBoxOptions = this._getPriceBoxElement().priceBox('option').priceConfig || null; if (priceBoxOptions && priceBoxOptions.optionTemplate) { options.optionTemplate = priceBoxOptions.optionTemplate; @@ -100,7 +105,7 @@ define([ options.settings = options.spConfig.containerId ? $(options.spConfig.containerId).find(options.superSelector) : - $(options.superSelector); + this.element.parents(this.options.selectorProduct).find(options.superSelector); options.values = options.spConfig.defaultValues || {}; options.parentImage = $('[data-role=base-image-container] img').attr('src'); @@ -580,7 +585,7 @@ define([ * configurable product's option selections. */ _reloadPrice: function () { - $(this.options.priceHolderSelector).trigger('updatePrice', this._getPrices()); + this._getPriceBoxElement().trigger('updatePrice', this._getPrices()); }, /** @@ -654,7 +659,7 @@ define([ * @private */ _calculatePrice: function (config) { - var displayPrices = $(this.options.priceHolderSelector).priceBox('option').prices, + var displayPrices = this._getPriceBoxElement().priceBox('option').prices, newPrices = this.options.spConfig.optionPrices[_.first(config.allowedProducts)] || {}; _.each(displayPrices, function (price, code) { @@ -702,8 +707,7 @@ define([ */ _displayRegularPriceBlock: function (optionId) { var shouldBeShown = true, - $priceBox = this.element.parents(this.options.selectorProduct) - .find(this.options.selectorProductPrice); + $priceBox = this._getPriceBoxElement(); _.each(this.options.settings, function (element) { if (element.value === '') { @@ -785,6 +789,18 @@ define([ } else { $(this.options.tierPriceBlockSelector).hide(); } + }, + + /** + * Returns the price container element + * + * @returns {*} + * @private + */ + _getPriceBoxElement: function () { + return this.element + .parents(this.options.selectorProduct) + .find(this.options.selectorProductPrice); } }); diff --git a/app/code/Magento/Customer/Controller/Account/EditPost.php b/app/code/Magento/Customer/Controller/Account/EditPost.php index 085b4ab2d3fd..499fc7d72f7f 100644 --- a/app/code/Magento/Customer/Controller/Account/EditPost.php +++ b/app/code/Magento/Customer/Controller/Account/EditPost.php @@ -16,6 +16,7 @@ use Magento\Customer\Model\AuthenticationInterface; use Magento\Customer\Model\Customer\Mapper; use Magento\Customer\Model\EmailNotificationInterface; +use Magento\Customer\Model\Metadata\Form\File; use Magento\Framework\App\CsrfAwareActionInterface; use Magento\Framework\App\ObjectManager; use Magento\Framework\App\Request\InvalidRequestException; @@ -136,6 +137,7 @@ class EditPost extends AbstractAccount implements CsrfAwareActionInterface, Http * @param SessionCleanerInterface|null $sessionCleaner * @param AccountConfirmation|null $accountConfirmation * @param Url|null $customerUrl + * @param Mapper|null $customerMapper */ public function __construct( Context $context, @@ -149,7 +151,8 @@ public function __construct( ?Filesystem $filesystem = null, ?SessionCleanerInterface $sessionCleaner = null, ?AccountConfirmation $accountConfirmation = null, - ?Url $customerUrl = null + ?Url $customerUrl = null, + ?Mapper $customerMapper = null ) { parent::__construct($context); $this->session = $customerSession; @@ -164,6 +167,7 @@ public function __construct( $this->accountConfirmation = $accountConfirmation ?: ObjectManager::getInstance() ->get(AccountConfirmation::class); $this->customerUrl = $customerUrl ?: ObjectManager::getInstance()->get(Url::class); + $this->customerMapper = $customerMapper ?: ObjectManager::getInstance()->get(Mapper::class); } /** @@ -233,9 +237,15 @@ public function execute() $customer = $this->getCustomerDataObject($this->session->getCustomerId()); $customerCandidate = $this->populateNewCustomerDataObject($this->_request, $customer); - $attributeToDelete = $this->_request->getParam('delete_attribute_value'); - if ($attributeToDelete !== null) { - $this->deleteCustomerFileAttribute($customerCandidate, $attributeToDelete); + $attributeToDelete = (string)$this->_request->getParam('delete_attribute_value'); + if ($attributeToDelete !== "") { + $attributesToDelete = $this->prepareAttributesToDelete($attributeToDelete); + foreach ($attributesToDelete as $attribute) { + $uploadedValue = $this->_request->getParam($attribute . File::UPLOADED_FILE_SUFFIX); + if ((string)$uploadedValue === "") { + $this->deleteCustomerFileAttribute($customerCandidate, $attribute); + } + } } try { @@ -300,6 +310,26 @@ public function execute() return $resultRedirect; } + /** + * Convert comma-separated list of attributes to delete into array + * + * @param string $attribute + * @return array + */ + private function prepareAttributesToDelete(string $attribute) : array + { + $result = []; + if ($attribute !== "") { + if (str_contains($attribute, ',')) { + $result = explode(',', $attribute); + } else { + $result[] = $attribute; + } + $result = array_unique($result); + } + return $result; + } + /** * Adds a complex success message if email confirmation is required * @@ -468,11 +498,7 @@ private function deleteCustomerFileAttribute( string $attributeToDelete ) : void { if ($attributeToDelete !== '') { - if (strpos($attributeToDelete, ',') !== false) { - $attributes = explode(',', $attributeToDelete); - } else { - $attributes[] = $attributeToDelete; - } + $attributes = $this->prepareAttributesToDelete($attributeToDelete); foreach ($attributes as $attr) { $attributeValue = $customerCandidateDataObject->getCustomAttribute($attr); if ($attributeValue!== null) { diff --git a/app/code/Magento/Customer/Helper/Address.php b/app/code/Magento/Customer/Helper/Address.php index 2f3585b2e9af..88f3024c2dc7 100644 --- a/app/code/Magento/Customer/Helper/Address.php +++ b/app/code/Magento/Customer/Helper/Address.php @@ -86,7 +86,7 @@ class Address extends \Magento\Framework\App\Helper\AbstractHelper implements Re * @var CustomerMetadataInterface * * @deprecated 101.0.0 - * phpcs:disable Magento2.Annotation.ClassPropertyPHPDocFormatting + * phpcs:disable Magento2.Commenting.ClassPropertyPHPDocFormatting */ protected $_customerMetadataService; diff --git a/app/code/Magento/Customer/Model/Address/CustomerAddressDataProvider.php b/app/code/Magento/Customer/Model/Address/CustomerAddressDataProvider.php index 4334607c6b17..b9197a329a32 100644 --- a/app/code/Magento/Customer/Model/Address/CustomerAddressDataProvider.php +++ b/app/code/Magento/Customer/Model/Address/CustomerAddressDataProvider.php @@ -7,18 +7,15 @@ namespace Magento\Customer\Model\Address; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Model\Config\Share; use Magento\Directory\Model\AllowedCountries; use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\LocalizedException; -/** - * Provides customer address data. - */ class CustomerAddressDataProvider { /** - * Customer addresses. - * * @var array */ private $customerAddresses = []; @@ -58,12 +55,14 @@ public function __construct( /** * Get addresses for customer. * - * @param \Magento\Customer\Api\Data\CustomerInterface $customer + * @param CustomerInterface $customer + * @param int|null $addressLimit * @return array - * @throws \Magento\Framework\Exception\LocalizedException + * @throws LocalizedException */ public function getAddressDataByCustomer( - \Magento\Customer\Api\Data\CustomerInterface $customer + \Magento\Customer\Api\Data\CustomerInterface $customer, + ?int $addressLimit = null ): array { if (!empty($this->customerAddresses)) { return $this->customerAddresses; @@ -83,6 +82,9 @@ public function getAddressDataByCustomer( } $customerAddresses[$address->getId()] = $this->customerAddressDataFormatter->prepareAddress($address); + if ($addressLimit && count($customerAddresses) >= $addressLimit) { + break; + } } $this->customerAddresses = $customerAddresses; diff --git a/app/code/Magento/Customer/Model/Cache/GroupExcludedWebsiteCache.php b/app/code/Magento/Customer/Model/Cache/GroupExcludedWebsiteCache.php new file mode 100644 index 000000000000..17f4d47c5236 --- /dev/null +++ b/app/code/Magento/Customer/Model/Cache/GroupExcludedWebsiteCache.php @@ -0,0 +1,66 @@ +<?php +/** + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained from + * Adobe. + */ +declare(strict_types=1); + +namespace Magento\Customer\Model\Cache; + +class GroupExcludedWebsiteCache +{ + /** + * @var array + */ + private array $customerGroupExcludedWebsite = []; + + /** + * Adds entry to GroupExcludedWebsite cache + * + * @param int $customerGroupId + * @param array $value + */ + public function addToCache(int $customerGroupId, array $value) + { + $this->customerGroupExcludedWebsite[$customerGroupId] = $value; + } + + /** + * Gets entry from GroupExcludedWebsite cache + * + * @param int $customerGroupId + * @return array + */ + public function getFromCache(int $customerGroupId): array + { + return $this->customerGroupExcludedWebsite[$customerGroupId] ?? []; + } + + /** + * Checks presence of cached customer group in GroupExcludedWebsite cache + * + * @param int $customerGroupId + * @return bool + */ + public function isCached(int $customerGroupId): bool + { + return isset($this->customerGroupExcludedWebsite[$customerGroupId]); + } + + /** + * Cleans the cache + */ + public function invalidate() + { + $this->customerGroupExcludedWebsite = []; + } +} diff --git a/app/code/Magento/Customer/Model/Plugin/SaveCustomerGroupExcludedWebsite.php b/app/code/Magento/Customer/Model/Plugin/SaveCustomerGroupExcludedWebsite.php index 33a857fd3527..9816a0bed4f9 100644 --- a/app/code/Magento/Customer/Model/Plugin/SaveCustomerGroupExcludedWebsite.php +++ b/app/code/Magento/Customer/Model/Plugin/SaveCustomerGroupExcludedWebsite.php @@ -145,5 +145,4 @@ private function isValueChanged(array $currentValues, array $newValues): bool return !($currentValues === array_intersect($currentValues, $newValues) && $newValues === array_intersect($newValues, $currentValues)); } - } diff --git a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php index 99720afc9829..c39a804aae33 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php +++ b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php @@ -204,6 +204,7 @@ public function save(CustomerInterface $customer, $passwordHash = null) if ($customer->getId()) { $prevCustomerData = $this->getById($customer->getId()); $prevCustomerDataArr = $this->prepareCustomerData($prevCustomerData->__toArray()); + $customer->setCreatedAt($prevCustomerData->getCreatedAt()); } /** @var $customer \Magento\Customer\Model\Data\Customer */ $customerArr = $customer->__toArray(); @@ -219,7 +220,6 @@ public function save(CustomerInterface $customer, $passwordHash = null) /** @var CustomerModel $customerModel */ $customerModel = $this->customerFactory->create(['data' => $customerData]); $this->populateWithOrigData($customerModel, $prevCustomerDataArr); - //Model's actual ID field maybe different than "id" so "id" field from $customerData may be ignored. $customerModel->setId($customer->getId()); $storeId = $customerModel->getStoreId(); diff --git a/app/code/Magento/Customer/Model/ResourceModel/GroupExcludedWebsite.php b/app/code/Magento/Customer/Model/ResourceModel/GroupExcludedWebsite.php index 61696154f231..93f975d3bea0 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/GroupExcludedWebsite.php +++ b/app/code/Magento/Customer/Model/ResourceModel/GroupExcludedWebsite.php @@ -9,12 +9,40 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Model\ResourceModel\Db\VersionControl\AbstractDb; +use Magento\Framework\ObjectManager\ResetAfterRequestInterface; +use Magento\Customer\Model\Cache\GroupExcludedWebsiteCache; +use Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite; +use Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot; +use Magento\Framework\Model\ResourceModel\Db\Context; /** * Excluded customer group website resource model. */ -class GroupExcludedWebsite extends AbstractDb +class GroupExcludedWebsite extends AbstractDb implements ResetAfterRequestInterface { + /** + * @var GroupExcludedWebsiteCache $groupExcludedWebsiteCache + */ + private GroupExcludedWebsiteCache $groupExcludedWebsiteCache; + + /** + * @param Context $context + * @param Snapshot $entitySnapshot + * @param RelationComposite $entityRelationComposite + * @param GroupExcludedWebsiteCache $groupExcludedWebsiteCache + * @param string $connectionName + */ + public function __construct( + Context $context, + Snapshot $entitySnapshot, + RelationComposite $entityRelationComposite, + GroupExcludedWebsiteCache $groupExcludedWebsiteCache, + $connectionName = null + ) { + parent::__construct($context, $entitySnapshot, $entityRelationComposite, $connectionName); + $this->groupExcludedWebsiteCache = $groupExcludedWebsiteCache; + } + /** * Resource initialization * @@ -25,6 +53,22 @@ protected function _construct() $this->_init('customer_group_excluded_website', 'entity_id'); } + /** + * @inheritDoc + */ + public function _resetState(): void + { + $this->groupExcludedWebsiteCache->invalidate(); + } + + /** + * Makes sure ExcludedWebsiteCache is invalidated when excluded websites are modified + */ + public function invalidateCache() + { + $this->_resetState(); + } + /** * Retrieve excluded website ids related to customer group. * @@ -32,8 +76,13 @@ protected function _construct() * @return array * @throws LocalizedException */ + public function loadCustomerGroupExcludedWebsites(int $customerGroupId): array { + if ($this->groupExcludedWebsiteCache->isCached($customerGroupId)) { + return $this->groupExcludedWebsiteCache->getFromCache($customerGroupId); + } + $connection = $this->getConnection(); $bind = ['customer_group_id' => $customerGroupId]; @@ -44,7 +93,8 @@ public function loadCustomerGroupExcludedWebsites(int $customerGroupId): array 'customer_group_id = :customer_group_id' ); - return $connection->fetchCol($select, $bind); + $this->groupExcludedWebsiteCache->addToCache($customerGroupId, $connection->fetchCol($select, $bind)); + return $this->groupExcludedWebsiteCache->getFromCache($customerGroupId); } /** @@ -76,6 +126,7 @@ public function delete($customerGroupId) { $connection = $this->getConnection(); $connection->beginTransaction(); + $this->invalidateCache(); try { $where = $connection->quoteInto('customer_group_id = ?', $customerGroupId); $connection->delete( @@ -87,7 +138,6 @@ public function delete($customerGroupId) $connection->rollBack(); throw $e; } - return $this; } diff --git a/app/code/Magento/Customer/Model/ResourceModel/GroupExcludedWebsiteRepository.php b/app/code/Magento/Customer/Model/ResourceModel/GroupExcludedWebsiteRepository.php index 1237ff3017c1..00d2a30a1258 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/GroupExcludedWebsiteRepository.php +++ b/app/code/Magento/Customer/Model/ResourceModel/GroupExcludedWebsiteRepository.php @@ -26,7 +26,7 @@ class GroupExcludedWebsiteRepository implements GroupExcludedWebsiteRepositoryIn * @param GroupExcludedWebsite $groupExcludedWebsiteResourceModel */ public function __construct( - GroupExcludedWebsite $groupExcludedWebsiteResourceModel + GroupExcludedWebsite $groupExcludedWebsiteResourceModel, ) { $this->groupExcludedWebsiteResourceModel = $groupExcludedWebsiteResourceModel; } @@ -43,7 +43,6 @@ public function save(GroupExcludedWebsiteInterface $groupExcludedWebsite): Group __('Could not save customer group website to exclude from customer group: "%1"', $e->getMessage()) ); } - return $groupExcludedWebsite; } @@ -78,8 +77,8 @@ public function getAllExcludedWebsites(): array if (!empty($allExcludedWebsites)) { foreach ($allExcludedWebsites as $allExcludedWebsite) { - $customerGroupId = (int)$allExcludedWebsite['customer_group_id']; - $websiteId = (int)$allExcludedWebsite['website_id']; + $customerGroupId = (int) $allExcludedWebsite['customer_group_id']; + $websiteId = (int) $allExcludedWebsite['website_id']; $excludedWebsites[$customerGroupId][] = $websiteId; } } @@ -109,7 +108,7 @@ public function delete(int $customerGroupId): bool public function deleteByWebsite(int $websiteId): bool { try { - return (bool)$this->groupExcludedWebsiteResourceModel->deleteByWebsite($websiteId); + return (bool) $this->groupExcludedWebsiteResourceModel->deleteByWebsite($websiteId); } catch (LocalizedException $e) { throw new LocalizedException( __('Could not delete customer group excluded website by id.') diff --git a/app/code/Magento/Customer/Model/Validator/Name.php b/app/code/Magento/Customer/Model/Validator/Name.php index 9b4d57f1835b..75d460358970 100644 --- a/app/code/Magento/Customer/Model/Validator/Name.php +++ b/app/code/Magento/Customer/Model/Validator/Name.php @@ -15,7 +15,7 @@ */ class Name extends AbstractValidator { - private const PATTERN_NAME = '/(?:[\p{L}\p{M}\,\-\_\.\'’`\s\d]){1,255}+/u'; + private const PATTERN_NAME = '/(?:[\p{L}\p{M}\,\-\_\.\'’`&\s\d]){1,255}+/u'; /** * Validate name fields. diff --git a/app/code/Magento/Customer/README.md b/app/code/Magento/Customer/README.md index f63b7f063327..8bea0da02e2a 100644 --- a/app/code/Magento/Customer/README.md +++ b/app/code/Magento/Customer/README.md @@ -348,9 +348,9 @@ For information about a UI component in Magento 2, see [Overview of UI component More information can get at articles: -- [Customer Configurations](https://docs.magento.com/user-guide/configuration/customers/customer-configuration.html) -- [Customer Attributes](https://docs.magento.com/user-guide/stores/attributes-customer.html) -- [Customer Address Attributes](https://docs.magento.com/user-guide/stores/attributes-customer-address.html) +- [Customer Configurations](https://experienceleague.adobe.com/docs/commerce-admin/config/customers/customer-configuration.html) +- [Customer Attributes](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/attributes/attribute-properties.html) +- [Customer Address Attributes](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/attributes/address-attributes.html) - [EAV And Extension Attributes](https://developer.adobe.com/commerce/php/development/components/attributes/) - [2.4.x Release information](https://experienceleague.adobe.com/docs/commerce-operations/release/notes/overview.html) diff --git a/app/code/Magento/Customer/Test/Unit/Controller/Account/EditPostTest.php b/app/code/Magento/Customer/Test/Unit/Controller/Account/EditPostTest.php new file mode 100644 index 000000000000..7013c7464413 --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Controller/Account/EditPostTest.php @@ -0,0 +1,256 @@ +<?php +/** + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + */ +declare(strict_types=1); + +namespace Magento\Customer\Test\Unit\Controller\Account; + +use Magento\Customer\Api\SessionCleanerInterface; +use Magento\Customer\Api\AccountManagementInterface; +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Controller\Account\EditPost; +use Magento\Customer\Model\Metadata\Form\File; +use Magento\Customer\Model\Session; +use Magento\Customer\Model\AddressRegistry; +use Magento\Customer\Model\CustomerExtractor; +use Magento\Customer\Model\AccountConfirmation; +use Magento\Customer\Model\Url; +use Magento\Customer\Model\Customer\Mapper; +use Magento\Framework\Escaper; +use Magento\Framework\Exception\SessionException; +use Magento\Framework\Filesystem; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\App\Action\Context; +use Magento\Framework\Controller\Result\RedirectFactory; +use Magento\Framework\Controller\Result\Redirect; +use Magento\Framework\Data\Form\FormKey\Validator; +use Magento\Framework\Event\ManagerInterface as EventManagerInterface; +use Magento\Framework\Message\ManagerInterface as MessageManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class EditPostTest extends TestCase +{ + /** + * @var EditPost + */ + private $editPost; + + /** + * @var Context|MockObject + */ + private $context; + + /** + * @var Session|MockObject + */ + private $customerSession; + + /** + * @var AccountManagementInterface|MockObject + */ + private $accountManagement; + + /** + * @var CustomerRepositoryInterface|MockObject + */ + private $customerRepository; + + /** + * @var Validator|MockObject + */ + private $formKeyValidator; + + /** + * @var CustomerExtractor|MockObject + */ + private $customerExtractor; + + /** + * @var Escaper|MockObject + */ + private $escaper; + + /** + * @var AddressRegistry|MockObject + */ + private $addressRegistry; + + /** + * @var Filesystem|MockObject + */ + private $filesystem; + + /** + * @var SessionCleanerInterface|MockObject + */ + private $sessionCleaner; + + /** + * @var AccountConfirmation|MockObject + */ + private $accountConfirmation; + + /** + * @var Url|MockObject + */ + private $customerUrl; + + /** + * @var RequestInterface|MockObject + */ + private $request; + + /** + * @var Mapper|MockObject + */ + private $customerMapper; + + protected function setUp(): void + { + $this->context = $this->getMockBuilder(Context::class) + ->disableOriginalConstructor() + ->getMock(); + $this->customerSession = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->getMock(); + $this->accountManagement = $this->getMockBuilder(AccountManagementInterface::class) + ->getMockForAbstractClass(); + $this->customerRepository = $this->getMockBuilder(CustomerRepositoryInterface::class) + ->getMockForAbstractClass(); + $this->formKeyValidator = $this->getMockBuilder(Validator::class) + ->disableOriginalConstructor() + ->getMock(); + $this->customerExtractor = $this->getMockBuilder(CustomerExtractor::class) + ->disableOriginalConstructor() + ->getMock(); + $this->escaper = $this->getMockBuilder(Escaper::class) + ->disableOriginalConstructor() + ->getMock(); + $this->addressRegistry = $this->getMockBuilder(AddressRegistry::class) + ->disableOriginalConstructor() + ->getMock(); + $this->filesystem = $this->getMockBuilder(Filesystem::class) + ->disableOriginalConstructor() + ->getMock(); + $this->sessionCleaner = $this->getMockBuilder(SessionCleanerInterface::class) + ->getMockForAbstractClass(); + $this->accountConfirmation = $this->getMockBuilder(AccountConfirmation::class) + ->disableOriginalConstructor() + ->getMock(); + $this->customerUrl = $this->getMockBuilder(Url::class) + ->disableOriginalConstructor() + ->getMock(); + $this->customerMapper = $this->getMockBuilder(Mapper::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->request = $this->getMockBuilder(RequestInterface::class) + ->addMethods(['isPost', 'getPostValue']) + ->getMockForAbstractClass(); + $this->context->expects($this->any()) + ->method('getRequest') + ->willReturn($this->request); + $resultRedirectFactory = $this->getMockBuilder(RedirectFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->context->expects($this->any()) + ->method('getResultRedirectFactory') + ->willReturn($resultRedirectFactory); + $redirect = $this->getMockBuilder(Redirect::class) + ->disableOriginalConstructor() + ->getMock(); + $resultRedirectFactory->expects($this->any()) + ->method('create') + ->willReturn($redirect); + + $eventManager = $this->getMockBuilder(EventManagerInterface::class) + ->getMockForAbstractClass(); + $this->context->expects($this->any()) + ->method('getEventManager') + ->willReturn($eventManager); + + $messageManager = $this->getMockBuilder(MessageManagerInterface::class) + ->getMockForAbstractClass(); + $this->context->expects($this->any()) + ->method('getMessageManager') + ->willReturn($messageManager); + + $this->editPost = new EditPost( + $this->context, + $this->customerSession, + $this->accountManagement, + $this->customerRepository, + $this->formKeyValidator, + $this->customerExtractor, + $this->escaper, + $this->addressRegistry, + $this->filesystem, + $this->sessionCleaner, + $this->accountConfirmation, + $this->customerUrl, + $this->customerMapper + ); + } + + /** + * @return void + * @throws SessionException + */ + public function testExecute() + { + $this->formKeyValidator->expects($this->once()) + ->method('validate') + ->with($this->request) + ->willReturn(true); + $this->request->expects($this->once()) + ->method('isPost') + ->willReturn(true); + + $customer = $this->getMockBuilder(CustomerInterface::class) + ->getMockForAbstractClass(); + $customer->expects($this->any()) + ->method('getAddresses') + ->willReturn([]); + $this->customerRepository->expects($this->any()) + ->method('getById') + ->willReturn($customer); + + $this->customerMapper->expects($this->once()) + ->method('toFlatArray') + ->willReturn([]); + $this->customerExtractor->expects($this->once()) + ->method('extract') + ->willReturn($customer); + + $attr = 'attr1'; + $this->request->expects($this->exactly(5)) + ->method('getParam') + ->withConsecutive( + ['change_email'], + [ 'delete_attribute_value'], + [$attr . File::UPLOADED_FILE_SUFFIX] + )->willReturnOnConsecutiveCalls( + false, + $attr, + 'uploadedFileName' + ); + + $this->editPost->execute(); + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Model/Address/CustomerAddressDataProviderTest.php b/app/code/Magento/Customer/Test/Unit/Model/Address/CustomerAddressDataProviderTest.php new file mode 100644 index 000000000000..7d6ede8fb421 --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Model/Address/CustomerAddressDataProviderTest.php @@ -0,0 +1,93 @@ +<?php +/** + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + */ +declare(strict_types=1); + +namespace Magento\Customer\Test\Unit\Model\Address; + +use Magento\Customer\Api\Data\AddressInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\Address\CustomerAddressDataFormatter; +use Magento\Customer\Model\Address\CustomerAddressDataProvider; +use Magento\Customer\Model\Config\Share; +use Magento\Directory\Model\AllowedCountries; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class CustomerAddressDataProviderTest extends TestCase +{ + /** + * @var CustomerAddressDataFormatter|MockObject + */ + private CustomerAddressDataFormatter $customerAddressDataFormatter; + + /** + * @var Share|MockObject + */ + private Share $shareConfig; + + /** + * @var AllowedCountries|MockObject + */ + private AllowedCountries $allowedCountryReader; + + /** + * @var CustomerAddressDataProvider + */ + private CustomerAddressDataProvider $provider; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->customerAddressDataFormatter = $this->createMock(CustomerAddressDataFormatter::class); + $this->shareConfig = $this->createMock(Share::class); + $this->allowedCountryReader = $this->createMock(AllowedCountries::class); + + $this->provider = new CustomerAddressDataProvider( + $this->customerAddressDataFormatter, + $this->shareConfig, + $this->allowedCountryReader + ); + } + + /** + * @return void + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function testGetAddressDataByCustomer(): void + { + $addressLimit = 1; + $this->allowedCountryReader->expects($this->once())->method('getAllowedCountries')->willReturn(['1']); + $this->customerAddressDataFormatter->expects($this->once()) + ->method('prepareAddress') + ->willreturn([1]); + $this->shareConfig->expects($this->any())->method('isGlobalScope')->willReturn(false); + + $viableAddress = $this->getMockForAbstractClass(AddressInterface::class); + $viableAddress->expects($this->once())->method('getId')->willReturn(1); + $faultyAddress = $this->getMockForAbstractClass(AddressInterface::class); + + $customer = $this->getMockForAbstractClass(CustomerInterface::class); + $customer->expects($this->once()) + ->method('getAddresses') + ->willReturn([$viableAddress, $faultyAddress]); + + $expectedResult = [ + '1' => [1] + ]; + $this->assertSame($expectedResult, $this->provider->getAddressDataByCustomer($customer, $addressLimit)); + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Model/Validator/NameTest.php b/app/code/Magento/Customer/Test/Unit/Model/Validator/NameTest.php index 9a9821df88df..5033774d5449 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Validator/NameTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Validator/NameTest.php @@ -87,6 +87,12 @@ public function expectedPunctuationInNamesDataProvider(): array 'middleName' => '', 'lastNameName' => 'O`Doe', 'message' => 'Grave accent back quote character must be allowed in names' + ], + [ + 'firstName' => 'John & Smith', + 'middleName' => '', + 'lastNameName' => 'O`Doe', + 'message' => 'Special character ampersand(&) must be allowed in names' ] ]; } diff --git a/app/code/Magento/Customer/view/base/ui_component/customer_form.xml b/app/code/Magento/Customer/view/base/ui_component/customer_form.xml index 0a9df3016df0..384eb9198f5a 100644 --- a/app/code/Magento/Customer/view/base/ui_component/customer_form.xml +++ b/app/code/Magento/Customer/view/base/ui_component/customer_form.xml @@ -129,7 +129,7 @@ </validation> <dataType>number</dataType> <tooltip> - <link>https://docs.magento.com/user-guide/configuration/scope.html</link> + <link>https://experienceleague.adobe.com/docs/commerce-admin/start/setup/websites-stores-views.html#scope-settings</link> <description translate="true">If your Magento installation has multiple websites, you can edit the scope to associate the customer with a specific site.</description> </tooltip> <imports> diff --git a/app/code/Magento/CustomerImportExport/README.md b/app/code/Magento/CustomerImportExport/README.md index 50c978eae1a7..444895fd70a1 100644 --- a/app/code/Magento/CustomerImportExport/README.md +++ b/app/code/Magento/CustomerImportExport/README.md @@ -26,5 +26,5 @@ For more information about a layout in Magento 2, see the [Layout documentation] You can get more information about import/export processes in magento at the articles: -- [Import](https://docs.magento.com/user-guide/system/data-import.html) -- [Export](https://docs.magento.com/user-guide/system/data-export.html) +- [Import](https://experienceleague.adobe.com/docs/commerce-admin/systems/data-transfer/import/data-import.html) +- [Export](https://experienceleague.adobe.com/docs/commerce-admin/systems/data-transfer/data-export.html) diff --git a/app/code/Magento/Directory/Setup/Patch/Data/AddRegionsForIndia.php b/app/code/Magento/Directory/Setup/Patch/Data/AddRegionsForIndia.php new file mode 100644 index 000000000000..3cc3e2bc69b8 --- /dev/null +++ b/app/code/Magento/Directory/Setup/Patch/Data/AddRegionsForIndia.php @@ -0,0 +1,89 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Directory\Setup\Patch\Data; + +use Magento\Directory\Setup\DataInstaller; +use Magento\Directory\Setup\DataInstallerFactory; +use Magento\Framework\Setup\ModuleDataSetupInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\PatchVersionInterface; + +/** + * Add Regions/States for India. + */ +class AddRegionsForIndia implements DataPatchInterface +{ + /** + * @var ModuleDataSetupInterface + */ + private ModuleDataSetupInterface $moduleDataSetup; + + /** + * @var DataInstallerFactory + */ + private DataInstallerFactory $dataInstallerFactory; + + /** + * AddRegionsForIndia constructor. + * + * @param ModuleDataSetupInterface $moduleDataSetup + * @param DataInstallerFactory $dataInstallerFactory + */ + public function __construct( + ModuleDataSetupInterface $moduleDataSetup, + DataInstallerFactory $dataInstallerFactory + ) { + $this->moduleDataSetup = $moduleDataSetup; + $this->dataInstallerFactory = $dataInstallerFactory; + } + + /** + * @inheritdoc + */ + public function apply() + { + /** @var DataInstaller $dataInstaller */ + $dataInstaller = $this->dataInstallerFactory->create(); + $dataInstaller->addCountryRegions( + $this->moduleDataSetup->getConnection(), + $this->getDataForIndia() + ); + + return $this; + } + + /** + * Indian states data. + * + * @return array + */ + private function getDataForIndia(): array + { + return [ + ['IN', 'LA', 'Ladakh'] + ]; + } + + /** + * @inheritdoc + */ + public static function getDependencies(): array + { + return [ + InitializeDirectoryData::class, + ]; + } + + /** + * @inheritdoc + */ + public function getAliases(): array + { + return []; + } +} diff --git a/app/code/Magento/Elasticsearch/README.md b/app/code/Magento/Elasticsearch/README.md index d7d7fb5ce89d..22c61013fd49 100644 --- a/app/code/Magento/Elasticsearch/README.md +++ b/app/code/Magento/Elasticsearch/README.md @@ -14,7 +14,7 @@ For information about a module installation in Magento 2, see [Enable or disable ## Structure -`ElasticAdapter/` - the directory that contains the core files for providing support to ElasticSearch 7.x and 8.x +`ElasticAdapter/` - the directory that contains the core files for providing support to ElasticSearch 7.x and 8.x version. `SearchAdapter/` - the directory that contains solutions for adapting ElasticSearch query searching. @@ -27,7 +27,7 @@ For information about significant changes in patch releases, see [2.4.x Release More information about ElasticSearch are at articles: -- [Configuring Catalog Search](https://docs.magento.com/user-guide/catalog/search-configuration.html). +- [Configuring Catalog Search](https://experienceleague.adobe.com/docs/commerce-admin/catalog/catalog/search/search-configuration.html). - [Installation Guide/Elasticsearch](https://experienceleague.adobe.com/docs/commerce-operations/installation-guide/prerequisites/search-engine/overview.html). - [Configure and maintain Elasticsearch](https://experienceleague.adobe.com/docs/commerce-operations/configuration-guide/search/overview-search.html). - Magento Commerce Cloud - [set up Elasticsearch service](https://experienceleague.adobe.com/docs/commerce-cloud-service/user-guide/configure/service/elasticsearch.html). diff --git a/app/code/Magento/Elasticsearch7/README.md b/app/code/Magento/Elasticsearch7/README.md index a0c4063da5d3..c4b2ddfdeec2 100644 --- a/app/code/Magento/Elasticsearch7/README.md +++ b/app/code/Magento/Elasticsearch7/README.md @@ -22,7 +22,7 @@ For information about significant changes in patch releases, see [2.4.x Release More information about ElasticSearch are at articles: -- [Configuring Catalog Search](https://docs.magento.com/user-guide/catalog/search-configuration.html). +- [Configuring Catalog Search](https://experienceleague.adobe.com/docs/commerce-admin/catalog/catalog/search/search-configuration.html). - [Installation Guide/Elasticsearch](https://experienceleague.adobe.com/docs/commerce-operations/installation-guide/prerequisites/search-engine/overview.html). - [Configure and maintain Elasticsearch](https://experienceleague.adobe.com/docs/commerce-operations/configuration-guide/search/overview-search.html). - Magento Commerce Cloud - [set up Elasticsearch service](https://experienceleague.adobe.com/docs/commerce-cloud-service/user-guide/configure/service/elasticsearch.html). diff --git a/app/code/Magento/Email/README.md b/app/code/Magento/Email/README.md index 3844f0a1e3db..5ced4c5e0cfe 100644 --- a/app/code/Magento/Email/README.md +++ b/app/code/Magento/Email/README.md @@ -34,7 +34,7 @@ For information about significant changes in patch releases, see [2.4.x Release More information about email templates are at articles: -- [Marketing/Email](https://docs.magento.com/user-guide/marketing/email-templates.html) -- [Email templates list](https://docs.magento.com/user-guide/marketing/email-template-list.html) +- [Marketing/Email](https://experienceleague.adobe.com/docs/commerce-admin/systems/communications/email-templates.html) +- [Email templates list](https://experienceleague.adobe.com/docs/commerce-admin/systems/communications/email-templates.html#email-template-list) - [Customize email templates](https://developer.adobe.com/commerce/frontend-core/guide/templates/email/) - [Migrating custom email templates](https://developer.adobe.com/commerce/frontend-core/guide/templates/email-migration/#nested-arrays) diff --git a/app/code/Magento/EncryptionKey/README.md b/app/code/Magento/EncryptionKey/README.md index 1d4f642ac603..06821569c9a3 100644 --- a/app/code/Magento/EncryptionKey/README.md +++ b/app/code/Magento/EncryptionKey/README.md @@ -16,6 +16,6 @@ This module introduces the following layouts and layout handles in the `view/adm ## Additional information -For information about significant changes in patch releases, see [2.4.x Release information](https://experienceleague.adobe.com/docs/commerce-operations/release/notes/overview.html). +For information about significant changes in patch releases, see [Release information](https://experienceleague.adobe.com/docs/commerce-operations/release/notes/overview.html). -Some more information you can get at [Encryption Key](https://docs.magento.com/user-guide/system/encryption-key.html) article. +Some more information you can get at [Encryption Key](https://experienceleague.adobe.com/docs/commerce-admin/systems/security/encryption-key.html) article. diff --git a/app/code/Magento/Fedex/README.md b/app/code/Magento/Fedex/README.md index 419d9771987f..1bf0d61ea19d 100644 --- a/app/code/Magento/Fedex/README.md +++ b/app/code/Magento/Fedex/README.md @@ -25,6 +25,6 @@ This module introduces the following layouts in the `view/frontend/layout` direc You can get more information about delivery method in magento at the articles: -- [FedEx Configuration Settings](https://docs.magento.com/user-guide/shipping/fedex.html) -- [Delivery Methods Configuration](https://docs.magento.com/user-guide/configuration/sales/delivery-methods.html) +- [FedEx Configuration Settings](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/delivery/shipping-carriers/fedex.html) +- [Delivery Methods Configuration](https://experienceleague.adobe.com/docs/commerce-admin/config/sales/delivery-methods.html) - [Add custom shipping carrier](https://developer.adobe.com/commerce/php/tutorials/frontend/custom-checkout/add-shipping-carrier/) diff --git a/app/code/Magento/GiftMessage/README.md b/app/code/Magento/GiftMessage/README.md index ba3bb3962b06..1b80e3f4771b 100644 --- a/app/code/Magento/GiftMessage/README.md +++ b/app/code/Magento/GiftMessage/README.md @@ -103,4 +103,4 @@ For information about a public API in Magento 2, see [Public interfaces & APIs]( ## Additional information -[Learn more about Gift Options and Gift Message](https://docs.magento.com/user-guide/sales/gift-options.html). +[Learn more about Gift Options and Gift Message](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/point-of-purchase/cart/cart-configuration.html#gift-options). diff --git a/app/code/Magento/GoogleAdwords/README.md b/app/code/Magento/GoogleAdwords/README.md index d79a7837149d..ae24c481c39a 100644 --- a/app/code/Magento/GoogleAdwords/README.md +++ b/app/code/Magento/GoogleAdwords/README.md @@ -24,4 +24,4 @@ For more information about a layout in Magento 2, see the [Layout documentation] ## Additional information -[Learn how to configure Google AdWords](https://docs.magento.com/user-guide/marketing/google-adwords.html). +[Learn how to configure Google AdWords](https://experienceleague.adobe.com/docs/commerce-admin/marketing/google-tools/google-adwords.html). diff --git a/app/code/Magento/GoogleAnalytics/README.md b/app/code/Magento/GoogleAnalytics/README.md index 226871406e24..9467a381abaa 100644 --- a/app/code/Magento/GoogleAnalytics/README.md +++ b/app/code/Magento/GoogleAnalytics/README.md @@ -28,4 +28,4 @@ For more information about a layout in Magento 2, see the [Layout documentation] ## Additional information -[Learn how to configure Google Analytics](https://docs.magento.com/user-guide/marketing/google-universal-analytics.html). +[Learn how to configure Google Analytics](https://experienceleague.adobe.com/docs/commerce-admin/marketing/google-tools/google-analytics.html). diff --git a/app/code/Magento/GoogleGtag/README.md b/app/code/Magento/GoogleGtag/README.md index d5985c308bbc..89c51f43c6ff 100644 --- a/app/code/Magento/GoogleGtag/README.md +++ b/app/code/Magento/GoogleGtag/README.md @@ -29,4 +29,4 @@ For more information about a layout in Magento 2, see the [Layout documentation] ## Additional information -[Learn how to configure Google Analytics](https://docs.magento.com/user-guide/marketing/google-universal-analytics.html). +[Learn how to configure Google Analytics](https://experienceleague.adobe.com/docs/commerce-admin/marketing/google-tools/google-analytics.html). diff --git a/app/code/Magento/GoogleOptimizer/README.md b/app/code/Magento/GoogleOptimizer/README.md index 2d2a32562f82..e01fbdc10dc9 100644 --- a/app/code/Magento/GoogleOptimizer/README.md +++ b/app/code/Magento/GoogleOptimizer/README.md @@ -53,4 +53,4 @@ This allows to save different codes for products and categories on different sto This functionality can be switched on and off on the configuration page (`Stores -> Configuration -> General -> Google Api -> Google Analytics`). Also this functionality depends on Google Analytics module and configuration options. -[Learn how to configure Google Content Experiments](https://docs.magento.com/user-guide/marketing/google-content-experiments.html). +[Learn how to configure Google Content Experiments](https://experienceleague.adobe.com/docs/commerce-admin/marketing/google-tools/google-content-experiments.html). diff --git a/app/code/Magento/GraphQl/composer.json b/app/code/Magento/GraphQl/composer.json index af1fe042c6df..34f708571244 100644 --- a/app/code/Magento/GraphQl/composer.json +++ b/app/code/Magento/GraphQl/composer.json @@ -7,7 +7,6 @@ "magento/module-eav": "*", "magento/framework": "*", "magento/module-webapi": "*", - "magento/module-new-relic-reporting": "*", "magento/module-authorization": "*", "webonyx/graphql-php": "^15.0" }, diff --git a/app/code/Magento/GraphQl/etc/di.xml b/app/code/Magento/GraphQl/etc/di.xml index 85a2636fdaba..f3bc4e106ca7 100644 --- a/app/code/Magento/GraphQl/etc/di.xml +++ b/app/code/Magento/GraphQl/etc/di.xml @@ -104,13 +104,6 @@ <argument name="queryComplexity" xsi:type="number">300</argument> </arguments> </type> - <type name="Magento\GraphQl\Model\Query\Logger\LoggerPool"> - <arguments> - <argument name="loggers" xsi:type="array"> - <item name="newRelic" xsi:type="object">Magento\GraphQl\Model\Query\Logger\NewRelic</item> - </argument> - </arguments> - </type> <type name="Magento\Framework\GraphQl\Query\Resolver\Argument\Validator\CompositeValidator"> <arguments> <argument name="validators" xsi:type="array"> diff --git a/app/code/Magento/GraphQlNewRelic/LICENSE.txt b/app/code/Magento/GraphQlNewRelic/LICENSE.txt new file mode 100644 index 000000000000..49525fd99da9 --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/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 <insert your license name here>" 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/GraphQlNewRelic/LICENSE_AFL.txt b/app/code/Magento/GraphQlNewRelic/LICENSE_AFL.txt new file mode 100644 index 000000000000..f39d641b18a1 --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/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 <insert your license name here>" 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/GraphQl/Model/Query/Logger/NewRelic.php b/app/code/Magento/GraphQlNewRelic/Model/Query/Logger/NewRelic.php similarity index 73% rename from app/code/Magento/GraphQl/Model/Query/Logger/NewRelic.php rename to app/code/Magento/GraphQlNewRelic/Model/Query/Logger/NewRelic.php index 95d28ca46542..97cef3782a66 100644 --- a/app/code/Magento/GraphQl/Model/Query/Logger/NewRelic.php +++ b/app/code/Magento/GraphQlNewRelic/Model/Query/Logger/NewRelic.php @@ -4,36 +4,25 @@ * See COPYING.txt for license details. */ -namespace Magento\GraphQl\Model\Query\Logger; +namespace Magento\GraphQlNewRelic\Model\Query\Logger; use Magento\NewRelicReporting\Model\Config; use Magento\NewRelicReporting\Model\NewRelicWrapper; +use Magento\GraphQl\Model\Query\Logger\LoggerInterface; /** * Logs GraphQl query data for New Relic */ class NewRelic implements LoggerInterface { - /** - * @var Config - */ - private $config; - - /** - * @var NewRelicWrapper - */ - private $newRelicWrapper; - /** * @param Config $config * @param NewRelicWrapper $newRelicWrapper */ public function __construct( - Config $config, - NewRelicWrapper $newRelicWrapper + private Config $config, + private NewRelicWrapper $newRelicWrapper ) { - $this->config = $config; - $this->newRelicWrapper = $newRelicWrapper; } /** @@ -43,11 +32,9 @@ public function execute(array $queryDetails) { $transactionName = $queryDetails[LoggerInterface::TOP_LEVEL_OPERATION_NAME] ?? ''; $this->newRelicWrapper->setTransactionName('GraphQL-' . $transactionName); - if (!$this->config->isNewRelicEnabled()) { return; } - foreach ($queryDetails as $key => $value) { $this->newRelicWrapper->addCustomParameter($key, $value); } diff --git a/app/code/Magento/GraphQlNewRelic/Plugin/ReportError.php b/app/code/Magento/GraphQlNewRelic/Plugin/ReportError.php new file mode 100644 index 000000000000..c0829b6ef6a3 --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/Plugin/ReportError.php @@ -0,0 +1,46 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQlNewRelic\Plugin; + +use GraphQL\Error\Error; +use Magento\Framework\GraphQl\Query\ErrorHandler; +use Magento\NewRelicReporting\Model\NewRelicWrapper; + +/** + * Plugin that sends GraphQL Errors to New Relic + */ +class ReportError +{ + /** + * @param NewRelicWrapper $newRelicWrapper + */ + public function __construct(private NewRelicWrapper $newRelicWrapper) + { + } + + /** + * Sends error from GraphQL to New Relic + * + * @param ErrorHandler $subject + * @param Error[] $errors + * @param callable $formatter + * @return null + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeHandle(ErrorHandler $subject, array $errors, callable $formatter) + { + if (!empty($errors)) { + $error = $errors[0]; + if (($error instanceof Error ) && $error->getPrevious()) { + $error = $error->getPrevious(); + } + $this->newRelicWrapper->reportError($error); // Note: We only log the first error because performance + } + return null; + } +} diff --git a/app/code/Magento/GraphQlNewRelic/Plugin/ReportException.php b/app/code/Magento/GraphQlNewRelic/Plugin/ReportException.php new file mode 100644 index 000000000000..52e7c92c71d5 --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/Plugin/ReportException.php @@ -0,0 +1,42 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQlNewRelic\Plugin; + +use Magento\Framework\GraphQl\Exception\ExceptionFormatter; +use Magento\NewRelicReporting\Model\NewRelicWrapper; + +/** + * Plugin that sends GraphQL Errors to New Relic + */ +class ReportException +{ + /** + * @param NewRelicWrapper $newRelicWrapper + */ + public function __construct(private NewRelicWrapper $newRelicWrapper) + { + } + + /** + * Sends error from GraphQL to New Relic + * + * @param ExceptionFormatter $subject + * @param \Throwable $exception + * @param string|null $internalErrorMessage + * @return null + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeCheck( + ExceptionFormatter $subject, + \Throwable $exception, + string $internalErrorMessage = null + ) { + $this->newRelicWrapper->reportError($exception); + return null; + } +} diff --git a/app/code/Magento/GraphQlNewRelic/README.md b/app/code/Magento/GraphQlNewRelic/README.md new file mode 100644 index 000000000000..db348c905f96 --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/README.md @@ -0,0 +1,7 @@ +# GraphQlNewRelic + +The _GraphQlNewRelic_ module enables reporting for performance and reliability data of GraphQL using the New Relic service. + +## Prerequisites + +To take advantage of this module, you must have a New Relic account and install the New Relic extension on your environment diff --git a/app/code/Magento/GraphQlNewRelic/composer.json b/app/code/Magento/GraphQlNewRelic/composer.json new file mode 100644 index 000000000000..d61c3415efb4 --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/composer.json @@ -0,0 +1,23 @@ +{ + "name": "magento/module-graph-ql-new-relic", + "description": "N/A", + "type": "magento2-module", + "require": { + "php": "~8.1.0||~8.2.0", + "magento/framework": "*", + "magento/module-new-relic-reporting": "*", + "magento/module-graph-ql": "*" + }, + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\GraphQlNewRelic\\": "" + } + } +} diff --git a/app/code/Magento/GraphQlNewRelic/etc/di.xml b/app/code/Magento/GraphQlNewRelic/etc/di.xml new file mode 100644 index 000000000000..fbc0e75a68a8 --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/etc/di.xml @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\GraphQl\Model\Query\Logger\LoggerPool"> + <arguments> + <argument name="loggers" xsi:type="array"> + <item name="newRelic" xsi:type="object">Magento\GraphQlNewRelic\Model\Query\Logger\NewRelic</item> + </argument> + </arguments> + </type> + <type name="Magento\Framework\GraphQl\Exception\ExceptionFormatter"> + <plugin name="report-exception-to-new-relic" type="Magento\GraphQlNewRelic\Plugin\ReportException" sortOrder="50"/> + </type> + <type name="Magento\Framework\GraphQl\Query\ErrorHandler"> + <plugin name="report-error-to-new-relic" type="Magento\GraphQlNewRelic\Plugin\ReportError" sortOrder="50"/> + </type> +</config> diff --git a/app/code/Magento/GraphQlNewRelic/etc/module.xml b/app/code/Magento/GraphQlNewRelic/etc/module.xml new file mode 100644 index 000000000000..a593aaf91ed2 --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/etc/module.xml @@ -0,0 +1,10 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_GraphQlNewRelic" /> +</config> diff --git a/app/code/Magento/GraphQlNewRelic/registration.php b/app/code/Magento/GraphQlNewRelic/registration.php new file mode 100644 index 000000000000..f7648eaaf2bc --- /dev/null +++ b/app/code/Magento/GraphQlNewRelic/registration.php @@ -0,0 +1,10 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Component\ComponentRegistrar; + +ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magento_GraphQlNewRelic', __DIR__); diff --git a/app/code/Magento/GraphQlResolverCache/Model/Resolver/Result/ValueProcessor.php b/app/code/Magento/GraphQlResolverCache/Model/Resolver/Result/ValueProcessor.php index 7e53dd1c70b4..2b192368aa06 100644 --- a/app/code/Magento/GraphQlResolverCache/Model/Resolver/Result/ValueProcessor.php +++ b/app/code/Magento/GraphQlResolverCache/Model/Resolver/Result/ValueProcessor.php @@ -9,6 +9,7 @@ use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Framework\ObjectManager\ResetAfterRequestInterface; use Magento\Framework\ObjectManagerInterface; use Magento\GraphQlResolverCache\Model\Resolver\Result\ValueProcessor\FlagSetter\FlagSetterInterface; use Magento\GraphQlResolverCache\Model\Resolver\Result\ValueProcessor\FlagGetter\FlagGetterInterface; @@ -16,7 +17,7 @@ /** * Value processor for cached resolver value. */ -class ValueProcessor implements ValueProcessorInterface +class ValueProcessor implements ValueProcessorInterface, ResetAfterRequestInterface { /** * @var HydratorProviderInterface @@ -161,4 +162,13 @@ public function preProcessValueBeforeCacheSave(ResolverInterface $resolver, &$va $dehydrator->dehydrate($value); } } + + /** + * @inheritDoc + */ + public function _resetState(): void + { + $this->hydrators = []; + $this->processedValues = []; + } } diff --git a/app/code/Magento/GroupedImportExport/README.md b/app/code/Magento/GroupedImportExport/README.md index fd055be68bdb..399097449cd4 100644 --- a/app/code/Magento/GroupedImportExport/README.md +++ b/app/code/Magento/GroupedImportExport/README.md @@ -17,5 +17,5 @@ Extension developers can interact with the Magento_GroupedImportExport module. F You can get more information about import/export processes in magento at the articles: -- [Import](https://docs.magento.com/user-guide/system/data-import.html) -- [Export](https://docs.magento.com/user-guide/system/data-export.html) +- [Import](https://experienceleague.adobe.com/docs/commerce-admin/systems/data-transfer/import/data-import.html) +- [Export](https://experienceleague.adobe.com/docs/commerce-admin/systems/data-transfer/data-export.html) diff --git a/app/code/Magento/GroupedProduct/README.md b/app/code/Magento/GroupedProduct/README.md index 986b8f20791e..982324d74c85 100644 --- a/app/code/Magento/GroupedProduct/README.md +++ b/app/code/Magento/GroupedProduct/README.md @@ -95,4 +95,4 @@ For information about a public API in Magento 2, see [Public interfaces & APIs]( ## Additional information -For more information about creating grouped product, see [Creating Grouped Product](https://docs.magento.com/user-guide/catalog/product-create-grouped.html). +For more information about creating grouped product, see [Creating Grouped Product](https://experienceleague.adobe.com/docs/commerce-admin/catalog/products/types/product-create-grouped.html). diff --git a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php index f4ef5ec432ba..bf394c9ed0c4 100644 --- a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php +++ b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php @@ -292,7 +292,7 @@ private function getImportBehaviorTooltip() { $html = '<div class="admin__field-tooltip tooltip"> <a class="admin__field-tooltip-action action-help" target="_blank" title="What is this?" - href="https://docs.magento.com/user-guide/system/data-import.html"><span>' + href="https://experienceleague.adobe.com/docs/commerce-admin/systems/data-transfer/import/data-import.html"><span>' // @codingStandardsIgnoreLine . __('What is this?') . '</span></a></div>'; return $html; diff --git a/app/code/Magento/ImportExport/README.md b/app/code/Magento/ImportExport/README.md index a7a395c291cb..560c6d0c68f5 100644 --- a/app/code/Magento/ImportExport/README.md +++ b/app/code/Magento/ImportExport/README.md @@ -83,5 +83,5 @@ For information about a public API in Magento 2, see [Public interfaces & APIs]( You can get more information about import/export processes in magento at the articles: - [Create custom import entity](https://developer.adobe.com/commerce/php/tutorials/backend/create-custom-import-entity/) -- [Import](https://docs.magento.com/user-guide/system/data-import.html) -- [Export](https://docs.magento.com/user-guide/system/data-export.html) +- [Import](https://experienceleague.adobe.com/docs/commerce-admin/systems/data-transfer/import/data-import.html) +- [Export](https://experienceleague.adobe.com/docs/commerce-admin/systems/data-transfer/data-export.html) diff --git a/app/code/Magento/Indexer/README.md b/app/code/Magento/Indexer/README.md index 0285d3400924..da955afd248b 100644 --- a/app/code/Magento/Indexer/README.md +++ b/app/code/Magento/Indexer/README.md @@ -104,4 +104,4 @@ More information can get at articles: - [Learn more about Indexer optimization](https://developer.adobe.com/commerce/php/development/components/indexing/optimization/) - [Learn more how to add custom indexer](https://developer.adobe.com/commerce/php/development/components/indexing/custom-indexer/) - [Learn how to manage indexers](https://experienceleague.adobe.com/docs/commerce-operations/configuration-guide/cli/manage-indexers.html) -- [Learn more about Index Management](https://docs.magento.com/user-guide/system/index-management.html) +- [Learn more about Index Management](https://experienceleague.adobe.com/docs/commerce-admin/systems/tools/index-management.html) diff --git a/app/code/Magento/InstantPurchase/README.md b/app/code/Magento/InstantPurchase/README.md index f92335e4c470..baa3eb7956c9 100644 --- a/app/code/Magento/InstantPurchase/README.md +++ b/app/code/Magento/InstantPurchase/README.md @@ -90,7 +90,7 @@ Basic implementation is a good start point but it's recommended to provide own i 3. Customer has default shipping and billing address defined 4. Customer has valid stored payment method with instant purchase support -[Learn more about Instant Purchase](https://docs.magento.com/user-guide/sales/checkout-instant-purchase.html). +[Learn more about Instant Purchase](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/point-of-purchase/checkout-instant-purchase.html). ### Backward incompatible changes diff --git a/app/code/Magento/Integration/README.md b/app/code/Magento/Integration/README.md index c9caeb63a955..df1800963a69 100644 --- a/app/code/Magento/Integration/README.md +++ b/app/code/Magento/Integration/README.md @@ -107,5 +107,5 @@ Cron group configuration can be set at `etc/crontab.xml`: More information can get at articles: -- [Learn more about an Integration](https://docs.magento.com/user-guide/system/integrations.html) +- [Learn more about an Integration](https://experienceleague.adobe.com/docs/commerce-admin/systems/integrations.html) - [Lear how to create an Integration](https://developer.adobe.com/commerce/webapi/get-started/create-integration/) diff --git a/app/code/Magento/LayeredNavigation/README.md b/app/code/Magento/LayeredNavigation/README.md index 0d324c2a6c2f..d923e264bebb 100644 --- a/app/code/Magento/LayeredNavigation/README.md +++ b/app/code/Magento/LayeredNavigation/README.md @@ -55,5 +55,5 @@ This module modifies the following page_layout in the `view/frontend.page_layout More information can be found in: -- [Learn more about Layered Navigation](https://docs.magento.com/user-guide/catalog/navigation-layered.html) -- [Learn how to Configuring Layered Navigation](https://docs.magento.com/user-guide/catalog/navigation-layered-configuration.html) +- [Learn more about Layered Navigation](https://experienceleague.adobe.com/docs/commerce-admin/catalog/catalog/navigation/navigation-layered.html) +- [Learn how to Configuring Layered Navigation](https://experienceleague.adobe.com/docs/commerce-admin/catalog/catalog/navigation/navigation-layered.html#configure-layered-navigation) diff --git a/app/code/Magento/LoginAsCustomer/README.md b/app/code/Magento/LoginAsCustomer/README.md index 4efe9cca3c55..61f143859a32 100644 --- a/app/code/Magento/LoginAsCustomer/README.md +++ b/app/code/Magento/LoginAsCustomer/README.md @@ -12,4 +12,4 @@ For information about a module installation in Magento 2, see [Enable or disable This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html) +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html) diff --git a/app/code/Magento/LoginAsCustomerAdminUi/README.md b/app/code/Magento/LoginAsCustomerAdminUi/README.md index 3d447a730140..615d71ce244a 100644 --- a/app/code/Magento/LoginAsCustomerAdminUi/README.md +++ b/app/code/Magento/LoginAsCustomerAdminUi/README.md @@ -8,4 +8,4 @@ This module provides UI for Admin Panel for Login As Customer functionality. This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). diff --git a/app/code/Magento/LoginAsCustomerApi/README.md b/app/code/Magento/LoginAsCustomerApi/README.md index 39dc0d7bee6e..91bced641dfd 100644 --- a/app/code/Magento/LoginAsCustomerApi/README.md +++ b/app/code/Magento/LoginAsCustomerApi/README.md @@ -54,4 +54,4 @@ For information about a public API in Magento 2, see [Public interfaces & APIs]( This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). diff --git a/app/code/Magento/LoginAsCustomerAssistance/README.md b/app/code/Magento/LoginAsCustomerAssistance/README.md index 2fc609f45965..7e11bce098c8 100644 --- a/app/code/Magento/LoginAsCustomerAssistance/README.md +++ b/app/code/Magento/LoginAsCustomerAssistance/README.md @@ -12,4 +12,4 @@ For information about a module installation in Magento 2, see [Enable or disable This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). diff --git a/app/code/Magento/LoginAsCustomerFrontendUi/README.md b/app/code/Magento/LoginAsCustomerFrontendUi/README.md index f822a71e87d3..423d5deee6ae 100644 --- a/app/code/Magento/LoginAsCustomerFrontendUi/README.md +++ b/app/code/Magento/LoginAsCustomerFrontendUi/README.md @@ -6,4 +6,4 @@ This module provides UI for Storefront for Login As Customer functionality. This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). diff --git a/app/code/Magento/LoginAsCustomerGraphQl/README.md b/app/code/Magento/LoginAsCustomerGraphQl/README.md index fa3ff4d8cbcc..c1198415014b 100755 --- a/app/code/Magento/LoginAsCustomerGraphQl/README.md +++ b/app/code/Magento/LoginAsCustomerGraphQl/README.md @@ -17,6 +17,6 @@ For information about a module installation in Magento 2, see [Enable or disable This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). You can get more information about [GraphQl In Magento 2](https://developer.adobe.com/commerce/webapi/graphql/). diff --git a/app/code/Magento/LoginAsCustomerLog/README.md b/app/code/Magento/LoginAsCustomerLog/README.md index 197a5886e07e..1e11958d292d 100644 --- a/app/code/Magento/LoginAsCustomerLog/README.md +++ b/app/code/Magento/LoginAsCustomerLog/README.md @@ -45,4 +45,4 @@ For information about a public API in Magento 2, see [Public interfaces & APIs]( This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). diff --git a/app/code/Magento/LoginAsCustomerPageCache/README.md b/app/code/Magento/LoginAsCustomerPageCache/README.md index 298c6bdbb4c6..56f65969dbc6 100644 --- a/app/code/Magento/LoginAsCustomerPageCache/README.md +++ b/app/code/Magento/LoginAsCustomerPageCache/README.md @@ -6,4 +6,4 @@ This module provides adaptation to PageCache functionality for Login as Customer This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). diff --git a/app/code/Magento/LoginAsCustomerQuote/README.md b/app/code/Magento/LoginAsCustomerQuote/README.md index 7e3ee95478b9..9699819eebf5 100644 --- a/app/code/Magento/LoginAsCustomerQuote/README.md +++ b/app/code/Magento/LoginAsCustomerQuote/README.md @@ -6,4 +6,4 @@ The Magento_LoginAsCustomerQuote module is responsible for communication between This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). diff --git a/app/code/Magento/LoginAsCustomerSales/README.md b/app/code/Magento/LoginAsCustomerSales/README.md index 342872a7362e..a6205f9fca4a 100644 --- a/app/code/Magento/LoginAsCustomerSales/README.md +++ b/app/code/Magento/LoginAsCustomerSales/README.md @@ -6,4 +6,4 @@ This module is responsible for communication between Magento_LoginAsCustomer and This module is a part of Login As Customer feature. -[Learn more about Login As Customer feature](https://docs.magento.com/user-guide/customers/login-as-customer.html). +[Learn more about Login As Customer feature](https://experienceleague.adobe.com/docs/commerce-admin/customers/customer-accounts/manage/login-as-customer.html). diff --git a/app/code/Magento/MediaGallery/Model/ResourceModel/DeleteAssetsByPaths.php b/app/code/Magento/MediaGallery/Model/ResourceModel/DeleteAssetsByPaths.php index bf6379644cb6..bc6c3918b9ba 100644 --- a/app/code/Magento/MediaGallery/Model/ResourceModel/DeleteAssetsByPaths.php +++ b/app/code/Magento/MediaGallery/Model/ResourceModel/DeleteAssetsByPaths.php @@ -20,6 +20,7 @@ class DeleteAssetsByPaths implements DeleteAssetsByPathsInterface { private const TABLE_MEDIA_GALLERY_ASSET = 'media_gallery_asset'; private const MEDIA_GALLERY_ASSET_PATH = 'path'; + private const MEDIA_GALLERY_ASSET_ID = 'id'; /** * @var ResourceConnection @@ -78,13 +79,39 @@ public function execute(array $paths): void * Delete assets from database based on the first part (beginning) of the path * * @param string $path + * @throws \Zend_Db_Statement_Exception */ private function deleteAssetsByDirectoryPath(string $path): void + { + /** @var AdapterInterface $connection */ + $connection = $this->resourceConnection->getConnection(); + + $select = $connection->select() + ->from($this->resourceConnection->getTableName(self::TABLE_MEDIA_GALLERY_ASSET)) + ->where(self::MEDIA_GALLERY_ASSET_PATH . ' LIKE ?', $path . '%'); + + $assets = $connection->query($select)->fetchAll(); + + // Filter out assets with mixed case that doesn't match the paths + foreach ($assets as $asset) { + if (str_starts_with($asset[self::MEDIA_GALLERY_ASSET_PATH], $path)) { + $this->deleteAssetById((int)$asset[self::MEDIA_GALLERY_ASSET_ID]); + } + } + } + + /** + * Delete assets from database by asset id + * + * @param int $id + * @return void + */ + private function deleteAssetById(int $id): void { /** @var AdapterInterface $connection */ $connection = $this->resourceConnection->getConnection(); $tableName = $this->resourceConnection->getTableName(self::TABLE_MEDIA_GALLERY_ASSET); - $connection->delete($tableName, [self::MEDIA_GALLERY_ASSET_PATH . ' LIKE ?' => $path . '%']); + $connection->delete($tableName, [self::MEDIA_GALLERY_ASSET_ID . ' = ?' => $id]); } /** diff --git a/app/code/Magento/MediaGallery/Model/ResourceModel/GetAssetsByPaths.php b/app/code/Magento/MediaGallery/Model/ResourceModel/GetAssetsByPaths.php index b25d2e22aabd..706ba2247809 100644 --- a/app/code/Magento/MediaGallery/Model/ResourceModel/GetAssetsByPaths.php +++ b/app/code/Magento/MediaGallery/Model/ResourceModel/GetAssetsByPaths.php @@ -105,6 +105,29 @@ private function getAssetsData(array $paths): array $select = $connection->select() ->from($this->resourceConnection->getTableName(self::TABLE_MEDIA_GALLERY_ASSET)) ->where(self::MEDIA_GALLERY_ASSET_PATH . ' IN (?)', $paths); - return $connection->query($select)->fetchAll(); + $assets = $connection->query($select)->fetchAll(); + + return $this->filterCaseSensitiveAssets($assets, $paths); + } + + /** + * Filter out assets with mixed case that doesn't match the paths + * + * @param array $assets + * @param array $paths + * @return array + */ + private function filterCaseSensitiveAssets(array $assets, array $paths): array + { + $filteredAssets = []; + foreach ($assets as $asset) { + foreach ($paths as $path) { + if ($asset[self::MEDIA_GALLERY_ASSET_PATH] === $path) { + $filteredAssets[] = $asset; + } + } + } + + return $filteredAssets; } } diff --git a/app/code/Magento/MediaGallery/README.md b/app/code/Magento/MediaGallery/README.md index 96e19a9e9d23..89b18e610d32 100644 --- a/app/code/Magento/MediaGallery/README.md +++ b/app/code/Magento/MediaGallery/README.md @@ -22,4 +22,4 @@ Extension developers can interact with the Magento_MediaGallery module. For more For information about significant changes in patch releases, see [2.4.x Release information](https://experienceleague.adobe.com/docs/commerce-operations/release/notes/overview.html). -[Learn more about New Media Gallery](https://docs.magento.com/user-guide/cms/media-gallery.html). +[Learn more about New Media Gallery](https://experienceleague.adobe.com/docs/commerce-admin/content-design/media/gallery/media-gallery.html). diff --git a/app/code/Magento/MediaGallery/Test/Unit/Model/ResourceModel/DeleteAssetsByPathsTest.php b/app/code/Magento/MediaGallery/Test/Unit/Model/ResourceModel/DeleteAssetsByPathsTest.php new file mode 100644 index 000000000000..6e2f081e7317 --- /dev/null +++ b/app/code/Magento/MediaGallery/Test/Unit/Model/ResourceModel/DeleteAssetsByPathsTest.php @@ -0,0 +1,190 @@ +<?php +/** + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + */ +declare(strict_types=1); + +namespace Magento\MediaGallery\Test\Unit\Model\ResourceModel; + +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\Framework\Exception\CouldNotDeleteException; +use Magento\MediaGallery\Model\ResourceModel\DeleteAssetsByPaths; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class DeleteAssetsByPathsTest extends TestCase +{ + private const TABLE_NAME = 'media_gallery_asset'; + + /** + * @var DeleteAssetsByPaths + */ + private $deleteAssetsByPaths; + + /** + * @var AdapterInterface|MockObject + */ + private $adapter; + + /** + * @var Select|MockObject + */ + private $select; + + /** + * @var \Zend_Db_Statement_Interface|MockObject + */ + private $statement; + + /** + * When deleting an asset by path with mixed case, the asset with exact same path should be deleted + * + * @dataProvider assetDeleteByPathDataProvider + * @throws CouldNotDeleteException + */ + public function testDeleteCorrectAssetByPathWithCaseSensitiveMatches( + array $assets, + string $assetPathToDelete, + int $assetIdToAssert + ): void { + $this->adapter->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->adapter + ->expects($this->once()) + ->method('query') + ->with($this->select) + ->willReturn($this->statement); + $this->statement->expects($this->once())->method('fetchAll')->willReturn($assets); + + $this->adapter->expects($this->once()) + ->method('delete') + ->with(self::TABLE_NAME, ['id = ?' => $assetIdToAssert]); + + $this->deleteAssetsByPaths->execute([$assetPathToDelete]); + } + + protected function setUp(): void + { + $logger = $this->getMockForAbstractClass(LoggerInterface::class); + $resourceConnection = $this->createMock(ResourceConnection::class); + + $this->deleteAssetsByPaths = new DeleteAssetsByPaths( + $resourceConnection, + $logger + ); + + $this->adapter = $this->getMockForAbstractClass(AdapterInterface::class); + $this->select = $this->createMock(Select::class); + $this->statement = $this->createMock(\Zend_Db_Statement_Interface::class); + + $resourceConnection->expects($this->any()) + ->method('getConnection') + ->willReturn($this->adapter); + + $resourceConnection->expects($this->any()) + ->method('getTableName') + ->willReturn(self::TABLE_NAME); + } + + public function assetDeleteByPathDataProvider(): array + { + return [ + [ + 'assets' => $this->getAssets(), + 'pathToDelete' => 'catalog/category/folder/image.jpg', + 'assetIdToAssertDelete' => 1 + ], + [ + 'assets' => $this->getAssets(), + 'pathToDelete' => 'catalog/category/folder/Image.jpg', + 'assetIdToAssertDelete' => 2 + ], + [ + 'assets' => $this->getAssets(), + 'pathToDelete' => 'catalog/category/folder/IMAGE.JPG', + 'assetIdToAssertDelete' => 3 + ], + [ + 'assets' => $this->getAssets(), + 'pathToDelete' => 'catalog/category/FOLDER', + 'assetIdToAssertDelete' => 4 + ], + ]; + } + + private function getAssets(): array + { + return [ + [ + 'id' => '1', + 'path' => 'catalog/category/folder/image.jpg', + 'title' => 'image', + 'description' => null, + 'source' => 'Local', + 'hash' => '20b88741b3cfa5749d414a0312c8b909aefbaa1f', + 'content_type' => 'image/jpg', + 'width' => '1080', + 'height' => '1080', + 'size' => '53010', + 'created_at' => '2023-11-09 16:33:41', + 'updated_at' => '2023-11-09 16:33:41', + ], + [ + 'id' => '2', + 'path' => 'catalog/category/folder/Image.jpg', + 'title' => 'Image', + 'description' => null, + 'source' => 'Local', + 'hash' => '20b88741b3cfa5749d414a0312c8b909aefbaa1f', + 'content_type' => 'image/jpg', + 'width' => '1080', + 'height' => '1080', + 'size' => '53010', + 'created_at' => '2023-11-09 16:34:19', + 'updated_at' => '2023-11-09 16:34:19', + ], + [ + 'id' => '3', + 'path' => 'catalog/category/folder/IMAGE.JPG', + 'title' => 'IMAGE', + 'description' => null, + 'source' => 'Local', + 'hash' => '93a7c1f07373afafcd4918379dacf8e3de6a3eca', + 'content_type' => 'image/jpg', + 'width' => '1080', + 'height' => '1080', + 'size' => '101827', + 'created_at' => '2023-11-09 16:37:36', + 'updated_at' => '2023-11-09 16:37:36', + ], + [ + 'id' => '4', + 'path' => 'catalog/category/FOLDER/IMAGE.JPG', + 'title' => 'IMAGE', + 'description' => null, + 'source' => 'Local', + 'hash' => '93a7c1f07373afafcd4918379dacf8e3de6a3eca', + 'content_type' => 'image/jpg', + 'width' => '1080', + 'height' => '1080', + 'size' => '101827', + 'created_at' => '2023-11-09 16:37:36', + 'updated_at' => '2023-11-09 16:37:36', + ] + ]; + } +} diff --git a/app/code/Magento/MediaGallery/Test/Unit/Model/ResourceModel/GetAssetsByPathsTest.php b/app/code/Magento/MediaGallery/Test/Unit/Model/ResourceModel/GetAssetsByPathsTest.php new file mode 100644 index 000000000000..c61da0703d73 --- /dev/null +++ b/app/code/Magento/MediaGallery/Test/Unit/Model/ResourceModel/GetAssetsByPathsTest.php @@ -0,0 +1,198 @@ +<?php +/** + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + */ +declare(strict_types=1); + +namespace Magento\MediaGallery\Test\Unit\Model\ResourceModel; + +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\MediaGallery\Model\ResourceModel\GetAssetsByPaths; +use Magento\MediaGalleryApi\Api\Data\AssetInterfaceFactory; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class GetAssetsByPathsTest extends TestCase +{ + private const TABLE_NAME = 'media_gallery_asset'; + + /** + * @var GetAssetsByPaths + */ + private $getAssetsByPaths; + + /** + * @var AssetInterfaceFactory|MockObject + */ + private $assetInterfaceFactory; + + /** + * @var AdapterInterface|MockObject + */ + private $adapter; + + /** + * @var Select|MockObject + */ + private $select; + + /** + * @var \Zend_Db_Statement_Interface|MockObject + */ + private $statement; + + protected function setUp(): void + { + $logger = $this->getMockForAbstractClass(LoggerInterface::class); + $resourceConnection = $this->createMock(ResourceConnection::class); + $this->assetInterfaceFactory = $this->createMock(AssetInterfaceFactory::class); + + $this->getAssetsByPaths = new GetAssetsByPaths( + $resourceConnection, + $this->assetInterfaceFactory, + $logger + ); + + $this->adapter = $this->getMockForAbstractClass(AdapterInterface::class); + $this->select = $this->createMock(Select::class); + $this->statement = $this->createMock(\Zend_Db_Statement_Interface::class); + + $resourceConnection->expects($this->any()) + ->method('getConnection') + ->willReturn($this->adapter); + + $resourceConnection->expects($this->any()) + ->method('getTableName') + ->willReturn(self::TABLE_NAME); + } + + /** + * When getting an asset by path with mixed case, the asset with exact same path should be loaded + * + * @dataProvider assetDeleteByPathDataProvider + */ + public function testGetCorrectAssetByPathWithCaseSensitiveMatches( + array $assets, + int $assetIndex, + int $resultsCount + ): void { + $this->adapter->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->adapter + ->expects($this->once()) + ->method('query') + ->with($this->select) + ->willReturn($this->statement); + $this->statement->expects($this->once())->method('fetchAll')->willReturn($assets); + + $asset = $assets[$assetIndex]; + + $factoryParameters = [ + 'id' => $asset['id'], + 'path' => $asset['path'], + 'title' => $asset['title'], + 'description' => $asset['description'], + 'source' => $asset['source'], + 'hash' => $asset['hash'], + 'contentType' => $asset['content_type'], + 'width' => $asset['width'], + 'height' => $asset['height'], + 'size' => $asset['size'], + 'createdAt' => $asset['created_at'], + 'updatedAt' => $asset['updated_at'], + ]; + + $this->assetInterfaceFactory + ->expects($this->exactly($resultsCount)) + ->method('create') + ->with($factoryParameters); + + $this->getAssetsByPaths->execute([$asset['path']]); + } + + private function getAssets(): array + { + return [ + [ + 'id' => '1', + 'path' => 'catalog/category/folder/image.jpg', + 'title' => 'image', + 'description' => null, + 'source' => 'Local', + 'hash' => '20b88741b3cfa5749d414a0312c8b909aefbaa1f', + 'content_type' => 'image/jpg', + 'width' => '1080', + 'height' => '1080', + 'size' => '53010', + 'created_at' => '2023-11-09 16:33:41', + 'updated_at' => '2023-11-09 16:33:41', + ], + [ + 'id' => '2', + 'path' => 'catalog/category/folder/Image.jpg', + 'title' => 'Image', + 'description' => null, + 'source' => 'Local', + 'hash' => '20b88741b3cfa5749d414a0312c8b909aefbaa1f', + 'content_type' => 'image/jpg', + 'width' => '1080', + 'height' => '1080', + 'size' => '53010', + 'created_at' => '2023-11-09 16:34:19', + 'updated_at' => '2023-11-09 16:34:19', + ], + [ + 'id' => '3', + 'path' => 'catalog/category/folder/IMAGE.JPG', + 'title' => 'IMAGE', + 'description' => null, + 'source' => 'Local', + 'hash' => '93a7c1f07373afafcd4918379dacf8e3de6a3eca', + 'content_type' => 'image/jpg', + 'width' => '1080', + 'height' => '1080', + 'size' => '101827', + 'created_at' => '2023-11-09 16:37:36', + 'updated_at' => '2023-11-09 16:37:36', + ], + [ + 'id' => '4', + 'path' => 'catalog/category/FOLDER/IMAGE.JPG', + 'title' => 'IMAGE', + 'description' => null, + 'source' => 'Local', + 'hash' => '93a7c1f07373afafcd4918379dacf8e3de6a3eca', + 'content_type' => 'image/jpg', + 'width' => '1080', + 'height' => '1080', + 'size' => '101827', + 'created_at' => '2023-11-09 16:37:36', + 'updated_at' => '2023-11-09 16:37:36', + ] + ]; + } + public function assetDeleteByPathDataProvider(): array + { + return [ + [ + 'assets' => $this->getAssets(), + 'assetIndex' => 0, + 'resultsCount' => 1 + ], + ]; + } +} diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/ActionGroup/AdminEditCategoryInGridPageActionGroup.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/ActionGroup/AdminEditCategoryInGridPageActionGroup.xml index 50ee9e890ad2..4a1ba0431c1b 100644 --- a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/ActionGroup/AdminEditCategoryInGridPageActionGroup.xml +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/ActionGroup/AdminEditCategoryInGridPageActionGroup.xml @@ -12,10 +12,15 @@ <description>Clicks the Edit action from the Media Gallery Category Grid</description> </annotations> - <arguments> + <arguments> <argument name="categoryName" type="string"/> </arguments> + <fillField selector="{{AdminMediaGalleryCatalogUiCategoryGridSection.mediaGalleryCategorySearchField}}" userInput="{{categoryName}}" stepKey="fillSearchFiled"/> + <click selector="{{AdminMediaGalleryCatalogUiCategoryGridSection.mediaGalleryCategorySearchButton}}" stepKey="clickOnSearchButton"/> + <comment userInput="Comment is added to search category first" stepKey="commentStepKey"/> + <waitForPageLoad time="30" stepKey="waitForCategoryDetailsPageLoad1"/> + <click selector="{{AdminMediaGalleryCatalogUiCategoryGridSection.edit(categoryName, 'Edit')}}" stepKey="clickOnCategoryRow"/> <waitForPageLoad time="30" stepKey="waitForCategoryDetailsPageLoad"/> </actionGroup> diff --git a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Section/AdminMediaGalleryCatalogUiCategoryGridSection.xml b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Section/AdminMediaGalleryCatalogUiCategoryGridSection.xml index 96b4bad5d5ad..19bc3e2f84e2 100644 --- a/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Section/AdminMediaGalleryCatalogUiCategoryGridSection.xml +++ b/app/code/Magento/MediaGalleryCatalogUi/Test/Mftf/Section/AdminMediaGalleryCatalogUiCategoryGridSection.xml @@ -14,5 +14,7 @@ <element name="image" type="text" selector="//tr//td[count(//div[@data-role='grid-wrapper']//tr//th[contains(., 'Image')]/preceding-sibling::th) +1]//img"/> <element name="columnValue" type="text" selector="//tr//td[count(//div[@data-role='grid-wrapper']//tr//th[contains(., '{{columnName}}')]/preceding-sibling::th) +1 ]//div" parameterized="true"/> <element name="edit" type="button" selector="//tr[td//text()[contains(., '{{categoryName}}')]]//td[count(//div[@data-role='grid-wrapper']//tr//th[contains(., 'Action')]/preceding-sibling::th) +1 ]//*[text()='{{actionButton}}']" parameterized="true"/> + <element name="mediaGalleryCategorySearchField" type="input" selector="//*[@id='name']"/> + <element name="mediaGalleryCategorySearchButton" type="input" selector="//*[@id='container']/div/div/div[2]/div[1]/div[2]/button"/> </section> </sections> diff --git a/app/code/Magento/MediaGalleryUi/README.md b/app/code/Magento/MediaGalleryUi/README.md index c1dc448bc799..714d115c3f0d 100644 --- a/app/code/Magento/MediaGalleryUi/README.md +++ b/app/code/Magento/MediaGalleryUi/README.md @@ -44,4 +44,4 @@ For information about a UI component in Magento 2, see [Overview of UI component For information about significant changes in patch releases, see [2.4.x Release information](https://experienceleague.adobe.com/docs/commerce-operations/release/notes/overview.html). -[Learn more about New Media Gallery](https://docs.magento.com/user-guide/cms/media-gallery.html). +[Learn more about New Media Gallery](https://experienceleague.adobe.com/docs/commerce-admin/content-design/media/gallery/media-gallery.html). diff --git a/app/code/Magento/MediaGalleryUiApi/README.md b/app/code/Magento/MediaGalleryUiApi/README.md index 585428276f13..d2ca2d618760 100644 --- a/app/code/Magento/MediaGalleryUiApi/README.md +++ b/app/code/Magento/MediaGalleryUiApi/README.md @@ -10,4 +10,4 @@ For information about module installation in Magento 2, see [Enable or disable m For information about significant changes in patch releases, see [2.4.x Release information](https://experienceleague.adobe.com/docs/commerce-operations/release/notes/overview.html). -[Learn more about New Media Gallery](https://docs.magento.com/user-guide/cms/media-gallery.html). +[Learn more about New Media Gallery](https://experienceleague.adobe.com/docs/commerce-admin/content-design/media/gallery/media-gallery.html). diff --git a/app/code/Magento/MediaStorage/README.md b/app/code/Magento/MediaStorage/README.md index 3e401c7aa605..6a655baf1dd2 100644 --- a/app/code/Magento/MediaStorage/README.md +++ b/app/code/Magento/MediaStorage/README.md @@ -37,5 +37,5 @@ Extension developers can interact with the Magento_MediaStorage module. For more More information can get at articles: -- [Learn how to configure Media Storage Database](https://docs.magento.com/user-guide/system/media-storage-database.html). +- [Learn how to configure Media Storage Database](https://experienceleague.adobe.com/docs/commerce-admin/content-design/media/storage/media-storage-database.html). - [Learn how to Resize catalog images](https://developer.adobe.com/commerce/frontend-core/guide/themes/configure/#resize-catalog-images) diff --git a/app/code/Magento/Multishipping/Plugin/MultishippingQuoteRepository.php b/app/code/Magento/Multishipping/Plugin/MultishippingQuoteRepository.php index af19e4bc91f5..e5f26b46accc 100644 --- a/app/code/Magento/Multishipping/Plugin/MultishippingQuoteRepository.php +++ b/app/code/Magento/Multishipping/Plugin/MultishippingQuoteRepository.php @@ -144,6 +144,7 @@ private function getQuoteItems(Quote $quote, Quote\Address $address): array $quoteItem = $quote->getItemById($addressItem->getQuoteItemId()); if ($quoteItem) { $multishippingQuoteItem = clone $quoteItem; + $multishippingQuoteItem->setQuote($quoteItem->getQuote()); $qty = $addressItem->getQty(); $sku = $multishippingQuoteItem->getSku(); if (isset($quoteItems[$sku])) { diff --git a/app/code/Magento/Multishipping/Test/Unit/Plugin/MultishippingQuoteRepositoryTest.php b/app/code/Magento/Multishipping/Test/Unit/Plugin/MultishippingQuoteRepositoryTest.php new file mode 100644 index 000000000000..35b9668f25a9 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Unit/Plugin/MultishippingQuoteRepositoryTest.php @@ -0,0 +1,323 @@ +<?php +/************************************************************************ + * + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + * ************************************************************************ + */ +declare(strict_types=1); + +namespace Magento\Multishipping\Test\Unit\Plugin; + +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Type\Simple; +use Magento\Multishipping\Plugin\MultishippingQuoteRepository; +use Magento\Payment\Model\Method\AbstractMethod; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Api\Data\CartExtensionInterface; +use Magento\Quote\Api\Data\CartInterface; +use Magento\Quote\Model\Quote\Address; +use Magento\Quote\Model\Quote\Address\Item; +use Magento\Quote\Model\Quote\Item as QuoteItem; +use Magento\Quote\Model\Quote\Address\Rate; +use Magento\Quote\Model\Quote\Payment; +use Magento\Quote\Model\Quote\ShippingAssignment\ShippingProcessor; +use Magento\Quote\Model\ShippingAssignmentFactory; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Unit Test case for MultishippingQuoteRepository plugin + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class MultishippingQuoteRepositoryTest extends TestCase +{ + /** + * @var CartRepositoryInterface|MockObject + */ + private $cartMock; + + /** + * @var CartInterface|MockObject + */ + private $quoteMock; + + /** + * @var QuoteItem|MockObject + */ + private $quoteItemMock; + + /** + * @var ShippingAssignmentFactory|MockObject + */ + private $shippingAssignmentFactoryMock; + + /** + * @var ShippingProcessor|MockObject + */ + private $shippingProcessorMock; + + /** + * @var MultishippingQuoteRepository + */ + private $multishippingQuoteRepository; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->cartMock = $this->createMock(CartRepositoryInterface::class); + $this->quoteMock = $this->getMockBuilder(CartInterface::class) + ->addMethods( + [ + 'hasVirtualItems', + 'getAllShippingAddresses', + 'getPayment', + 'getIsMultiShipping', + 'reserveOrderId' + ] + ) + ->onlyMethods(['setItems', 'getItems']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->quoteItemMock = $this->getMockBuilder(QuoteItem::class) + ->disableOriginalConstructor() + ->onlyMethods(['getProductType', 'getProduct', 'getQuote', 'getQty', 'getPrice', 'setQuote']) + ->getMock(); + $this->shippingAssignmentFactoryMock = $this->getMockBuilder(ShippingAssignmentFactory::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->shippingProcessorMock = $this->getMockBuilder(ShippingProcessor::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->multishippingQuoteRepository = new MultishippingQuoteRepository( + $this->shippingAssignmentFactoryMock, + $this->shippingProcessorMock + ); + } + + /** + * Test afterGet plugin and check the quote has items or null + * + * @param bool $isMultiShippingMode + * @param array $productData + * @return void + * @dataProvider pluginForAfterGetMultiShippingModeDataProvider + */ + public function testPluginAfterGetWithMultiShippingMode(bool $isMultiShippingMode, array $productData): void + { + $simpleProductTypeMock = $this->getMockBuilder(Simple::class) + ->disableOriginalConstructor() + ->onlyMethods(['getOrderOptions']) + ->getMock(); + $productMock = $this->getProductMock($simpleProductTypeMock); + $this->getQuoteItemMock($productData['productType'], $productMock); + $quoteAddressItemMock = $this->getQuoteAddressItemMock( + $productData['productType'], + $productData['productOptions'] + ); + list($shippingAddressMock, $billingAddressMock) = + $this->getQuoteAddressesMock($quoteAddressItemMock); + $this->setQuoteMockData($productData['paymentProviderCode'], $shippingAddressMock, $billingAddressMock); + $this->quoteItemMock->method('setQuote')->with($this->quoteMock)->willReturnSelf(); + $this->quoteItemMock->method('getQuote')->willReturn($this->quoteMock); + $extensionAttributesMock = $this->getMockBuilder(CartExtensionInterface::class) + ->disableOriginalConstructor() + ->addMethods(['getShippingAssignments']) + ->getMockForAbstractClass(); + $this->quoteMock->expects($this->any()) + ->method('getIsMultiShipping') + ->willReturn($isMultiShippingMode); + $this->quoteMock->expects($this->any()) + ->method('getExtensionAttributes') + ->willReturn($extensionAttributesMock); + $extensionAttributesMock->expects($this->any()) + ->method('getShippingAssignments') + ->willReturn($this->shippingAssignmentFactoryMock); + + $quote = $this->multishippingQuoteRepository->afterGet($this->cartMock, $this->quoteMock); + $this->assertNotEmpty($quote); + $this->assertEquals(1, count($quote->getItems())); + $this->assertNotEmpty(current($quote->getItems())); + } + + /** + * Return Product Mock. + * + * @param Simple|MockObject $simpleProductTypeMock + * @return MockObject + */ + private function getProductMock($simpleProductTypeMock): MockObject + { + $productMock = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->onlyMethods(['getTypeInstance']) + ->getMock(); + $productMock->method('getTypeInstance')->willReturn($simpleProductTypeMock); + + return $productMock; + } + + /** + * Return Quote Item Mock. + * + * @param string $productType + * @param Product|MockObject $productMock + * @return void + */ + private function getQuoteItemMock(string $productType, Product|MockObject $productMock): void + { + $this->quoteItemMock->method('getProductType')->willReturn($productType); + $this->quoteItemMock->method('getProduct')->willReturn($productMock); + $this->quoteItemMock->method('getQty')->willReturn(1); + $this->quoteItemMock->method('getPrice')->willReturn(10); + $this->quoteItemMock->method('getQuote')->willReturn($this->quoteMock); + } + + /** + * Return Quote Address Item Mock + * + * @param string $productType + * @param array $productOptions + * @return MockObject + */ + private function getQuoteAddressItemMock(string $productType, array $productOptions): MockObject + { + $quoteAddressItemMock = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->addMethods(['getQuoteItem','setProductType', 'setProductOptions']) + ->onlyMethods(['getParentItem']) + ->getMock(); + $quoteAddressItemMock->method('getQuoteItem')->willReturn($this->quoteItemMock); + $quoteAddressItemMock->method('setProductType')->with($productType)->willReturnSelf(); + $quoteAddressItemMock->method('setProductOptions')->willReturn($productOptions); + $quoteAddressItemMock->method('getParentItem')->willReturn(false); + + return $quoteAddressItemMock; + } + + /** + * Return Quote Addresses Mock + * @param Item|MockObject $quoteAddressItemMock + * @return array + */ + private function getQuoteAddressesMock(Item|MockObject $quoteAddressItemMock): array + { + $shippingAddressMock = $this->getMockBuilder(Address::class) + ->disableOriginalConstructor() + ->addMethods(['getAddressType', 'getGrandTotal']) + ->onlyMethods( + [ + 'validate', + 'getShippingMethod', + 'getShippingRateByCode', + 'getCountryId', + 'getAllItems', + ] + )->getMock(); + $shippingAddressMock->method('validate')->willReturn(true); + $shippingAddressMock->method('getAllItems')->willReturn([$quoteAddressItemMock]); + $shippingAddressMock->method('getAddressType')->willReturn('shipping'); + + $shippingRateMock = $this->getMockBuilder(Rate::class) + ->disableOriginalConstructor() + ->addMethods([ 'getPrice' ]) + ->getMock(); + $shippingAddressMock->method('getShippingRateByCode')->willReturn($shippingRateMock); + + $billingAddressMock = $this->getMockBuilder(Address::class) + ->disableOriginalConstructor() + ->onlyMethods(['validate']) + ->getMock(); + $billingAddressMock->method('validate')->willReturn(true); + + return [$shippingAddressMock, $billingAddressMock]; + } + + /** + * Set data for Quote Mock. + * + * @param string $paymentProviderCode + * @param Address|MockObject $shippingAddressMock + * @param Address|MockObject $billingAddressMock + * @return void + */ + private function setQuoteMockData( + string $paymentProviderCode, + Address|MockObject $shippingAddressMock, + Address|MockObject $billingAddressMock + ): void { + $paymentMock = $this->getPaymentMock($paymentProviderCode); + $this->quoteMock->method('getPayment') + ->willReturn($paymentMock); + $this->quoteMock->method('getAllShippingAddresses') + ->willReturn([$shippingAddressMock]); + $this->quoteMock->method('getBillingAddress') + ->willReturn($billingAddressMock); + $this->quoteMock->method('hasVirtualItems') + ->willReturn(false); + $this->quoteMock->expects($this->any())->method('reserveOrderId')->willReturnSelf(); + $this->quoteMock->method('setIsActive')->with(false)->willReturnSelf(); + $this->quoteMock->method('setItems')->with([$this->quoteItemMock])->willReturnSelf(); + $this->quoteMock->method('getItems')->willReturn([$this->quoteItemMock]); + } + + /** + * Return Payment Mock. + * + * @param string $paymentProviderCode + * @return MockObject + */ + private function getPaymentMock(string $paymentProviderCode): MockObject + { + $abstractMethod = $this->getMockBuilder(AbstractMethod::class) + ->disableOriginalConstructor() + ->onlyMethods(['isAvailable']) + ->getMockForAbstractClass(); + $abstractMethod->method('isAvailable')->willReturn(true); + + $paymentMock = $this->getMockBuilder(Payment::class) + ->disableOriginalConstructor() + ->onlyMethods(['getMethodInstance', 'getMethod']) + ->getMock(); + $paymentMock->method('getMethodInstance')->willReturn($abstractMethod); + $paymentMock->method('getMethod')->willReturn($paymentProviderCode); + + return $paymentMock; + } + + /** + * DataProvider for pluginForAfterGetMultiShippingModeDataProvider(). + * + * @return array + */ + public function pluginForAfterGetMultiShippingModeDataProvider(): array + { + $productData = [ + 'productType' => Type::TYPE_SIMPLE, + 'paymentProviderCode' => 'checkmo', + 'productOptions' => [ + 'info_buyRequest' => [ + 'product' => '1', + 'qty' => 1, + ], + ] + ]; + return [ + 'test case for multi shipping quote' => [true, $productData], + 'test case for single shipping quote' => [false, $productData] + ]; + } +} diff --git a/app/code/Magento/NewRelicReporting/README.md b/app/code/Magento/NewRelicReporting/README.md index a2cebb0ee45f..c50ee0511b02 100644 --- a/app/code/Magento/NewRelicReporting/README.md +++ b/app/code/Magento/NewRelicReporting/README.md @@ -31,7 +31,7 @@ Extension developers can interact with the Magento_NewRelicReporting module. For ## Additional information -[Learn more about New Relic Reporting](https://docs.magento.com/user-guide/reports/new-relic-reporting.html). +[Learn more about New Relic Reporting](https://experienceleague.adobe.com/docs/commerce-admin/start/reporting/new-relic-reporting.html). ### Console commands diff --git a/app/code/Magento/Newsletter/README.md b/app/code/Magento/Newsletter/README.md index b51cc7508d3f..d15f211ae444 100644 --- a/app/code/Magento/Newsletter/README.md +++ b/app/code/Magento/Newsletter/README.md @@ -76,7 +76,7 @@ For information about a UI component in Magento 2, see [Overview of UI component ## Additional information -[Learn more about newsletter](https://docs.magento.com/user-guide/marketing/newsletters.html). +[Learn more about newsletter](https://experienceleague.adobe.com/docs/commerce-admin/marketing/communications/newsletters/newsletters.html). ### Cron options diff --git a/app/code/Magento/OfflinePayments/README.md b/app/code/Magento/OfflinePayments/README.md index 8b34b3f2c499..1b9bf56ec122 100644 --- a/app/code/Magento/OfflinePayments/README.md +++ b/app/code/Magento/OfflinePayments/README.md @@ -36,4 +36,4 @@ For more information about a layout in Magento 2, see the [Layout documentation] ## Additional information -[Learn how to configure Offline Payment Methods](https://docs.magento.com/user-guide/payment/offline-payment-methods.html). +[Learn how to configure Offline Payment Methods](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/payments.html#offline-payment-methods). diff --git a/app/code/Magento/OfflineShipping/Model/Carrier/Freeshipping.php b/app/code/Magento/OfflineShipping/Model/Carrier/Freeshipping.php index a1fca2b155f1..5db7fd3cf238 100644 --- a/app/code/Magento/OfflineShipping/Model/Carrier/Freeshipping.php +++ b/app/code/Magento/OfflineShipping/Model/Carrier/Freeshipping.php @@ -136,11 +136,10 @@ public function collectRates(RateRequest $request) protected function _updateFreeMethodQuote($request) { $freeShipping = false; - $items = $request->getAllItems(); - $c = count($items); - for ($i = 0; $i < $c; $i++) { - if ($items[$i]->getProduct() instanceof \Magento\Catalog\Model\Product) { - if ($items[$i]->getFreeShipping()) { + $items = $request->getAllItems() ?: []; + foreach ($items as $item) { + if ($item->getProduct() instanceof \Magento\Catalog\Model\Product) { + if ($item->getFreeShipping()) { $freeShipping = true; } else { return; diff --git a/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate.php b/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate.php index 70723ba5b6d4..07517192a0a2 100644 --- a/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate.php +++ b/app/code/Magento/OfflineShipping/Model/ResourceModel/Carrier/Tablerate.php @@ -295,7 +295,6 @@ private function importData(array $fields, array $values) * @return Tablerate * @throws LocalizedException * @todo: this method should be refactored as soon as updated design will be provided - * @see https://wiki.corp.x.com/display/MCOMS/Magento+Filesystem+Decisions */ public function uploadAndImport(DataObject $object) { diff --git a/app/code/Magento/OfflineShipping/README.md b/app/code/Magento/OfflineShipping/README.md index 440f2bc3e154..316abb58b841 100644 --- a/app/code/Magento/OfflineShipping/README.md +++ b/app/code/Magento/OfflineShipping/README.md @@ -60,7 +60,7 @@ For information about a UI component in Magento 2, see [Overview of UI component You can get more information about offline shipping methods in magento at the articles: -- [How to configure Free Shipping](https://docs.magento.com/user-guide/shipping/shipping-free.html) -- [How to configure Flat Rate](https://docs.magento.com/user-guide/shipping/shipping-flat-rate.html) -- [How to configure Table Rates](https://docs.magento.com/user-guide/shipping/shipping-table-rate.html) -- [How to configure Store Pickup](https://docs.magento.com/user-guide/shipping/shipping-in-store-delivery.html) +- [How to configure Free Shipping](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/delivery/basic-methods/shipping-free.html) +- [How to configure Flat Rate](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/delivery/basic-methods/shipping-flat-rate.html) +- [How to configure Table Rates](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/delivery/basic-methods/shipping-table-rate.html) +- [How to configure Store Pickup](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/delivery/basic-methods/shipping-in-store-delivery.html) diff --git a/app/code/Magento/OrderCancellation/README.md b/app/code/Magento/OrderCancellation/README.md index b3af3df5f946..8ca4bfa8282c 100644 --- a/app/code/Magento/OrderCancellation/README.md +++ b/app/code/Magento/OrderCancellation/README.md @@ -5,5 +5,3 @@ This module allows to cancel an order and specify the order cancellation reason. This functionality is enabled / disabled by a feature flag that is set at storeView level. After the cancellation, the customer receive an email confirming it and this cancellation is reflected in the customer's order history. - - diff --git a/app/code/Magento/Paypal/etc/adminhtml/system.xml b/app/code/Magento/Paypal/etc/adminhtml/system.xml index 62df44be4600..c415d7f5a8bd 100644 --- a/app/code/Magento/Paypal/etc/adminhtml/system.xml +++ b/app/code/Magento/Paypal/etc/adminhtml/system.xml @@ -82,7 +82,7 @@ <label>Payments Pro</label> <attribute type="activity_path">payment/paypal_payment_pro/active</attribute> <group id="configuration_details"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payments-pro.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payments-pro.html</comment> </group> <group id="paypal_payflow_required" showInDefault="1" showInWebsite="1" sortOrder="10"> <field id="enable_paypal_payflow"> @@ -102,7 +102,7 @@ <comment>Accept credit card and PayPal payments securely.</comment> <attribute type="activity_path">payment/wps_express/active</attribute> <group id="configuration_details"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payments-standard.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payments-standard.html</comment> </group> <group id="express_checkout_required"> <group id="express_checkout_required_express_checkout"> @@ -184,7 +184,7 @@ <comment>Accept credit card and PayPal payments securely.</comment> <attribute type="activity_path">payment/wps_express/active</attribute> <group id="configuration_details"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payments-standard.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payments-standard.html</comment> </group> <group id="express_checkout_required"> <group id="express_checkout_required_express_checkout" translate="label"> @@ -259,7 +259,7 @@ <comment>Accept credit card and PayPal payments securely.</comment> <attribute type="activity_path">payment/wps_express/active</attribute> <group id="configuration_details"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payments-standard.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payments-standard.html</comment> </group> <group id="express_checkout_required"> <group id="express_checkout_required_express_checkout" translate="label"> @@ -306,7 +306,7 @@ <label>Website Payments Pro</label> <attribute type="activity_path">payment/paypal_payment_pro/active</attribute> <group id="configuration_details"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payments-pro.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payments-pro.html</comment> </group> <group id="paypal_payflow_required" translate="label" showInDefault="1" showInWebsite="1" sortOrder="10"> <group id="paypal_payflow_api_settings"> diff --git a/app/code/Magento/Paypal/etc/adminhtml/system/express_checkout.xml b/app/code/Magento/Paypal/etc/adminhtml/system/express_checkout.xml index a3d239370494..f6635a72b527 100644 --- a/app/code/Magento/Paypal/etc/adminhtml/system/express_checkout.xml +++ b/app/code/Magento/Paypal/etc/adminhtml/system/express_checkout.xml @@ -13,7 +13,7 @@ <comment>Add PayPal as an additional payment method to your checkout page.</comment> <attribute type="activity_path">payment/paypal_express/active</attribute> <group id="configuration_details" showInDefault="1" showInWebsite="1" showInStore="1" sortOrder="4"> - <comment>https://docs.magento.com/user-guide/payment/paypal-express-checkout.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-express-checkout.html</comment> <frontend_model>Magento\Paypal\Block\Adminhtml\System\Config\Fieldset\Hint</frontend_model> </group> <group id="express_checkout_required" translate="label" showInDefault="1" showInWebsite="1" sortOrder="10"> diff --git a/app/code/Magento/Paypal/etc/adminhtml/system/payflow_advanced.xml b/app/code/Magento/Paypal/etc/adminhtml/system/payflow_advanced.xml index 3e237ffe0a4d..43012c380e0a 100644 --- a/app/code/Magento/Paypal/etc/adminhtml/system/payflow_advanced.xml +++ b/app/code/Magento/Paypal/etc/adminhtml/system/payflow_advanced.xml @@ -13,7 +13,7 @@ <comment><![CDATA[Accept payments with a PCI-compliant checkout that keeps customers on your site. (<u>Includes Express Checkout</u>)]]></comment> <attribute type="activity_path">payment/payflow_advanced/active</attribute> <group id="configuration_details" showInDefault="1" showInWebsite="1" showInStore="1" sortOrder="4"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payments-advanced.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payments-advanced.html</comment> <frontend_model>Magento\Paypal\Block\Adminhtml\System\Config\Fieldset\Hint</frontend_model> </group> <group id="required_settings" translate="label" showInDefault="1" showInWebsite="1" sortOrder="10"> diff --git a/app/code/Magento/Paypal/etc/adminhtml/system/payflow_link.xml b/app/code/Magento/Paypal/etc/adminhtml/system/payflow_link.xml index cef6193bed7a..377b1b2cf915 100644 --- a/app/code/Magento/Paypal/etc/adminhtml/system/payflow_link.xml +++ b/app/code/Magento/Paypal/etc/adminhtml/system/payflow_link.xml @@ -13,7 +13,7 @@ <comment><![CDATA[Connect your merchant account with a PCI-compliant gateway that lets customers pay without leaving your site. (<u>Includes Express Checkout</u>)]]></comment> <attribute type="activity_path">payment/payflow_link/active</attribute> <group id="configuration_details" showInDefault="1" showInWebsite="1" showInStore="1" sortOrder="4"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payflow-link.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payflow-link.html</comment> <frontend_model>Magento\Paypal\Block\Adminhtml\System\Config\Fieldset\Hint</frontend_model> </group> <group id="payflow_link_required" translate="label" showInDefault="1" showInWebsite="1" sortOrder="10"> diff --git a/app/code/Magento/Paypal/etc/adminhtml/system/payments_pro_hosted_solution.xml b/app/code/Magento/Paypal/etc/adminhtml/system/payments_pro_hosted_solution.xml index 5e4cb2e55aec..c83cc81f7f61 100644 --- a/app/code/Magento/Paypal/etc/adminhtml/system/payments_pro_hosted_solution.xml +++ b/app/code/Magento/Paypal/etc/adminhtml/system/payments_pro_hosted_solution.xml @@ -14,7 +14,7 @@ <comment><![CDATA[Accept payments with a PCI compliant checkout that keeps customers on your site. (<u>Includes Express Checkout</u>)]]></comment> <attribute type="paypal_ec_separate">1</attribute> <group id="configuration_details" showInDefault="1" showInWebsite="1" showInStore="1" sortOrder="4"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payments-pro.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payments-pro.html</comment> <frontend_model>Magento\Paypal\Block\Adminhtml\System\Config\Fieldset\Hint</frontend_model> </group> <group id="pphs_required_settings" translate="label" showInDefault="1" showInWebsite="1" sortOrder="10"> diff --git a/app/code/Magento/Paypal/etc/adminhtml/system/paypal_payflowpro.xml b/app/code/Magento/Paypal/etc/adminhtml/system/paypal_payflowpro.xml index 56cc21dd9ca0..d1955c49dc0c 100644 --- a/app/code/Magento/Paypal/etc/adminhtml/system/paypal_payflowpro.xml +++ b/app/code/Magento/Paypal/etc/adminhtml/system/paypal_payflowpro.xml @@ -14,7 +14,7 @@ <attribute type="activity_path">payment/payflowpro/active</attribute> <attribute type="paypal_ec_separate">1</attribute> <group id="configuration_details" showInDefault="1" showInWebsite="1" showInStore="1" sortOrder="4"> - <comment>https://docs.magento.com/user-guide/payment/paypal-payflow-pro.html</comment> + <comment>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/payments/paypal/paypal-payflow-pro.html</comment> <frontend_model>Magento\Paypal\Block\Adminhtml\System\Config\Fieldset\Hint</frontend_model> </group> <group id="paypal_payflow_required" translate="label" showInDefault="1" showInWebsite="1" sortOrder="10"> diff --git a/app/code/Magento/Persistent/README.md b/app/code/Magento/Persistent/README.md index 3d2f19e4fc91..efd182ba29be 100644 --- a/app/code/Magento/Persistent/README.md +++ b/app/code/Magento/Persistent/README.md @@ -53,7 +53,7 @@ For more information about a layout in Magento 2, see the [Layout documentation] More information can get at articles: -- [Persistent Shopping Cart](https://docs.magento.com/user-guide/configuration/customers/persistent-shopping-cart.html) +- [Persistent Shopping Cart](https://experienceleague.adobe.com/docs/commerce-admin/config/customers/persistent-shopping-cart.html) - [Persistent Cart](https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/point-of-purchase/cart/cart-persistent.html) ### Cron options diff --git a/app/code/Magento/ProductAlert/README.md b/app/code/Magento/ProductAlert/README.md index 1d54f5e7b811..e1f19a158de2 100644 --- a/app/code/Magento/ProductAlert/README.md +++ b/app/code/Magento/ProductAlert/README.md @@ -39,8 +39,8 @@ For more information about a layout in Magento 2, see the [Layout documentation] More information can get at articles: -- [Product Alerts](https://docs.magento.com/user-guide/catalog/inventory-product-alerts.html) -- [Product Alert Run Settings](https://docs.magento.com/user-guide/catalog/inventory-product-alert-run-settings.html) +- [Product Alerts](https://experienceleague.adobe.com/docs/commerce-admin/inventory/configuration/product-alerts/alert-setup.html) +- [Product Alert Run Settings](https://experienceleague.adobe.com/docs/commerce-admin/inventory/configuration/product-alerts/alert-setup.html) ### Cron options diff --git a/app/code/Magento/ProductVideo/README.md b/app/code/Magento/ProductVideo/README.md index f3b9926dd111..12a1278f9401 100644 --- a/app/code/Magento/ProductVideo/README.md +++ b/app/code/Magento/ProductVideo/README.md @@ -46,5 +46,5 @@ For information about a UI component in Magento 2, see [Overview of UI component More information can get at articles: -- [Learn how to add Product Video](https://docs.magento.com/user-guide/catalog/product-video.html) +- [Learn how to add Product Video](https://experienceleague.adobe.com/docs/commerce-admin/catalog/products/digital-assets/product-video.html) - [Learn how to configure Product Video](https://developer.adobe.com/commerce/frontend-core/guide/themes/product-video/) diff --git a/app/code/Magento/Quote/Model/ResourceModel/Quote/Address/Rate/Collection.php b/app/code/Magento/Quote/Model/ResourceModel/Quote/Address/Rate/Collection.php index 4e0650de2485..7c7b5f62832f 100644 --- a/app/code/Magento/Quote/Model/ResourceModel/Quote/Address/Rate/Collection.php +++ b/app/code/Magento/Quote/Model/ResourceModel/Quote/Address/Rate/Collection.php @@ -5,10 +5,12 @@ */ namespace Magento\Quote\Model\ResourceModel\Quote\Address\Rate; +use Magento\Quote\Model\ResourceModel\Quote\Address\Rate; + /** * Quote addresses shipping rates collection * - * @author Magento Core Team <core@magentocommerce.com> + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Collection extends \Magento\Framework\Model\ResourceModel\Db\VersionControl\Collection { @@ -24,6 +26,11 @@ class Collection extends \Magento\Framework\Model\ResourceModel\Db\VersionContro */ private $_carrierFactory; + /** + * @var Delete + */ + private Delete $deleteRates; + /** * @param \Magento\Framework\Data\Collection\EntityFactory $entityFactory * @param \Psr\Log\LoggerInterface $logger @@ -31,8 +38,9 @@ class Collection extends \Magento\Framework\Model\ResourceModel\Db\VersionContro * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot $entitySnapshot * @param \Magento\Shipping\Model\CarrierFactoryInterface $carrierFactory - * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection - * @param \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource + * @param Delete $deleteRates + * @param \Magento\Framework\DB\Adapter\AdapterInterface|null $connection + * @param \Magento\Framework\Model\ResourceModel\Db\AbstractDb|null $resource */ public function __construct( \Magento\Framework\Data\Collection\EntityFactory $entityFactory, @@ -41,8 +49,9 @@ public function __construct( \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot $entitySnapshot, \Magento\Shipping\Model\CarrierFactoryInterface $carrierFactory, + Delete $deleteRates, \Magento\Framework\DB\Adapter\AdapterInterface $connection = null, - \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource = null + \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource = null, ) { parent::__construct( $entityFactory, @@ -53,6 +62,7 @@ public function __construct( $connection, $resource ); + $this->deleteRates = $deleteRates; $this->_carrierFactory = $carrierFactory; } @@ -112,4 +122,27 @@ public function addItem(\Magento\Framework\DataObject $rate) } return parent::addItem($rate); } + + /** + * @inheritdoc + */ + public function save() + { + $itemsToDelete = []; + $itemsToSave = []; + /** @var Rate $item */ + foreach ($this->getItems() as $item) { + if ($item->isDeleted()) { + $itemsToDelete[] = $item; + } else { + $itemsToSave[] = $item; + } + } + $this->deleteRates->execute($itemsToDelete); + /** @var Rate $item */ + foreach ($itemsToSave as $item) { + $item->save(); + } + return $this; + } } diff --git a/app/code/Magento/Quote/Model/ResourceModel/Quote/Address/Rate/Delete.php b/app/code/Magento/Quote/Model/ResourceModel/Quote/Address/Rate/Delete.php new file mode 100644 index 000000000000..fb6467103adb --- /dev/null +++ b/app/code/Magento/Quote/Model/ResourceModel/Quote/Address/Rate/Delete.php @@ -0,0 +1,65 @@ +<?php +/************************************************************************ + * + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + * *********************************************************************** + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\ResourceModel\Quote\Address\Rate; + +use Magento\Framework\App\ResourceConnection; +use Magento\Quote\Model\Quote\Address\Rate; + +class Delete +{ + private const TABLE = 'quote_shipping_rate'; + private const FIELD_RATE_ID = 'rate_id'; + + /** + * @var ResourceConnection + */ + private ResourceConnection $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct(ResourceConnection $resourceConnection) + { + $this->resourceConnection = $resourceConnection; + } + + /** + * Remove shipping rates + * + * @param Rate[] $rates + * @return void + */ + public function execute(array $rates): void + { + if (empty($rates)) { + return; + } + $this->resourceConnection->getConnection()->delete( + $this->resourceConnection->getTableName(self::TABLE), + [ + self::FIELD_RATE_ID . ' IN (?)' => array_map( + function ($rate) { + return $rate->getId(); + }, + $rates + ) + ] + ); + } +} diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/PlaceOrder.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/PlaceOrder.php index e77894a3eef4..35abc60d9c87 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/PlaceOrder.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/PlaceOrder.php @@ -7,8 +7,10 @@ namespace Magento\QuoteGraphQl\Model\Resolver; +use Magento\Framework\Exception\AuthorizationException; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; @@ -78,6 +80,10 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value $cart = $this->getCartForCheckout->execute($maskedCartId, $userId, $storeId); $orderId = $this->placeOrder->execute($cart, $maskedCartId, $userId); $order = $this->orderRepository->get($orderId); + } catch (AuthorizationException $exception) { + throw new GraphQlAuthorizationException( + __($exception->getMessage()) + ); } catch (LocalizedException $e) { throw $this->errorMessageFormatter->getFormatted( $e, diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/ShippingAddresses.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/ShippingAddresses.php index ac0bfe36627c..97d947b7c87b 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/ShippingAddresses.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/ShippingAddresses.php @@ -54,6 +54,11 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value $cart = $value['model']; $addressesData = []; + + if ($cart->getIsVirtual()) { + return $addressesData; + } + $shippingAddresses = $cart->getAllShippingAddresses(); if (count($shippingAddresses)) { diff --git a/app/code/Magento/ReleaseNotification/README.md b/app/code/Magento/ReleaseNotification/README.md index c6e55ab091bc..88500f35d946 100644 --- a/app/code/Magento/ReleaseNotification/README.md +++ b/app/code/Magento/ReleaseNotification/README.md @@ -74,4 +74,4 @@ A clickable link to internal or external content in any text field will be creat #### Link Format Example: -The text: `https://devdocs.magento.com/ [Magento DevDocs].` will appear as [Magento DevDocs](https://devdocs.magento.com/). +The text: `https://developer.adobe.com/commerce/docs/ [Adobe Commerce Developer Documentation].` will appear as [Adobe Commerce Developer Documentation](https://developer.adobe.com/commerce/docs/). diff --git a/app/code/Magento/Reports/Model/ResourceModel/Helper.php b/app/code/Magento/Reports/Model/ResourceModel/Helper.php index feae4265691f..9096d3416ee5 100644 --- a/app/code/Magento/Reports/Model/ResourceModel/Helper.php +++ b/app/code/Magento/Reports/Model/ResourceModel/Helper.php @@ -11,14 +11,27 @@ */ namespace Magento\Reports\Model\ResourceModel; +use Magento\Framework\App\ResourceConnection; +use Magento\Store\Model\StoreManagerInterface; + class Helper extends \Magento\Framework\DB\Helper implements \Magento\Reports\Model\ResourceModel\HelperInterface { /** - * @param \Magento\Framework\App\ResourceConnection $resource + * @var StoreManagerInterface + */ + private StoreManagerInterface $storeManager; + + /** + * @param ResourceConnection $resource + * @param StoreManagerInterface $storeManager * @param string $modulePrefix */ - public function __construct(\Magento\Framework\App\ResourceConnection $resource, $modulePrefix = 'reports') - { + public function __construct( + ResourceConnection $resource, + StoreManagerInterface $storeManager, + string $modulePrefix = 'reports' + ) { + $this->storeManager = $storeManager; parent::__construct($resource, $modulePrefix); } @@ -42,63 +55,67 @@ public function mergeVisitorProductIndex($mainTable, $data, $matchFields) */ public function updateReportRatingPos($connection, $type, $column, $mainTable, $aggregationTable) { - $periodSubSelect = $connection->select(); - $ratingSubSelect = $connection->select(); - $ratingSelect = $connection->select(); + foreach ($this->storeManager->getStores(true) as $store) { + $periodSubSelect = $connection->select(); + $ratingSubSelect = $connection->select(); + $ratingSelect = $connection->select(); - switch ($type) { - case 'year': - $periodCol = $connection->getDateFormatSql('t.period', '%Y-01-01'); - break; - case 'month': - $periodCol = $connection->getDateFormatSql('t.period', '%Y-%m-01'); - break; - default: - $periodCol = 't.period'; - break; - } + switch ($type) { + case 'year': + $periodCol = $connection->getDateFormatSql('t.period', '%Y-01-01'); + break; + case 'month': + $periodCol = $connection->getDateFormatSql('t.period', '%Y-%m-01'); + break; + default: + $periodCol = 't.period'; + break; + } - $columns = [ - 'period' => 't.period', - 'store_id' => 't.store_id', - 'product_id' => 't.product_id', - 'product_name' => 't.product_name', - 'product_price' => 't.product_price', - ]; + $columns = [ + 'period' => 't.period', + 'store_id' => 't.store_id', + 'product_id' => 't.product_id', + 'product_name' => 't.product_name', + 'product_price' => 't.product_price', + ]; - if ($type == 'day') { - $columns['id'] = 't.id'; // to speed-up insert on duplicate key update - } + if ($type == 'day') { + $columns['id'] = 't.id'; // to speed-up insert on duplicate key update + } - $cols = array_keys($columns); - $cols['total_qty'] = new \Zend_Db_Expr('SUM(t.' . $column . ')'); - $periodSubSelect->from( - ['t' => $mainTable], - $cols - )->group( - ['t.store_id', $periodCol, 't.product_id'] - )->order( - ['t.store_id', $periodCol, 'total_qty DESC'] - ); + $cols = array_keys($columns); + $cols['total_qty'] = new \Zend_Db_Expr('SUM(t.' . $column . ')'); + $periodSubSelect->from( + ['t' => $mainTable], + $cols + )->group( + ['t.store_id', $periodCol, 't.product_id'] + )->order( + ['t.store_id', $periodCol, 'total_qty DESC'] + ); - $cols = $columns; - $cols[$column] = 't.total_qty'; - $cols['rating_pos'] = new \Zend_Db_Expr( - "(@pos := IF(t.`store_id` <> @prevStoreId OR {$periodCol} <> @prevPeriod, 1, @pos+1))" - ); - $cols['prevStoreId'] = new \Zend_Db_Expr('(@prevStoreId := t.`store_id`)'); - $cols['prevPeriod'] = new \Zend_Db_Expr("(@prevPeriod := {$periodCol})"); - $ratingSubSelect->from($periodSubSelect, $cols); + $cols = $columns; + $cols[$column] = 't.total_qty'; + $cols['rating_pos'] = new \Zend_Db_Expr( + "(@pos := IF(t.`store_id` <> @prevStoreId OR {$periodCol} <> @prevPeriod, 1, @pos+1))" + ); + $cols['prevStoreId'] = new \Zend_Db_Expr('(@prevStoreId := t.`store_id`)'); + $cols['prevPeriod'] = new \Zend_Db_Expr("(@prevPeriod := {$periodCol})"); + $ratingSubSelect->from($periodSubSelect, $cols); - $cols = $columns; - $cols['period'] = $periodCol; - $cols[$column] = 't.' . $column; - $cols['rating_pos'] = 't.rating_pos'; - $ratingSelect->from($ratingSubSelect, $cols); + $cols = $columns; + $cols['period'] = $periodCol; + $cols[$column] = 't.' . $column; + $cols['rating_pos'] = 't.rating_pos'; + + $ratingSubSelect->where('t.store_id = ' . $store->getId()); + $ratingSelect->from($ratingSubSelect, $cols); + $sql = $ratingSelect->insertFromSelect($aggregationTable, array_keys($cols)); + $connection->query("SET @pos = 0, @prevStoreId = -1, @prevPeriod = '0000-00-00'"); + $connection->query($sql); + } - $sql = $ratingSelect->insertFromSelect($aggregationTable, array_keys($cols)); - $connection->query("SET @pos = 0, @prevStoreId = -1, @prevPeriod = '0000-00-00'"); - $connection->query($sql); return $this; } } diff --git a/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/HelperTest.php b/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/HelperTest.php index 7a51fd6df4aa..13069c747ee9 100644 --- a/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/HelperTest.php +++ b/app/code/Magento/Reports/Test/Unit/Model/ResourceModel/HelperTest.php @@ -11,16 +11,13 @@ use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\DB\Select; use Magento\Reports\Model\ResourceModel\Helper; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class HelperTest extends TestCase { - /** - * @var Helper - */ - protected $helper; - /** * @var ResourceConnection|MockObject */ @@ -31,6 +28,11 @@ class HelperTest extends TestCase */ protected $connectionMock; + /** + * @var StoreManagerInterface + */ + private StoreManagerInterface $storeManager; + /** * {@inheritDoc} */ @@ -48,9 +50,7 @@ protected function setUp(): void ->method('getConnection') ->willReturn($this->connectionMock); - $this->helper = new Helper( - $this->resourceMock - ); + $this->storeManager = $this->createMock(StoreManagerInterface::class); } /** @@ -67,7 +67,11 @@ public function testMergeVisitorProductIndex() ->method('insertOnDuplicate') ->with($mainTable, $data, array_keys($data)); - $this->helper->mergeVisitorProductIndex($mainTable, $data, $matchFields); + $helper = new Helper( + $this->resourceMock, + $this->storeManager + ); + $helper->mergeVisitorProductIndex($mainTable, $data, $matchFields); } /** @@ -82,6 +86,9 @@ public function testUpdateReportRatingPos($type, $result) $column = 'column'; $aggregationTable = 'aggregationTable'; + $store = $this->createMock(StoreInterface::class); + $store->expects($this->once())->method('getId')->willReturn(1); + $this->storeManager->expects($this->once())->method('getStores')->willReturn([$store]); $selectMock = $this->getMockBuilder(Select::class) ->disableOriginalConstructor() ->getMock(); @@ -108,7 +115,11 @@ public function testUpdateReportRatingPos($type, $result) ->method('select') ->willReturn($selectMock); - $this->helper->updateReportRatingPos($this->connectionMock, $type, $column, $mainTable, $aggregationTable); + $helper = new Helper( + $this->resourceMock, + $this->storeManager + ); + $helper->updateReportRatingPos($this->connectionMock, $type, $column, $mainTable, $aggregationTable); } /** diff --git a/app/code/Magento/Review/Controller/Adminhtml/Product/MassUpdateStatus.php b/app/code/Magento/Review/Controller/Adminhtml/Product/MassUpdateStatus.php index ff4acfb96489..835c3c0cc3f7 100644 --- a/app/code/Magento/Review/Controller/Adminhtml/Product/MassUpdateStatus.php +++ b/app/code/Magento/Review/Controller/Adminhtml/Product/MassUpdateStatus.php @@ -127,6 +127,7 @@ private function getCollection(): Collection ->getIdFieldName(), $this->getRequest()->getParam('reviews') ); + $collection->addStoreData(); $this->collection = $collection; } diff --git a/app/code/Magento/Review/Test/Unit/Controller/Adminhtml/Product/MassUpdateStatusTest.php b/app/code/Magento/Review/Test/Unit/Controller/Adminhtml/Product/MassUpdateStatusTest.php new file mode 100644 index 000000000000..a9582e8a102c --- /dev/null +++ b/app/code/Magento/Review/Test/Unit/Controller/Adminhtml/Product/MassUpdateStatusTest.php @@ -0,0 +1,153 @@ +<?php +/************************************************************************ + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + * ************************************************************************ + */ +declare(strict_types=1); + +namespace Magento\Review\Test\Unit\Controller\Adminhtml\Product; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Magento\Review\Controller\Adminhtml\Product\MassUpdateStatus; +use Magento\Backend\App\Action\Context; +use Magento\Framework\Registry; +use Magento\Framework\Controller\ResultFactory; +use Magento\Review\Model\RatingFactory; +use Magento\Review\Model\Review; +use Magento\Review\Model\ResourceModel\Review\Collection as ReviewCollection; +use Magento\Review\Model\ResourceModel\Review\CollectionFactory; +use Magento\Review\Model\ReviewFactory; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Message\ManagerInterface; +use Magento\Backend\Model\View\Result\Redirect; +use Magento\Review\Model\ResourceModel\Review as ReviewResourceModel; + +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class MassUpdateStatusTest extends TestCase +{ + /** + * @var MassUpdateStatus + */ + private $massUpdateStatus; + + /** + * @var Collection|MockObject + */ + private $collectionMock; + + /** + * @var CollectionFactory|MockObject + */ + private $collectionFactoryMock; + + /** + * @var RequestInterface|MockObject + */ + private $requestMock; + + /** + * @var ManagerInterface|MockObject + */ + private $messageManagerMock; + + /** + * @var ResultFactory|MockObject + */ + private $resultRedirectFactory; + + /** + * @var Redirect|MockObject + */ + private $redirectMock; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->collectionMock = $this->createMock(ReviewCollection::class); + $resource = $this->createMock(ReviewResourceModel::class); + $resource->method('getIdFieldName') + ->willReturn('id'); + $this->collectionMock->expects($this->once()) + ->method('getResource') + ->willReturn($resource); + $this->collectionFactoryMock = $this->createMock(CollectionFactory::class); + $contextMock = $this->createMock(Context::class); + $this->requestMock = $this->createMock(RequestInterface::class); + $contextMock->method('getRequest') + ->willReturn($this->requestMock); + $this->messageManagerMock = $this->createMock(ManagerInterface::class); + $contextMock->method('getMessageManager') + ->willReturn($this->messageManagerMock); + $this->resultRedirectFactory = $this->createMock(ResultFactory::class); + $this->redirectMock = $this->createMock(Redirect::class); + $this->resultRedirectFactory->method('create')->willReturn($this->redirectMock); + $contextMock->method('getResultFactory') + ->willReturn($this->resultRedirectFactory); + $this->massUpdateStatus = new MassUpdateStatus( + $contextMock, + $this->createMock(Registry::class), + $this->createMock(ReviewFactory::class), + $this->createMock(RatingFactory::class), + $this->collectionFactoryMock + ); + } + + /** + * @return void + */ + public function testExecute(): void + { + $this->requestMock->expects(self::atLeastOnce()) + ->method('getParam') + ->willReturnMap( + [ + ['reviews', null, [1, 2]], + ['status', null, Review::STATUS_APPROVED], + ['ret', null, 'index'], + + ] + ); + $this->collectionFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->collectionMock); + $this->collectionMock->expects($this->once()) + ->method('addStoreData') + ->willReturnSelf(); + $this->collectionMock->expects($this->once()) + ->method('addFieldToFilter') + ->with('main_table.id', [1, 2]) + ->willReturnSelf(); + $modelMock = $this->getMockBuilder(Review::class) + ->disableOriginalConstructor() + ->addMethods(['setStatusId']) + ->onlyMethods(['_getResource']) + ->getMock(); + $modelMock->expects($this->once()) + ->method('setStatusId') + ->with(Review::STATUS_APPROVED) + ->willReturnSelf(); + $modelMock->method('_getResource') + ->willReturn($this->createMock(ReviewResourceModel::class)); + $this->collectionMock->expects($this->once())->method('getIterator') + ->willReturn(new \ArrayIterator([$modelMock])); + $this->messageManagerMock->expects($this->once()) + ->method('addSuccessMessage') + ->with(__('A total of %1 record(s) have been updated.', 2)); + $this->massUpdateStatus->execute(); + } +} diff --git a/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/AbstractForm.php b/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/AbstractForm.php index 6b87c1fe39d8..964f6f230f7b 100644 --- a/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/AbstractForm.php +++ b/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/AbstractForm.php @@ -5,51 +5,66 @@ */ namespace Magento\Sales\Block\Adminhtml\Order\Create\Form; +use IntlDateFormatter; +use Magento\Backend\Block\Template\Context; +use Magento\Backend\Block\Widget\Form\Renderer\Element; +use Magento\Backend\Block\Widget\Form\Renderer\Fieldset; +use Magento\Backend\Model\Session\Quote; +use Magento\Customer\Api\Data\OptionInterface; +use Magento\Customer\Block\Adminhtml\Edit\Renderer\Region; +use Magento\Customer\Block\Adminhtml\Form\Element\Boolean; +use Magento\Customer\Block\Adminhtml\Form\Element\File; +use Magento\Customer\Block\Adminhtml\Form\Element\Image; +use Magento\Framework\Data\Form; +use Magento\Framework\Data\Form\Element\AbstractElement; +use Magento\Framework\Data\FormFactory; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Customer\Api\Data\AttributeMetadataInterface; +use Magento\Framework\Reflection\DataObjectProcessor; +use Magento\Sales\Block\Adminhtml\Order\Create\AbstractCreate; +use Magento\Sales\Model\AdminOrder\Create; /** * Sales Order Create Form Abstract Block * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -abstract class AbstractForm extends \Magento\Sales\Block\Adminhtml\Order\Create\AbstractCreate +abstract class AbstractForm extends AbstractCreate { /** - * Form factory - * - * @var \Magento\Framework\Data\FormFactory + * @var FormFactory */ protected $_formFactory; /** * Data Form object * - * @var \Magento\Framework\Data\Form + * @var Form */ protected $_form; /** - * @var \Magento\Framework\Reflection\DataObjectProcessor + * @var DataObjectProcessor */ protected $dataObjectProcessor; /** - * @param \Magento\Backend\Block\Template\Context $context - * @param \Magento\Backend\Model\Session\Quote $sessionQuote - * @param \Magento\Sales\Model\AdminOrder\Create $orderCreate + * @param Context $context + * @param Quote $sessionQuote + * @param Create $orderCreate * @param PriceCurrencyInterface $priceCurrency - * @param \Magento\Framework\Data\FormFactory $formFactory - * @param \Magento\Framework\Reflection\DataObjectProcessor $dataObjectProcessor + * @param FormFactory $formFactory + * @param DataObjectProcessor $dataObjectProcessor * @param array $data */ public function __construct( - \Magento\Backend\Block\Template\Context $context, - \Magento\Backend\Model\Session\Quote $sessionQuote, - \Magento\Sales\Model\AdminOrder\Create $orderCreate, + Context $context, + Quote $sessionQuote, + Create $orderCreate, PriceCurrencyInterface $priceCurrency, - \Magento\Framework\Data\FormFactory $formFactory, - \Magento\Framework\Reflection\DataObjectProcessor $dataObjectProcessor, + FormFactory $formFactory, + DataObjectProcessor $dataObjectProcessor, array $data = [] ) { $this->_formFactory = $formFactory; @@ -61,26 +76,27 @@ public function __construct( * Prepare global layout. Add renderers to \Magento\Framework\Data\Form * * @return $this + * @throws LocalizedException */ protected function _prepareLayout() { parent::_prepareLayout(); - \Magento\Framework\Data\Form::setElementRenderer( + Form::setElementRenderer( $this->getLayout()->createBlock( - \Magento\Backend\Block\Widget\Form\Renderer\Element::class, + Element::class, $this->getNameInLayout() . '_element' ) ); - \Magento\Framework\Data\Form::setFieldsetRenderer( + Form::setFieldsetRenderer( $this->getLayout()->createBlock( - \Magento\Backend\Block\Widget\Form\Renderer\Fieldset::class, + Fieldset::class, $this->getNameInLayout() . '_fieldset' ) ); - \Magento\Framework\Data\Form::setFieldsetElementRenderer( + Form::setFieldsetElementRenderer( $this->getLayout()->createBlock( - \Magento\Backend\Block\Widget\Form\Renderer\Fieldset\Element::class, + Fieldset\Element::class, $this->getNameInLayout() . '_fieldset_element' ) ); @@ -91,7 +107,8 @@ protected function _prepareLayout() /** * Return Form object * - * @return \Magento\Framework\Data\Form + * @return Form + * @throws LocalizedException */ public function getForm() { @@ -117,9 +134,9 @@ abstract protected function _prepareForm(); protected function _getAdditionalFormElementTypes() { return [ - 'file' => \Magento\Customer\Block\Adminhtml\Form\Element\File::class, - 'image' => \Magento\Customer\Block\Adminhtml\Form\Element\Image::class, - 'boolean' => \Magento\Customer\Block\Adminhtml\Form\Element\Boolean::class + 'file' => File::class, + 'image' => Image::class, + 'boolean' => Boolean::class ]; } @@ -127,12 +144,13 @@ protected function _getAdditionalFormElementTypes() * Return array of additional form element renderers by element id * * @return array + * @throws LocalizedException */ protected function _getAdditionalFormElementRenderers() { return [ 'region' => $this->getLayout()->createBlock( - \Magento\Customer\Block\Adminhtml\Edit\Renderer\Region::class + Region::class ) ]; } @@ -140,11 +158,11 @@ protected function _getAdditionalFormElementRenderers() /** * Add additional data to form element * - * @param \Magento\Framework\Data\Form\Element\AbstractElement $element + * @param AbstractElement $element * @return $this * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - protected function _addAdditionalFormElementData(\Magento\Framework\Data\Form\Element\AbstractElement $element) + protected function _addAdditionalFormElementData(AbstractElement $element) { return $this; } @@ -153,11 +171,12 @@ protected function _addAdditionalFormElementData(\Magento\Framework\Data\Form\El * Add rendering EAV attributes to Form element * * @param AttributeMetadataInterface[] $attributes - * @param \Magento\Framework\Data\Form\AbstractForm $form + * @param Form\AbstractForm $form * @return $this * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @throws LocalizedException */ - protected function _addAttributesToForm($attributes, \Magento\Framework\Data\Form\AbstractForm $form) + protected function _addAttributesToForm($attributes, Form\AbstractForm $form) { // add additional form types $types = $this->_getAdditionalFormElementTypes(); @@ -178,10 +197,23 @@ protected function _addAttributesToForm($attributes, \Magento\Framework\Data\For 'label' => __($attribute->getStoreLabel()), 'class' => $this->getValidationClasses($attribute), 'required' => $attribute->isRequired(), + 'sort_order' => $attribute->getSortOrder() ] ); - if ($inputType == 'multiline') { - $element->setLineCount($attribute->getMultilineCount()); + switch ($inputType) { + case 'multiline': + $element->setLineCount($attribute->getMultilineCount()); + break; + case 'select': + case 'multiselect': + $this->addSelectOptions($attribute, $element); + break; + case 'date': + $format = $this->_localeDate->getDateFormat( + IntlDateFormatter::SHORT + ); + $element->setDateFormat($format); + break; } $element->setEntityAttribute($attribute); $this->_addAdditionalFormElementData($element); @@ -189,29 +221,6 @@ protected function _addAttributesToForm($attributes, \Magento\Framework\Data\For if (!empty($renderers[$attribute->getAttributeCode()])) { $element->setRenderer($renderers[$attribute->getAttributeCode()]); } - - if ($inputType == 'select' || $inputType == 'multiselect') { - $options = []; - foreach ($attribute->getOptions() as $optionData) { - $data = $this->dataObjectProcessor->buildOutputDataArray( - $optionData, - \Magento\Customer\Api\Data\OptionInterface::class - ); - foreach ($data as $key => $value) { - if (is_array($value)) { - unset($data[$key]); - $data['value'] = $value; - } - } - $options[] = $data; - } - $element->setValues($options); - } elseif ($inputType == 'date') { - $format = $this->_localeDate->getDateFormat( - \IntlDateFormatter::SHORT - ); - $element->setDateFormat($format); - } } } @@ -245,8 +254,7 @@ private function getValidationClasses(AttributeMetadataInterface $attribute) : s $out = array_merge($out, $textClasses); } - $out = !empty($out) ? implode(' ', array_unique(array_filter($out))) : ''; - return $out; + return implode(' ', array_unique(array_filter($out))); } /** @@ -281,4 +289,30 @@ private function getTextLengthValidateClasses(AttributeMetadataInterface $attrib return $classes; } + + /** + * Add select options for SELECT and MULTISELECT attribute + * + * @param AttributeMetadataInterface $attribute + * @param AbstractElement $element + * @return void + */ + private function addSelectOptions(AttributeMetadataInterface $attribute, AbstractElement $element): void + { + $options = []; + foreach ($attribute->getOptions() as $optionData) { + $data = $this->dataObjectProcessor->buildOutputDataArray( + $optionData, + OptionInterface::class + ); + foreach ($data as $key => $value) { + if (is_array($value)) { + unset($data[$key]); + $data['value'] = $value; + } + } + $options[] = $data; + } + $element->setValues($options); + } } diff --git a/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/Address.php b/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/Address.php index d840e530ecbf..9e49d5ec5b7d 100644 --- a/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/Address.php +++ b/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/Address.php @@ -8,6 +8,7 @@ use Magento\Backend\Model\Session\Quote; use Magento\Framework\App\ObjectManager; use Magento\Framework\Data\Form\Element\AbstractElement; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Customer\Api\Data\AddressInterface; use Magento\Eav\Model\AttributeDataFactory; @@ -219,6 +220,7 @@ public function getAddressCollectionJson() * @return $this * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) + * @throws LocalizedException */ protected function _prepareForm() { @@ -229,6 +231,12 @@ protected function _prepareForm() $addressForm = $this->_customerFormFactory->create('customer_address', 'adminhtml_customer_address'); $attributes = $addressForm->getAttributes(); + uasort( + $attributes, + function ($attr1, $attr2) { + return $attr1->getSortOrder() <=> $attr2->getSortOrder(); + } + ); $this->_addAttributesToForm($attributes, $fieldset); $prefixElement = $this->_form->getElement('prefix'); diff --git a/app/code/Magento/Sales/Model/Order/Config.php b/app/code/Magento/Sales/Model/Order/Config.php index 92d631d1f78c..883295128317 100644 --- a/app/code/Magento/Sales/Model/Order/Config.php +++ b/app/code/Magento/Sales/Model/Order/Config.php @@ -6,8 +6,9 @@ namespace Magento\Sales\Model\Order; use Magento\Framework\App\Area; -use Magento\Framework\Exception\LocalizedException; use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\ObjectManager\ResetAfterRequestInterface; /** * Order configuration model @@ -15,7 +16,7 @@ * @api * @since 100.0.2 */ -class Config +class Config implements ResetAfterRequestInterface { /** * @var \Magento\Sales\Model\ResourceModel\Order\Status\Collection @@ -84,6 +85,14 @@ public function __construct( $this->statusLabel = $statusLabel ?: ObjectManager::getInstance()->get(StatusLabel::class); } + /** + * @inheritDoc + */ + public function _resetState(): void + { + $this->collection = null; + } + /** * Get collection. * @@ -130,7 +139,6 @@ public function getStateDefaultStatus($state): ?string return $status; } - /** * Retrieve status label for detected area * diff --git a/app/code/Magento/Sales/Model/Service/OrderService.php b/app/code/Magento/Sales/Model/Service/OrderService.php index 2234e8ed877d..2ec17cc9077b 100644 --- a/app/code/Magento/Sales/Model/Service/OrderService.php +++ b/app/code/Magento/Sales/Model/Service/OrderService.php @@ -160,10 +160,21 @@ public function getCommentsList($id) * @param int $id * @param \Magento\Sales\Api\Data\OrderStatusHistoryInterface $statusHistory * @return bool + * @throws \Magento\Framework\Exception\LocalizedException */ public function addComment($id, \Magento\Sales\Api\Data\OrderStatusHistoryInterface $statusHistory) { $order = $this->orderRepository->get($id); + + /** + * change order status is not allowed during add comment to the order + */ + if ($statusHistory->getStatus() && $statusHistory->getStatus() != $order->getStatus()) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Unable to add comment: The status "%1" is not part of the order + status history.', $statusHistory->getStatus()) + ); + } $order->addStatusHistory($statusHistory); $this->orderRepository->save($order); $notify = $statusHistory['is_customer_notified'] ?? false; diff --git a/app/code/Magento/Sales/Test/Unit/Model/Service/OrderServiceTest.php b/app/code/Magento/Sales/Test/Unit/Model/Service/OrderServiceTest.php index 09344ea068cc..b518fe8b212a 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Service/OrderServiceTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Service/OrderServiceTest.php @@ -28,6 +28,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Magento\Framework\Phrase; +use Magento\Framework\Exception\LocalizedException; /** * @@ -295,6 +297,22 @@ public function testAddComment() $this->assertTrue($this->orderService->addComment(123, $this->orderStatusHistoryMock)); } + /** + * test for add comment with order status change case + */ + public function testAddCommentWithStatus() + { + $params = ['status' => 'holded']; + $inputException = new LocalizedException( + new Phrase('Unable to add comment: The status "%1" is not part of the order + status history.', $params) + ); + $this->orderStatusHistoryMock->method('getStatus') + ->willThrowException($inputException); + $this->expectException(LocalizedException::class); + $this->orderService->addComment(123, $this->orderStatusHistoryMock); + } + public function testNotify() { $this->orderRepositoryMock->expects($this->once()) diff --git a/app/code/Magento/SalesRule/Model/Coupon/Quote/UpdateCouponUsages.php b/app/code/Magento/SalesRule/Model/Coupon/Quote/UpdateCouponUsages.php index 02da921e032e..f2a1cff4c5b9 100644 --- a/app/code/Magento/SalesRule/Model/Coupon/Quote/UpdateCouponUsages.php +++ b/app/code/Magento/SalesRule/Model/Coupon/Quote/UpdateCouponUsages.php @@ -8,6 +8,7 @@ namespace Magento\SalesRule\Model\Coupon\Quote; use Magento\Quote\Api\Data\CartInterface; +use Magento\SalesRule\Model\Coupon\Usage\Processor as CouponUsageProcessor; use Magento\SalesRule\Model\Coupon\Usage\UpdateInfo; use Magento\SalesRule\Model\Coupon\Usage\UpdateInfoFactory; use Magento\SalesRule\Model\Service\CouponUsagePublisher; @@ -27,16 +28,24 @@ class UpdateCouponUsages */ private $couponUsagePublisher; + /** + * @var CouponUsageProcessor + */ + private $processor; + /** * @param CouponUsagePublisher $couponUsagePublisher * @param UpdateInfoFactory $updateInfoFactory + * @param CouponUsageProcessor $processor */ public function __construct( CouponUsagePublisher $couponUsagePublisher, - UpdateInfoFactory $updateInfoFactory + UpdateInfoFactory $updateInfoFactory, + CouponUsageProcessor $processor ) { $this->couponUsagePublisher = $couponUsagePublisher; $this->updateInfoFactory = $updateInfoFactory; + $this->processor = $processor; } /** @@ -54,11 +63,14 @@ public function execute(CartInterface $quote, bool $increment): void /** @var UpdateInfo $updateInfo */ $updateInfo = $this->updateInfoFactory->create(); - $updateInfo->setAppliedRuleIds(explode(',', $quote->getAppliedRuleIds())); + $appliedRuleIds = explode(',', $quote->getAppliedRuleIds()); + $appliedRuleIds = array_filter(array_map('intval', array_unique($appliedRuleIds))); + $updateInfo->setAppliedRuleIds($appliedRuleIds); $updateInfo->setCouponCode((string)$quote->getCouponCode()); $updateInfo->setCustomerId((int)$quote->getCustomerId()); $updateInfo->setIsIncrement($increment); $this->couponUsagePublisher->publish($updateInfo); + $this->processor->updateCustomerRulesUsages($updateInfo); } } diff --git a/app/code/Magento/SalesRule/Model/Coupon/UpdateCouponUsages.php b/app/code/Magento/SalesRule/Model/Coupon/UpdateCouponUsages.php index 2d4535120b1e..3ae4ec80f537 100644 --- a/app/code/Magento/SalesRule/Model/Coupon/UpdateCouponUsages.php +++ b/app/code/Magento/SalesRule/Model/Coupon/UpdateCouponUsages.php @@ -55,7 +55,9 @@ public function execute(OrderInterface $subject, bool $increment): OrderInterfac /** @var UpdateInfo $updateInfo */ $updateInfo = $this->updateInfoFactory->create(); - $updateInfo->setAppliedRuleIds(explode(',', $subject->getAppliedRuleIds())); + $appliedRuleIds = explode(',', $subject->getAppliedRuleIds()); + $appliedRuleIds = array_filter(array_map('intval', array_unique($appliedRuleIds))); + $updateInfo->setAppliedRuleIds($appliedRuleIds); $updateInfo->setCouponCode((string)$subject->getCouponCode()); $updateInfo->setCustomerId((int)$subject->getCustomerId()); $updateInfo->setIsIncrement($increment); diff --git a/app/code/Magento/SalesRule/Model/Coupon/Usage/Processor.php b/app/code/Magento/SalesRule/Model/Coupon/Usage/Processor.php index 3845ace8639c..37bdd2568b50 100644 --- a/app/code/Magento/SalesRule/Model/Coupon/Usage/Processor.php +++ b/app/code/Magento/SalesRule/Model/Coupon/Usage/Processor.php @@ -8,6 +8,7 @@ namespace Magento\SalesRule\Model\Coupon\Usage; use Magento\SalesRule\Model\Coupon; +use Magento\SalesRule\Model\CouponFactory; use Magento\SalesRule\Model\ResourceModel\Coupon\Usage; use Magento\SalesRule\Model\Rule\CustomerFactory; use Magento\SalesRule\Model\RuleFactory; @@ -28,9 +29,9 @@ class Processor private $ruleCustomerFactory; /** - * @var Coupon + * @var CouponFactory */ - private $coupon; + private $couponFactory; /** * @var Usage @@ -40,18 +41,18 @@ class Processor /** * @param RuleFactory $ruleFactory * @param CustomerFactory $ruleCustomerFactory - * @param Coupon $coupon + * @param CouponFactory $couponFactory * @param Usage $couponUsage */ public function __construct( RuleFactory $ruleFactory, CustomerFactory $ruleCustomerFactory, - Coupon $coupon, + CouponFactory $couponFactory, Usage $couponUsage ) { $this->ruleFactory = $ruleFactory; $this->ruleCustomerFactory = $ruleCustomerFactory; - $this->coupon = $coupon; + $this->couponFactory = $couponFactory; $this->couponUsage = $couponUsage; } @@ -66,21 +67,9 @@ public function process(UpdateInfo $updateInfo): void return; } - if (!empty($updateInfo->getCouponCode())) { - $this->updateCouponUsages($updateInfo); - } - $isIncrement = $updateInfo->isIncrement(); - $customerId = $updateInfo->getCustomerId(); - // use each rule (and apply to customer, if applicable) - foreach (array_unique($updateInfo->getAppliedRuleIds()) as $ruleId) { - if (!(int)$ruleId) { - continue; - } - $this->updateRuleUsages($isIncrement, (int)$ruleId); - if ($customerId) { - $this->updateCustomerRuleUsages($isIncrement, (int)$ruleId, $customerId); - } - } + $this->updateCouponUsages($updateInfo); + $this->updateRuleUsages($updateInfo); + $this->updateCustomerRulesUsages($updateInfo); } /** @@ -88,37 +77,36 @@ public function process(UpdateInfo $updateInfo): void * * @param UpdateInfo $updateInfo */ - private function updateCouponUsages(UpdateInfo $updateInfo): void + public function updateCouponUsages(UpdateInfo $updateInfo): void { + $coupon = $this->retrieveCoupon($updateInfo); + if (!$coupon) { + return; + } + $isIncrement = $updateInfo->isIncrement(); - $this->coupon->load($updateInfo->getCouponCode(), 'code'); - if ($this->coupon->getId()) { - if (!$updateInfo->isCouponAlreadyApplied() - && ($updateInfo->isIncrement() || $this->coupon->getTimesUsed() > 0)) { - $this->coupon->setTimesUsed($this->coupon->getTimesUsed() + ($isIncrement ? 1 : -1)); - $this->coupon->save(); - } - if ($updateInfo->getCustomerId()) { - $this->couponUsage->updateCustomerCouponTimesUsed( - $updateInfo->getCustomerId(), - $this->coupon->getId(), - $isIncrement - ); - } + if (!$updateInfo->isCouponAlreadyApplied() + && ($updateInfo->isIncrement() || $coupon->getTimesUsed() > 0)) { + $coupon->setTimesUsed($coupon->getTimesUsed() + ($isIncrement ? 1 : -1)); + $coupon->save(); } } /** * Update the number of rule usages * - * @param bool $isIncrement - * @param int $ruleId + * @param UpdateInfo $updateInfo */ - private function updateRuleUsages(bool $isIncrement, int $ruleId): void + public function updateRuleUsages(UpdateInfo $updateInfo): void { - $rule = $this->ruleFactory->create(); - $rule->load($ruleId); - if ($rule->getId()) { + $isIncrement = $updateInfo->isIncrement(); + foreach ($updateInfo->getAppliedRuleIds() as $ruleId) { + $rule = $this->ruleFactory->create(); + $rule->load($ruleId); + if (!$rule->getId()) { + continue; + } + $rule->loadCouponCode(); if ($isIncrement || $rule->getTimesUsed() > 0) { $rule->setTimesUsed($rule->getTimesUsed() + ($isIncrement ? 1 : -1)); @@ -127,6 +115,29 @@ private function updateRuleUsages(bool $isIncrement, int $ruleId): void } } + /** + * Update the number of rules usages per customer + * + * @param UpdateInfo $updateInfo + */ + public function updateCustomerRulesUsages(UpdateInfo $updateInfo): void + { + $customerId = $updateInfo->getCustomerId(); + if (!$customerId) { + return; + } + + $isIncrement = $updateInfo->isIncrement(); + foreach ($updateInfo->getAppliedRuleIds() as $ruleId) { + $this->updateCustomerRuleUsages($isIncrement, $ruleId, $customerId); + } + + $coupon = $this->retrieveCoupon($updateInfo); + if ($coupon) { + $this->couponUsage->updateCustomerCouponTimesUsed($customerId, $coupon->getId(), $isIncrement); + } + } + /** * Update the number of rule usages per customer * @@ -151,4 +162,22 @@ private function updateCustomerRuleUsages(bool $isIncrement, int $ruleId, int $c $ruleCustomer->save(); } } + + /** + * Retrieve coupon from update info + * + * @param UpdateInfo $updateInfo + * @return Coupon|null + */ + private function retrieveCoupon(UpdateInfo $updateInfo): ?Coupon + { + if (!$updateInfo->getCouponCode()) { + return null; + } + + $coupon = $this->couponFactory->create(); + $coupon->loadByCode($updateInfo->getCouponCode()); + + return $coupon->getId() ? $coupon : null; + } } diff --git a/app/code/Magento/SalesRule/Model/CouponUsageConsumer.php b/app/code/Magento/SalesRule/Model/CouponUsageConsumer.php index 0520cb658e40..a3224f52ea53 100644 --- a/app/code/Magento/SalesRule/Model/CouponUsageConsumer.php +++ b/app/code/Magento/SalesRule/Model/CouponUsageConsumer.php @@ -80,7 +80,8 @@ public function process(OperationInterface $operation): void $data = $this->serializer->unserialize($serializedData); $updateInfo = $this->updateInfoFactory->create(); $updateInfo->setData($data); - $this->processor->process($updateInfo); + $this->processor->updateCouponUsages($updateInfo); + $this->processor->updateRuleUsages($updateInfo); } catch (NotFoundException $e) { $this->logger->critical($e->getMessage()); $status = OperationInterface::STATUS_TYPE_NOT_RETRIABLY_FAILED; diff --git a/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesFormSection.xml b/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesFormSection.xml index 80169374fd8d..e05049295fc9 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesFormSection.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesFormSection.xml @@ -49,7 +49,7 @@ <element name="ruleParameterSelect" type="select" selector="rule[conditions][{{arg}}][operator]" parameterized="true"/> <element name="ruleParameterInput" type="input" selector="rule[conditions][{{arg}}][value]" parameterized="true"/> <element name="openChooser" type="button" selector="//label[@for='conditions__{{arg}}__value']" parameterized="true"/> - <element name="categoryCheckbox" type="checkbox" selector="//span[contains(text(), '{{arg}}')]/parent::a/preceding-sibling::input[@type='checkbox']" parameterized="true"/> + <element name="categoryCheckbox" type="checkbox" selector="//a[contains(text(), '{{arg}}')]" parameterized="true"/> <element name="newCondition" type="button" selector=".rule-param.rule-param-new-child" timeout="30"/> <element name="conditionSelect" type="select" selector="select[name='rule[conditions][1][new_child]']"/> <element name="targetEllipsis" type="input" selector="//ul[contains(@id, 'conditions')]//a[.='...']"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Section/PriceRuleConditionsSection.xml b/app/code/Magento/SalesRule/Test/Mftf/Section/PriceRuleConditionsSection.xml index 89398051fcf6..ce70809d3e0e 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Section/PriceRuleConditionsSection.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Section/PriceRuleConditionsSection.xml @@ -14,9 +14,9 @@ <element name="firstProductAttributeSelected" type="select" selector="#conditions__1__children .rule-param:nth-of-type(2) a:nth-child(1)"/> <element name="changeCategoriesButton" type="text" selector="#conditions__1--1__children>li>span.rule-param:nth-of-type(2)>a"/> <element name="categoriesChooser" type="text" selector="#conditions__1--1__children>li>span.rule-param:nth-of-type(2)>span>label>a"/> - <element name="treeRoot" type="text" selector=".x-tree-root-ct.x-tree-lines"/> - <element name="lastTreeNode" type="text" selector=".x-tree-root-ct.x-tree-lines > div > li > ul > li:last-child div img.x-tree-elbow-end-plus"/> - <element name="subcategory4level" type="text" selector=".x-tree-root-ct.x-tree-lines > div > li > ul > li > ul > li > ul > li > ul > li > div img.x-tree-elbow-end-plus"/> + <element name="treeRoot" type="text" selector=".jstree-container-ul.jstree-children"/> + <element name="lastTreeNode" type="text" selector="//li[contains(@class,'jstree-node') and contains(@class,'jstree-closed')]//i[contains(@class,'jstree-ocl')]"/> + <element name="subcategory4level" type="text" selector=".jstree-container-ul .jstree-children li > ul > li > ul > li > ul > li i.jstree-ocl"/> <element name="ruleParamLink" type="button" selector="//*[@id='conditions__{{var1}}__children']/li[{{var2}}]/span[{{var3}}]/a" parameterized="true"/> <element name="operatorByIndex" type="input" selector="#conditions__{{var1}}--{{var2}}__operator" parameterized="true"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/PriceRuleCategoryNestingTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/PriceRuleCategoryNestingTest.xml index ac54224095fb..bc674aefd19b 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Test/PriceRuleCategoryNestingTest.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/PriceRuleCategoryNestingTest.xml @@ -59,8 +59,9 @@ <waitForElement selector="{{PriceRuleConditionsSection.treeRoot}}" stepKey="wait3"/> <click selector="{{PriceRuleConditionsSection.lastTreeNode}}" stepKey="openLatestTreeNode1"/> <click selector="{{PriceRuleConditionsSection.lastTreeNode}}" stepKey="openLatestTreeNode2"/> - <click selector="{{PriceRuleConditionsSection.lastTreeNode}}" stepKey="openLatestTreeNode3"/> <waitForAjaxLoad stepKey="ajaxLoad4"/> + <click selector="{{PriceRuleConditionsSection.lastTreeNode}}" stepKey="openLatestTreeNode3"/> + <waitForAjaxLoad stepKey="ajaxLoad5"/> <waitForElement selector="{{PriceRuleConditionsSection.subcategory4level}}" stepKey="wait4"/> <click selector="{{PriceRuleConditionsSection.subcategory4level}}" stepKey="openLatestTreeNode4"/> <scrollToTopOfPage stepKey="scrollToTop"/> diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/Coupon/Usage/ProcessorTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/Coupon/Usage/ProcessorTest.php index e46f3ae48085..53a0859bb10d 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/Coupon/Usage/ProcessorTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/Coupon/Usage/ProcessorTest.php @@ -7,8 +7,8 @@ namespace Magento\SalesRule\Test\Unit\Model\Coupon\Usage; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\SalesRule\Model\Coupon; +use Magento\SalesRule\Model\CouponFactory; use Magento\SalesRule\Model\Coupon\Usage\Processor; use Magento\SalesRule\Model\Coupon\Usage\UpdateInfo; use Magento\SalesRule\Model\ResourceModel\Coupon\Usage; @@ -16,6 +16,7 @@ use Magento\SalesRule\Model\Rule\Customer; use Magento\SalesRule\Model\Rule\CustomerFactory; use Magento\SalesRule\Model\RuleFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class ProcessorTest extends TestCase @@ -26,27 +27,27 @@ class ProcessorTest extends TestCase private $processor; /** - * @var RuleFactory + * @var RuleFactory|MockObject */ private $ruleFactoryMock; /** - * @var CustomerFactory + * @var CustomerFactory|MockObject */ private $ruleCustomerFactoryMock; /** - * @var Coupon + * @var CouponFactory|MockObject */ - private $couponMock; + private $couponFactoryMock; /** - * @var Usage + * @var Usage|MockObject */ private $couponUsageMock; /** - * @var UpdateInfo + * @var UpdateInfo|MockObject */ private $updateInfoMock; @@ -55,34 +56,17 @@ class ProcessorTest extends TestCase */ protected function setUp(): void { - $this->ruleFactoryMock = $this->getMockBuilder(RuleFactory::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->ruleCustomerFactoryMock = $this->getMockBuilder(CustomerFactory::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->couponMock = $this->getMockBuilder(Coupon::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->couponUsageMock = $this->getMockBuilder(Usage::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->updateInfoMock = $this->getMockBuilder(UpdateInfo::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->processor = (new ObjectManager($this))->getObject( - Processor::class, - [ - 'ruleFactory' => $this->ruleFactoryMock, - 'ruleCustomerFactory' => $this->ruleCustomerFactoryMock, - 'coupon' => $this->couponMock, - 'couponUsage' => $this->couponUsageMock - ] + $this->ruleFactoryMock = $this->createMock(RuleFactory::class); + $this->ruleCustomerFactoryMock = $this->createMock(CustomerFactory::class); + $this->couponFactoryMock = $this->createMock(CouponFactory::class); + $this->couponUsageMock = $this->createMock(Usage::class); + $this->updateInfoMock = $this->createMock(UpdateInfo::class); + + $this->processor = new Processor( + $this->ruleFactoryMock, + $this->ruleCustomerFactoryMock, + $this->couponFactoryMock, + $this->couponUsageMock ); } @@ -91,7 +75,6 @@ protected function setUp(): void * * @param $isIncrement * @param $timesUsed - * * @return void * @dataProvider dataProvider */ @@ -104,18 +87,19 @@ public function testProcess($isIncrement, $timesUsed): void $setTimesUsed = $timesUsed + ($isIncrement ? 1 : -1); $ruleCustomerId = 13; - $this->updateInfoMock->expects($this->exactly(2))->method('getAppliedRuleIds')->willReturn([$couponId]); - $this->updateInfoMock->expects($this->exactly(2))->method('getCouponCode')->willReturn($couponCode); - $this->updateInfoMock->expects($this->exactly(3))->method('isIncrement')->willReturn($isIncrement); + $this->updateInfoMock->expects($this->atLeastOnce())->method('getAppliedRuleIds')->willReturn([$couponId]); + $this->updateInfoMock->expects($this->atLeastOnce())->method('getCouponCode')->willReturn($couponCode); + $this->updateInfoMock->expects($this->atLeastOnce())->method('isIncrement')->willReturn($isIncrement); - $this->couponMock->expects($this->once())->method('load')->with($couponCode, 'code') - ->willReturnSelf(); - $this->couponMock->expects($this->exactly(2))->method('getId')->willReturn($couponId); - $this->couponMock->expects($this->atLeastOnce())->method('getTimesUsed')->willReturn($timesUsed); - $this->couponMock->expects($this->any())->method('setTimesUsed')->with($setTimesUsed)->willReturnSelf(); - $this->couponMock->expects($this->any())->method('save')->willReturnSelf(); + $couponMock = $this->createMock(Coupon::class); + $this->couponFactoryMock->expects($this->exactly(2))->method('create')->willReturn($couponMock); + $couponMock->expects($this->exactly(2))->method('loadByCode')->with($couponCode)->willReturnSelf(); + $couponMock->expects($this->atLeastOnce())->method('getId')->willReturn($couponId); + $couponMock->expects($this->atLeastOnce())->method('getTimesUsed')->willReturn($timesUsed); + $couponMock->expects($this->any())->method('setTimesUsed')->with($setTimesUsed)->willReturnSelf(); + $couponMock->expects($this->any())->method('save')->willReturnSelf(); - $this->updateInfoMock->expects($this->exactly(3))->method('getCustomerId')->willReturn($customerId); + $this->updateInfoMock->expects($this->atLeastOnce())->method('getCustomerId')->willReturn($customerId); $this->couponUsageMock->expects($this->once()) ->method('updateCustomerCouponTimesUsed') diff --git a/app/code/Magento/SalesRule/view/adminhtml/ui_component/sales_rule_form.xml b/app/code/Magento/SalesRule/view/adminhtml/ui_component/sales_rule_form.xml index 81f13fa1353b..556639febb39 100644 --- a/app/code/Magento/SalesRule/view/adminhtml/ui_component/sales_rule_form.xml +++ b/app/code/Magento/SalesRule/view/adminhtml/ui_component/sales_rule_form.xml @@ -120,7 +120,7 @@ </validation> <dataType>number</dataType> <tooltip> - <link>https://docs.magento.com/user-guide/configuration/scope.html</link> + <link>https://experienceleague.adobe.com/docs/commerce-admin/start/setup/websites-stores-views.html#scope-settings</link> <description>What is this?</description> </tooltip> <label translate="true">Websites</label> diff --git a/app/code/Magento/Search/view/adminhtml/ui_component/search_synonyms_form.xml b/app/code/Magento/Search/view/adminhtml/ui_component/search_synonyms_form.xml index 9cff8c07d82b..7e1471cba9f9 100644 --- a/app/code/Magento/Search/view/adminhtml/ui_component/search_synonyms_form.xml +++ b/app/code/Magento/Search/view/adminhtml/ui_component/search_synonyms_form.xml @@ -72,7 +72,7 @@ </validation> <dataType>text</dataType> <tooltip> - <link>https://docs.magento.com/user-guide/stores/websites-stores-views.html</link> + <link>https://experienceleague.adobe.com/docs/commerce-admin/start/setup/websites-stores-views.html</link> <description translate="true">You can adjust the scope of this synonym group by selecting an option from the list.</description> </tooltip> <label translate="true">Scope</label> diff --git a/app/code/Magento/Store/Model/Config/ReloadDeploymentConfig.php b/app/code/Magento/Store/Model/Config/ReloadDeploymentConfig.php new file mode 100644 index 000000000000..234d603b449a --- /dev/null +++ b/app/code/Magento/Store/Model/Config/ReloadDeploymentConfig.php @@ -0,0 +1,53 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); +namespace Magento\Store\Model\Config; + +use Magento\Framework\App\State\ReloadProcessorInterface; +use Magento\Store\App\Config\Type\Scopes; +use Magento\Store\Model\GroupRepository; +use Magento\Store\Model\StoreRepository; +use Magento\Store\Model\WebsiteRepository; + +/** + * Store module specific reset state part + */ +class ReloadDeploymentConfig implements ReloadProcessorInterface +{ + + /** + * @param StoreRepository $storeRepository + * @param WebsiteRepository $websiteRepository + * @param GroupRepository $groupRepository + * @param Scopes $scopes + */ + public function __construct( + private readonly StoreRepository $storeRepository, + private readonly WebsiteRepository $websiteRepository, + private readonly GroupRepository $groupRepository, + private readonly Scopes $scopes + ) { + } + + /** + * Tells the system state to reload itself. + * + * @return void + */ + public function reloadState(): void + { + // Note: Magento\Store\Model\StoreManager::reinitStores can't be called because it flushes the caches which + // we don't want to do because that is already taken care of. Instead, we call the same clean methods that + // it calls, but we skip cleaning the cache. + + $this->storeRepository->clean(); + $this->websiteRepository->clean(); + $this->groupRepository->clean(); + + $this->scopes->clean(); + $this->scopes->get(); + } +} diff --git a/app/code/Magento/Store/etc/di.xml b/app/code/Magento/Store/etc/di.xml index 8ec1c8e6f1b5..20a828003aa4 100644 --- a/app/code/Magento/Store/etc/di.xml +++ b/app/code/Magento/Store/etc/di.xml @@ -467,4 +467,11 @@ </argument> </arguments> </type> + <type name="Magento\Framework\App\State\ReloadProcessorComposite"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="Magento_ApplicationServer::process" xsi:type="object">Magento\Store\Model\Config\ReloadDeploymentConfig</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Swatches/view/base/web/js/swatch-renderer.js b/app/code/Magento/Swatches/view/base/web/js/swatch-renderer.js index 734cde2b4cc3..41c93393cbdb 100644 --- a/app/code/Magento/Swatches/view/base/web/js/swatch-renderer.js +++ b/app/code/Magento/Swatches/view/base/web/js/swatch-renderer.js @@ -280,7 +280,7 @@ define([ // tier prise selectors end // A price label selector - normalPriceLabelSelector: '.product-info-main .normal-price .price-label', + normalPriceLabelSelector: '.normal-price .price-label', qtyInfo: '#qty' }, @@ -1039,18 +1039,18 @@ define([ $(this.options.tierPriceBlockSelector).hide(); } - $(this.options.normalPriceLabelSelector).hide(); + $product.find(this.options.normalPriceLabelSelector).hide(); - _.each($('.' + this.options.classes.attributeOptionsWrapper), function (attribute) { + _.each(this.element.find('.' + 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(); + $product.find(this.options.normalPriceLabelSelector).show(); } }.bind(this)); } else { - $(this.options.normalPriceLabelSelector).show(); + $product.find(this.options.normalPriceLabelSelector).show(); } } }.bind(this)); diff --git a/app/code/Magento/Tax/Model/Config.php b/app/code/Magento/Tax/Model/Config.php index 3955ae943340..2694fba6630c 100644 --- a/app/code/Magento/Tax/Model/Config.php +++ b/app/code/Magento/Tax/Model/Config.php @@ -921,7 +921,9 @@ public function needPriceConversion($store = null) $res = false; $priceIncludesTax = $this->priceIncludesTax($store) || $this->getNeedUseShippingExcludeTax() - || $this->shippingPriceIncludesTax($store); + || $this->shippingPriceIncludesTax($store) + || $this->displayCartShippingInclTax() + || $this->displayCartShippingBoth(); if ($priceIncludesTax) { switch ($this->getPriceDisplayType($store)) { case self::DISPLAY_TYPE_EXCLUDING_TAX: diff --git a/app/code/Magento/Tax/Model/Sales/Total/Quote/Shipping.php b/app/code/Magento/Tax/Model/Sales/Total/Quote/Shipping.php index ddfb6f9fd507..46097bc5c9fc 100644 --- a/app/code/Magento/Tax/Model/Sales/Total/Quote/Shipping.php +++ b/app/code/Magento/Tax/Model/Sales/Total/Quote/Shipping.php @@ -38,6 +38,7 @@ public function collect( return $this; } + $shippingAddress = $shippingAssignment->getShipping()->getAddress(); $quoteDetails = $this->prepareQuoteDetails($shippingAssignment, [$shippingDataObject]); $taxDetails = $this->taxCalculationService ->calculateTax($quoteDetails, $storeId); @@ -48,10 +49,8 @@ public function collect( ->calculateTax($baseQuoteDetails, $storeId); $baseTaxDetailsItems = $baseTaxDetails->getItems()[self::ITEM_CODE_SHIPPING]; - $quote->getShippingAddress() - ->setShippingAmount($taxDetailsItems->getRowTotal()); - $quote->getShippingAddress() - ->setBaseShippingAmount($baseTaxDetailsItems->getRowTotal()); + $shippingAddress->setShippingAmount($taxDetailsItems->getRowTotal()); + $shippingAddress->setBaseShippingAmount($baseTaxDetailsItems->getRowTotal()); $this->processShippingTaxInfo( $shippingAssignment, @@ -64,6 +63,8 @@ public function collect( } /** + * Fetch shipping including tax + * * @param \Magento\Quote\Model\Quote $quote * @param Address\Total $total * @return array|null diff --git a/app/code/Magento/Tax/Test/Unit/Model/ConfigTest.php b/app/code/Magento/Tax/Test/Unit/Model/ConfigTest.php index 1bc9124ac500..22db794e2058 100644 --- a/app/code/Magento/Tax/Test/Unit/Model/ConfigTest.php +++ b/app/code/Magento/Tax/Test/Unit/Model/ConfigTest.php @@ -381,4 +381,50 @@ public function dataProviderScopeConfigMethods(): array ] ]; } + + /** + * Tests check if necessary do product price conversion + * + * @return void + */ + public function testNeedPriceConversion(): void + { + $scopeConfigMock = $this->getMockForAbstractClass(ScopeConfigInterface::class); + $scopeConfigMock + ->method('getValue') + ->willReturnMap( + [ + [ + Config::XML_PATH_DISPLAY_CART_SHIPPING, + ScopeInterface::SCOPE_STORE, + null, + true + ], + [ + Config::XML_PATH_DISPLAY_CART_SHIPPING, + ScopeInterface::SCOPE_STORE, + null, + false + ], + [ + Config::CONFIG_XML_PATH_PRICE_DISPLAY_TYPE, + ScopeInterface::SCOPE_STORE, + null, + true + ], + [ + Config::XML_PATH_DISPLAY_CART_PRICE, + ScopeInterface::SCOPE_STORE, + null, + false + ] + ] + ); + /** @var Config */ + $model = new Config($scopeConfigMock); + $model->setPriceIncludesTax(false); + $model->setNeedUseShippingExcludeTax(false); + $model->setShippingPriceIncludeTax(false); + $this->assertEquals(true, $model->needPriceConversion()); + } } diff --git a/app/code/Magento/Tax/Test/Unit/Model/Sales/Total/Quote/ShippingTest.php b/app/code/Magento/Tax/Test/Unit/Model/Sales/Total/Quote/ShippingTest.php index a48325bee137..f9a5487add3e 100644 --- a/app/code/Magento/Tax/Test/Unit/Model/Sales/Total/Quote/ShippingTest.php +++ b/app/code/Magento/Tax/Test/Unit/Model/Sales/Total/Quote/ShippingTest.php @@ -108,6 +108,7 @@ public function testCollectDoesNotCalculateTaxIfThereIsNoItemsRelatedToGivenAddr { $storeId = 1; $this->quoteMock->expects($this->once())->method('getStoreId')->willReturn($storeId); + $this->quoteMock->expects($this->never())->method('getShippingAddress'); $addressMock = $this->getMockObject(Address::class, [ 'all_items' => [], @@ -168,6 +169,7 @@ private function getMockObject($className, array $objectState) public function testFetch() { + $this->quoteMock->expects($this->never())->method('getShippingAddress'); $value = 42; $total = new Total(); $total->setShippingInclTax($value); @@ -181,6 +183,8 @@ public function testFetch() public function testFetchWithZeroShipping() { + $this->quoteMock->expects($this->never())->method('getShippingAddress'); + $value = 0; $total = new Total(); $total->setShippingInclTax($value); diff --git a/app/code/Magento/Tax/etc/config.xml b/app/code/Magento/Tax/etc/config.xml index 3c8206c63d50..398a45c4fc21 100644 --- a/app/code/Magento/Tax/etc/config.xml +++ b/app/code/Magento/Tax/etc/config.xml @@ -49,7 +49,7 @@ <zero_tax>0</zero_tax> </sales_display> <notification> - <info_url>https://docs.magento.com/user-guide/tax/warning-messages.html</info_url> + <info_url>https://experienceleague.adobe.com/docs/commerce-admin/stores-sales/site-store/taxes/taxes.html#warning-messages</info_url> </notification> </tax> </default> diff --git a/app/code/Magento/Theme/view/frontend/templates/messages.phtml b/app/code/Magento/Theme/view/frontend/templates/messages.phtml index 1ef50f0cacfe..7b96b1dc5857 100644 --- a/app/code/Magento/Theme/view/frontend/templates/messages.phtml +++ b/app/code/Magento/Theme/view/frontend/templates/messages.phtml @@ -18,10 +18,9 @@ </div> <!-- /ko --> - <!-- ko if: messages().messages && messages().messages.length > 0 --> <div aria-atomic="true" role="alert" class="messages" data-bind="foreach: { data: messages().messages, as: 'message' - }"> + }, afterRender: purgeMessages"> <div data-bind="attr: { class: 'message-' + message.type + ' ' + message.type + ' message', 'data-ui-id': 'message-' + message.type @@ -29,8 +28,8 @@ <div data-bind="html: $parent.prepareMessageForHtml(message.text)"></div> </div> </div> - <!-- /ko --> </div> + <script type="text/x-magento-init"> { "*": { diff --git a/app/code/Magento/Theme/view/frontend/web/js/view/messages.js b/app/code/Magento/Theme/view/frontend/web/js/view/messages.js index 5e574e342114..9b9c4c6f0288 100644 --- a/app/code/Magento/Theme/view/frontend/web/js/view/messages.js +++ b/app/code/Magento/Theme/view/frontend/web/js/view/messages.js @@ -44,11 +44,6 @@ define([ disposableCustomerData: 'messages' }); - // Force to clean obsolete messages - if (!_.isEmpty(this.messages().messages)) { - customerData.set('messages', {}); - } - $.mage.cookies.set('mage-messages', '', { samesite: 'strict', domain: '' @@ -63,6 +58,11 @@ define([ */ prepareMessageForHtml: function (message) { return escaper.escapeHtml(message, this.allowedTags); + }, + purgeMessages: function () { + if (!_.isEmpty(this.messages().messages)) { + customerData.set('messages', {}); + } } }); }); diff --git a/app/code/Magento/Translation/App/Config/ReloadConfig.php b/app/code/Magento/Translation/App/Config/ReloadConfig.php new file mode 100644 index 000000000000..0439d67eb81c --- /dev/null +++ b/app/code/Magento/Translation/App/Config/ReloadConfig.php @@ -0,0 +1,33 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); +namespace Magento\Translation\App\Config; + +use Magento\Framework\App\State\ReloadProcessorInterface; +use Magento\Translation\App\Config\Type\Translation; + +/** + * Translation module specific reset state part + */ +class ReloadConfig implements ReloadProcessorInterface +{ + /** + * @param Translation $translation + */ + public function __construct(private readonly Translation $translation) + { + } + + /** + * Tells the system state to reload itself. + * + * @return void + */ + public function reloadState(): void + { + $this->translation->clean(); + } +} diff --git a/app/code/Magento/Translation/etc/di.xml b/app/code/Magento/Translation/etc/di.xml index c3d84c46725e..8736226c5c36 100644 --- a/app/code/Magento/Translation/etc/di.xml +++ b/app/code/Magento/Translation/etc/di.xml @@ -149,4 +149,11 @@ </argument> </arguments> </type> + <type name="Magento\Framework\App\State\ReloadProcessorComposite"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="Magento_Translate::config" xsi:type="object">Magento\Translation\App\Config\ReloadConfig</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php b/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php index 3600992011ed..ca5e295492fe 100644 --- a/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php +++ b/app/code/Magento/Ui/Component/Form/Element/DataType/Date.php @@ -5,17 +5,20 @@ */ namespace Magento\Ui\Component\Form\Element\DataType; +use Exception; use Magento\Framework\Locale\ResolverInterface; use Magento\Framework\Stdlib\DateTime\TimezoneInterface; use Magento\Framework\View\Element\UiComponentInterface; use Magento\Framework\View\Element\UiComponent\ContextInterface; +use Magento\Framework\Stdlib\DateTime\Intl\DateFormatterFactory; +use Magento\Framework\App\ObjectManager; /** * UI component date type */ class Date extends AbstractDataType { - const NAME = 'date'; + public const NAME = 'date'; /** * Current locale @@ -25,7 +28,7 @@ class Date extends AbstractDataType protected $locale; /** - * Wrapped component + * Wrapped component for date type * * @var UiComponentInterface */ @@ -36,6 +39,11 @@ class Date extends AbstractDataType */ private $localeDate; + /** + * @var DateFormatterFactory + */ + private $dateFormatterFactory; + /** * Constructor * @@ -44,17 +52,21 @@ class Date extends AbstractDataType * @param ResolverInterface $localeResolver * @param array $components * @param array $data + * @param DateFormatterFactory|null $dateFormatterFactory */ public function __construct( ContextInterface $context, TimezoneInterface $localeDate, ResolverInterface $localeResolver, array $components = [], - array $data = [] + array $data = [], + ?DateFormatterFactory $dateFormatterFactory = null ) { $this->locale = $localeResolver->getLocale(); $this->localeDate = $localeDate; parent::__construct($context, $components, $data); + $objectManager = ObjectManager::getInstance(); + $this->dateFormatterFactory = $dateFormatterFactory ?? $objectManager->get(DateFormatterFactory::class); } /** @@ -111,6 +123,7 @@ public function getComponentName() public function convertDate($date, $hour = 0, $minute = 0, $second = 0, $setUtcTimeZone = true) { try { + $date = $this->convertDateFormat($date); $dateObj = $this->localeDate->date($date, $this->getLocale(), false, false); $dateObj->setTime($hour, $minute, $second); //convert store date to default date in UTC timezone without DST @@ -118,7 +131,7 @@ public function convertDate($date, $hour = 0, $minute = 0, $second = 0, $setUtcT $dateObj->setTimezone(new \DateTimeZone('UTC')); } return $dateObj; - } catch (\Exception $e) { + } catch (Exception $e) { return null; } } @@ -144,4 +157,30 @@ public function convertDatetime(string $date, bool $setUtcTimezone = true): ?\Da return null; } } + + /** + * Convert given date to specific date format based on locale + * + * @param string $date + * @return String + * @throws Exception + */ + public function convertDateFormat(string $date): String + { + $formatter = $this->dateFormatterFactory->create( + $this->getLocale(), + \IntlDateFormatter::SHORT, + \IntlDateFormatter::NONE, + date_default_timezone_get() + ); + + $formatter->setLenient(false); + if (!$formatter->parse($date)) { + $date = $formatter->formatObject( + new \DateTime($date), + $formatter->getPattern() + ); + } + return $date; + } } diff --git a/app/code/Magento/Ui/Test/Unit/Component/Form/Element/DataType/DateTest.php b/app/code/Magento/Ui/Test/Unit/Component/Form/Element/DataType/DateTest.php index ce80ab1ebf48..60acf4410858 100644 --- a/app/code/Magento/Ui/Test/Unit/Component/Form/Element/DataType/DateTest.php +++ b/app/code/Magento/Ui/Test/Unit/Component/Form/Element/DataType/DateTest.php @@ -7,7 +7,9 @@ namespace Magento\Ui\Test\Unit\Component\Form\Element\DataType; +use Exception; use Magento\Framework\Locale\ResolverInterface; +use Magento\Framework\Stdlib\DateTime\Intl\DateFormatterFactory; use Magento\Framework\Stdlib\DateTime\TimezoneInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\View\Element\UiComponent\Context; @@ -36,6 +38,11 @@ class DateTest extends TestCase /** @var ObjectManager */ private $objectManagerHelper; + /** + * @var DateFormatterFactory|MockObject + */ + private $dateFormatterFactoryMock; + /** * @inheritdoc */ @@ -47,6 +54,7 @@ protected function setUp(): void $this->objectManagerHelper = new ObjectManager($this); $this->processorMock = $this->createMock(Processor::class); $this->contextMock->method('getProcessor')->willReturn($this->processorMock); + $this->dateFormatterFactoryMock = $this->getMockForAbstractClass(DateFormatterFactory::class); } /** @@ -182,4 +190,77 @@ public function convertDatetimeDataProvider(): array ['2019-09-30T12:32:00.000Z', true, '2019-09-30 19:32:00'], ]; } + + /** + * Run test for convertDateFormat() method + * + * @param string $date + * @param string $locale + * @param string $expected + * @return void + * @dataProvider convertDateFormatDataProvider + * @throws Exception + */ + public function testConvertDateFormat( + string $date, + string $locale, + string $expected + ): void { + $this->localeResolverMock + ->expects($this->any()) + ->method('getLocale') + ->willReturn($locale); + $this->date = $this->objectManagerHelper->getObject( + Date::class, + [ + 'localeResolver' => $this->localeResolverMock, + 'dateFormatterFactory' => $this->dateFormatterFactoryMock + ] + ); + $this->assertEquals( + $expected, + $this->date->convertDateFormat($date) + ); + } + + /** + * DataProvider for testConvertDateFormat() + * + * @return array + */ + public function convertDateFormatDataProvider(): array + { + return [ + [ + '2023-10-15', + 'en_US', + '10/15/2023' + ], + [ + '10/15/2023', + 'en_US', + '10/15/2023' + ], + [ + '2023-10-15', + 'en_GB', + '15/10/2023' + ], + [ + '15/10/2023', + 'en_GB', + '15/10/2023' + ], + [ + '2023-10-15', + 'ja_JP', + '2023/10/15' + ], + [ + '2023/10/15', + 'ja_JP', + '2023/10/15' + ] + ]; + } } diff --git a/app/code/Magento/Ups/Helper/Config.php b/app/code/Magento/Ups/Helper/Config.php index a48a06578484..f79fb1fbbf36 100644 --- a/app/code/Magento/Ups/Helper/Config.php +++ b/app/code/Magento/Ups/Helper/Config.php @@ -115,6 +115,7 @@ protected function getCodes() '03' => __('UPS Ground'), '07' => __('UPS Worldwide Express'), '08' => __('UPS Worldwide Expedited'), + '12' => __('UPS Three-Day Select'), '14' => __('UPS Next Day Air Early A.M.'), '54' => __('UPS Worldwide Express Plus'), '65' => __('UPS Saver'), diff --git a/app/code/Magento/Ups/Model/Carrier.php b/app/code/Magento/Ups/Model/Carrier.php index da2120cf55ed..091bd2b5a11c 100644 --- a/app/code/Magento/Ups/Model/Carrier.php +++ b/app/code/Magento/Ups/Model/Carrier.php @@ -8,6 +8,7 @@ namespace Magento\Ups\Model; use GuzzleHttp\Exception\GuzzleException; +use Laminas\Http\Client; use Magento\CatalogInventory\Api\StockRegistryInterface; use Magento\Directory\Helper\Data; use Magento\Directory\Model\CountryFactory; @@ -18,7 +19,6 @@ use Magento\Framework\Async\CallbackDeferred; use Magento\Framework\DataObject; use Magento\Framework\Exception\LocalizedException; -use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\HTTP\AsyncClient\HttpException; use Magento\Framework\HTTP\AsyncClient\HttpResponseDeferredInterface; use Magento\Framework\HTTP\AsyncClient\Request; @@ -57,6 +57,7 @@ * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.TooManyFields) */ class Carrier extends AbstractCarrierOnline implements CarrierInterface { @@ -88,19 +89,38 @@ class Carrier extends AbstractCarrierOnline implements CarrierInterface */ protected $_request; + /** + * Rate result data + * + * @var Result + */ + protected $_result; + /** * @var float */ protected $_baseCurrencyRate; + /** + * @var string + */ + protected $_xmlAccessRequest; + + /** + * @var string + */ + protected $_defaultCgiGatewayUrl = 'https://www.ups.com/using/services/rave/qcostcgi.cgi'; + /** * Test urls for shipment * * @var array */ protected $_defaultUrls = [ - 'ShipConfirm' => 'https://wwwcie.ups.com/api/shipments/v1/ship', + 'ShipConfirm' => 'https://wwwcie.ups.com/ups.app/xml/ShipConfirm', + 'ShipAccept' => 'https://wwwcie.ups.com/ups.app/xml/ShipAccept', 'AuthUrl' => 'https://wwwcie.ups.com/security/v1/oauth/token', + 'ShipRestConfirm' => 'https://wwwcie.ups.com/api/shipments/v1/ship', ]; /** @@ -109,8 +129,10 @@ class Carrier extends AbstractCarrierOnline implements CarrierInterface * @var array */ protected $_liveUrls = [ - 'ShipConfirm' => 'https://onlinetools.ups.com/api/shipments/v1/ship', + 'ShipConfirm' => 'https://onlinetools.ups.com/ups.app/xml/ShipConfirm', + 'ShipAccept' => 'https://onlinetools.ups.com/ups.app/xml/ShipAccept', 'AuthUrl' => 'https://onlinetools.ups.com/security/v1/oauth/token', + 'ShipRestConfirm' => 'https://onlinetools.ups.com/api/shipments/v1/ship', ]; /** @@ -138,7 +160,6 @@ class Carrier extends AbstractCarrierOnline implements CarrierInterface /** * @var UpsAuth */ - protected $upsAuth; /** @@ -147,6 +168,7 @@ class Carrier extends AbstractCarrierOnline implements CarrierInterface protected $_debugReplacePrivateDataKeys = [ 'UserId', 'Password', + 'AccessLicenseNumber', ]; /** @@ -486,6 +508,27 @@ public function getResult() return $this->_result; } + /** + * Do remote request for and handle errors + * + * @return Result|null + */ + protected function _getQuotes() + { + switch ($this->getConfigData('type')) { + case 'UPS': + return $this->_getCgiQuotes(); + case 'UPS_XML': + return $this->_getXmlQuotes(); + case 'UPS_REST': + return $this->_getRestQuotes(); + default: + break; + } + + return null; + } + /** * Set free method request * @@ -506,6 +549,64 @@ protected function _setFreeMethodRequest($freeMethod) $r->setProduct($freeMethod); } + /** + * Get cgi rates + * + * @return Result + */ + protected function _getCgiQuotes() + { + $rowRequest = $this->_rawRequest; + if (self::USA_COUNTRY_ID == $rowRequest->getDestCountry()) { + $destPostal = substr((string) $rowRequest->getDestPostal(), 0, 5); + } else { + $destPostal = $rowRequest->getDestPostal(); + } + + $params = [ + 'accept_UPS_license_agreement' => 'yes', + '10_action' => $rowRequest->getAction(), + '13_product' => $rowRequest->getProduct(), + '14_origCountry' => $rowRequest->getOrigCountry(), + '15_origPostal' => $rowRequest->getOrigPostal(), + 'origCity' => $rowRequest->getOrigCity(), + '19_destPostal' => $destPostal, + '22_destCountry' => $rowRequest->getDestCountry(), + '23_weight' => $rowRequest->getWeight(), + '47_rate_chart' => $rowRequest->getPickup(), + '48_container' => $rowRequest->getContainer(), + '49_residential' => $rowRequest->getDestType(), + 'weight_std' => strtolower((string)$rowRequest->getUnitMeasure()), + ]; + $params['47_rate_chart'] = $params['47_rate_chart']['label']; + + $responseBody = $this->_getCachedQuotes($params); + if ($responseBody === null) { + $debugData = ['request' => $params]; + try { + $url = $this->getConfigData('gateway_url'); + if (!$url) { + $url = $this->_defaultCgiGatewayUrl; + } + $client = new Client(); + $client->setUri($url); + $client->setOptions(['maxredirects' => 0, 'timeout' => 30]); + $client->setParameterGet($params); + $response = $client->send(); + $responseBody = $response->getBody(); + + $debugData['result'] = $responseBody; + $this->_setCachedQuotes($params, $responseBody); + } catch (Throwable $e) { + $debugData['result'] = ['error' => $e->getMessage(), 'code' => $e->getCode()]; + $responseBody = ''; + } + $this->_debug($debugData); + } + + return $this->_parseCgiResponse($responseBody); + } + /** * Get shipment by code * @@ -527,52 +628,128 @@ public function getShipmentByCode($code, $origin = null) } /** - * Get REST rates + * Prepare shipping rate result based on response * + * @param string $response * @return Result * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function _getQuotes() + protected function _parseCgiResponse($response) { - $url = $this->getConfigData('gateway_url'); - $accessToken = $this->setAPIAccessRequest(); + $costArr = []; + $priceArr = []; + if ($response !== null && strlen(trim($response)) > 0) { + $rRows = explode("\n", $response); + $allowedMethods = explode(",", (string)$this->getConfigData('allowed_methods')); + foreach ($rRows as $rRow) { + $row = explode('%', $rRow); + switch (substr($row[0], -1)) { + case 3: + case 4: + if (in_array($row[1], $allowedMethods)) { + $responsePrice = $this->_localeFormat->getNumber($row[8]); + $costArr[$row[1]] = $responsePrice; + $priceArr[$row[1]] = $this->getMethodPrice($responsePrice, $row[1]); + } + break; + case 5: + $errorTitle = $row[1]; + $message = __( + 'Sorry, something went wrong. Please try again or contact us and we\'ll try to help.' + ); + $this->_logger->debug($message . ': ' . $errorTitle); + break; + case 6: + if (in_array($row[3], $allowedMethods)) { + $responsePrice = $this->_localeFormat->getNumber($row[10]); + $costArr[$row[3]] = $responsePrice; + $priceArr[$row[3]] = $this->getMethodPrice($responsePrice, $row[3]); + } + break; + default: + break; + } + } + asort($priceArr); + } - $rowRequest = $this->_rawRequest; - if (self::USA_COUNTRY_ID == $rowRequest->getDestCountry()) { - $destPostal = substr((string)$rowRequest->getDestPostal(), 0, 5); + $result = $this->_rateFactory->create(); + + if (empty($priceArr)) { + $error = $this->_rateErrorFactory->create(); + $error->setCarrier('ups'); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setErrorMessage($this->getConfigData('specificerrmsg')); + $result->append($error); } else { - $destPostal = $rowRequest->getDestPostal(); + foreach ($priceArr as $method => $price) { + $rate = $this->_rateMethodFactory->create(); + $rate->setCarrier('ups'); + $rate->setCarrierTitle($this->getConfigData('title')); + $rate->setMethod($method); + $methodArray = $this->configHelper->getCode('method', $method); + $rate->setMethodTitle($methodArray); + $rate->setCost($costArr[$method]); + $rate->setPrice($price); + $result->append($rate); + } } - $params = [ - '10_action' => $rowRequest->getAction(), - '13_product' => $rowRequest->getProduct(), - '14_origCountry' => $rowRequest->getOrigCountry(), - '15_origPostal' => $rowRequest->getOrigPostal(), - 'origCity' => $rowRequest->getOrigCity(), - 'origRegionCode' => $rowRequest->getOrigRegionCode(), - '19_destPostal' => $destPostal, - '22_destCountry' => $rowRequest->getDestCountry(), - 'destRegionCode' => $rowRequest->getDestRegionCode(), - '23_weight' => $rowRequest->getWeight(), - '47_rate_chart' => $rowRequest->getPickup(), - '48_container' => $rowRequest->getContainer(), - '49_residential' => $rowRequest->getDestType(), - ]; - if ($params['10_action'] == '4') { - $params['10_action'] = 'Shop'; - $serviceCode = null; - } else { - $params['10_action'] = 'Rate'; - $serviceCode = $rowRequest->getProduct() ? $rowRequest->getProduct() : null; + return $result; + } + + /** + * Get xml rates + * + * @return Result + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function _getXmlQuotes() + { + $url = $this->getConfigData('gateway_xml_url'); + $this->setXMLAccessRequest(); + $xmlRequest = $this->_xmlAccessRequest; + $rowRequest = $this->_rawRequest; + + $params = $this->setQuoteRequestData($rowRequest); + $serviceCode = $params['serviceCode']; + $serviceDescription = $params['serviceDescription']; + $params['accept_UPS_license_agreement'] = 'yes'; + + $xmlParams = <<<XMLRequest +<?xml version="1.0"?> +<RatingServiceSelectionRequest xml:lang="en-US"> + <Request> + <TransactionReference> + <CustomerContext>Rating and Service</CustomerContext> + <XpciVersion>1.0</XpciVersion> + </TransactionReference> + <RequestAction>Rate</RequestAction> + <RequestOption>{$params['10_action']}</RequestOption> + </Request> + <PickupType> + <Code>{$params['47_rate_chart']['code']}</Code> + <Description>{$params['47_rate_chart']['label']}</Description> + </PickupType> + + <Shipment> +XMLRequest; + + if ($serviceCode !== null) { + $xmlParams .= "<Service>" . + "<Code>{$serviceCode}</Code>" . + "<Description>{$serviceDescription}</Description>" . + "</Service>"; } - $serviceDescription = $serviceCode ? $this->getShipmentByCode($serviceCode) : ''; - $shipperNumber = ''; + $xmlParams .= <<<XMLRequest + <Shipper> +XMLRequest; + if ($this->getConfigFlag('negotiated_active') && ($shipperNumber = $this->getConfigData('shipper_number'))) { - $shipperNumber = $this->getConfigData('shipper_number'); + $xmlParams .= "<ShipperNumber>{$shipperNumber}</ShipperNumber>"; } if ($rowRequest->getIsReturn()) { @@ -587,110 +764,80 @@ protected function _getQuotes() $shipperStateProvince = $params['origRegionCode']; } - $residentialAddressIndicator = ''; + $xmlParams .= <<<XMLRequest + <Address> + <City>{$shipperCity}</City> + <PostalCode>{$shipperPostalCode}</PostalCode> + <CountryCode>{$shipperCountryCode}</CountryCode> + <StateProvinceCode>{$shipperStateProvince}</StateProvinceCode> + </Address> + </Shipper> + + <ShipTo> + <Address> + <PostalCode>{$params['19_destPostal']}</PostalCode> + <CountryCode>{$params['22_destCountry']}</CountryCode> + <ResidentialAddress>{$params['49_residential']}</ResidentialAddress> + <StateProvinceCode>{$params['destRegionCode']}</StateProvinceCode> +XMLRequest; + if ($params['49_residential'] === '01') { - $residentialAddressIndicator = $params['49_residential']; + $xmlParams .= "<ResidentialAddressIndicator>{$params['49_residential']}</ResidentialAddressIndicator>"; } - $rateParams = [ - "RateRequest" => [ - "Request" => [ - "TransactionReference" => [ - "CustomerContext" => "Rating and Service" - ] - ], - "Shipment" => [ - "Shipper" => [ - "Name" => "UPS", - "ShipperNumber" => "{$shipperNumber}", - "Address" => [ - "AddressLine" => [ - "{$residentialAddressIndicator}", - ], - "City" => "{$shipperCity}", - "StateProvinceCode" => "{$shipperStateProvince}", - "PostalCode" => "{$shipperPostalCode}", - "CountryCode" => "{$shipperCountryCode}" - ] - ], - "ShipTo" => [ - "Address" => [ - "AddressLine" => ["{$params['49_residential']}"], - "StateProvinceCode" => "{$params['destRegionCode']}", - "PostalCode" => "{$params['19_destPostal']}", - "CountryCode" => "{$params['22_destCountry']}", - "ResidentialAddressIndicator" => "{$residentialAddressIndicator}" - ] - ], - "ShipFrom" => [ - "Address" => [ - "AddressLine" => [], - "StateProvinceCode" => "{$params['origRegionCode']}", - "PostalCode" => "{$params['15_origPostal']}", - "CountryCode" => "{$params['14_origCountry']}" - ] - ], - ] - ] - ]; + $xmlParams .= <<<XMLRequest + </Address> + </ShipTo> + + <ShipFrom> + <Address> + <PostalCode>{$params['15_origPostal']}</PostalCode> + <CountryCode>{$params['14_origCountry']}</CountryCode> + <StateProvinceCode>{$params['origRegionCode']}</StateProvinceCode> + </Address> + </ShipFrom> +XMLRequest; + + foreach ($rowRequest->getPackages() as $package) { + $xmlParams .= <<<XMLRequest + <Package> + <PackagingType> + <Code>{$params['48_container']}</Code> + </PackagingType> + <PackageWeight> + <UnitOfMeasurement> + <Code>{$rowRequest->getUnitMeasure()}</Code> + </UnitOfMeasurement> + <Weight>{$this->_getCorrectWeight($package['weight'])}</Weight> + </PackageWeight> + </Package> +XMLRequest; + } if ($this->getConfigFlag('negotiated_active')) { - $rateParams['RateRequest']['Shipment']['ShipmentRatingOptions']['TPFCNegotiatedRatesIndicator'] = "Y"; - $rateParams['RateRequest']['Shipment']['ShipmentRatingOptions']['NegotiatedRatesIndicator'] = "Y"; + $xmlParams .= "<RateInformation><NegotiatedRatesIndicator/></RateInformation>"; } if ($this->getConfigFlag('include_taxes')) { - $rateParams['RateRequest']['Shipment']['TaxInformationIndicator'] = "Y"; - } - - if ($serviceCode !== null) { - $rateParams['RateRequest']['Shipment']['Service']['code'] = $serviceCode; - $rateParams['RateRequest']['Shipment']['Service']['Description'] = $serviceDescription; + $xmlParams .= "<TaxInformationIndicator/>"; } - foreach ($rowRequest->getPackages() as $package) { - $rateParams['RateRequest']['Shipment']['Package'][] = [ - "PackagingType" => [ - "Code" => "{$params['48_container']}", - "Description" => "Packaging" - ], - "Dimensions" => [ - "UnitOfMeasurement" => [ - "Code" => "IN", - "Description" => "Inches" - ], - "Length" => "5", - "Width" => "5", - "Height" => "5" - ], - "PackageWeight" => [ - "UnitOfMeasurement" => [ - "Code" => "{$rowRequest->getUnitMeasure()}" - ], - "Weight" => "{$this->_getCorrectWeight($package['weight'])}" - ] - ]; - } + $xmlParams .= <<<XMLRequest + </Shipment> + </RatingServiceSelectionRequest> +XMLRequest; - $ratePayload = json_encode($rateParams, JSON_PRETTY_PRINT); + $xmlRequest .= $xmlParams; - /** Rest API Payload */ - $version = "v1"; - $requestOption = $params['10_action']; - $headers = [ - "Authorization" => "Bearer " . $accessToken, - "Content-Type" => "application/json" - ]; $httpResponse = $this->asyncHttpClient->request( - new Request($url.$version . "/" . $requestOption, Request::METHOD_POST, $headers, $ratePayload) + new Request($url, Request::METHOD_POST, ['Content-Type' => 'application/xml'], $xmlRequest) ); - - $debugData['request'] = $ratePayload; + $debugData['request'] = $xmlParams; return $this->deferredProxyFactory->create( [ 'deferred' => new CallbackDeferred( function () use ($httpResponse, $debugData) { $responseResult = null; - $jsonResponse = ''; + $xmlResponse = ''; try { $responseResult = $httpResponse->get(); } catch (HttpException $e) { @@ -698,12 +845,12 @@ function () use ($httpResponse, $debugData) { $this->_logger->critical($e); } if ($responseResult) { - $jsonResponse = $responseResult->getStatusCode() >= 400 ? '' : $responseResult->getBody(); + $xmlResponse = $responseResult->getStatusCode() >= 400 ? '' : $responseResult->getBody(); } - $debugData['result'] = $jsonResponse; + $debugData['result'] = $xmlResponse; $this->_debug($debugData); - return $this->_parseRestResponse($jsonResponse); + return $this->_parseXmlResponse($xmlResponse); } ) ] @@ -748,41 +895,47 @@ private function mapCurrencyCode($code) /** * Prepare shipping rate result based on response * - * @param mixed $rateResponse + * @param mixed $xmlResponse * @return Result * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.ElseExpression) */ - protected function _parseRestResponse($rateResponse) + protected function _parseXmlResponse($xmlResponse) { $costArr = []; $priceArr = []; - if ($rateResponse !== null && strlen($rateResponse) > 0) { - $rateResponseData = json_decode($rateResponse, true); - if ($rateResponseData['RateResponse']['Response']['ResponseStatus']['Description'] === 'Success') { - $arr = $rateResponseData['RateResponse']['RatedShipment'] ?? []; + $errorTitle = ''; + if ($xmlResponse !== null && strlen(trim($xmlResponse)) > 0) { + $xml = new \Magento\Framework\Simplexml\Config(); + $xml->loadString($xmlResponse); + $arr = $xml->getXpath("//RatingServiceSelectionResponse/Response/ResponseStatusCode/text()"); + $success = (int)$arr[0]; + if ($success === 1) { + $arr = $xml->getXpath("//RatingServiceSelectionResponse/RatedShipment"); $allowedMethods = explode(",", $this->getConfigData('allowed_methods') ?? ''); + // Negotiated rates + $negotiatedArr = $xml->getXpath("//RatingServiceSelectionResponse/RatedShipment/NegotiatedRates"); + $negotiatedActive = $this->getConfigFlag('negotiated_active') + && $this->getConfigData('shipper_number') + && !empty($negotiatedArr); + $allowedCurrencies = $this->_currencyFactory->create()->getConfigAllowCurrencies(); foreach ($arr as $shipElement) { - // Negotiated rates - $negotiatedArr = $shipElement['NegotiatedRateCharges'] ?? [] ; - $negotiatedActive = $this->getConfigFlag('negotiated_active') - && $this->getConfigData('shipper_number') - && !empty($negotiatedArr); - - $this->processShippingRateForItem( + $this->processShippingXmlRateForItem( $shipElement, $allowedMethods, $allowedCurrencies, $costArr, $priceArr, - $negotiatedActive + $negotiatedActive, + $xml ); } } else { - $errorTitle = $rateResponseData['RateResponse']['Response']['ResponseStatus']['Description']; + $arr = $xml->getXpath("//RatingServiceSelectionResponse/Response/Error/ErrorDescription/text()"); + $errorTitle = (string)$arr[0][0]; $error = $this->_rateErrorFactory->create(); $error->setCarrier('ups'); $error->setCarrierTitle($this->getConfigData('title')); @@ -790,87 +943,72 @@ protected function _parseRestResponse($rateResponse) } } - $result = $this->_rateFactory->create(); - - if (empty($priceArr)) { - $error = $this->_rateErrorFactory->create(); - $error->setCarrier('ups'); - $error->setCarrierTitle($this->getConfigData('title')); - if ($this->getConfigData('specificerrmsg') !== '') { - $errorTitle = $this->getConfigData('specificerrmsg'); - } - if (!isset($errorTitle)) { - $errorTitle = __('Cannot retrieve shipping rates'); - } - $error->setErrorMessage($errorTitle); - $result->append($error); - } else { - foreach ($priceArr as $method => $price) { - $rate = $this->_rateMethodFactory->create(); - $rate->setCarrier('ups'); - $rate->setCarrierTitle($this->getConfigData('title')); - $rate->setMethod($method); - $methodArr = $this->getShipmentByCode($method); - $rate->setMethodTitle($methodArr); - $rate->setCost($costArr[$method]); - $rate->setPrice($price); - $result->append($rate); - } - } - - return $result; - } + return $this->setRatePriceData($priceArr, $costArr, $errorTitle); + } /** * Processing rate for ship element * - * @param array $shipElement + * @param \Magento\Framework\Simplexml\Element $shipElement * @param array $allowedMethods * @param array $allowedCurrencies * @param array $costArr * @param array $priceArr * @param bool $negotiatedActive + * @param \Magento\Framework\Simplexml\Config $xml * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - private function processShippingRateForItem( - array $shipElement, + private function processShippingXmlRateForItem( + \Magento\Framework\Simplexml\Element $shipElement, array $allowedMethods, array $allowedCurrencies, array &$costArr, array &$priceArr, - bool $negotiatedActive + bool $negotiatedActive, + \Magento\Framework\Simplexml\Config $xml ): void { - $code = $shipElement['Service']['Code'] ?? ''; + $code = (string)$shipElement->Service->Code; if (in_array($code, $allowedMethods)) { //The location of tax information is in a different place // depending on whether we are using negotiated rates or not if ($negotiatedActive) { - $includeTaxesArr = $shipElement['NegotiatedRateCharges']['TotalChargesWithTaxes'] ?? []; + $includeTaxesArr = $xml->getXpath( + "//RatingServiceSelectionResponse/RatedShipment/NegotiatedRates" + . "/NetSummaryCharges/TotalChargesWithTaxes" + ); $includeTaxesActive = $this->getConfigFlag('include_taxes') && !empty($includeTaxesArr); if ($includeTaxesActive) { - $cost = $shipElement['NegotiatedRateCharges']['TotalChargesWithTaxes']['MonetaryValue']; + $cost = $shipElement->NegotiatedRates + ->NetSummaryCharges + ->TotalChargesWithTaxes + ->MonetaryValue; $responseCurrencyCode = $this->mapCurrencyCode( - (string)$shipElement['NegotiatedRateCharges']['TotalChargesWithTaxes']['CurrencyCode'] + (string)$shipElement->NegotiatedRates + ->NetSummaryCharges + ->TotalChargesWithTaxes + ->CurrencyCode ); } else { - $cost = $shipElement['NegotiatedRateCharges']['TotalCharge']['MonetaryValue']; + $cost = $shipElement->NegotiatedRates->NetSummaryCharges->GrandTotal->MonetaryValue; $responseCurrencyCode = $this->mapCurrencyCode( - (string)$shipElement['NegotiatedRateCharges']['TotalCharge']['CurrencyCode'] + (string)$shipElement->NegotiatedRates->NetSummaryCharges->GrandTotal->CurrencyCode ); } } else { - $includeTaxesArr = $shipElement['TotalChargesWithTaxes'] ?? []; + $includeTaxesArr = $xml->getXpath( + "//RatingServiceSelectionResponse/RatedShipment/TotalChargesWithTaxes" + ); $includeTaxesActive = $this->getConfigFlag('include_taxes') && !empty($includeTaxesArr); if ($includeTaxesActive) { - $cost = $shipElement['TotalChargesWithTaxes']['MonetaryValue']; + $cost = $shipElement->TotalChargesWithTaxes->MonetaryValue; $responseCurrencyCode = $this->mapCurrencyCode( - (string)$shipElement['TotalChargesWithTaxes']['CurrencyCode'] + (string)$shipElement->TotalChargesWithTaxes->CurrencyCode ); } else { - $cost = $shipElement['TotalCharges']['MonetaryValue']; + $cost = $shipElement->TotalCharges->MonetaryValue; $responseCurrencyCode = $this->mapCurrencyCode( - (string)$shipElement['TotalCharges']['CurrencyCode'] + (string)$shipElement->TotalCharges->CurrencyCode ); } } @@ -902,89 +1040,681 @@ private function processShippingRateForItem( } /** - * Get final price for shipping method with handling fee per package + * Get REST rates * - * @param float $cost - * @param string $handlingType - * @param float $handlingFee - * @return float + * @return Result + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function _getPerpackagePrice($cost, $handlingType, $handlingFee) + protected function _getRestQuotes() { - if ($handlingType == AbstractCarrier::HANDLING_TYPE_PERCENT) { - return $cost + $cost * $this->_numBoxes * $handlingFee / 100; + $url = $this->getConfigData('gateway_rest_url'); + $accessToken = $this->setAPIAccessRequest(); + $rowRequest = $this->_rawRequest; + + $params = $this->setQuoteRequestData($rowRequest); + $serviceCode = $params['serviceCode']; + $serviceDescription = $params['serviceDescription']; + + $shipperNumber = ''; + if ($this->getConfigFlag('negotiated_active') && ($shipperNumber = $this->getConfigData('shipper_number'))) { + $shipperNumber = $this->getConfigData('shipper_number'); } - return $cost + $this->_numBoxes * $handlingFee; - } + if ($rowRequest->getIsReturn()) { + $shipperCity = ''; + $shipperPostalCode = $params['19_destPostal']; + $shipperCountryCode = $params['22_destCountry']; + $shipperStateProvince = $params['destRegionCode']; + } else { + $shipperCity = $params['origCity']; + $shipperPostalCode = $params['15_origPostal']; + $shipperCountryCode = $params['14_origCountry']; + $shipperStateProvince = $params['origRegionCode']; + } - /** - * Get final price for shipping method with handling fee per order - * - * @param float $cost - * @param string $handlingType - * @param float $handlingFee - * @return float - */ - protected function _getPerorderPrice($cost, $handlingType, $handlingFee) - { - if ($handlingType == self::HANDLING_TYPE_PERCENT) { - return $cost + $cost * $handlingFee / 100; + $residentialAddressIndicator = ''; + if ($params['49_residential'] === '01') { + $residentialAddressIndicator = $params['49_residential']; } - return $cost + $handlingFee; - } + $rateParams = [ + "RateRequest" => [ + "Request" => [ + "TransactionReference" => [ + "CustomerContext" => "Rating and Service" + ] + ], + "Shipment" => [ + "Shipper" => [ + "Name" => "UPS", + "ShipperNumber" => "{$shipperNumber}", + "Address" => [ + "AddressLine" => [ + "{$residentialAddressIndicator}", + ], + "City" => "{$shipperCity}", + "StateProvinceCode" => "{$shipperStateProvince}", + "PostalCode" => "{$shipperPostalCode}", + "CountryCode" => "{$shipperCountryCode}" + ] + ], + "ShipTo" => [ + "Address" => [ + "AddressLine" => ["{$params['49_residential']}"], + "StateProvinceCode" => "{$params['destRegionCode']}", + "PostalCode" => "{$params['19_destPostal']}", + "CountryCode" => "{$params['22_destCountry']}", + "ResidentialAddressIndicator" => "{$residentialAddressIndicator}" + ] + ], + "ShipFrom" => [ + "Address" => [ + "AddressLine" => [], + "StateProvinceCode" => "{$params['origRegionCode']}", + "PostalCode" => "{$params['15_origPostal']}", + "CountryCode" => "{$params['14_origCountry']}" + ] + ], + ] + ] + ]; - /** - * Get tracking - * - * @param string|string[] $trackings - * @return Result - */ - public function getTracking($trackings) - { - if (!is_array($trackings)) { - $trackings = [$trackings]; + if ($this->getConfigFlag('negotiated_active')) { + $rateParams['RateRequest']['Shipment']['ShipmentRatingOptions']['TPFCNegotiatedRatesIndicator'] = "Y"; + $rateParams['RateRequest']['Shipment']['ShipmentRatingOptions']['NegotiatedRatesIndicator'] = "Y"; + } + if ($this->getConfigFlag('include_taxes')) { + $rateParams['RateRequest']['Shipment']['TaxInformationIndicator'] = "Y"; } - $this->_getRestTracking($trackings); - return $this->_result; + if ($serviceCode !== null) { + $rateParams['RateRequest']['Shipment']['Service']['code'] = $serviceCode; + $rateParams['RateRequest']['Shipment']['Service']['Description'] = $serviceDescription; + } + + foreach ($rowRequest->getPackages() as $package) { + $rateParams['RateRequest']['Shipment']['Package'][] = [ + "PackagingType" => [ + "Code" => "{$params['48_container']}", + "Description" => "Packaging" + ], + "Dimensions" => [ + "UnitOfMeasurement" => [ + "Code" => "IN", + "Description" => "Inches" + ], + "Length" => "5", + "Width" => "5", + "Height" => "5" + ], + "PackageWeight" => [ + "UnitOfMeasurement" => [ + "Code" => "{$rowRequest->getUnitMeasure()}" + ], + "Weight" => "{$this->_getCorrectWeight($package['weight'])}" + ] + ]; + } + + $ratePayload = json_encode($rateParams, JSON_PRETTY_PRINT); + /** Rest API Payload */ + $version = "v1"; + $requestOption = $params['10_action']; + $headers = [ + "Authorization" => "Bearer " . $accessToken, + "Content-Type" => "application/json" + ]; + $httpResponse = $this->asyncHttpClient->request( + new Request($url.$version . "/" . $requestOption, Request::METHOD_POST, $headers, $ratePayload) + ); + + $debugData['request'] = $ratePayload; + return $this->deferredProxyFactory->create( + [ + 'deferred' => new CallbackDeferred( + function () use ($httpResponse, $debugData) { + $responseResult = null; + $jsonResponse = ''; + try { + $responseResult = $httpResponse->get(); + } catch (HttpException $e) { + $debugData['result'] = ['error' => $e->getMessage(), 'code' => $e->getCode()]; + $this->_logger->critical($e); + } + if ($responseResult) { + $jsonResponse = $responseResult->getStatusCode() >= 400 ? '' : $responseResult->getBody(); + } + $debugData['result'] = $jsonResponse; + $this->_debug($debugData); + + return $this->_parseRestResponse($jsonResponse); + } + ) + ] + ); } /** - * To receive access token + * Setting common request params for Rate Request * - * @return mixed - * @throws LocalizedException + * @param \Magento\Framework\DataObject|null $rowRequest + * @return array */ - protected function setAPIAccessRequest() + private function setQuoteRequestData($rowRequest) { - $userId = $this->getConfigData('username'); - $userIdPass = $this->getConfigData('password'); - if ($this->getConfigData('is_account_live')) { - $authUrl = $this->_liveUrls['AuthUrl']; + if (self::USA_COUNTRY_ID == $rowRequest->getDestCountry()) { + $destPostal = substr((string)$rowRequest->getDestPostal(), 0, 5); } else { - $authUrl = $this->_defaultUrls['AuthUrl']; + $destPostal = $rowRequest->getDestPostal(); } - return $this->upsAuth->getAccessToken($userId, $userIdPass, $authUrl); + $params = [ + '10_action' => $rowRequest->getAction(), + '13_product' => $rowRequest->getProduct(), + '14_origCountry' => $rowRequest->getOrigCountry(), + '15_origPostal' => $rowRequest->getOrigPostal(), + 'origCity' => $rowRequest->getOrigCity(), + 'origRegionCode' => $rowRequest->getOrigRegionCode(), + '19_destPostal' => $destPostal, + '22_destCountry' => $rowRequest->getDestCountry(), + 'destRegionCode' => $rowRequest->getDestRegionCode(), + '23_weight' => $rowRequest->getWeight(), + '47_rate_chart' => $rowRequest->getPickup(), + '48_container' => $rowRequest->getContainer(), + '49_residential' => $rowRequest->getDestType(), + ]; + + if ($params['10_action'] == '4') { + $params['10_action'] = 'Shop'; + $params['serviceCode'] = null; + } else { + $params['10_action'] = 'Rate'; + $params['serviceCode'] = $rowRequest->getProduct() ? $rowRequest->getProduct() : null; + } + $params['serviceDescription'] = $params['serviceCode'] ? $this->getShipmentByCode($params['serviceCode']) : ''; + return $params; } /** - * Get REST tracking + * Prepare shipping rate result based on response * - * @param string[] $trackings + * @param mixed $rateResponse * @return Result + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.ElseExpression) */ - protected function _getRestTracking($trackings) + protected function _parseRestResponse($rateResponse) { - $url = $this->getConfigData('tracking_url'); - $accessToken = $this->setAPIAccessRequest(); + $costArr = []; + $priceArr = []; + $errorTitle = ''; + if ($rateResponse !== null && strlen($rateResponse) > 0) { + $rateResponseData = json_decode($rateResponse, true); + if ($rateResponseData['RateResponse']['Response']['ResponseStatus']['Description'] === 'Success') { + $arr = $rateResponseData['RateResponse']['RatedShipment'] ?? []; + $allowedMethods = explode(",", $this->getConfigData('allowed_methods') ?? ''); - /** @var HttpResponseDeferredInterface[] $trackingResponses */ - $trackingResponses = []; - $tracking = ''; - $debugData = []; - foreach ($trackings as $tracking) { + $allowedCurrencies = $this->_currencyFactory->create()->getConfigAllowCurrencies(); + foreach ($arr as $shipElement) { + // Negotiated rates + $negotiatedArr = $shipElement['NegotiatedRateCharges'] ?? [] ; + $negotiatedActive = $this->getConfigFlag('negotiated_active') + && $this->getConfigData('shipper_number') + && !empty($negotiatedArr); + + $this->processShippingRestRateForItem( + $shipElement, + $allowedMethods, + $allowedCurrencies, + $costArr, + $priceArr, + $negotiatedActive + ); + } + } else { + $errorTitle = $rateResponseData['RateResponse']['Response']['ResponseStatus']['Description']; + $error = $this->_rateErrorFactory->create(); + $error->setCarrier('ups'); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setErrorMessage($this->getConfigData('specificerrmsg')); + } + } + + return $this->setRatePriceData($priceArr, $costArr, $errorTitle); + } + + /** + * Set Rate Response Price Data + * + * @param array $priceArr + * @param array $costArr + * @param string $errorTitle + * @return Result + */ + private function setRatePriceData($priceArr, $costArr, $errorTitle) + { + $result = $this->_rateFactory->create(); + + if (empty($priceArr)) { + $error = $this->_rateErrorFactory->create(); + $error->setCarrier('ups'); + $error->setCarrierTitle($this->getConfigData('title')); + if ($this->getConfigData('specificerrmsg') !== '') { + $errorTitle = $this->getConfigData('specificerrmsg'); + } + $error->setErrorMessage($errorTitle); + $result->append($error); + } else { + foreach ($priceArr as $method => $price) { + $rate = $this->_rateMethodFactory->create(); + $rate->setCarrier('ups'); + $rate->setCarrierTitle($this->getConfigData('title')); + $rate->setMethod($method); + $methodArr = $this->getShipmentByCode($method); + $rate->setMethodTitle($methodArr); + $rate->setCost($costArr[$method]); + $rate->setPrice($price); + $result->append($rate); + } + } + + return $result; + } + + /** + * Processing rate for ship element + * + * @param array $shipElement + * @param array $allowedMethods + * @param array $allowedCurrencies + * @param array $costArr + * @param array $priceArr + * @param bool $negotiatedActive + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + private function processShippingRestRateForItem( + array $shipElement, + array $allowedMethods, + array $allowedCurrencies, + array &$costArr, + array &$priceArr, + bool $negotiatedActive + ): void { + $code = $shipElement['Service']['Code'] ?? ''; + if (in_array($code, $allowedMethods)) { + //The location of tax information is in a different place + // depending on whether we are using negotiated rates or not + if ($negotiatedActive) { + $includeTaxesArr = $shipElement['NegotiatedRateCharges']['TotalChargesWithTaxes'] ?? []; + $includeTaxesActive = $this->getConfigFlag('include_taxes') && !empty($includeTaxesArr); + if ($includeTaxesActive) { + $cost = $shipElement['NegotiatedRateCharges']['TotalChargesWithTaxes']['MonetaryValue']; + + $responseCurrencyCode = $this->mapCurrencyCode( + (string)$shipElement['NegotiatedRateCharges']['TotalChargesWithTaxes']['CurrencyCode'] + ); + } else { + $cost = $shipElement['NegotiatedRateCharges']['TotalCharge']['MonetaryValue']; + $responseCurrencyCode = $this->mapCurrencyCode( + (string)$shipElement['NegotiatedRateCharges']['TotalCharge']['CurrencyCode'] + ); + } + } else { + $includeTaxesArr = $shipElement['TotalChargesWithTaxes'] ?? []; + $includeTaxesActive = $this->getConfigFlag('include_taxes') && !empty($includeTaxesArr); + if ($includeTaxesActive) { + $cost = $shipElement['TotalChargesWithTaxes']['MonetaryValue']; + $responseCurrencyCode = $this->mapCurrencyCode( + (string)$shipElement['TotalChargesWithTaxes']['CurrencyCode'] + ); + } else { + $cost = $shipElement['TotalCharges']['MonetaryValue']; + $responseCurrencyCode = $this->mapCurrencyCode( + (string)$shipElement['TotalCharges']['CurrencyCode'] + ); + } + } + + //convert price with Origin country currency code to base currency code + $successConversion = true; + if ($responseCurrencyCode) { + if (in_array($responseCurrencyCode, $allowedCurrencies)) { + $cost = (double)$cost * $this->_getBaseCurrencyRate($responseCurrencyCode); + } else { + $errorTitle = __( + 'We can\'t convert a rate from "%1-%2".', + $responseCurrencyCode, + $this->_request->getPackageCurrency()->getCode() + ); + $error = $this->_rateErrorFactory->create(); + $error->setCarrier('ups'); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setErrorMessage($errorTitle); + $successConversion = false; + } + } + + if ($successConversion) { + $costArr[$code] = $cost; + $priceArr[$code] = $this->getMethodPrice((float)$cost, $code); + } + } + } + + /** + * Get final price for shipping method with handling fee per package + * + * @param float $cost + * @param string $handlingType + * @param float $handlingFee + * @return float + */ + protected function _getPerpackagePrice($cost, $handlingType, $handlingFee) + { + if ($handlingType == AbstractCarrier::HANDLING_TYPE_PERCENT) { + return $cost + $cost * $this->_numBoxes * $handlingFee / 100; + } + + return $cost + $this->_numBoxes * $handlingFee; + } + + /** + * Get final price for shipping method with handling fee per order + * + * @param float $cost + * @param string $handlingType + * @param float $handlingFee + * @return float + */ + protected function _getPerorderPrice($cost, $handlingType, $handlingFee) + { + if ($handlingType == self::HANDLING_TYPE_PERCENT) { + return $cost + $cost * $handlingFee / 100; + } + + return $cost + $handlingFee; + } + + /** + * Get tracking + * + * @param string|string[] $trackings + * @return Result + */ + public function getTracking($trackings) + { + if (!is_array($trackings)) { + $trackings = [$trackings]; + } + + if ($this->getConfigData('type') == 'UPS') { + $this->_getCgiTracking($trackings); + } elseif ($this->getConfigData('type') == 'UPS_XML') { + $this->setXMLAccessRequest(); + $this->_getXmlTracking($trackings); + } elseif ($this->getConfigData('type') == 'UPS_REST') { + $this->_getRestTracking($trackings); + } + + return $this->_result; + } + + /** + * Set xml access request + * + * @return void + */ + protected function setXMLAccessRequest() + { + $userId = $this->getConfigData('username'); + $userIdPass = $this->getConfigData('password'); + $accessKey = $this->getConfigData('access_license_number'); + + $this->_xmlAccessRequest = <<<XMLAuth +<?xml version="1.0" ?> +<AccessRequest xml:lang="en-US"> + <AccessLicenseNumber>$accessKey</AccessLicenseNumber> + <UserId>$userId</UserId> + <Password>$userIdPass</Password> +</AccessRequest> +XMLAuth; + } + + /** + * To receive access token + * + * @return mixed + * @throws LocalizedException + */ + protected function setAPIAccessRequest() + { + $userId = $this->getConfigData('username'); + $userIdPass = $this->getConfigData('password'); + if ($this->getConfigData('is_account_live')) { + $authUrl = $this->_liveUrls['AuthUrl']; + } else { + $authUrl = $this->_defaultUrls['AuthUrl']; + } + return $this->upsAuth->getAccessToken($userId, $userIdPass, $authUrl); + } + + /** + * Get cgi tracking + * + * @param string[] $trackings + * @return TrackFactory + */ + protected function _getCgiTracking($trackings) + { + //ups no longer support tracking for data streaming version + //so we can only reply the popup window to ups. + $result = $this->_trackFactory->create(); + foreach ($trackings as $tracking) { + $status = $this->_trackStatusFactory->create(); + $status->setCarrier('ups'); + $status->setCarrierTitle($this->getConfigData('title')); + $status->setTracking($tracking); + $status->setPopup(1); + $status->setUrl( + "http://wwwapps.ups.com/WebTracking/processInputRequest?HTMLVersion=5.0&error_carried=true" . + "&tracknums_displayed=5&TypeOfInquiryNumber=T&loc=en_US&InquiryNumber1={$tracking}" . + "&AgreeToTermsAndConditions=yes" + ); + $result->append($status); + } + + $this->_result = $result; + + return $result; + } + + /** + * Get xml tracking + * + * @param string[] $trackings + * @return Result + */ + protected function _getXmlTracking($trackings) + { + $url = $this->getConfigData('tracking_url'); + + /** @var HttpResponseDeferredInterface[] $trackingResponses */ + $trackingResponses = []; + $tracking = ''; + $debugData = []; + foreach ($trackings as $tracking) { + /** + * RequestOption==>'1' to request all activities + */ + $xmlRequest = <<<XMLAuth +<?xml version="1.0" ?> +<TrackRequest xml:lang="en-US"> + <IncludeMailInnovationIndicator/> + <Request> + <RequestAction>Track</RequestAction> + <RequestOption>1</RequestOption> + </Request> + <TrackingNumber>$tracking</TrackingNumber> + <IncludeFreight>01</IncludeFreight> +</TrackRequest> +XMLAuth; + $debugData[$tracking] = ['request' => $this->filterDebugData($this->_xmlAccessRequest) . $xmlRequest]; + $trackingResponses[$tracking] = $this->asyncHttpClient->request( + new Request( + $url, + Request::METHOD_POST, + ['Content-Type' => 'application/xml'], + $this->_xmlAccessRequest . $xmlRequest + ) + ); + } + foreach ($trackingResponses as $tracking => $response) { + $httpResponse = $response->get(); + $xmlResponse = $httpResponse->getStatusCode() >= 400 ? '' : $httpResponse->getBody(); + + $debugData[$tracking]['result'] = $xmlResponse; + $this->_debug($debugData); + $this->_parseXmlTrackingResponse($tracking, $xmlResponse); + } + + return $this->_result; + } + + /** + * Parse xml tracking response + * + * @param string $trackingValue + * @param string $xmlResponse + * @return null + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function _parseXmlTrackingResponse($trackingValue, $xmlResponse) + { + $errorTitle = 'For some reason we can\'t retrieve tracking info right now.'; + $resultArr = []; + $packageProgress = []; + + if ($xmlResponse) { + $xml = new \Magento\Framework\Simplexml\Config(); + $xml->loadString($xmlResponse); + $arr = $xml->getXpath("//TrackResponse/Response/ResponseStatusCode/text()"); + $success = (int)$arr[0][0]; + + if ($success === 1) { + $arr = $xml->getXpath("//TrackResponse/Shipment/Service/Description/text()"); + $resultArr['service'] = (string)$arr[0]; + + $arr = $xml->getXpath("//TrackResponse/Shipment/PickupDate/text()"); + $resultArr['shippeddate'] = (string)$arr[0]; + + $arr = $xml->getXpath("//TrackResponse/Shipment/Package/PackageWeight/Weight/text()"); + $weight = (string)$arr[0]; + + $arr = $xml->getXpath("//TrackResponse/Shipment/Package/PackageWeight/UnitOfMeasurement/Code/text()"); + $unit = (string)$arr[0]; + + $resultArr['weight'] = "{$weight} {$unit}"; + + $activityTags = $xml->getXpath("//TrackResponse/Shipment/Package/Activity"); + if ($activityTags) { + $index = 1; + foreach ($activityTags as $activityTag) { + $this->processActivityXmlTagInfo($activityTag, $index, $resultArr, $packageProgress); + } + $resultArr['progressdetail'] = $packageProgress; + } + } else { + $arr = $xml->getXpath("//TrackResponse/Response/Error/ErrorDescription/text()"); + $errorTitle = (string)$arr[0][0]; + } + } + + return $this->setTrackingResultData($resultArr, $trackingValue, $errorTitle); + } + + /** + * Process tracking info from activity tag + * + * @param \Magento\Framework\Simplexml\Element $activityTag + * @param int $index + * @param array $resultArr + * @param array $packageProgress + */ + private function processActivityXmlTagInfo( + \Magento\Framework\Simplexml\Element $activityTag, + int &$index, + array &$resultArr, + array &$packageProgress + ) { + $addressArr = []; + if (isset($activityTag->ActivityLocation->Address->City)) { + $addressArr[] = (string)$activityTag->ActivityLocation->Address->City; + } + if (isset($activityTag->ActivityLocation->Address->StateProvinceCode)) { + $addressArr[] = (string)$activityTag->ActivityLocation->Address->StateProvinceCode; + } + if (isset($activityTag->ActivityLocation->Address->CountryCode)) { + $addressArr[] = (string)$activityTag->ActivityLocation->Address->CountryCode; + } + $dateArr = []; + $date = (string)$activityTag->Date; + //YYYYMMDD + $dateArr[] = substr($date, 0, 4); + $dateArr[] = substr($date, 4, 2); + $dateArr[] = substr($date, -2, 2); + + $timeArr = []; + $time = (string)$activityTag->Time; + //HHMMSS + $timeArr[] = substr($time, 0, 2); + $timeArr[] = substr($time, 2, 2); + $timeArr[] = substr($time, -2, 2); + + if ($index === 1) { + $resultArr['status'] = (string)$activityTag->Status->StatusType->Description; + $resultArr['deliverydate'] = implode('-', $dateArr); + //YYYY-MM-DD + $resultArr['deliverytime'] = implode(':', $timeArr); + //HH:MM:SS + $resultArr['deliverylocation'] = (string)$activityTag->ActivityLocation->Description; + $resultArr['signedby'] = (string)$activityTag->ActivityLocation->SignedForByName; + if ($addressArr) { + $resultArr['deliveryto'] = implode(', ', $addressArr); + } + } else { + $tempArr = []; + $tempArr['activity'] = (string)$activityTag->Status->StatusType->Description; + $tempArr['deliverydate'] = implode('-', $dateArr); + //YYYY-MM-DD + $tempArr['deliverytime'] = implode(':', $timeArr); + //HH:MM:SS + if ($addressArr) { + $tempArr['deliverylocation'] = implode(', ', $addressArr); + } + $packageProgress[] = $tempArr; + } + $index++; + } + + /** + * Get REST tracking + * + * @param string[] $trackings + * @return Result + */ + protected function _getRestTracking($trackings) + { + $url = $this->getConfigData('tracking_rest_url'); + $accessToken = $this->setAPIAccessRequest(); + + /** @var HttpResponseDeferredInterface[] $trackingResponses */ + $trackingResponses = []; + $tracking = ''; + $debugData = []; + foreach ($trackings as $tracking) { /** * RequestOption==>'1' to request all activities */ @@ -1002,195 +1732,785 @@ protected function _getRestTracking($trackings) "transactionSrc" => "testing" ]; - $debugData[$tracking] = ['request' => $trackPayload]; - $trackingResponses[$tracking] = $this->asyncHttpClient->request( + $debugData[$tracking] = ['request' => $trackPayload]; + $trackingResponses[$tracking] = $this->asyncHttpClient->request( + new Request( + $url.'v1/details/'. $tracking . "?" . http_build_query($queryParams), + Request::METHOD_GET, + $headers, + $trackPayload + ) + ); + } + foreach ($trackingResponses as $tracking => $response) { + $httpResponse = $response->get(); + $jsonResponse = $httpResponse->getStatusCode() >= 400 ? '' : $httpResponse->getBody(); + $debugData[$tracking]['result'] = $jsonResponse; + $this->_debug($debugData); + $this->_parseRestTrackingResponse($tracking, $jsonResponse); + } + + return $this->_result; + } + + /** + * Parse REST tracking response + * + * @param string $trackingValue + * @param string $jsonResponse + * @return null + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function _parseRestTrackingResponse($trackingValue, $jsonResponse) + { + $errorTitle = 'For some reason we can\'t retrieve tracking info right now.'; + $resultArr = []; + $packageProgress = []; + + if ($jsonResponse) { + $responseData = json_decode($jsonResponse, true); + + if ($responseData['trackResponse']['shipment']) { + $activityTags = $responseData['trackResponse']['shipment'][0]['package'][0]['activity'] ?? []; + if ($activityTags) { + $index = 1; + foreach ($activityTags as $activityTag) { + $this->processActivityRestTagInfo($activityTag, $index, $resultArr, $packageProgress); + } + $resultArr['progressdetail'] = $packageProgress; + } + } else { + $errorTitle = $responseData['errors']['message']; + } + } + + return $this->setTrackingResultData($resultArr, $trackingValue, $errorTitle); + } + + /** + * Set Tracking Response Data + * + * @param array $resultArr + * @param string $trackingValue + * @param string $errorTitle + * @return Result|\Magento\Shipping\Model\Tracking\Result + */ + private function setTrackingResultData($resultArr, $trackingValue, $errorTitle) + { + if (!$this->_result) { + $this->_result = $this->_trackFactory->create(); + } + + if ($resultArr) { + $tracking = $this->_trackStatusFactory->create(); + $tracking->setCarrier('ups'); + $tracking->setCarrierTitle($this->getConfigData('title')); + $tracking->setTracking($trackingValue); + $tracking->addData($resultArr); + $this->_result->append($tracking); + } else { + $error = $this->_trackErrorFactory->create(); + $error->setCarrier('ups'); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setTracking($trackingValue); + $error->setErrorMessage($errorTitle); + $this->_result->append($error); + } + + return $this->_result; + } + + /** + * Process tracking info from activity tag + * + * @param array $activityTag + * @param int $index + * @param array $resultArr + * @param array $packageProgress + */ + private function processActivityRestTagInfo( + array $activityTag, + int &$index, + array &$resultArr, + array &$packageProgress + ) { + $addressArr = []; + if (isset($activityTag['location']['address']['city'])) { + $addressArr[] = (string)$activityTag['location']['address']['city']; + } + if (isset($activityTag['location']['address']['stateProvince'])) { + $addressArr[] = (string)$activityTag['location']['address']['stateProvince']; + } + if (isset($activityTag['location']['address']['countryCode'])) { + $addressArr[] = (string)$activityTag['location']['address']['countryCode']; + } + $dateArr = []; + $date = (string)$activityTag['date']; + //YYYYMMDD + $dateArr[] = substr($date, 0, 4); + $dateArr[] = substr($date, 4, 2); + $dateArr[] = substr($date, -2, 2); + + $timeArr = []; + $time = (string)$activityTag['time']; + //HHMMSS + $timeArr[] = substr($time, 0, 2); + $timeArr[] = substr($time, 2, 2); + $timeArr[] = substr($time, -2, 2); + + if ($index === 1) { + $resultArr['status'] = (string)$activityTag['status']['description']; + $resultArr['deliverydate'] = implode('-', $dateArr); + //YYYY-MM-DD + $resultArr['deliverytime'] = implode(':', $timeArr); + //HH:MM:SS + if ($addressArr) { + $resultArr['deliveryto'] = implode(', ', $addressArr); + } + } else { + $tempArr = []; + $tempArr['activity'] = (string)$activityTag['status']['description']; + $tempArr['deliverydate'] = implode('-', $dateArr); + //YYYY-MM-DD + $tempArr['deliverytime'] = implode(':', $timeArr); + //HH:MM:SS + if ($addressArr) { + $tempArr['deliverylocation'] = implode(', ', $addressArr); + } + $packageProgress[] = $tempArr; + } + $index++; + } + + /** + * Get tracking response + * + * @return string + */ + public function getResponse() + { + $statuses = ''; + if ($this->_result instanceof \Magento\Shipping\Model\Tracking\Result) { + $trackings = $this->_result->getAllTrackings(); + if ($trackings) { + foreach ($trackings as $tracking) { + $data = $tracking->getAllData(); + if ($data) { + if (isset($data['status'])) { + $statuses .= __($data['status']); + } else { + $statuses .= __($data['error_message']); + } + } + } + } + } + + return $statuses ?: __('Empty response'); + } + + /** + * Get allowed shipping methods. + * + * @return array + */ + public function getAllowedMethods() + { + $allowedMethods = explode(',', (string)$this->getConfigData('allowed_methods')); + $isUpsXml = $this->getConfigData('type') === 'UPS_XML'; + $isUpsRest = $this->getConfigData('type') === 'UPS_REST'; + $origin = $this->getConfigData('origin_shipment'); + + $availableByTypeMethods = ($isUpsXml || $isUpsRest) + ? $this->configHelper->getCode('originShipment', $origin) + : $this->configHelper->getCode('method'); + + $methods = []; + foreach ($availableByTypeMethods as $methodCode => $methodData) { + if (in_array($methodCode, $allowedMethods)) { + $methods[$methodCode] = $methodData->getText(); + } + } + + return $methods; + } + + /** + * Form XML for shipment request + * + * @param DataObject $request + * @return string + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function _formShipmentRequest(DataObject $request) + { + $packages = $request->getPackages(); + $shipmentItems = []; + foreach ($packages as $package) { + $shipmentItems[] = $package['items']; + } + $shipmentItems = array_merge([], ...$shipmentItems); + + $xmlRequest = $this->_xmlElFactory->create( + ['data' => '<?xml version = "1.0" ?><ShipmentConfirmRequest xml:lang="en-US"/>'] + ); + $requestPart = $xmlRequest->addChild('Request'); + $requestPart->addChild('RequestAction', 'ShipConfirm'); + $requestPart->addChild('RequestOption', 'nonvalidate'); + + $shipmentPart = $xmlRequest->addChild('Shipment'); + if ($request->getIsReturn()) { + $returnPart = $shipmentPart->addChild('ReturnService'); + // UPS Print Return Label + $returnPart->addChild('Code', '9'); + } + $shipmentPart->addChild('Description', $this->generateShipmentDescription($shipmentItems)); + //empirical + + $shipperPart = $shipmentPart->addChild('Shipper'); + if ($request->getIsReturn()) { + $shipperPart->addChild('Name', $request->getRecipientContactCompanyName()); + $shipperPart->addChild('AttentionName', $request->getRecipientContactPersonName()); + $shipperPart->addChild('ShipperNumber', $this->getConfigData('shipper_number')); + $shipperPart->addChild('PhoneNumber', $request->getRecipientContactPhoneNumber()); + + $addressPart = $shipperPart->addChild('Address'); + $addressPart->addChild('AddressLine1', $request->getRecipientAddressStreet1()); + $addressPart->addChild('AddressLine2', $request->getRecipientAddressStreet2()); + $addressPart->addChild('City', $request->getRecipientAddressCity()); + $addressPart->addChild('CountryCode', $request->getRecipientAddressCountryCode()); + $addressPart->addChild('PostalCode', $request->getRecipientAddressPostalCode()); + if ($request->getRecipientAddressStateOrProvinceCode()) { + $addressPart->addChild('StateProvinceCode', $request->getRecipientAddressStateOrProvinceCode()); + } + } else { + $shipperPart->addChild('Name', $request->getShipperContactCompanyName()); + $shipperPart->addChild('AttentionName', $request->getShipperContactPersonName()); + $shipperPart->addChild('ShipperNumber', $this->getConfigData('shipper_number')); + $shipperPart->addChild('PhoneNumber', $request->getShipperContactPhoneNumber()); + + $addressPart = $shipperPart->addChild('Address'); + $addressPart->addChild('AddressLine1', $request->getShipperAddressStreet1()); + $addressPart->addChild('AddressLine2', $request->getShipperAddressStreet2()); + $addressPart->addChild('City', $request->getShipperAddressCity()); + $addressPart->addChild('CountryCode', $request->getShipperAddressCountryCode()); + $addressPart->addChild('PostalCode', $request->getShipperAddressPostalCode()); + if ($request->getShipperAddressStateOrProvinceCode()) { + $addressPart->addChild('StateProvinceCode', $request->getShipperAddressStateOrProvinceCode()); + } + } + + $shipToPart = $shipmentPart->addChild('ShipTo'); + $shipToPart->addChild('AttentionName', $request->getRecipientContactPersonName()); + $shipToPart->addChild( + 'CompanyName', + $request->getRecipientContactCompanyName() ? $request->getRecipientContactCompanyName() : 'N/A' + ); + $shipToPart->addChild('PhoneNumber', $request->getRecipientContactPhoneNumber()); + + $addressPart = $shipToPart->addChild('Address'); + $addressPart->addChild('AddressLine1', $request->getRecipientAddressStreet1()); + $addressPart->addChild('AddressLine2', $request->getRecipientAddressStreet2()); + $addressPart->addChild('City', $request->getRecipientAddressCity()); + $addressPart->addChild('CountryCode', $request->getRecipientAddressCountryCode()); + $addressPart->addChild('PostalCode', $request->getRecipientAddressPostalCode()); + if ($request->getRecipientAddressStateOrProvinceCode()) { + $addressPart->addChild('StateProvinceCode', $request->getRecipientAddressRegionCode()); + } + if ($this->getConfigData('dest_type') == 'RES') { + $addressPart->addChild('ResidentialAddress'); + } + + if ($request->getIsReturn()) { + $shipFromPart = $shipmentPart->addChild('ShipFrom'); + $shipFromPart->addChild('AttentionName', $request->getShipperContactPersonName()); + $shipFromPart->addChild( + 'CompanyName', + $request->getShipperContactCompanyName() ? $request + ->getShipperContactCompanyName() : $request + ->getShipperContactPersonName() + ); + $shipFromAddress = $shipFromPart->addChild('Address'); + $shipFromAddress->addChild('AddressLine1', $request->getShipperAddressStreet1()); + $shipFromAddress->addChild('AddressLine2', $request->getShipperAddressStreet2()); + $shipFromAddress->addChild('City', $request->getShipperAddressCity()); + $shipFromAddress->addChild('CountryCode', $request->getShipperAddressCountryCode()); + $shipFromAddress->addChild('PostalCode', $request->getShipperAddressPostalCode()); + if ($request->getShipperAddressStateOrProvinceCode()) { + $shipFromAddress->addChild('StateProvinceCode', $request->getShipperAddressStateOrProvinceCode()); + } + + $addressPart = $shipToPart->addChild('Address'); + $addressPart->addChild('AddressLine1', $request->getShipperAddressStreet1()); + $addressPart->addChild('AddressLine2', $request->getShipperAddressStreet2()); + $addressPart->addChild('City', $request->getShipperAddressCity()); + $addressPart->addChild('CountryCode', $request->getShipperAddressCountryCode()); + $addressPart->addChild('PostalCode', $request->getShipperAddressPostalCode()); + if ($request->getShipperAddressStateOrProvinceCode()) { + $addressPart->addChild('StateProvinceCode', $request->getShipperAddressStateOrProvinceCode()); + } + if ($this->getConfigData('dest_type') == 'RES') { + $addressPart->addChild('ResidentialAddress'); + } + } + + $servicePart = $shipmentPart->addChild('Service'); + $servicePart->addChild('Code', $request->getShippingMethod()); + + $packagePart = []; + $customsTotal = 0; + $packagingTypes = []; + $deliveryConfirmationLevel = $this->_getDeliveryConfirmationLevel( + $request->getRecipientAddressCountryCode() + ); + foreach ($packages as $packageId => $package) { + $packageItems = $package['items']; + $packageParams = new DataObject($package['params']); + $packagingType = $package['params']['container']; + $packagingTypes[] = $packagingType; + $height = $packageParams->getHeight(); + $width = $packageParams->getWidth(); + $length = $packageParams->getLength(); + $weight = $packageParams->getWeight(); + $weightUnits = $packageParams->getWeightUnits() == Weight::POUND ? 'LBS' : 'KGS'; + $dimensionsUnits = $packageParams->getDimensionUnits() == Length::INCH ? 'IN' : 'CM'; + $deliveryConfirmation = $packageParams->getDeliveryConfirmation(); + $customsTotal += $packageParams->getCustomsValue(); + + $packagePart[$packageId] = $shipmentPart->addChild('Package'); + $packagePart[$packageId]->addChild('Description', $this->generateShipmentDescription($packageItems)); + //empirical + $packagePart[$packageId]->addChild('PackagingType')->addChild('Code', $packagingType); + $packageWeight = $packagePart[$packageId]->addChild('PackageWeight'); + $packageWeight->addChild('Weight', $weight); + $packageWeight->addChild('UnitOfMeasurement')->addChild('Code', $weightUnits); + + // set dimensions + if ($length || $width || $height) { + $packageDimensions = $packagePart[$packageId]->addChild('Dimensions'); + $packageDimensions->addChild('UnitOfMeasurement')->addChild('Code', $dimensionsUnits); + $packageDimensions->addChild('Length', $length); + $packageDimensions->addChild('Width', $width); + $packageDimensions->addChild('Height', $height); + } + + // ups support reference number only for domestic service + if ($this->_isUSCountry($request->getRecipientAddressCountryCode()) + && $this->_isUSCountry($request->getShipperAddressCountryCode()) + ) { + if ($request->getReferenceData()) { + $referenceData = $request->getReferenceData() . $packageId; + } else { + $referenceData = 'Order #' . + $request->getOrderShipment()->getOrder()->getIncrementId() . + ' P' . + $packageId; + } + $referencePart = $packagePart[$packageId]->addChild('ReferenceNumber'); + $referencePart->addChild('Code', '02'); + $referencePart->addChild('Value', $referenceData); + } + + if ($deliveryConfirmation && $deliveryConfirmationLevel === self::DELIVERY_CONFIRMATION_PACKAGE) { + $serviceOptionsNode = $packagePart[$packageId]->addChild('PackageServiceOptions'); + $serviceOptionsNode + ->addChild('DeliveryConfirmation') + ->addChild('DCISType', $deliveryConfirmation); + } + } + + if (!empty($deliveryConfirmation) && $deliveryConfirmationLevel === self::DELIVERY_CONFIRMATION_SHIPMENT) { + $serviceOptionsNode = $shipmentPart->addChild('ShipmentServiceOptions'); + $serviceOptionsNode + ->addChild('DeliveryConfirmation') + ->addChild('DCISType', $deliveryConfirmation); + } + + $shipmentPart->addChild('PaymentInformation') + ->addChild('Prepaid') + ->addChild('BillShipper') + ->addChild('AccountNumber', $this->getConfigData('shipper_number')); + + if (!in_array($this->configHelper->getCode('container', 'ULE'), $packagingTypes) + && $request->getShipperAddressCountryCode() == self::USA_COUNTRY_ID + && ($request->getRecipientAddressCountryCode() == 'CA' + || $request->getRecipientAddressCountryCode() == 'PR') + ) { + $invoiceLineTotalPart = $shipmentPart->addChild('InvoiceLineTotal'); + $invoiceLineTotalPart->addChild('CurrencyCode', $request->getBaseCurrencyCode()); + $invoiceLineTotalPart->addChild('MonetaryValue', ceil($customsTotal)); + } + + $labelPart = $xmlRequest->addChild('LabelSpecification'); + $labelPart->addChild('LabelPrintMethod')->addChild('Code', 'GIF'); + $labelPart->addChild('LabelImageFormat')->addChild('Code', 'GIF'); + + return $xmlRequest->asXml(); + } + + /** + * Generates shipment description. + * + * @param array $items + * @return string + */ + private function generateShipmentDescription(array $items): string + { + $itemsDesc = []; + $itemsShipment = $items; + foreach ($itemsShipment as $itemShipment) { + $item = new \Magento\Framework\DataObject(); + $item->setData($itemShipment); + $itemsDesc[] = $item->getName(); + } + + return substr(implode(' ', $itemsDesc), 0, 35); + } + + /** + * Send and process shipment accept request + * + * @param Element $shipmentConfirmResponse + * @return DataObject + * @deprecated 100.3.3 New asynchronous methods introduced. + * @see requestToShipment + */ + protected function _sendShipmentAcceptRequest(Element $shipmentConfirmResponse) + { + $xmlRequest = $this->_xmlElFactory->create( + ['data' => '<?xml version = "1.0" ?><ShipmentAcceptRequest/>'] + ); + $request = $xmlRequest->addChild('Request'); + $request->addChild('RequestAction', 'ShipAccept'); + $xmlRequest->addChild('ShipmentDigest', $shipmentConfirmResponse->ShipmentDigest); + $debugData = ['request' => $this->filterDebugData($this->_xmlAccessRequest) . $xmlRequest->asXML()]; + + try { + $deferredResponse = $this->asyncHttpClient->request( new Request( - $url.'v1/details/'. $tracking . "?" . http_build_query($queryParams), - Request::METHOD_GET, - $headers, - $trackPayload + $this->getShipAcceptUrl(), + Request::METHOD_POST, + ['Content-Type' => 'application/xml'], + $this->_xmlAccessRequest . $xmlRequest->asXML() ) ); + $xmlResponse = $deferredResponse->get()->getBody(); + $debugData['result'] = $xmlResponse; + $this->_setCachedQuotes($xmlRequest, $xmlResponse); + } catch (Throwable $e) { + $debugData['result'] = ['error' => $e->getMessage(), 'code' => $e->getCode()]; + $xmlResponse = ''; } - foreach ($trackingResponses as $tracking => $response) { - $httpResponse = $response->get(); - $jsonResponse = $httpResponse->getStatusCode() >= 400 ? '' : $httpResponse->getBody(); - $debugData[$tracking]['result'] = $jsonResponse; - $this->_debug($debugData); - $this->_parseRestTrackingResponse($tracking, $jsonResponse); + + $response = ''; + try { + $response = $this->_xmlElFactory->create(['data' => $xmlResponse]); + } catch (Throwable $e) { + $response = $this->_xmlElFactory->create(['data' => '']); + $debugData['result'] = ['error' => $e->getMessage(), 'code' => $e->getCode()]; + } + + $result = new DataObject(); + if (isset($response->Error)) { + $result->setErrors((string)$response->Error->ErrorDescription); + } else { + $shippingLabelContent = (string)$response->ShipmentResults->PackageResults->LabelImage->GraphicImage; + $trackingNumber = (string)$response->ShipmentResults->PackageResults->TrackingNumber; + + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $result->setShippingLabelContent(base64_decode($shippingLabelContent)); + $result->setTrackingNumber($trackingNumber); + } + + $this->_debug($debugData); + + return $result; + } + + /** + * Get ship accept url + * + * @return string + */ + public function getShipAcceptUrl() + { + if ($this->getConfigData('is_account_live')) { + $url = $this->_liveUrls['ShipAccept']; + } else { + $url = $this->_defaultUrls['ShipAccept']; + } + + return $url; + } + + /** + * Request quotes for given packages. + * + * @param DataObject $request + * @return string[] Quote IDs. + * @throws LocalizedException + * @throws RuntimeException + */ + private function requestQuotes(DataObject $request): array + { + $request->setShipperAddressCountryCode( + $this->getNormalizedCountryCode( + $request->getShipperAddressCountryCode(), + $request->getShipperAddressStateOrProvinceCode(), + $request->getShipperAddressPostalCode(), + ) + ); + + $request->setRecipientAddressCountryCode( + $this->getNormalizedCountryCode( + $request->getRecipientAddressCountryCode(), + $request->getRecipientAddressStateOrProvinceCode(), + $request->getRecipientAddressPostalCode(), + ) + ); + + /** @var HttpResponseDeferredInterface[] $quotesRequests */ + //Getting quotes + $this->_prepareShipmentRequest($request); + $rawXmlRequest = $this->_formShipmentRequest($request); + $this->setXMLAccessRequest(); + $xmlRequest = $this->_xmlAccessRequest . $rawXmlRequest; + $this->_debug(['request_quote' => $this->filterDebugData($this->_xmlAccessRequest) . $rawXmlRequest]); + $quotesRequests[] = $this->asyncHttpClient->request( + new Request( + $this->getShipConfirmUrl(), + Request::METHOD_POST, + ['Content-Type' => 'application/xml'], + $xmlRequest + ) + ); + + $ids = []; + //Processing quote responses + foreach ($quotesRequests as $quotesRequest) { + $httpResponse = $quotesRequest->get(); + if ($httpResponse->getStatusCode() >= 400) { + throw new LocalizedException(__('Failed to get the quote')); + } + try { + /** @var Element $response */ + $response = $this->_xmlElFactory->create(['data' => $httpResponse->getBody()]); + $this->_debug(['response_quote' => $response]); + } catch (Throwable $e) { + throw new RuntimeException($e->getMessage()); + } + if (isset($response->Response->Error) + && in_array($response->Response->Error->ErrorSeverity, ['Hard', 'Transient']) + ) { + throw new RuntimeException((string)$response->Response->Error->ErrorDescription); + } + + $ids[] = $response->ShipmentDigest; + } + + return $ids; + } + + /** + * Request UPS to ship items based on quotes. + * + * @param string[] $quoteIds + * @return DataObject[] + * @throws LocalizedException + * @throws RuntimeException + */ + private function requestShipments(array $quoteIds): array + { + /** @var HttpResponseDeferredInterface[] $shippingRequests */ + $shippingRequests = []; + foreach ($quoteIds as $quoteId) { + /** @var Element $xmlRequest */ + $xmlRequest = $this->_xmlElFactory->create( + ['data' => '<?xml version = "1.0" ?><ShipmentAcceptRequest/>'] + ); + $request = $xmlRequest->addChild('Request'); + $request->addChild('RequestAction', 'ShipAccept'); + $xmlRequest->addChild('ShipmentDigest', $quoteId); + + $debugRequest = $this->filterDebugData($this->_xmlAccessRequest) . $xmlRequest->asXml(); + $this->_debug( + [ + 'request_shipment' => $debugRequest + ] + ); + $shippingRequests[] = $this->asyncHttpClient->request( + new Request( + $this->getShipAcceptUrl(), + Request::METHOD_POST, + ['Content-Type' => 'application/xml'], + $this->_xmlAccessRequest . $xmlRequest->asXml() + ) + ); + } + //Processing shipment requests + /** @var DataObject[] $results */ + $results = []; + foreach ($shippingRequests as $shippingRequest) { + $httpResponse = $shippingRequest->get(); + if ($httpResponse->getStatusCode() >= 400) { + throw new LocalizedException(__('Failed to send the package')); + } + try { + /** @var Element $response */ + $response = $this->_xmlElFactory->create(['data' => $httpResponse->getBody()]); + $this->_debug(['response_shipment' => $response]); + } catch (Throwable $e) { + throw new RuntimeException($e->getMessage()); + } + if (isset($response->Error)) { + throw new RuntimeException((string)$response->Error->ErrorDescription); + } + + foreach ($response->ShipmentResults->PackageResults as $packageResult) { + $result = new DataObject(); + $shippingLabelContent = (string)$packageResult->LabelImage->GraphicImage; + $trackingNumber = (string)$packageResult->TrackingNumber; + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $result->setLabelContent(base64_decode($shippingLabelContent)); + $result->setTrackingNumber($trackingNumber); + $results[] = $result; + } } - return $this->_result; + return $results; } /** - * Parse REST tracking response + * Do shipment request to carrier web service, obtain Print Shipping Labels and process errors in response * - * @param string $trackingValue - * @param string $jsonResponse - * @return null - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @param DataObject $request + * @return DataObject + * @deprecated 100.3.3 New asynchronous methods introduced. + * @see requestToShipment */ - protected function _parseRestTrackingResponse($trackingValue, $jsonResponse) + protected function _doShipmentRequest(DataObject $request) { - $errorTitle = 'For some reason we can\'t retrieve tracking info right now.'; - $resultArr = []; - $packageProgress = []; - - if ($jsonResponse) { - $responseData = json_decode($jsonResponse, true); + $this->_prepareShipmentRequest($request); + $result = new DataObject(); + $rawXmlRequest = $this->_formShipmentRequest($request); + $this->setXMLAccessRequest(); + $xmlRequest = $this->_xmlAccessRequest . $rawXmlRequest; + $xmlResponse = $this->_getCachedQuotes($xmlRequest); + $debugData = []; - if ($responseData['trackResponse']['shipment']) { - $activityTags = $responseData['trackResponse']['shipment'][0]['package'][0]['activity'] ?? []; - if ($activityTags) { - $index = 1; - foreach ($activityTags as $activityTag) { - $this->processActivityTagInfo($activityTag, $index, $resultArr, $packageProgress); - } - $resultArr['progressdetail'] = $packageProgress; - } - } else { - $errorTitle = $responseData['errors']['message']; + if ($xmlResponse === null) { + $debugData['request'] = $this->filterDebugData($this->_xmlAccessRequest) . $rawXmlRequest; + $url = $this->getShipConfirmUrl(); + try { + $deferredResponse = $this->asyncHttpClient->request( + new Request( + $url, + Request::METHOD_POST, + ['Content-Type' => 'application/xml'], + $xmlRequest + ) + ); + $xmlResponse = $deferredResponse->get()->getBody(); + $debugData['result'] = $xmlResponse; + $this->_setCachedQuotes($xmlRequest, $xmlResponse); + } catch (Throwable $e) { + $debugData['result'] = ['code' => $e->getCode(), 'error' => $e->getMessage()]; } } - if (!$this->_result) { - $this->_result = $this->_trackFactory->create(); - } - - if ($resultArr) { - $tracking = $this->_trackStatusFactory->create(); - $tracking->setCarrier('ups'); - $tracking->setCarrierTitle($this->getConfigData('title')); - $tracking->setTracking($trackingValue); - $tracking->addData($resultArr); - $this->_result->append($tracking); - } else { - $error = $this->_trackErrorFactory->create(); - $error->setCarrier('ups'); - $error->setCarrierTitle($this->getConfigData('title')); - $error->setTracking($trackingValue); - $error->setErrorMessage($errorTitle); - $this->_result->append($error); + try { + $response = $this->_xmlElFactory->create(['data' => $xmlResponse]); + } catch (Throwable $e) { + $debugData['result'] = ['error' => $e->getMessage(), 'code' => $e->getCode()]; + $result->setErrors($e->getMessage()); } - return $this->_result; - } - - /** - * Process tracking info from activity tag - * - * @param array $activityTag - * @param int $index - * @param array $resultArr - * @param array $packageProgress - */ - private function processActivityTagInfo( - array $activityTag, - int &$index, - array &$resultArr, - array &$packageProgress - ) { - $addressArr = []; - if (isset($activityTag['location']['address']['city'])) { - $addressArr[] = (string)$activityTag['location']['address']['city']; - } - if (isset($activityTag['location']['address']['stateProvince'])) { - $addressArr[] = (string)$activityTag['location']['address']['stateProvince']; - } - if (isset($activityTag['location']['address']['countryCode'])) { - $addressArr[] = (string)$activityTag['location']['address']['countryCode']; + if (isset($response->Response->Error) + && in_array($response->Response->Error->ErrorSeverity, ['Hard', 'Transient']) + ) { + $result->setErrors((string)$response->Response->Error->ErrorDescription); } - $dateArr = []; - $date = (string)$activityTag['date']; - //YYYYMMDD - $dateArr[] = substr($date, 0, 4); - $dateArr[] = substr($date, 4, 2); - $dateArr[] = substr($date, -2, 2); - $timeArr = []; - $time = (string)$activityTag['time']; - //HHMMSS - $timeArr[] = substr($time, 0, 2); - $timeArr[] = substr($time, 2, 2); - $timeArr[] = substr($time, -2, 2); + $this->_debug($debugData); - if ($index === 1) { - $resultArr['status'] = (string)$activityTag['status']['description']; - $resultArr['deliverydate'] = implode('-', $dateArr); - //YYYY-MM-DD - $resultArr['deliverytime'] = implode(':', $timeArr); - //HH:MM:SS - if ($addressArr) { - $resultArr['deliveryto'] = implode(', ', $addressArr); - } + if ($result->hasErrors() || empty($response)) { + return $result; } else { - $tempArr = []; - $tempArr['activity'] = (string)$activityTag['status']['description']; - $tempArr['deliverydate'] = implode('-', $dateArr); - //YYYY-MM-DD - $tempArr['deliverytime'] = implode(':', $timeArr); - //HH:MM:SS - if ($addressArr) { - $tempArr['deliverylocation'] = implode(', ', $addressArr); - } - $packageProgress[] = $tempArr; + return $this->_sendShipmentAcceptRequest($response); } - $index++; } /** - * Get tracking response + * Get ship confirm url * * @return string */ - public function getResponse() + public function getShipConfirmUrl() { - $statuses = ''; - if ($this->_result instanceof \Magento\Shipping\Model\Tracking\Result) { - $trackings = $this->_result->getAllTrackings(); - if ($trackings) { - foreach ($trackings as $tracking) { - $data = $tracking->getAllData(); - if ($data) { - if (isset($data['status'])) { - $statuses .= __($data['status']); - } else { - $statuses .= __($data['error_message']); - } - } + $url = $this->getConfigData('url'); + if (!$url) { + if ($this->getConfigData('is_account_live')) { + if ($this->getConfigData('type') == 'UPS_XML') { + $url = $this->_liveUrls['ShipConfirm']; + } else { + $url = $this->_liveUrls['ShipRestConfirm']; } + + return $url; + } else { + if ($this->getConfigData('type') == 'UPS_XML') { + $url = $this->_defaultUrls['ShipConfirm']; + } else { + $url = $this->_defaultUrls['ShipRestConfirm']; + } + + return $url; } } - return $statuses ?: __('Empty response'); + return $url; } /** - * Get allowed shipping methods. - * - * @return array + * @inheritDoc */ - public function getAllowedMethods() + public function requestToShipment($request) { - $allowedMethods = explode(',', (string)$this->getConfigData('allowed_methods')); - $origin = $this->getConfigData('origin_shipment'); - - $availableByTypeMethods = $this->configHelper->getCode('originShipment', $origin); + $packages = $request->getPackages(); + if (!is_array($packages) || !$packages) { + throw new LocalizedException(__('No packages for request')); + } + if ($request->getStoreId() != null) { + $this->setStore($request->getStoreId()); + } - $methods = []; - foreach ($availableByTypeMethods as $methodCode => $methodData) { - if (in_array($methodCode, $allowedMethods)) { - $methods[$methodCode] = $methodData->getText(); + // phpcs:disable + try { + $labels = ''; + if ($this->getConfigData('type') == 'UPS_XML') { + $quoteIds = $this->requestQuotes($request); + $labels = $this->requestShipments($quoteIds); + } elseif ($this->getConfigData('type') == 'UPS_REST') { + $labels = $this->requestRestQuotes($request); } + + } catch (LocalizedException $exception) { + $this->_logger->critical($exception); + return new DataObject(['errors' => [$exception->getMessage()]]); + } catch (RuntimeException $exception) { + $this->_logger->critical($exception); + return new DataObject(['errors' => __('Failed to send items')]); } + // phpcs:enable - return $methods; + return new DataObject(['info' => $labels]); } /** - * Form XML for shipment request + * Form REST for shipment request * * @param DataObject $request * @return string @@ -1198,7 +2518,7 @@ public function getAllowedMethods() * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - protected function _formShipmentRequest(DataObject $request) + protected function _formShipmentRestRequest(DataObject $request) { $packages = $request->getPackages(); $shipmentItems = []; @@ -1330,21 +2650,21 @@ protected function _formShipmentRequest(DataObject $request) $request->getRecipientAddressCountryCode() ); foreach ($packages as $packageId => $package) { - $packageItems = $package['items']; - $packageParams = new DataObject($package['params']); + $packageRestItems = $package['items']; + $packageRestParams = new DataObject($package['params']); $packagingType = $package['params']['container']; $packagingTypes[] = $packagingType; - $height = $packageParams->getHeight(); - $width = $packageParams->getWidth(); - $length = $packageParams->getLength(); - $weight = $packageParams->getWeight(); - $weightUnits = $packageParams->getWeightUnits() == Weight::POUND ? 'LBS' : 'KGS'; - $dimensionsUnits = $packageParams->getDimensionUnits() == Length::INCH ? 'IN' : 'CM'; - $deliveryConfirmation = $packageParams->getDeliveryConfirmation(); - $customsTotal += $packageParams->getCustomsValue(); + $height = $packageRestParams->getHeight(); + $width = $packageRestParams->getWidth(); + $length = $packageRestParams->getLength(); + $weight = $packageRestParams->getWeight(); + $weightUnits = $packageRestParams->getWeightUnits() == Weight::POUND ? 'LBS' : 'KGS'; + $dimensionsUnits = $packageRestParams->getDimensionUnits() == Length::INCH ? 'IN' : 'CM'; + $deliveryConfirmation = $packageRestParams->getDeliveryConfirmation(); + $customsTotal += $packageRestParams->getCustomsValue(); $packagePart[$packageId] = &$shipParams['ShipmentRequest']['Shipment']['Package']; - $packagePart[$packageId]['Description'] = $this->generateShipmentDescription($packageItems); + $packagePart[$packageId]['Description'] = $this->generateShipmentDescription($packageRestItems); //empirical $packagePart[$packageId]['Packaging']['Code'] = $packagingType; $packagePart[$packageId]['PackageWeight'] = []; @@ -1410,25 +2730,6 @@ protected function _formShipmentRequest(DataObject $request) return json_encode($shipParams); } - /** - * Generates shipment description. - * - * @param array $items - * @return string - */ - private function generateShipmentDescription(array $items): string - { - $itemsDesc = []; - $itemsShipment = $items; - foreach ($itemsShipment as $itemShipment) { - $item = new \Magento\Framework\DataObject(); - $item->setData($itemShipment); - $itemsDesc[] = $item->getName(); - } - - return substr(implode(' ', $itemsDesc), 0, 35); - } - /** * Request quotes for given packages. * @@ -1437,7 +2738,7 @@ private function generateShipmentDescription(array $items): string * @throws LocalizedException * @throws RuntimeException */ - private function requestQuotes(DataObject $request): array + private function requestRestQuotes(DataObject $request): array { $request->setShipperAddressCountryCode( $this->getNormalizedCountryCode( @@ -1458,7 +2759,7 @@ private function requestQuotes(DataObject $request): array /** @var HttpResponseDeferredInterface[] $quotesRequests */ //Getting quotes $this->_prepareShipmentRequest($request); - $rawJsonRequest = $this->_formShipmentRequest($request); + $rawJsonRequest = $this->_formShipmentRestRequest($request); $accessToken = $this->setAPIAccessRequest(); $this->_debug(['request_quote' => $rawJsonRequest]); $headers = [ @@ -1509,57 +2810,6 @@ private function requestQuotes(DataObject $request): array return $results; } - /** - * Get ship confirm url - * - * @return string - */ - public function getShipConfirmUrl() - { - $url = $this->getConfigData('url'); - if (!$url) { - if ($this->getConfigData('is_account_live')) { - $url = $this->_liveUrls['ShipConfirm']; - - return $url; - } else { - $url = $this->_defaultUrls['ShipConfirm']; - - return $url; - } - } - - return $url; - } - - /** - * @inheritDoc - */ - public function requestToShipment($request) - { - $packages = $request->getPackages(); - if (!is_array($packages) || !$packages) { - throw new LocalizedException(__('No packages for request')); - } - if ($request->getStoreId() != null) { - $this->setStore($request->getStoreId()); - } - - // phpcs:disable - try { - $labels = $this->requestQuotes($request); - } catch (LocalizedException $exception) { - $this->_logger->critical($exception); - return new DataObject(['errors' => [$exception->getMessage()]]); - } catch (RuntimeException $exception) { - $this->_logger->critical($exception); - return new DataObject(['errors' => __('Failed to send items')]); - } - // phpcs:enable - - return new DataObject(['info' => $labels]); - } - /** * @inheritDoc */ @@ -1737,14 +2987,4 @@ private function createPackages(float $totalWeight, array $packages): array return $packages; } - - /** - * @inheritDoc - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * phpcs:disable - */ - protected function _doShipmentRequest(\Magento\Framework\DataObject $request) - { - return ''; //This method has kept empty as not required. - } } diff --git a/app/code/Magento/Ups/Model/Config/Source/Type.php b/app/code/Magento/Ups/Model/Config/Source/Type.php new file mode 100644 index 000000000000..34e68858dcb7 --- /dev/null +++ b/app/code/Magento/Ups/Model/Config/Source/Type.php @@ -0,0 +1,39 @@ +<?php +/************************************************************************ + * + * ADOBE CONFIDENTIAL + * ___________________ + * + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + * ************************************************************************ + */ +declare(strict_types=1); + +namespace Magento\Ups\Model\Config\Source; + +use Magento\Framework\Data\OptionSourceInterface; + +class Type implements OptionSourceInterface +{ + /** + * @inheritdoc + */ + public function toOptionArray() + { + return [ + ['value' => 'UPS', 'label' => __('United Parcel Service')], + ['value' => 'UPS_XML', 'label' => __('United Parcel Service XML')], + ['value' => 'UPS_REST', 'label' => __('United Parcel Service REST')] + ]; + } +} diff --git a/app/code/Magento/Ups/Model/UpsAuth.php b/app/code/Magento/Ups/Model/UpsAuth.php index 5337d92bf9fb..e85c6b1aaf92 100644 --- a/app/code/Magento/Ups/Model/UpsAuth.php +++ b/app/code/Magento/Ups/Model/UpsAuth.php @@ -115,6 +115,6 @@ public function getAccessToken($clientId, $clientSecret, $clientUrl) */ public function collectRates(RateRequest $request) { - return ''; // This method has kept empty as not required. + return ''; // This block is empty as not required. } } diff --git a/app/code/Magento/Ups/Test/Mftf/Section/AdminShippingMethodsUpsSection.xml b/app/code/Magento/Ups/Test/Mftf/Section/AdminShippingMethodsUpsSection.xml index 5fe832a64e66..ee69a4df9e68 100644 --- a/app/code/Magento/Ups/Test/Mftf/Section/AdminShippingMethodsUpsSection.xml +++ b/app/code/Magento/Ups/Test/Mftf/Section/AdminShippingMethodsUpsSection.xml @@ -10,8 +10,12 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminShippingMethodsUpsSection"> <element name="carriersUpsTab" type="button" selector="#carriers_ups-head"/> + <element name="carriersUpsType" type="select" selector="#carriers_ups_type"/> + <element name="selectedUpsType" type="text" selector="#carriers_ups_type option[selected]"/> <element name="carriersUPSActive" type="input" selector="#carriers_ups_active_inherit"/> + <element name="carriersUPSTypeSystem" type="input" selector="#carriers_ups_type_inherit"/> <element name="carriersUPSAccountLive" type="input" selector="#carriers_ups_is_account_live_inherit"/> + <element name="carriersUPSGatewayXMLUrl" type="input" selector="#carriers_ups_gateway_xml_url_inherit"/> <element name="carriersUPSGatewayUrl" type="input" selector="#carriers_ups_gateway_url_inherit"/> <element name="carriersUPSModeXML" type="input" selector="#carriers_ups_mode_xml_inherit"/> <element name="carriersUPSOriginShipment" type="input" selector="#carriers_ups_origin_shipment_inherit"/> diff --git a/app/code/Magento/Ups/Test/Mftf/Test/AdminCheckInputFieldsDisabledAfterAppConfigDumpTest.xml b/app/code/Magento/Ups/Test/Mftf/Test/AdminCheckInputFieldsDisabledAfterAppConfigDumpTest.xml index 6871ee81ba16..839a9b9b88e1 100644 --- a/app/code/Magento/Ups/Test/Mftf/Test/AdminCheckInputFieldsDisabledAfterAppConfigDumpTest.xml +++ b/app/code/Magento/Ups/Test/Mftf/Test/AdminCheckInputFieldsDisabledAfterAppConfigDumpTest.xml @@ -19,12 +19,17 @@ <actualResult type="const">$grabUPSActiveDisabled</actualResult> <expectedResult type="string">true</expectedResult> </assertEquals> + <grabAttributeFrom selector="{{AdminShippingMethodsUpsSection.carriersUPSTypeSystem}}" userInput="disabled" stepKey="grabUPSTypeDisabled"/> + <assertEquals stepKey="assertUPSTypeDisabled"> + <actualResult type="const">$grabUPSTypeDisabled</actualResult> + <expectedResult type="string">true</expectedResult> + </assertEquals> <grabAttributeFrom selector="{{AdminShippingMethodsUpsSection.carriersUPSAccountLive}}" userInput="disabled" stepKey="grabUPSAccountLiveDisabled"/> <assertEquals stepKey="assertUPSAccountLiveDisabled"> <actualResult type="const">$grabUPSAccountLiveDisabled</actualResult> <expectedResult type="string">true</expectedResult> </assertEquals> - <grabAttributeFrom selector="{{AdminShippingMethodsUpsSection.carriersUPSGatewayUrl}}" userInput="disabled" stepKey="grabUPSGatewayUrlDisabled"/> + <grabAttributeFrom selector="{{AdminShippingMethodsUpsSection.carriersUPSGatewayXMLUrl}}" userInput="disabled" stepKey="grabUPSGatewayUrlDisabled"/> <assertEquals stepKey="assertUPSGatewayUrlDisabled"> <actualResult type="const">$grabUPSGatewayUrlDisabled</actualResult> <expectedResult type="string">true</expectedResult> diff --git a/app/code/Magento/Ups/Test/Mftf/Test/DefaultConfigForUPSTypeTest.xml b/app/code/Magento/Ups/Test/Mftf/Test/DefaultConfigForUPSTypeTest.xml new file mode 100644 index 000000000000..4d583fce1a98 --- /dev/null +++ b/app/code/Magento/Ups/Test/Mftf/Test/DefaultConfigForUPSTypeTest.xml @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +/************************************************************************ + * + * ADOBE CONFIDENTIAL + * ___________________ + * + * Copyright 2023 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + * ************************************************************************ + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="DefaultConfigForUPSTypeTest"> + <annotations> + <features value="Ups"/> + <stories value="UPS configuration"/> + <title value="Default Configuration for UPS Type"/> + <stories value="UPS"/> + <description value="Default Configuration for UPS Type"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-99012"/> + <useCaseId value="MAGETWO-98947"/> + <group value="ups"/> + <group value="cloud"/> + </annotations> + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + </before> + <after> + <!--Collapse UPS tab and logout--> + <selectOption userInput="UPS_XML" selector="{{AdminShippingMethodsUpsSection.carriersUpsType}}" stepKey="selectUpsDefOption"/> + <checkOption selector="{{AdminShippingMethodsUpsSection.carriersUPSTypeSystem}}" stepKey="checkDefault" ></checkOption> + <comment userInput="Collapse UPS tab and logout" stepKey="collapseTabAndLogout"/> + <click selector="{{AdminShippingMethodsUpsSection.carriersUpsTab}}" stepKey="collapseTab"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <!-- Set shipping methods UPS type to default --> + <comment userInput="Set shipping methods UPS type to default" stepKey="setToDefaultShippingMethodsUpsType"/> + <!--<createData entity="ShippingMethodsUpsTypeSetDefault" stepKey="setShippingMethodsUpsTypeToDefault"/>--> + <!-- Navigate to Stores -> Configuration -> Sales -> Shipping Methods Page --> + <comment userInput="Navigate to Stores -> Configuration -> Sales -> Shipping Methods Page" stepKey="goToAdminShippingMethodsPage"/> + <amOnPage url="{{AdminShippingMethodsConfigPage.url}}" stepKey="navigateToAdminShippingMethodsPage"/> + <waitForPageLoad stepKey="waitPageToLoad"/> + <!-- Expand 'UPS' tab --> + <comment userInput="Expand UPS tab" stepKey="expandUpsTab"/> + <conditionalClick selector="{{AdminShippingMethodsUpsSection.carriersUpsTab}}" dependentSelector="{{AdminShippingMethodsUpsSection.carriersUpsType}}" visible="false" stepKey="expandTab"/> + <waitForElementVisible selector="{{AdminShippingMethodsUpsSection.carriersUpsType}}" stepKey="waitTabToExpand"/> + <!-- Assert that selected UPS type is 'United Parcel Service REST' --> + <comment userInput="Check that selected UPS type is 'United Parcel Service REST'" stepKey="assertResUpsType"/> + <uncheckOption selector="{{AdminShippingMethodsUpsSection.carriersUPSTypeSystem}}" stepKey="uncheckDefault" ></uncheckOption> + <selectOption userInput="UPS_REST" selector="{{AdminShippingMethodsUpsSection.carriersUpsType}}" stepKey="selectUpsOption"/> + <grabValueFrom selector="{{AdminShippingMethodsUpsSection.carriersUpsType}}" stepKey="grabSelectedOptionValue"/> + <assertEquals stepKey="assertRestUpsType"> + <actualResult type="const">($grabSelectedOptionValue)</actualResult> + <expectedResult type="string">UPS_REST</expectedResult> + </assertEquals> + </test> +</tests> diff --git a/app/code/Magento/Ups/Test/Unit/Model/CarrierTest.php b/app/code/Magento/Ups/Test/Unit/Model/CarrierTest.php index e0bc9a160a05..376206b01abb 100644 --- a/app/code/Magento/Ups/Test/Unit/Model/CarrierTest.php +++ b/app/code/Magento/Ups/Test/Unit/Model/CarrierTest.php @@ -427,7 +427,7 @@ public function requestToShipmentDataProvider(): array 'recipient_address_country_code' => 'US', 'shipper_address_state_or_province_code' => 'PR', 'shipper_address_postal_code' => '00968', - 'shipper_address_country_code' => 'PR', + 'shipper_address_country_code' => 'US', ] ], [ @@ -442,7 +442,7 @@ public function requestToShipmentDataProvider(): array [ 'recipient_address_state_or_province_code' => 'PR', 'recipient_address_postal_code' => '00968', - 'recipient_address_country_code' => 'PR', + 'recipient_address_country_code' => 'US', 'shipper_address_state_or_province_code' => 'CA', 'shipper_address_postal_code' => '90230', 'shipper_address_country_code' => 'US', @@ -468,6 +468,7 @@ public function getCountryById(?string $id): Country } /** + * @param string $carrierType * @param string $methodType * @param string $methodCode * @param string $methodTitle @@ -478,6 +479,7 @@ public function getCountryById(?string $id): Country * @dataProvider allowedMethodsDataProvider */ public function testGetAllowedMethods( + string $carrierType, string $methodType, string $methodCode, string $methodTitle, @@ -493,6 +495,12 @@ public function testGetAllowedMethods( null, $allowedMethods ], + [ + 'carriers/ups/type', + ScopeInterface::SCOPE_STORE, + null, + $carrierType + ], [ 'carriers/ups/origin_shipment', ScopeInterface::SCOPE_STORE, @@ -515,6 +523,23 @@ public function allowedMethodsDataProvider(): array { return [ [ + 'UPS', + 'method', + '1DM', + 'Next Day Air Early AM', + '', + [] + ], + [ + 'UPS', + 'method', + '1DM', + 'Next Day Air Early AM', + '1DM,1DML,1DA', + ['1DM' => 'Next Day Air Early AM'] + ], + [ + 'UPS_XML', 'originShipment', '01', 'UPS Next Day Air', @@ -522,13 +547,7 @@ public function allowedMethodsDataProvider(): array ['01' => 'UPS Next Day Air'] ], [ - 'originShipment', - '02', - 'UPS Second Day Air', - '01,02,03', - ['02' => 'UPS Second Day Air'] - ], - [ + 'UPS_REST', 'originShipment', '03', 'UPS Ground', diff --git a/app/code/Magento/Ups/etc/adminhtml/system.xml b/app/code/Magento/Ups/etc/adminhtml/system.xml index 269d33b1ad9a..55c85d080d15 100644 --- a/app/code/Magento/Ups/etc/adminhtml/system.xml +++ b/app/code/Magento/Ups/etc/adminhtml/system.xml @@ -10,6 +10,10 @@ <section id="carriers"> <group id="ups" translate="label" type="text" sortOrder="100" showInDefault="1" showInWebsite="1" showInStore="1"> <label>UPS</label> + <field id="access_license_number" translate="label" type="obscure" sortOrder="30" showInDefault="1" showInWebsite="1"> + <label>Access License Number</label> + <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model> + </field> <field id="active" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" canRestore="1"> <label>Enabled for Checkout</label> <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> @@ -51,6 +55,14 @@ <label>Gateway URL</label> <backend_model>Magento\Ups\Model\Config\Backend\UpsUrl</backend_model> </field> + <field id="gateway_xml_url" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" canRestore="1"> + <label>Gateway XML URL</label> + <backend_model>Magento\Ups\Model\Config\Backend\UpsUrl</backend_model> + </field> + <field id="gateway_rest_url" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" canRestore="1"> + <label>Gateway REST URL</label> + <backend_model>Magento\Ups\Model\Config\Backend\UpsUrl</backend_model> + </field> <field id="handling_type" translate="label" type="select" sortOrder="110" showInDefault="1" showInWebsite="1" canRestore="1"> <label>Calculate Handling Fee</label> <source_model>Magento\Shipping\Model\Source\HandlingType</source_model> @@ -91,9 +103,17 @@ <label>Title</label> </field> <field id="tracking_url" translate="label" type="text" sortOrder="60" showInDefault="1" showInWebsite="1" canRestore="1"> - <label>Tracking URL</label> + <label>Tracking XML URL</label> + <backend_model>Magento\Ups\Model\Config\Backend\UpsUrl</backend_model> + </field> + <field id="tracking_rest_url" translate="label" type="text" sortOrder="60" showInDefault="1" showInWebsite="1" canRestore="1"> + <label>Tracking REST URL</label> <backend_model>Magento\Ups\Model\Config\Backend\UpsUrl</backend_model> </field> + <field id="type" translate="label" type="select" sortOrder="20" showInDefault="1" showInWebsite="1" canRestore="1"> + <label>UPS Type</label> + <source_model>Magento\Ups\Model\Config\Source\Type</source_model> + </field> <field id="is_account_live" translate="label" type="select" sortOrder="25" showInDefault="1" showInWebsite="1" canRestore="1"> <label>Live Account</label> <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> diff --git a/app/code/Magento/Ups/etc/config.xml b/app/code/Magento/Ups/etc/config.xml index 52290a2bea82..015ec60d6c5a 100644 --- a/app/code/Magento/Ups/etc/config.xml +++ b/app/code/Magento/Ups/etc/config.xml @@ -9,6 +9,7 @@ <default> <carriers> <ups> + <access_license_number backend_model="Magento\Config\Model\Config\Backend\Encrypted" /> <active>0</active> <sallowspecific>0</sallowspecific> <allowed_methods>1DM,1DML,1DA,1DAL,1DAPI,1DP,1DPL,2DM,2DML,2DA,2DAL,3DS,GND,GNDCOM,GNDRES,STD,XPR,WXS,XPRL,XDM,XDML,XPD,01,02,03,07,08,11,12,14,54,59,65</allowed_methods> @@ -18,12 +19,15 @@ <cutoff_cost /> <dest_type>RES</dest_type> <free_method>GND</free_method> - <gateway_url>https://wwwcie.ups.com/api/rating/</gateway_url> + <gateway_url>https://www.ups.com/using/services/rave/qcostcgi.cgi</gateway_url> + <gateway_xml_url>https://onlinetools.ups.com/ups.app/xml/Rate</gateway_xml_url> + <gateway_rest_url>https://wwwcie.ups.com/api/rating/</gateway_rest_url> <handling>0</handling> <model>Magento\Ups\Model\Carrier</model> <pickup>CC</pickup> <title>United Parcel Service - https://wwwcie.ups.com/api/track/ + https://onlinetools.ups.com/ups.app/xml/Track + https://wwwcie.ups.com/api/track/ LBS @@ -35,6 +39,7 @@ 0 0 1 + UPS_XML 0 0 1 diff --git a/app/code/Magento/Ups/etc/di.xml b/app/code/Magento/Ups/etc/di.xml index 5e0febf34c24..f5512055e823 100644 --- a/app/code/Magento/Ups/etc/di.xml +++ b/app/code/Magento/Ups/etc/di.xml @@ -11,13 +11,20 @@ 1 1 + 1 1 + 1 + 1 + 1 1 1 + 1 1 1 + 1 + 1 1 1 1 diff --git a/app/code/Magento/Ups/view/adminhtml/templates/system/shipping/carrier_config.phtml b/app/code/Magento/Ups/view/adminhtml/templates/system/shipping/carrier_config.phtml index a1f12175c6b5..d6d89ad55cbb 100644 --- a/app/code/Magento/Ups/view/adminhtml/templates/system/shipping/carrier_config.phtml +++ b/app/code/Magento/Ups/view/adminhtml/templates/system/shipping/carrier_config.phtml @@ -31,14 +31,17 @@ if (!$storeCode && $websiteCode) { $storedAllowedMethods = explode(',', $web->getConfig('carriers/ups/allowed_methods')); $storedOriginShipment = $escaper->escapeHtml($web->getConfig('carriers/ups/origin_shipment')); $storedFreeShipment = $escaper->escapeHtml($web->getConfig('carriers/ups/free_method')); + $storedUpsType = $escaper->escapeHtml($web->getConfig('carriers/ups/type')); } elseif ($storeCode) { $storedAllowedMethods = explode(',', $block->getConfig('carriers/ups/allowed_methods', $storeCode)); $storedOriginShipment = $escaper->escapeHtml($block->getConfig('carriers/ups/origin_shipment', $storeCode)); $storedFreeShipment = $escaper->escapeHtml($block->getConfig('carriers/ups/free_method', $storeCode)); + $storedUpsType = $escaper->escapeHtml($block->getConfig('carriers/ups/type', $storeCode)); } else { $storedAllowedMethods = explode(',', $block->getConfig('carriers/ups/allowed_methods')); $storedOriginShipment = $escaper->escapeHtml($block->getConfig('carriers/ups/origin_shipment')); $storedFreeShipment = $escaper->escapeHtml($block->getConfig('carriers/ups/free_method')); + $storedUpsType = $escaper->escapeHtml($block->getConfig('carriers/ups/type')); } ?> @@ -70,31 +73,40 @@ require(["prototype"], function(){ return false; } - var upsRest = Class.create(); - upsRest.prototype = { + var upsXml = Class.create(); + upsXml.prototype = { initialize: function() { this.carriersUpsActiveId = 'carriers_ups_active'; - if (!$(this.carriersUpsActiveId)) { + this.carriersUpsTypeId = 'carriers_ups_type'; + if (!$(this.carriersUpsTypeId)) { return; } - this.checkingUpsId = ['carriers_ups_gateway_url','carriers_ups_username', + this.checkingUpsXmlId = ['carriers_ups_gateway_xml_url','carriers_ups_username', + 'carriers_ups_password','carriers_ups_access_license_number']; + this.checkingUpsId = ['carriers_ups_gateway_url']; + this.checkingUpsRestId = ['carriers_ups_gateway_rest_url','carriers_ups_username', 'carriers_ups_password']; this.originShipmentTitle = ''; this.allowedMethodsId = 'carriers_ups_allowed_methods'; this.freeShipmentId = 'carriers_ups_free_method'; - this.onlyUpsElements = ['carriers_ups_gateway_url','carriers_ups_tracking_url', - 'carriers_ups_username','carriers_ups_password', + this.onlyUpsXmlElements = ['carriers_ups_gateway_xml_url','carriers_ups_tracking_url', + 'carriers_ups_username','carriers_ups_password','carriers_ups_access_license_number', 'carriers_ups_origin_shipment','carriers_ups_negotiated_active','carriers_ups_shipper_number', 'carriers_ups_mode_xml','carriers_ups_include_taxes']; - this.authUpsElements = ['carriers_ups_username', - 'carriers_ups_password']; + this.onlyUpsElements = ['carriers_ups_gateway_url']; + this.onlyUpsRestElements = ['carriers_ups_gateway_rest_url','carriers_ups_tracking_rest_url', + 'carriers_ups_username','carriers_ups_password','carriers_ups_origin_shipment', + 'carriers_ups_negotiated_active','carriers_ups_shipper_number','carriers_ups_mode_xml', + 'carriers_ups_include_taxes']; + this.authUpsXmlElements = ['carriers_ups_username', + 'carriers_ups_password','carriers_ups_access_license_number']; script; $scriptString .= 'this.storedOriginShipment = \'' . /* @noEscape */ $storedOriginShipment . '\'; - this.storedFreeShipment = \'' . /* @noEscape */ $storedFreeShipment . '\';'; - + this.storedFreeShipment = \'' . /* @noEscape */ $storedFreeShipment . '\'; + this.storedUpsType = \'' . /* @noEscape */ $storedUpsType . '\';'; ?> jsonEncode($storedAllowedMethods) . '; @@ -103,6 +115,7 @@ $scriptString .= 'this.storedOriginShipment = \'' . /* @noEscape */ $storedOrigi $scriptString .= <<