From a6dff37e245d495131090bbcea6fe3578337de00 Mon Sep 17 00:00:00 2001
From: biz87
Date: Wed, 4 Mar 2026 00:47:18 +0500
Subject: [PATCH 1/3] Add OnManagerSearch event to allow extending quick search
(#16912)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a system event that fires during manager quick search processing,
allowing plugins to register custom search providers via declarative
configuration arrays. The processor builds and executes xPDO queries
from these configs, supporting joins, custom icons/labels, and
permission checks. No JS changes needed — the frontend already
supports custom result types.
---
_build/data/transport.core.events.php | 8 ++
.../Revolution/Processors/Search/Search.php | 97 +++++++++++++++++++
.../3.2.1-add-on-manager-search-event.php | 35 +++++++
setup/includes/upgrades/mysql/3.2.1-pl.php | 12 +++
4 files changed, 152 insertions(+)
create mode 100644 setup/includes/upgrades/common/3.2.1-add-on-manager-search-event.php
create mode 100644 setup/includes/upgrades/mysql/3.2.1-pl.php
diff --git a/_build/data/transport.core.events.php b/_build/data/transport.core.events.php
index ed1b5882a87..5492f065d72 100644
--- a/_build/data/transport.core.events.php
+++ b/_build/data/transport.core.events.php
@@ -1197,6 +1197,14 @@
'groupname' => 'Media Sources',
], '', true, true);
+/* Search */
+$events['OnManagerSearch']= $xpdo->newObject(modEvent::class);
+$events['OnManagerSearch']->fromArray([
+ 'name' => 'OnManagerSearch',
+ 'service' => 2,
+ 'groupname' => 'System',
+], '', true, true);
+
/* Package Manager */
$events['OnPackageInstall']= $xpdo->newObject(modEvent::class);
$events['OnPackageInstall']->fromArray([
diff --git a/core/src/Revolution/Processors/Search/Search.php b/core/src/Revolution/Processors/Search/Search.php
index 369cbbe7a8c..7dcd47da3aa 100644
--- a/core/src/Revolution/Processors/Search/Search.php
+++ b/core/src/Revolution/Processors/Search/Search.php
@@ -11,6 +11,7 @@
namespace MODX\Revolution\Processors\Search;
+use ArrayObject;
use MODX\Revolution\modChunk;
use MODX\Revolution\modContext;
use MODX\Revolution\modElement;
@@ -94,6 +95,19 @@ public function process()
if ($this->modx->hasPermission('edit_user')) {
$this->searchUsers();
}
+
+ $providers = new ArrayObject();
+ $this->modx->invokeEvent('OnManagerSearch', [
+ 'query' => $this->query,
+ 'providers' => $providers,
+ 'maxResults' => $this->getMaxResults(),
+ 'searchInContent' => $this->searchInContent(),
+ ]);
+ foreach ($providers as $provider) {
+ if (is_array($provider)) {
+ $this->searchProvider($provider);
+ }
+ }
}
return $this->outputArray($this->results);
@@ -224,4 +238,87 @@ protected function searchUsers()
];
}
}
+
+ /**
+ * Searches using a provider configuration from the OnManagerSearch event.
+ *
+ * Required config keys: class, type, nameField, action.
+ * Optional: descriptionField, contentField, label, icon, permission, joins, searchFields.
+ *
+ * @param array $config Provider configuration
+ */
+ protected function searchProvider(array $config)
+ {
+ if (
+ empty($config['class'])
+ || empty($config['type'])
+ || empty($config['nameField'])
+ || empty($config['action'])
+ ) {
+ return;
+ }
+
+ if (!empty($config['permission']) && !$this->modx->hasPermission($config['permission'])) {
+ return;
+ }
+
+ $class = $config['class'];
+ $nameField = $config['nameField'];
+ $descriptionField = $config['descriptionField'] ?? '';
+ $contentField = $config['contentField'] ?? '';
+
+ $c = $this->modx->newQuery($class);
+
+ if (!empty($config['joins']) && is_array($config['joins'])) {
+ foreach ($config['joins'] as $join) {
+ if (empty($join['class']) || empty($join['alias']) || empty($join['on'])) {
+ continue;
+ }
+ $joinType = strtolower($join['type'] ?? 'left');
+ match ($joinType) {
+ 'inner' => $c->innerJoin($join['class'], $join['alias'], $join['on']),
+ 'right' => $c->rightJoin($join['class'], $join['alias'], $join['on']),
+ default => $c->leftJoin($join['class'], $join['alias'], $join['on']),
+ };
+ }
+ }
+
+ $querySearch = [
+ $nameField . ':LIKE' => '%' . $this->query . '%',
+ ];
+ if (!empty($descriptionField)) {
+ $querySearch['OR:' . $descriptionField . ':LIKE'] = '%' . $this->query . '%';
+ }
+ if ($this->searchInContent() && !empty($contentField)) {
+ $querySearch['OR:' . $contentField . ':LIKE'] = '%' . $this->query . '%';
+ }
+ if (!empty($config['searchFields']) && is_array($config['searchFields'])) {
+ foreach ($config['searchFields'] as $field) {
+ $querySearch['OR:' . $field . ':LIKE'] = '%' . $this->query . '%';
+ }
+ }
+ $querySearch['OR:id:='] = $this->query;
+ $c->where($querySearch);
+
+ $c->sortby('IF(`' . $nameField . '` = ' . $this->modx->quote($this->query) . ', 0, 1)');
+
+ $c->limit($this->getMaxResults());
+
+ $collection = $this->modx->getIterator($class, $c);
+ foreach ($collection as $record) {
+ $result = [
+ 'name' => $record->get($nameField),
+ 'description' => !empty($descriptionField) ? $record->get($descriptionField) : '',
+ '_action' => $config['action'] . $record->get('id'),
+ 'type' => $config['type'],
+ ];
+ if (!empty($config['label'])) {
+ $result['label'] = $config['label'];
+ }
+ if (!empty($config['icon'])) {
+ $result['icon'] = $config['icon'];
+ }
+ $this->results[] = $result;
+ }
+ }
}
diff --git a/setup/includes/upgrades/common/3.2.1-add-on-manager-search-event.php b/setup/includes/upgrades/common/3.2.1-add-on-manager-search-event.php
new file mode 100644
index 00000000000..eaba68eac80
--- /dev/null
+++ b/setup/includes/upgrades/common/3.2.1-add-on-manager-search-event.php
@@ -0,0 +1,35 @@
+%s
';
+
+$event = $modx->getObject(modEvent::class, ['name' => 'OnManagerSearch']);
+if (!$event) {
+ $event = $modx->newObject(modEvent::class);
+ $event->fromArray([
+ 'name' => 'OnManagerSearch',
+ 'service' => 2,
+ 'groupname' => 'System',
+ ]);
+ if ($event->save()) {
+ $this->runner->addResult(
+ modInstallRunner::RESULT_SUCCESS,
+ sprintf($messageTemplate, 'ok', 'Added OnManagerSearch system event.')
+ );
+ } else {
+ $this->runner->addResult(
+ modInstallRunner::RESULT_ERROR,
+ sprintf($messageTemplate, 'error', 'Failed to add OnManagerSearch system event.')
+ );
+ }
+}
diff --git a/setup/includes/upgrades/mysql/3.2.1-pl.php b/setup/includes/upgrades/mysql/3.2.1-pl.php
new file mode 100644
index 00000000000..8351e08482e
--- /dev/null
+++ b/setup/includes/upgrades/mysql/3.2.1-pl.php
@@ -0,0 +1,12 @@
+
Date: Wed, 4 Mar 2026 01:03:54 +0500
Subject: [PATCH 2/3] Fix review issues in searchProvider()
- Fix sortby with prefixed nameField (e.g. Product.name)
- Add select() for main class when joins are used to prevent column conflicts
- Validate xPDO class via getTableName() before querying
- Add optional idField config key (default: id) for custom primary keys
- Validate searchFields elements are non-empty strings
---
.../Revolution/Processors/Search/Search.php | 23 +++++++++++++++----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/core/src/Revolution/Processors/Search/Search.php b/core/src/Revolution/Processors/Search/Search.php
index 7dcd47da3aa..9bb40cf9f4b 100644
--- a/core/src/Revolution/Processors/Search/Search.php
+++ b/core/src/Revolution/Processors/Search/Search.php
@@ -243,7 +243,7 @@ protected function searchUsers()
* Searches using a provider configuration from the OnManagerSearch event.
*
* Required config keys: class, type, nameField, action.
- * Optional: descriptionField, contentField, label, icon, permission, joins, searchFields.
+ * Optional: descriptionField, contentField, label, icon, permission, idField, joins, searchFields.
*
* @param array $config Provider configuration
*/
@@ -263,13 +263,23 @@ protected function searchProvider(array $config)
}
$class = $config['class'];
+
+ if (!$this->modx->getTableName($class)) {
+ return;
+ }
+
$nameField = $config['nameField'];
$descriptionField = $config['descriptionField'] ?? '';
$contentField = $config['contentField'] ?? '';
+ $idField = $config['idField'] ?? 'id';
$c = $this->modx->newQuery($class);
if (!empty($config['joins']) && is_array($config['joins'])) {
+ $pos = strrpos($class, '\\');
+ $classAlias = $pos !== false ? substr($class, $pos + 1) : $class;
+ $c->select($this->modx->getSelectColumns($class, $classAlias));
+
foreach ($config['joins'] as $join) {
if (empty($join['class']) || empty($join['alias']) || empty($join['on'])) {
continue;
@@ -294,13 +304,16 @@ protected function searchProvider(array $config)
}
if (!empty($config['searchFields']) && is_array($config['searchFields'])) {
foreach ($config['searchFields'] as $field) {
- $querySearch['OR:' . $field . ':LIKE'] = '%' . $this->query . '%';
+ if (is_string($field) && $field !== '') {
+ $querySearch['OR:' . $field . ':LIKE'] = '%' . $this->query . '%';
+ }
}
}
- $querySearch['OR:id:='] = $this->query;
+ $querySearch['OR:' . $idField . ':='] = $this->query;
$c->where($querySearch);
- $c->sortby('IF(`' . $nameField . '` = ' . $this->modx->quote($this->query) . ', 0, 1)');
+ $sortField = str_contains($nameField, '.') ? $nameField : '`' . $nameField . '`';
+ $c->sortby('IF(' . $sortField . ' = ' . $this->modx->quote($this->query) . ', 0, 1)');
$c->limit($this->getMaxResults());
@@ -309,7 +322,7 @@ protected function searchProvider(array $config)
$result = [
'name' => $record->get($nameField),
'description' => !empty($descriptionField) ? $record->get($descriptionField) : '',
- '_action' => $config['action'] . $record->get('id'),
+ '_action' => $config['action'] . $record->get($idField),
'type' => $config['type'],
];
if (!empty($config['label'])) {
From f6098426159ad635401818606e2d0becfb0bce1d Mon Sep 17 00:00:00 2001
From: biz87
Date: Thu, 5 Mar 2026 21:58:40 +0500
Subject: [PATCH 3/3] Fix searchProvider() for prefixed field names
(Alias.field)
Select dotted fields from joined tables and strip alias prefix
before xPDO get() calls to correctly resolve values.
---
.../src/Revolution/Processors/Search/Search.php | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/core/src/Revolution/Processors/Search/Search.php b/core/src/Revolution/Processors/Search/Search.php
index 9bb40cf9f4b..085f92535a3 100644
--- a/core/src/Revolution/Processors/Search/Search.php
+++ b/core/src/Revolution/Processors/Search/Search.php
@@ -291,6 +291,12 @@ protected function searchProvider(array $config)
default => $c->leftJoin($join['class'], $join['alias'], $join['on']),
};
}
+
+ foreach ([$nameField, $descriptionField, $contentField, $idField] as $field) {
+ if ($field !== '' && str_contains($field, '.')) {
+ $c->select($field);
+ }
+ }
}
$querySearch = [
@@ -317,12 +323,17 @@ protected function searchProvider(array $config)
$c->limit($this->getMaxResults());
+ $nameFieldKey = str_contains($nameField, '.') ? substr($nameField, strrpos($nameField, '.') + 1) : $nameField;
+ $descFieldKey = ($descriptionField !== '' && str_contains($descriptionField, '.'))
+ ? substr($descriptionField, strrpos($descriptionField, '.') + 1) : $descriptionField;
+ $idFieldKey = str_contains($idField, '.') ? substr($idField, strrpos($idField, '.') + 1) : $idField;
+
$collection = $this->modx->getIterator($class, $c);
foreach ($collection as $record) {
$result = [
- 'name' => $record->get($nameField),
- 'description' => !empty($descriptionField) ? $record->get($descriptionField) : '',
- '_action' => $config['action'] . $record->get($idField),
+ 'name' => $record->get($nameFieldKey),
+ 'description' => !empty($descFieldKey) ? $record->get($descFieldKey) : '',
+ '_action' => $config['action'] . $record->get($idFieldKey),
'type' => $config['type'],
];
if (!empty($config['label'])) {