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;
+ }
+}