├── Controller └── Adminhtml │ └── Index │ └── Index.php ├── Model ├── QualityPatches.php ├── Source │ └── PatchFilter.php └── UpdateNotification.php ├── README.md ├── Ui └── DataProvider │ └── QualityPatchesProvider.php ├── composer.json ├── etc ├── acl.xml ├── adminhtml │ ├── di.xml │ ├── menu.xml │ └── routes.xml └── module.xml ├── registration.php └── view └── adminhtml ├── layout └── aimes_patches_index_index.xml ├── templates └── messages │ └── update-available.phtml └── ui_component └── aimes_quality_patches_grid.xml /Controller/Adminhtml/Index/Index.php: -------------------------------------------------------------------------------- 1 | resultPageFactory = $resultPageFactory; 40 | $this->updateNotification = $updateNotification; 41 | } 42 | 43 | public function execute() 44 | { 45 | $resultPage = $this->resultPageFactory->create(); 46 | 47 | $this->updateNotification->addMessages(); 48 | 49 | $resultPage->setActiveMenu('Aimes_QualityPatchesUi::quality_patches'); 50 | $resultPage->getConfig()->getTitle()->prepend(__('Magento Quality Patches')); 51 | 52 | return $resultPage; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Model/QualityPatches.php: -------------------------------------------------------------------------------- 1 | containerFactory = $containerFactory; 49 | $this->filesystem = $filesystem; 50 | $this->json = $json; 51 | } 52 | 53 | /** 54 | * Get all patches for the current Magento/software version 55 | * 56 | * @return array 57 | */ 58 | public function getAllPatches() 59 | { 60 | if ($this->patches !== null) { 61 | return $this->patches; 62 | } 63 | 64 | // Since the quality patches tool is outside the Magento application, we have to emulate the CLI command 65 | $container = $this->containerFactory->create([ 66 | 'basePath' => $this->getCloudPatchesBaseDir(), 67 | 'magentoBasePath' => $this->getMagentoRootDir(), 68 | ]); 69 | 70 | $application = new Application($container); 71 | 72 | $input = new ArrayInput([ 73 | 'command' => Status::NAME, 74 | '--format' => ShowStatus::FORMAT_JSON, 75 | ]); 76 | $input->setInteractive(false); 77 | 78 | $output = new BufferedOutput(); 79 | 80 | try { 81 | $application->get(Status::NAME)->run($input, $output); 82 | $patchInfo = $this->json->unserialize($output->fetch()); 83 | 84 | // Fix the seemingly random newline characters breaking up words 85 | foreach ($patchInfo as &$patch) { 86 | $patch['Title'] = $this->removeNewlines($patch['Title']); 87 | } 88 | 89 | $this->patches = $patchInfo; 90 | } catch (ExceptionInterface $exception) { 91 | $this->patches = []; 92 | } 93 | 94 | return $this->patches; 95 | } 96 | 97 | /** 98 | * Get patch by name/ID for the current Magento/software version 99 | * 100 | * @param string $id 101 | * 102 | * @return array|null 103 | */ 104 | public function getPatchById(string $id): ?array 105 | { 106 | $patches = $this->getAllPatches(); 107 | 108 | foreach ($patches as $key => $patch) { 109 | if ($patch['Id'] === $id) { 110 | return $patches[$key]; 111 | } 112 | } 113 | 114 | return null; 115 | } 116 | 117 | /** 118 | * Get root path of the magento cloud patches package 119 | * 120 | * @return string 121 | */ 122 | private function getCloudPatchesBaseDir(): string 123 | { 124 | $applicationReflection = new ReflectionClass(Application::class); 125 | $filepath = $applicationReflection->getFileName(); 126 | 127 | return dirname($filepath, 2); 128 | } 129 | 130 | /** 131 | * Get Magento root directory 132 | * 133 | * @return string 134 | */ 135 | private function getMagentoRootDir(): string 136 | { 137 | return $this->filesystem->getDirectoryRead(DirectoryList::ROOT)->getAbsolutePath(); 138 | } 139 | 140 | /** 141 | * Remove newline characters from the JSON output 142 | * 143 | * @param string $string 144 | * 145 | * @return string 146 | */ 147 | private function removeNewlines(string $string): string 148 | { 149 | return str_replace("\n", '', $string); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Model/Source/PatchFilter.php: -------------------------------------------------------------------------------- 1 | qualityPatches = $qualityPatches; 30 | $this->filterKey = $filterKey; 31 | } 32 | 33 | /** 34 | * Get statuses available from patch list 35 | * 36 | * @return array 37 | */ 38 | public function toOptionArray(): array 39 | { 40 | $patches = $this->qualityPatches->getAllPatches(); 41 | $options = []; 42 | $values = []; 43 | 44 | foreach ($patches as $patch) { 45 | $values[] = $patch[$this->filterKey]; 46 | } 47 | 48 | $values = array_unique($values); 49 | 50 | foreach ($values as $value) { 51 | $options[] = [ 52 | 'label' => $value, 53 | 'value' => $value, 54 | ]; 55 | } 56 | 57 | return $options; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Model/UpdateNotification.php: -------------------------------------------------------------------------------- 1 | 'https://experienceleague.adobe.com/docs/commerce-cloud-service/user-guide/release-notes/cloud-patches.html', 29 | 'magento/quality-patches' => 'https://experienceleague.adobe.com/docs/commerce-operations/tools/quality-patches-tool/release-notes.html', 30 | ]; 31 | 32 | foreach ($packageReleaseNotesMapping as $packageName => $releaseNotesUrl) { 33 | $isUpdateAvailable = $this->composerVersioning->isUpdateAvailable($packageName); 34 | 35 | if (!$isUpdateAvailable) { 36 | continue; 37 | } 38 | 39 | $packageInfo = $this->composerVersioning->getPackageInfo($packageName); 40 | $packageInfo['release_notes_url'] = $releaseNotesUrl; 41 | 42 | $this->messageManager->addComplexNoticeMessage( 43 | 'qualityPatchesUpdateNotification', 44 | $packageInfo 45 | ); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aimes_QualityPatchesUi 2 | !["Supported Magento Version"][magento-badge] !["Latest Release"][release-badge] 3 | 4 | * Compatible with _Magento Open Source_ and _Adobe Commerce_ `2.4.x` 5 | 6 | ## Features 7 | - Display Magento Quality Patch Status as a grid, without need for CLI access, in the admin panel 8 | - Notify users of any updates available to the following packages (when viewing the grid), as a new version may contain new patches: 9 | - `magento/magento-cloud-patches` 10 | - `magento/quality-patches` 11 | 12 | ## Requirements 13 | * Magento Open Source or Adobe Commerce version `2.4.x` 14 | 15 | ## Installation 16 | Please install this module via Composer. This module is hosted on [Packagist][packagist]. 17 | 18 | * `composer require aimes/module-quality-patches-ui` 19 | * `bin/magento module:enable Aimes_QualityPatchesUi` 20 | * `bin/magento setup:upgrade` 21 | 22 | ## Usage 23 | Navigate to `Reports -> Patch Status -> Quality Patches` in the admin area 24 | 25 | ## Preview 26 | ![preview](https://user-images.githubusercontent.com/4225347/222785352-a849b27d-2de0-4e4e-9db4-aac77cbd14de.png) 27 | ![preview filtering](https://user-images.githubusercontent.com/4225347/222785473-d04b9e5f-d965-4e3f-b4ff-e86756750fbe.png) 28 | 29 | 30 | ## Licence 31 | [GPLv3][gpl] © [Rob Aimes][author] 32 | 33 | [magento-badge]:https://img.shields.io/badge/magento-2.3.x%20%7C%202.4.x-orange.svg?logo=magento&style=for-the-badge 34 | [release-badge]:https://img.shields.io/github/v/release/robaimes/module-quality-patches-ui 35 | [packagist]:https://packagist.org/packages/aimes/module-quality-patches-ui 36 | [gpl]:https://www.gnu.org/licenses/gpl-3.0.en.html 37 | [author]:https://aimes.dev/ 38 | [composer-patches]:https://github.com/cweagans/composer-patches 39 | -------------------------------------------------------------------------------- /Ui/DataProvider/QualityPatchesProvider.php: -------------------------------------------------------------------------------- 1 | qualityPatches = $qualityPatches; 65 | } 66 | 67 | /** 68 | * Get 'static' data from Magento cloud patches output 69 | * 70 | * Pagination, sorting and filtering is normally done via collection. 71 | * 72 | * @return array 73 | */ 74 | public function getData(): array 75 | { 76 | $patches = $this->patchData; 77 | 78 | if (!$patches) { 79 | $this->patchData = $patches = $this->qualityPatches->getAllPatches(); 80 | } 81 | 82 | $searchCriteria = $this->getSearchCriteria(); 83 | 84 | $this->filterResults($patches, $searchCriteria); 85 | $this->sortResults($patches, $searchCriteria); 86 | 87 | return [ 88 | 'totalRecords' => count($patches), 89 | 'items' => $this->getPaginationItems($patches, $searchCriteria), 90 | ]; 91 | } 92 | 93 | /** 94 | * Filter results manually as we have no 'resource' or database 95 | * 96 | * @param array $results 97 | * @param SearchCriteria $searchCriteria 98 | * 99 | * @return void 100 | * @see Collection::addFieldToFilter 101 | */ 102 | private function filterResults(array &$results, SearchCriteria $searchCriteria): void 103 | { 104 | $filterGroups = $searchCriteria->getFilterGroups(); 105 | $filters = []; 106 | 107 | // This grid is assumed to match other grid components and as such functions as logical AND when filtering 108 | foreach ($filterGroups as $filterGroup) { 109 | foreach ($filterGroup->getFilters() as $filter) { 110 | $filters[] = $filter; 111 | } 112 | } 113 | 114 | /** @var Filter[] $filters */ 115 | foreach ($filters as $filter) { 116 | foreach ($results as $index => $result) { 117 | if (!$result[$filter->getField()]) { 118 | unset($results[$index]); 119 | } 120 | 121 | $filterValue = strtolower($filter->getValue()); 122 | $dataValue = strtolower($result[$filter->getField()]); 123 | 124 | switch ($filter->getConditionType()) { 125 | case 'eq': 126 | if ($dataValue === $filterValue) { 127 | continue 2; 128 | } 129 | 130 | break; 131 | case 'like': 132 | if (strpos($dataValue, trim($filterValue, '%')) !== false) { 133 | continue 2; 134 | } 135 | 136 | break; 137 | default: 138 | break; 139 | } 140 | 141 | unset($results[$index]); 142 | } 143 | } 144 | } 145 | 146 | /** 147 | * Sort grid results manually as we have no 'resource' or database 148 | * 149 | * @param array $results 150 | * @param SearchCriteria $searchCriteria 151 | * 152 | * @return void 153 | */ 154 | private function sortResults(array &$results, SearchCriteria $searchCriteria): void 155 | { 156 | $sortOrders = $searchCriteria->getSortOrders(); 157 | 158 | foreach ($sortOrders as $sortOrder) { 159 | if (!$sortOrder->getField()) { 160 | continue; 161 | } 162 | 163 | usort($results, function ($itemA, $itemB) use ($sortOrder) { 164 | $comparisonFieldA = $itemA[$sortOrder->getField()]; 165 | $comparisonFieldB = $itemB[$sortOrder->getField()]; 166 | 167 | if (strtoupper($sortOrder->getDirection()) === Collection::SORT_ORDER_ASC) { 168 | return $comparisonFieldA <=> $comparisonFieldB; 169 | } else { 170 | return $comparisonFieldB <=> $comparisonFieldA; 171 | } 172 | }); 173 | } 174 | } 175 | 176 | /** 177 | * Paginate results manually as we have no 'resource' or database 178 | * 179 | * @param array $items 180 | * @param SearchCriteria $searchCriteria 181 | * 182 | * @return array 183 | */ 184 | private function getPaginationItems(array $items, SearchCriteria $searchCriteria): array 185 | { 186 | $pageSize = $searchCriteria->getPageSize(); 187 | $pageCurrent = $searchCriteria->getCurrentPage(); 188 | $pageOffset = ($pageCurrent - 1) * $pageSize; 189 | 190 | return array_slice($items, $pageOffset, $pageSize); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aimes/module-quality-patches-ui", 3 | "type": "magento2-module", 4 | "description": "Admin panel GUI for reporting status of Magento Quality Patches", 5 | "license": [ 6 | "GPL-3.0-or-later" 7 | ], 8 | "authors": [ 9 | { 10 | "name": "Rob Aimes", 11 | "email": "rob@aimes.dev", 12 | "homepage": "https://aimes.dev" 13 | } 14 | ], 15 | "require": { 16 | "aimes/magento2-module-substratum": "^1.0", 17 | "magento/magento-cloud-patches": "^1.0.20", 18 | "magento/quality-patches": "^1.1" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "Aimes\\QualityPatchesUi\\": "" 23 | }, 24 | "files": [ 25 | "registration.php" 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /etc/acl.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /etc/adminhtml/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 13 | 14 | Status 15 | 16 | 17 | 19 | 20 | Category 21 | 22 | 23 | 25 | 26 | Origin 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Magento\Framework\View\Element\Message\Renderer\BlockRenderer::CODE 35 | 36 | Aimes_QualityPatchesUi::messages/update-available.phtml 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /etc/adminhtml/menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | 20 | 21 | 30 | 31 | 32 | 40 | 41 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /etc/adminhtml/routes.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /etc/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /registration.php: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /view/adminhtml/templates/messages/update-available.phtml: -------------------------------------------------------------------------------- 1 | 13 |
14 | 15 | escapeHtml(__( 16 | 'An update for the %package_name package is available. Additional patches may be available.', 17 | [ 18 | 'package_name' => $block->getName(), 19 | ] 20 | ), ['code']); ?> 21 | 22 |
23 |
24 | 25 | escapeHtml(__( 26 | 'Your current version is %current_version and the latest version available is %latest_version.', 27 | [ 28 | 'current_version' => $block->getCurrentVersion(), 29 | 'latest_version' => $block->getLatest(), 30 | ] 31 | ), ['code']); ?> 32 | 33 |
34 | 35 | escapeHtml(__('View release notes')); ?> 36 | 37 |
38 | -------------------------------------------------------------------------------- /view/adminhtml/ui_component/aimes_quality_patches_grid.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | aimes_quality_patches_grid.aimes_quality_patches_grid_data_source 13 | 14 | 15 | 16 | aimes_quality_patches_columns 17 | 18 | aimes_quality_patches_grid.aimes_quality_patches_grid_data_source 19 | 20 | 21 | 22 | 23 | 24 | Id 25 | 26 | 27 | 28 | 30 | 31 | Id 32 | Id 33 | 34 | 35 | 36 | 37 | 38 | true 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | aimes_quality_patches_grid.aimes_quality_patches_grid.listing_top.bookmarks 49 | current.${ $.storageConfig.root} 50 | 51 | 52 | true 53 | 54 | 55 | 56 | 57 | text 58 | text 59 | 60 | true 61 | true 62 | 63 | 64 | 65 | 66 | select 67 | select 68 | 69 | 70 | true 71 | asc 72 | true 73 | 74 | 75 | 76 | 77 | text 78 | text 79 | 80 | false 81 | true 82 | 83 | 84 | 85 | 86 | select 87 | select 88 | 89 | 90 | true 91 | false 92 | 93 | 94 | 95 | 96 | select 97 | select 98 | 99 | 100 | true 101 | false 102 | 103 | 104 | 105 | 106 | Magento_Catalog/grid/cells/preserved 107 | text 108 | 109 | false 110 | false 111 | 112 | 113 | 114 | 115 | --------------------------------------------------------------------------------