This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 9936619c feat: support multi-term navigation search (#763)
9936619c is described below
commit 9936619c8bb55b4fb9c3f3c8ec368696fc089213
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:38:14 2026 +0800
feat: support multi-term navigation search (#763)
---
web/src/layouts/navigationSearch.test.ts | 22 ++++++++++++++++++
web/src/layouts/navigationSearch.ts | 40 +++++++++++++++++++++++++++-----
2 files changed, 56 insertions(+), 6 deletions(-)
diff --git a/web/src/layouts/navigationSearch.test.ts
b/web/src/layouts/navigationSearch.test.ts
index be107851..18f14801 100644
--- a/web/src/layouts/navigationSearch.test.ts
+++ b/web/src/layouts/navigationSearch.test.ts
@@ -22,6 +22,7 @@ describe('navigation search helpers', () => {
const entries = [
{ key: '/cluster', label: 'RocketMQ 集群' },
{ key: '/settings', label: 'Settings' },
+ { key: '/ops/system-alerts', label: 'Alert Events' },
];
it('filters labels case-insensitively and ignores query whitespace', () => {
@@ -34,6 +35,27 @@ describe('navigation search helpers', () => {
expect(filterNavigationEntries(entries,
'/settings')).toEqual([entries[1]]);
});
+ it('matches multiple terms across labels and normalized route segments', ()
=> {
+ expect(filterNavigationEntries(entries, 'system
alerts')).toEqual([entries[2]]);
+ expect(filterNavigationEntries(entries, 'OPS /
SYSTEM')).toEqual([entries[2]]);
+ expect(filterNavigationEntries(entries, 'rocketmq
cluster')).toEqual([entries[0]]);
+ expect(filterNavigationEntries(entries, 'system cluster')).toEqual([]);
+ });
+
+ it('ranks exact and prefix matches before broader matches', () => {
+ const rankedEntries = [
+ { key: '/settings/data-sources', label: 'Data Source Settings' },
+ { key: '/settings', label: 'Settings' },
+ { key: '/settings/general', label: 'Settings - General' },
+ ];
+
+ expect(filterNavigationEntries(rankedEntries, 'settings')).toEqual([
+ rankedEntries[1],
+ rankedEntries[2],
+ rankedEntries[0],
+ ]);
+ });
+
it('recognizes Control/Command-K but rejects alternative shortcuts', () => {
expect(
isNavigationSearchShortcut({ key: 'k', ctrlKey: true, metaKey: false,
altKey: false }),
diff --git a/web/src/layouts/navigationSearch.ts
b/web/src/layouts/navigationSearch.ts
index 04c58f7d..0be63cbc 100644
--- a/web/src/layouts/navigationSearch.ts
+++ b/web/src/layouts/navigationSearch.ts
@@ -23,17 +23,45 @@ export interface NavigationSearchEntry {
icon?: ReactNode;
}
+function normalizeSearchText(value: string): string {
+ return value
+ .normalize('NFKC')
+ .toLocaleLowerCase()
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
+ .trim();
+}
+
+function matchScore(entry: NavigationSearchEntry, query: string, terms:
string[]): number | null {
+ const label = normalizeSearchText(entry.label);
+ const key = normalizeSearchText(entry.key);
+ const searchableText = `${label} ${key}`;
+
+ if (!terms.every((term) => searchableText.includes(term))) return null;
+ if (label === query) return 0;
+ if (key === query) return 1;
+ if (label.startsWith(query)) return 2;
+ if (key.startsWith(query)) return 3;
+ if (label.includes(query)) return 4;
+ if (key.includes(query)) return 5;
+ return 6;
+}
+
export function filterNavigationEntries(
entries: NavigationSearchEntry[],
query: string,
): NavigationSearchEntry[] {
- const normalizedQuery = query.trim().toLocaleLowerCase();
+ const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return entries;
- return entries.filter(
- (entry) =>
- entry.label.toLocaleLowerCase().includes(normalizedQuery) ||
- entry.key.toLocaleLowerCase().includes(normalizedQuery),
- );
+ const terms = normalizedQuery.split(' ');
+
+ return entries
+ .map((entry, index) => ({ entry, index, score: matchScore(entry,
normalizedQuery, terms) }))
+ .filter(
+ (match): match is { entry: NavigationSearchEntry; index: number; score:
number } =>
+ match.score !== null,
+ )
+ .sort((left, right) => left.score - right.score || left.index -
right.index)
+ .map(({ entry }) => entry);
}
export function isNavigationSearchShortcut(event: {