From 7fdc89e0ed4abbe4f33c4fbdaf7e471ecd6bd42c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?= Date: Mon, 2 Nov 2026 10:16:31 +0000 Subject: [PATCH] feat: Resolve CMS records in find-replace results Pages, areas, blocks, menus and menu items name the record behind each hit and link to its edit screen. Menu items open their menu. Pages carry a status facet and badge; deleted pages have no edit link. Co-Authored-By: Claude Opus 5.5 --- src/FindReplace/Resolver/Area.php | 81 +++++++++++++ src/FindReplace/Resolver/Block.php | 81 +++++++++++++ src/FindReplace/Resolver/Menu.php | 136 ++++++++++++++++++++++ src/FindReplace/Resolver/Page.php | 139 +++++++++++++++++++++++ tests/FindReplace/Resolver/AreaTest.php | 80 +++++++++++++ tests/FindReplace/Resolver/BlockTest.php | 80 +++++++++++++ tests/FindReplace/Resolver/MenuTest.php | 100 ++++++++++++++++ tests/FindReplace/Resolver/PageTest.php | 111 ++++++++++++++++++ 8 files changed, 808 insertions(+) create mode 100644 src/FindReplace/Resolver/Area.php create mode 100644 src/FindReplace/Resolver/Block.php create mode 100644 src/FindReplace/Resolver/Menu.php create mode 100644 src/FindReplace/Resolver/Page.php create mode 100644 tests/FindReplace/Resolver/AreaTest.php create mode 100644 tests/FindReplace/Resolver/BlockTest.php create mode 100644 tests/FindReplace/Resolver/MenuTest.php create mode 100644 tests/FindReplace/Resolver/PageTest.php diff --git a/src/FindReplace/Resolver/Area.php b/src/FindReplace/Resolver/Area.php new file mode 100644 index 0000000..cc649b9 --- /dev/null +++ b/src/FindReplace/Resolver/Area.php @@ -0,0 +1,81 @@ + $match->getId(), $matches))); + + if ($ids === []) { + return []; + } + + $editUrl = $this->editUrl(); + $hits = []; + + foreach ($this->rows($table, 'id, label', $ids) as $row) { + $id = (string) $row->id; + + $hits[$id] = new Hit( + $table, + $id, + entityLabel: 'CMS Area', + entityId: $id, + title: (string) $row->label ?: null, + actions: [ + new Action('Edit', $editUrl . '/' . $id), + ], + ); + } + + return $hits; + } + + /** + * @param list $ids + * + * @return list<\stdClass> + */ + protected function rows(string $table, string $columns, array $ids): array + { + /** @var Database $database */ + $database = Factory::service('Database'); + $rows = []; + + foreach (array_chunk($ids, static::IDS_PER_QUERY) as $chunk) { + $database->select($columns); + $database->where_in('id', $chunk); + $rows = array_merge($rows, $database->get($table)->result()); + } + + return $rows; + } + + protected function editUrl(): string + { + return Controller\Area::url('edit'); + } +} diff --git a/src/FindReplace/Resolver/Block.php b/src/FindReplace/Resolver/Block.php new file mode 100644 index 0000000..a65860b --- /dev/null +++ b/src/FindReplace/Resolver/Block.php @@ -0,0 +1,81 @@ + $match->getId(), $matches))); + + if ($ids === []) { + return []; + } + + $editUrl = $this->editUrl(); + $hits = []; + + foreach ($this->rows($table, 'id, label', $ids) as $row) { + $id = (string) $row->id; + + $hits[$id] = new Hit( + $table, + $id, + entityLabel: 'CMS Block', + entityId: $id, + title: (string) $row->label ?: null, + actions: [ + new Action('Edit', $editUrl . '/' . $id), + ], + ); + } + + return $hits; + } + + /** + * @param list $ids + * + * @return list<\stdClass> + */ + protected function rows(string $table, string $columns, array $ids): array + { + /** @var Database $database */ + $database = Factory::service('Database'); + $rows = []; + + foreach (array_chunk($ids, static::IDS_PER_QUERY) as $chunk) { + $database->select($columns); + $database->where_in('id', $chunk); + $rows = array_merge($rows, $database->get($table)->result()); + } + + return $rows; + } + + protected function editUrl(): string + { + return Controller\Block::url('edit'); + } +} diff --git a/src/FindReplace/Resolver/Menu.php b/src/FindReplace/Resolver/Menu.php new file mode 100644 index 0000000..57c3ae0 --- /dev/null +++ b/src/FindReplace/Resolver/Menu.php @@ -0,0 +1,136 @@ + $match->getId(), $matches))); + + if ($ids === []) { + return []; + } + + return $table === NAILS_DB_PREFIX . 'cms_menu_item' + ? $this->items($table, $ids) + : $this->menus($table, $ids); + } + + /** + * @param list $ids + * + * @return array + */ + private function menus(string $table, array $ids): array + { + $editUrl = $this->editUrl(); + $hits = []; + + foreach ($this->rows($table, 'id, label', $ids) as $row) { + $id = (string) $row->id; + + $hits[$id] = new Hit( + $table, + $id, + entityLabel: 'CMS Menu', + entityId: $id, + title: (string) $row->label ?: null, + actions: [ + new Action('Edit', $editUrl . '/' . $id), + ], + ); + } + + return $hits; + } + + /** + * Items are edited on their menu's screen. + * + * @param list $ids + * + * @return array + */ + private function items(string $table, array $ids): array + { + $items = $this->rows($table, 'id, menu_id, label', $ids); + $menuIds = array_values(array_unique(array_map(fn(\stdClass $item): string => (string) $item->menu_id, $items))); + $menus = []; + + if ($menuIds !== []) { + foreach ($this->rows(NAILS_DB_PREFIX . 'cms_menu', 'id, label', $menuIds) as $menu) { + $menus[(string) $menu->id] = (string) $menu->label; + } + } + + $editUrl = $this->editUrl(); + $hits = []; + + foreach ($items as $item) { + $id = (string) $item->id; + $menuId = (string) $item->menu_id; + $title = array_filter([$menus[$menuId] ?? null, (string) $item->label]); + + $hits[$id] = new Hit( + $table, + $id, + entityLabel: 'CMS Menu Item', + entityId: $id, + title: implode(' › ', $title) ?: null, + actions: [ + new Action('Edit menu', $editUrl . '/' . $menuId), + ], + ); + } + + return $hits; + } + + /** + * @param list $ids + * + * @return list<\stdClass> + */ + protected function rows(string $table, string $columns, array $ids): array + { + /** @var Database $database */ + $database = Factory::service('Database'); + $rows = []; + + foreach (array_chunk($ids, static::IDS_PER_QUERY) as $chunk) { + $database->select($columns); + $database->where_in('id', $chunk); + $rows = array_merge($rows, $database->get($table)->result()); + } + + return $rows; + } + + protected function editUrl(): string + { + return Controller\Menu::url('edit'); + } +} diff --git a/src/FindReplace/Resolver/Page.php b/src/FindReplace/Resolver/Page.php new file mode 100644 index 0000000..8741954 --- /dev/null +++ b/src/FindReplace/Resolver/Page.php @@ -0,0 +1,139 @@ + $match->getId(), $matches))); + + if ($ids === []) { + return []; + } + + $editUrl = $this->editUrl(); + $hits = []; + + foreach ($this->rows($table, 'id, published_title, draft_title, is_published, is_deleted', $ids) as $row) { + $id = (string) $row->id; + $status = match (true) { + (bool) $row->is_deleted => self::STATUS_DELETED, + (bool) $row->is_published => self::STATUS_PUBLISHED, + default => self::STATUS_DRAFT, + }; + + // Deleted pages cannot be opened in the editor. + $actions = $status === self::STATUS_DELETED + ? [] + : [new Action('Edit', $editUrl . '/' . $id)]; + + $hits[$id] = new Hit( + $table, + $id, + entityLabel: 'CMS Page', + entityId: $id, + title: $this->title($row), + actions: $actions, + facets: [ + 'status' => $status, + ], + ); + } + + return $hits; + } + + public function facets(): array + { + return [ + 'status' => 'Status', + ]; + } + + public function values(): array + { + return [ + 'status' => [ + self::STATUS_PUBLISHED, + self::STATUS_DRAFT, + self::STATUS_DELETED, + ], + ]; + } + + public function badges(): array + { + return ['status']; + } + + /** + * The published title for a published page, otherwise the draft title. + */ + private function title(\stdClass $row): ?string + { + $published = (string) $row->published_title; + $draft = (string) $row->draft_title; + + if ($row->is_published && $published !== '') { + return $published; + } + + return ($draft !== '' ? $draft : $published) ?: null; + } + + /** + * @param list $ids + * + * @return list<\stdClass> + */ + protected function rows(string $table, string $columns, array $ids): array + { + /** @var Database $database */ + $database = Factory::service('Database'); + $rows = []; + + foreach (array_chunk($ids, static::IDS_PER_QUERY) as $chunk) { + $database->select($columns); + $database->where_in('id', $chunk); + $rows = array_merge($rows, $database->get($table)->result()); + } + + return $rows; + } + + protected function editUrl(): string + { + return Controller\Pages::url('edit'); + } +} diff --git a/tests/FindReplace/Resolver/AreaTest.php b/tests/FindReplace/Resolver/AreaTest.php new file mode 100644 index 0000000..65200bb --- /dev/null +++ b/tests/FindReplace/Resolver/AreaTest.php @@ -0,0 +1,80 @@ +tables()); + } + + public function testResolvesByLabel(): void + { + $table = NAILS_DB_PREFIX . 'cms_area'; + $resolver = $this->resolver([ + (object) ['id' => 5, 'label' => 'Footer'], + (object) ['id' => 6, 'label' => ''], + ]); + + $hits = $resolver->resolve($table, [ + $this->match($table, '5'), + $this->match($table, '6'), + $this->match($table, '9'), + ]); + + self::assertCount(2, $hits); + self::assertSame('CMS Area', $hits['5']->getEntityLabel()); + self::assertSame('5', $hits['5']->getEntityId()); + self::assertSame('Footer', $hits['5']->getTitle()); + self::assertSame('Edit', $hits['5']->getActions()[0]->getLabel()); + self::assertSame('https://example.com/admin/cms/area/edit/5', $hits['5']->getActions()[0]->getUrl()); + self::assertNull($hits['6']->getTitle()); + } + + public function testNoMatchesResolvesNothing(): void + { + self::assertSame([], $this->resolver([])->resolve(NAILS_DB_PREFIX . 'cms_area', [])); + } + + private function match(string $table, string $id): Matched + { + return new Matched($table, 'label', 'plaintext', 'example', $id, 'example'); + } + + /** + * @param list<\stdClass> $rows + */ + private function resolver(array $rows): Area + { + return new class ($rows) extends Area { + /** + * @param list<\stdClass> $stub + */ + public function __construct(private readonly array $stub) + { + } + + protected function rows(string $table, string $columns, array $ids): array + { + return $this->stub; + } + + protected function editUrl(): string + { + return 'https://example.com/admin/cms/area/edit'; + } + }; + } +} diff --git a/tests/FindReplace/Resolver/BlockTest.php b/tests/FindReplace/Resolver/BlockTest.php new file mode 100644 index 0000000..1797c8d --- /dev/null +++ b/tests/FindReplace/Resolver/BlockTest.php @@ -0,0 +1,80 @@ +tables()); + } + + public function testResolvesByLabel(): void + { + $table = NAILS_DB_PREFIX . 'cms_block'; + $resolver = $this->resolver([ + (object) ['id' => 5, 'label' => 'Telephone number'], + (object) ['id' => 6, 'label' => ''], + ]); + + $hits = $resolver->resolve($table, [ + $this->match($table, '5'), + $this->match($table, '6'), + $this->match($table, '9'), + ]); + + self::assertCount(2, $hits); + self::assertSame('CMS Block', $hits['5']->getEntityLabel()); + self::assertSame('5', $hits['5']->getEntityId()); + self::assertSame('Telephone number', $hits['5']->getTitle()); + self::assertSame('Edit', $hits['5']->getActions()[0]->getLabel()); + self::assertSame('https://example.com/admin/cms/block/edit/5', $hits['5']->getActions()[0]->getUrl()); + self::assertNull($hits['6']->getTitle()); + } + + public function testNoMatchesResolvesNothing(): void + { + self::assertSame([], $this->resolver([])->resolve(NAILS_DB_PREFIX . 'cms_block', [])); + } + + private function match(string $table, string $id): Matched + { + return new Matched($table, 'label', 'plaintext', 'example', $id, 'example'); + } + + /** + * @param list<\stdClass> $rows + */ + private function resolver(array $rows): Block + { + return new class ($rows) extends Block { + /** + * @param list<\stdClass> $stub + */ + public function __construct(private readonly array $stub) + { + } + + protected function rows(string $table, string $columns, array $ids): array + { + return $this->stub; + } + + protected function editUrl(): string + { + return 'https://example.com/admin/cms/block/edit'; + } + }; + } +} diff --git a/tests/FindReplace/Resolver/MenuTest.php b/tests/FindReplace/Resolver/MenuTest.php new file mode 100644 index 0000000..d2e7d01 --- /dev/null +++ b/tests/FindReplace/Resolver/MenuTest.php @@ -0,0 +1,100 @@ +tables() + ); + } + + public function testResolvesMenus(): void + { + $table = NAILS_DB_PREFIX . 'cms_menu'; + $resolver = $this->resolver([ + $table => [(object) ['id' => 2, 'label' => 'Main']], + ]); + + $hits = $resolver->resolve($table, [$this->match($table, '2', 'description')]); + + self::assertSame('CMS Menu', $hits['2']->getEntityLabel()); + self::assertSame('Main', $hits['2']->getTitle()); + self::assertSame('https://example.com/admin/cms/menu/edit/2', $hits['2']->getActions()[0]->getUrl()); + } + + public function testResolvesItemsThroughTheirMenu(): void + { + $table = NAILS_DB_PREFIX . 'cms_menu_item'; + $resolver = $this->resolver([ + $table => [ + (object) ['id' => 10, 'menu_id' => 2, 'label' => 'Contact'], + (object) ['id' => 11, 'menu_id' => 3, 'label' => 'Orphan'], + ], + NAILS_DB_PREFIX . 'cms_menu' => [ + (object) ['id' => 2, 'label' => 'Main'], + ], + ]); + + $hits = $resolver->resolve($table, [ + $this->match($table, '10', 'url'), + $this->match($table, '10', 'label'), + $this->match($table, '11', 'url'), + ]); + + self::assertCount(2, $hits); + self::assertSame('CMS Menu Item', $hits['10']->getEntityLabel()); + self::assertSame('10', $hits['10']->getEntityId()); + self::assertSame('Main › Contact', $hits['10']->getTitle()); + self::assertSame('Edit menu', $hits['10']->getActions()[0]->getLabel()); + self::assertSame('https://example.com/admin/cms/menu/edit/2', $hits['10']->getActions()[0]->getUrl()); + self::assertSame('Orphan', $hits['11']->getTitle()); + } + + private function match(string $table, string $id, string $column): Matched + { + return new Matched($table, $column, 'plaintext', 'example', $id, 'example'); + } + + /** + * @param array> $rows Keyed by table. + */ + private function resolver(array $rows): Menu + { + return new class ($rows) extends Menu { + /** + * @param array> $stub + */ + public function __construct(private readonly array $stub) + { + } + + protected function rows(string $table, string $columns, array $ids): array + { + return array_values(array_filter( + $this->stub[$table] ?? [], + fn(\stdClass $row): bool => in_array((string) $row->id, $ids, true) + )); + } + + protected function editUrl(): string + { + return 'https://example.com/admin/cms/menu/edit'; + } + }; + } +} diff --git a/tests/FindReplace/Resolver/PageTest.php b/tests/FindReplace/Resolver/PageTest.php new file mode 100644 index 0000000..fa6881a --- /dev/null +++ b/tests/FindReplace/Resolver/PageTest.php @@ -0,0 +1,111 @@ +tables()); + } + + public function testFacets(): void + { + $resolver = new Page(); + + self::assertSame(['status' => 'Status'], $resolver->facets()); + self::assertSame(['status' => ['PUBLISHED', 'DRAFT', 'DELETED']], $resolver->values()); + self::assertSame(['status'], $resolver->badges()); + } + + public function testResolvesPublishedDraftAndDeletedPages(): void + { + $table = NAILS_DB_PREFIX . 'cms_page'; + $resolver = $this->resolver([ + (object) ['id' => 1, 'published_title' => 'About', 'draft_title' => 'About us', 'is_published' => '1', 'is_deleted' => '0'], + (object) ['id' => 2, 'published_title' => '', 'draft_title' => 'Coming soon', 'is_published' => '0', 'is_deleted' => '0'], + (object) ['id' => 3, 'published_title' => 'Old', 'draft_title' => '', 'is_published' => '1', 'is_deleted' => '1'], + ]); + + $hits = $resolver->resolve($table, [ + $this->match($table, '1', 'published_template_data'), + $this->match($table, '1', 'draft_template_data'), + $this->match($table, '2', 'draft_title'), + $this->match($table, '3', 'published_title'), + ]); + + self::assertCount(3, $hits); + + self::assertSame('CMS Page', $hits['1']->getEntityLabel()); + self::assertSame('1', $hits['1']->getEntityId()); + self::assertSame('About', $hits['1']->getTitle()); + self::assertSame(['status' => 'PUBLISHED'], $hits['1']->getFacets()); + self::assertSame('https://example.com/admin/cms/pages/edit/1', $hits['1']->getActions()[0]->getUrl()); + + self::assertSame('Coming soon', $hits['2']->getTitle()); + self::assertSame(['status' => 'DRAFT'], $hits['2']->getFacets()); + self::assertCount(1, $hits['2']->getActions()); + + self::assertSame('Old', $hits['3']->getTitle()); + self::assertSame(['status' => 'DELETED'], $hits['3']->getFacets()); + self::assertSame([], $hits['3']->getActions()); + } + + public function testUntitledPageHasNoTitle(): void + { + $table = NAILS_DB_PREFIX . 'cms_page'; + $resolver = $this->resolver([ + (object) ['id' => 4, 'published_title' => '', 'draft_title' => '', 'is_published' => '0', 'is_deleted' => '0'], + ]); + + $hits = $resolver->resolve($table, [$this->match($table, '4', 'draft_seo_title')]); + + self::assertNull($hits['4']->getTitle()); + } + + public function testNoMatchesResolvesNothing(): void + { + self::assertSame([], $this->resolver([])->resolve(NAILS_DB_PREFIX . 'cms_page', [])); + } + + private function match(string $table, string $id, string $column): Matched + { + return new Matched($table, $column, 'plaintext', 'example', $id, 'example'); + } + + /** + * @param list<\stdClass> $rows + */ + private function resolver(array $rows): Page + { + return new class ($rows) extends Page { + /** + * @param list<\stdClass> $stub + */ + public function __construct(private readonly array $stub) + { + } + + protected function rows(string $table, string $columns, array $ids): array + { + return $this->stub; + } + + protected function editUrl(): string + { + return 'https://example.com/admin/cms/pages/edit'; + } + }; + } +}