diff --git a/composer.json b/composer.json index 8b34201..a1ab02c 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,9 @@ "dealerdirect/phpcodesniffer-composer-installer": true, "phpstan/extension-installer": true, "phpro/grumphp": true, - "emico/code-quality": true + "emico/code-quality": true, + "magento/magento-composer-installer": true, + "emico/codeception-m2": true } } } diff --git a/src/Model/ResourceModel/Page/Collection.php b/src/Model/ResourceModel/Page/Collection.php index df6a359..9dc4ebb 100644 --- a/src/Model/ResourceModel/Page/Collection.php +++ b/src/Model/ResourceModel/Page/Collection.php @@ -27,8 +27,6 @@ protected function _construct() } /** - * Initialize select with join - * * @return $this */ protected function _initSelect() diff --git a/src/Model/ResourceModel/Page/Grid/Collection.php b/src/Model/ResourceModel/Page/Grid/Collection.php new file mode 100644 index 0000000..7dcdbc2 --- /dev/null +++ b/src/Model/ResourceModel/Page/Grid/Collection.php @@ -0,0 +1,97 @@ +getSelect() + ->joinLeft( + ['emico_attributelanding_page_store' => $this->getTable('emico_attributelanding_page_store')], + 'main_table.page_id = emico_attributelanding_page_store.page_id', + [ + 'store_urls' => new Zend_Db_Expr( + 'GROUP_CONCAT(DISTINCT CONCAT(emico_attributelanding_page_store.store_id, \':\', emico_attributelanding_page_store.url_path) ' + . 'ORDER BY emico_attributelanding_page_store.store_id SEPARATOR \',\')' + ), + 'name' => new Zend_Db_Expr( + 'GROUP_CONCAT(DISTINCT CONCAT(emico_attributelanding_page_store.store_id, \':\', emico_attributelanding_page_store.name) ' + . 'ORDER BY emico_attributelanding_page_store.store_id SEPARATOR \',\')' + ), + ] + ) + ->group('main_table.page_id'); + return parent::_beforeLoad(); + } + + /** + * Override the count SELECT to include the store join and GROUP BY when a HAVING + * clause is present, so GROUP_CONCAT aggregate filters work correctly during pagination. + * + * @return Select + */ + public function getSelectCountSql() + { + $countSelect = parent::getSelectCountSql(); + + $having = $countSelect->getPart(Select::HAVING); + if (empty($having)) { + return $countSelect; + } + + $countSelect->joinLeft( + ['emico_attributelanding_page_store' => $this->getTable('emico_attributelanding_page_store')], + 'main_table.page_id = emico_attributelanding_page_store.page_id', + [] + ); + $countSelect->group('main_table.page_id'); + + return $this->getConnection()->select()->from($countSelect, [new Zend_Db_Expr('COUNT(*)')]); + } + + /** + * Intercept store_urls and name filters and apply them as HAVING clauses so they + * work against the GROUP_CONCAT aggregates instead of raw columns. + * + * @param string|array $field + * @param mixed $condition + * @return $this + */ + public function addFieldToFilter($field, $condition = null) + { + if ($field === 'store_urls') { + $value = is_array($condition) ? ($condition['like'] ?? reset($condition)) : $condition; + $value = trim((string) $value, '%'); + $this->getSelect()->having( + 'GROUP_CONCAT(DISTINCT CONCAT(emico_attributelanding_page_store.store_id, \':\', emico_attributelanding_page_store.url_path) ORDER BY emico_attributelanding_page_store.store_id SEPARATOR \',\') LIKE ?', + '%' . $value . '%' + ); + return $this; + } + + if ($field === 'name') { + $value = is_array($condition) ? ($condition['like'] ?? reset($condition)) : $condition; + $value = trim((string) $value, '%'); + $this->getSelect()->having( + 'GROUP_CONCAT(DISTINCT CONCAT(emico_attributelanding_page_store.store_id, \':\', emico_attributelanding_page_store.name) ORDER BY emico_attributelanding_page_store.store_id SEPARATOR \',\') LIKE ?', + '%' . $value . '%' + ); + return $this; + } + + return parent::addFieldToFilter($field, $condition); + } +} diff --git a/src/Ui/Component/Listing/Column/StoreUrls.php b/src/Ui/Component/Listing/Column/StoreUrls.php new file mode 100644 index 0000000..9bd05e1 --- /dev/null +++ b/src/Ui/Component/Listing/Column/StoreUrls.php @@ -0,0 +1,75 @@ +storeRepository->getList(); + $storeNames = []; + foreach ($stores as $store) { + $storeNames[(int) $store->getId()] = $store->getName(); + } + + $columnName = $this->getData('name'); + foreach (array_keys($dataSource['data']['items']) as $index) { + $raw = $dataSource['data']['items'][$index][$columnName] ?? ''; + + if ($raw === '') { + $dataSource['data']['items'][$index][$columnName] = ''; + continue; + } + + $lines = []; + foreach (explode(',', (string) $raw) as $entry) { + $parts = explode(':', $entry, 2); + if (count($parts) !== 2) { + continue; + } + [$storeId, $urlPath] = $parts; + $storeId = (int) $storeId; + $storeName = $storeId === 0 ? __('Global') : ($storeNames[$storeId] ?? __('Store %1', $storeId)); + $lines[] = sprintf('%s: %s', $storeName, $urlPath); + } + + $dataSource['data']['items'][$index][$columnName] = implode('
', $lines); + } + + return $dataSource; + } +} diff --git a/src/etc/di.xml b/src/etc/di.xml index f30ece9..10f6ad6 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -19,15 +19,14 @@ type="Emico\AttributeLanding\Model\FilterHider\MagentoFilterHider"/> - + emico_attributelanding_page Emico\AttributeLanding\Model\ResourceModel\Page\Collection - + diff --git a/src/view/adminhtml/ui_component/emico_attributelanding_page_listing.xml b/src/view/adminhtml/ui_component/emico_attributelanding_page_listing.xml index 4b41e0a..c781264 100644 --- a/src/view/adminhtml/ui_component/emico_attributelanding_page_listing.xml +++ b/src/view/adminhtml/ui_component/emico_attributelanding_page_listing.xml @@ -84,10 +84,12 @@ - + text + false + ui/grid/cells/html @@ -102,6 +104,14 @@ + + + + false + text + ui/grid/cells/html + + page_id diff --git a/tests/Functional/InitialTest.php b/tests/Functional/InitialTest.php deleted file mode 100644 index f71fd2f..0000000 --- a/tests/Functional/InitialTest.php +++ /dev/null @@ -1,24 +0,0 @@ -tester->assertTrue(true); - } -} diff --git a/tests/Unit/Model/ResourceModel/Page/Grid/CollectionTest.php b/tests/Unit/Model/ResourceModel/Page/Grid/CollectionTest.php new file mode 100644 index 0000000..8a88265 --- /dev/null +++ b/tests/Unit/Model/ResourceModel/Page/Grid/CollectionTest.php @@ -0,0 +1,153 @@ +selectRenderer = Mockery::mock(SelectRenderer::class); + $this->eventManager = Mockery::mock(ManagerInterface::class); + $this->eventManager->shouldReceive('dispatch')->andReturnNull(); + + $this->connection = Mockery::mock(Mysql::class)->makePartial(); + $this->connection->shouldReceive('quoteIdentifier')->andReturnUsing( + static fn (string $identifier): string => $identifier + ); + $this->connection->shouldReceive('quoteInto')->andReturnUsing( + static fn (string $text, mixed $value): string => str_replace('?', sprintf("'%s'", (string) $value), $text) + ); + $this->connection->shouldReceive('select')->andReturnUsing(fn (): Select => $this->createSelect()); + } + + protected function tearDown(): void + { + parent::tearDown(); + Mockery::close(); + } + + public function testBeforeLoadAddsStoreJoinAndAggregatedColumns(): void + { + $select = $this->createSelect(); + $select->from(['main_table' => 'emico_attributelanding_page'], ['page_id']); + + $subject = $this->createSubject($select); + $subject->beforeLoad(); + + $from = $select->getPart(Select::FROM); + $columns = $select->getPart(Select::COLUMNS); + + $this->assertArrayHasKey('emico_attributelanding_page_store', $from); + $this->assertSame(['main_table.page_id'], $select->getPart(Select::GROUP)); + $this->assertCount(3, $columns); + $this->assertSame('store_urls', $columns[1][2]); + $this->assertInstanceOf(Zend_Db_Expr::class, $columns[1][1]); + $this->assertSame('name', $columns[2][2]); + $this->assertInstanceOf(Zend_Db_Expr::class, $columns[2][1]); + } + + public function testAddFieldToFilterAddsHavingForStoreUrls(): void + { + $select = $this->createSelect(); + $subject = $this->createSubject($select); + + $subject->addFieldToFilter('store_urls', ['like' => '%default/url%']); + + $having = implode(' ', $select->getPart(Select::HAVING)); + + $this->assertStringContainsString('GROUP_CONCAT(DISTINCT CONCAT(emico_attributelanding_page_store.store_id', $having); + $this->assertStringContainsString("'%default/url%'", $having); + } + + public function testAddFieldToFilterAddsHavingForName(): void + { + $select = $this->createSelect(); + $subject = $this->createSubject($select); + + $subject->addFieldToFilter('name', ['like' => '%Landing Page%']); + + $having = implode(' ', $select->getPart(Select::HAVING)); + + $this->assertStringContainsString('GROUP_CONCAT(DISTINCT CONCAT(emico_attributelanding_page_store.store_id', $having); + $this->assertStringContainsString('emico_attributelanding_page_store.name', $having); + $this->assertStringContainsString("'%Landing Page%'", $having); + } + + public function testGetSelectCountSqlWrapsGroupedQueryWhenHavingIsPresent(): void + { + $select = $this->createSelect(); + $select->from(['main_table' => 'emico_attributelanding_page'], ['page_id']); + $select->having('COUNT(*) > ?', 0); + + $subject = $this->createSubject($select); + $countSelect = $subject->getSelectCountSql(); + + $from = $countSelect->getPart(Select::FROM); + $innerSelect = reset($from)['tableName']; + + $this->assertInstanceOf(Select::class, $innerSelect); + $this->assertArrayHasKey('emico_attributelanding_page_store', $innerSelect->getPart(Select::FROM)); + $this->assertSame(['main_table.page_id'], $innerSelect->getPart(Select::GROUP)); + } + + private function createSubject(Select $select): GridCollection + { + return new class ($select, $this->connection, $this->eventManager) extends GridCollection { + public function __construct( + Select $select, + Mysql $connection, + ManagerInterface $eventManager + ) { + $this->_select = $select; + $this->_conn = $connection; + $this->_eventManager = $eventManager; + } + + public function beforeLoad(): self + { + return $this->_beforeLoad(); + } + + public function getConnection() + { + return $this->_conn; + } + + public function getSelect() + { + return $this->_select; + } + + public function getTable($table) + { + return $table; + } + }; + } + + private function createSelect(): Select + { + return new Select($this->connection, $this->selectRenderer); + } +} diff --git a/tests/Unit/Ui/Component/Listing/Column/StoreUrlsTest.php b/tests/Unit/Ui/Component/Listing/Column/StoreUrlsTest.php new file mode 100644 index 0000000..e155b75 --- /dev/null +++ b/tests/Unit/Ui/Component/Listing/Column/StoreUrlsTest.php @@ -0,0 +1,108 @@ +context = Mockery::mock(ContextInterface::class); + $this->uiComponentFactory = Mockery::mock(UiComponentFactory::class); + $this->storeRepository = Mockery::mock(StoreRepositoryInterface::class); + } + + protected function tearDown(): void + { + parent::tearDown(); + Mockery::close(); + } + + public function testPrepareDataSourceReturnsInputWhenItemsAreMissing(): void + { + $subject = $this->createSubject('store_urls'); + $dataSource = ['data' => []]; + + $this->assertSame($dataSource, $subject->prepareDataSource($dataSource)); + } + + public function testPrepareDataSourceFormatsStoreUrls(): void + { + $this->storeRepository->shouldReceive('getList')->andReturn([ + $this->createStore(1, 'Default Store'), + ]); + + $subject = $this->createSubject('store_urls'); + $result = $subject->prepareDataSource([ + 'data' => [ + 'items' => [ + ['store_urls' => '0:global-url,1:default/url,2:second/url,broken-entry'], + ['store_urls' => null], + ], + ], + ]); + + $this->assertSame( + 'Global: global-url
Default Store: default/url
Store 2: second/url', + $result['data']['items'][0]['store_urls'] + ); + $this->assertSame('', $result['data']['items'][1]['store_urls']); + } + + public function testPrepareDataSourceUsesConfiguredColumnName(): void + { + $this->storeRepository->shouldReceive('getList')->andReturn([ + $this->createStore(1, 'Default Store'), + ]); + + $subject = $this->createSubject('name'); + $result = $subject->prepareDataSource([ + 'data' => [ + 'items' => [ + ['name' => '1:Landing Page Name'], + ], + ], + ]); + + $this->assertSame('Default Store: Landing Page Name', $result['data']['items'][0]['name']); + } + + private function createSubject(string $columnName): StoreUrls + { + return new StoreUrls( + $this->context, + $this->uiComponentFactory, + $this->storeRepository, + [], + ['name' => $columnName] + ); + } + + private function createStore(int $id, string $name): StoreInterface|MockInterface + { + $store = Mockery::mock(StoreInterface::class); + $store->shouldReceive('getId')->andReturn($id); + $store->shouldReceive('getName')->andReturn($name); + + return $store; + } +}