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