From b04aa8bf258f179ecd1fc3b929804f7dc2437ec3 Mon Sep 17 00:00:00 2001 From: drita Date: Fri, 1 May 2026 14:24:39 +0200 Subject: [PATCH 01/11] feat: add react-highlight-words dependency --- package.json | 1 + yarn.lock | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/package.json b/package.json index d0478f6..7ae4ba1 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "apca-w3": "^0.1.9", "fuse.js": "^7.1.0", "js-cookie": "^3.0.5", + "react-highlight-words": "^0.21.0", "react-router": "^7.2.0", "styled-jsx": "^4.0.1" }, diff --git a/yarn.lock b/yarn.lock index e158b64..29def66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6588,6 +6588,11 @@ he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +highlight-words-core@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/highlight-words-core/-/highlight-words-core-1.2.3.tgz#781f37b2a220bf998114e4ef8c8cb6c7a4802ea8" + integrity sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz" @@ -8305,6 +8310,11 @@ memfs@^3.1.2, memfs@^3.4.3: dependencies: fs-monkey "^1.0.4" +memoize-one@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.1.0.tgz#a2387c58c03fff27ca390c31b764a79addf3f906" + integrity sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA== + meow@^13.2.0: version "13.2.0" resolved "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz" @@ -9531,6 +9541,14 @@ react-final-form@^6.5.3: dependencies: "@babel/runtime" "^7.15.4" +react-highlight-words@^0.21.0: + version "0.21.0" + resolved "https://registry.yarnpkg.com/react-highlight-words/-/react-highlight-words-0.21.0.tgz#a109acdf7dc6fac3ed7db82e9cba94e8d65c281c" + integrity sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ== + dependencies: + highlight-words-core "^1.2.0" + memoize-one "^4.0.0" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" From 7b4646744acb46d924f2a53c44591976887770e5 Mon Sep 17 00:00:00 2001 From: drita Date: Fri, 1 May 2026 15:06:52 +0200 Subject: [PATCH 02/11] feat: highlight fuzzy matched characters - use react-highlight-words highlighter component to mark all matched characters from the filter --- .../sections/highlighted-text.jsx | 41 +++++++++++++++++++ .../command-palette/sections/list-item.jsx | 27 ++++++++---- .../command-palette/views/list-view.jsx | 12 +++++- 3 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 src/components/header-bar/command-palette/sections/highlighted-text.jsx diff --git a/src/components/header-bar/command-palette/sections/highlighted-text.jsx b/src/components/header-bar/command-palette/sections/highlighted-text.jsx new file mode 100644 index 0000000..a592865 --- /dev/null +++ b/src/components/header-bar/command-palette/sections/highlighted-text.jsx @@ -0,0 +1,41 @@ +import { colors } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' +import Highlighter from 'react-highlight-words' + +const highlightStyle = { + background: colors.yellow100, + color: 'inherit', + padding: 0, + borderRadius: '2px', +} + +function HighlightedText({ text, indices }) { + if (!text) { + return null + } + if (!indices || indices.length === 0) { + return <>{text} + } + + return ( + + indices.map(([start, end]) => ({ start, end: end + 1 })) + } + highlightTag="mark" + highlightStyle={highlightStyle} + /> + ) +} + +HighlightedText.propTypes = { + indices: PropTypes.arrayOf( + PropTypes.arrayOf(PropTypes.number.isRequired) + ), + text: PropTypes.string, +} + +export default HighlightedText diff --git a/src/components/header-bar/command-palette/sections/list-item.jsx b/src/components/header-bar/command-palette/sections/list-item.jsx index 6d3c3be..1121a30 100644 --- a/src/components/header-bar/command-palette/sections/list-item.jsx +++ b/src/components/header-bar/command-palette/sections/list-item.jsx @@ -6,9 +6,12 @@ import React from 'react' import { Link } from 'react-router' import { linkClassName, linkStyles } from '../../react-router-link-styles.jsx' import { COMMAND, APP, SHORTCUT } from '../utils/constants.js' +import HighlightedText from './highlighted-text.jsx' function ListItem({ title, + titleMatchIndices, + appNameMatchIndices, icon, image, description, @@ -46,15 +49,19 @@ function ListItem({
{isShortcut && ( - <> - - {appName} + + - - - + + )} - {title} + {showDescription && ( {description} @@ -156,6 +163,9 @@ function ListItem({ ListItem.propTypes = { appName: PropTypes.string, + appNameMatchIndices: PropTypes.arrayOf( + PropTypes.arrayOf(PropTypes.number.isRequired) + ), dataTest: PropTypes.string, description: PropTypes.string, highlighted: PropTypes.bool, @@ -164,6 +174,9 @@ ListItem.propTypes = { path: PropTypes.string, resetModal: PropTypes.func, title: PropTypes.string, + titleMatchIndices: PropTypes.arrayOf( + PropTypes.arrayOf(PropTypes.number.isRequired) + ), type: PropTypes.string, onClickHandler: PropTypes.func, } diff --git a/src/components/header-bar/command-palette/views/list-view.jsx b/src/components/header-bar/command-palette/views/list-view.jsx index 2e352b5..e3fd80d 100644 --- a/src/components/header-bar/command-palette/views/list-view.jsx +++ b/src/components/header-bar/command-palette/views/list-view.jsx @@ -14,7 +14,7 @@ const ListView = ({ grid, currentItem, resetModal }) => { {listItems.length > 0 ? (
{listItems.map((listItem, idx) => { - const { item } = listItem + const { item, matches } = listItem const { appName, action, @@ -29,6 +29,14 @@ const ListView = ({ grid, currentItem, resetModal }) => { const isImage = typeof icon === 'string' const isIcon = React.isValidElement(icon) + const titleKey = displayName ? 'displayName' : 'name' + const titleMatchIndices = matches?.find( + (match) => match.key === titleKey + )?.indices + const appNameMatchIndices = matches?.find( + (match) => match.key === 'appName' + )?.indices + return ( { appName={appName} path={path} title={displayName || name} + titleMatchIndices={titleMatchIndices} + appNameMatchIndices={appNameMatchIndices} image={isImage ? icon : undefined} icon={isIcon ? icon : undefined} description={description} From ea0da342fa96b723c2f64c94f5ee701d37faa4ee Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 20 May 2026 01:01:06 +0300 Subject: [PATCH 03/11] refactor: extract grouping app with shortcuts logic into helper function - refine the logic in the filterItemsPerView function with separate helper - keep shortcut matches for each matched app for highlighting --- .../command-palette/utils/filter.js | 76 ++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/src/components/header-bar/command-palette/utils/filter.js b/src/components/header-bar/command-palette/utils/filter.js index 5c67529..308b3a7 100644 --- a/src/components/header-bar/command-palette/utils/filter.js +++ b/src/components/header-bar/command-palette/utils/filter.js @@ -23,6 +23,50 @@ export const filterItemsArray = (fuse, filter) => export const wrapAsFuseResult = (list) => list.map((item) => ({ item, matches: undefined })) + +const groupAppsWithShortcuts = ({ + filteredApps, + filteredShortcuts, + shortcuts, +}) => { + + // Group each app with its shortcuts + const shortcutsByApp = new Map() + for (const shortcut of shortcuts) { + if (!shortcutsByApp.has(shortcut.appName)) { + shortcutsByApp.set(shortcut.appName, []) + } + shortcutsByApp.get(shortcut.appName).push(shortcut) + } + + const matchesByShortcut = new Map( + // retain all the fuse matches for each filtered shortcut + filteredShortcuts.map(({ item, matches }) => [item, matches]) + ) + + // For all matched apps, return them with their shortcuts and their matches + const appsWithShortcuts = filteredApps.flatMap(({ item, matches }) => { + const appShortcuts = shortcutsByApp.get(item.displayName) ?? [] + + const appShortcutResults = appShortcuts.map((shortcut) => ({ + item: shortcut, + matches: matchesByShortcut.get(shortcut), + })) + return [{ item, matches }, ...appShortcutResults] + }) + + const matchedAppNames = new Set( + filteredApps.map(({ item }) => item.displayName || item.name) + ) + + // Filter for remaining shortcuts that matched the filter without their parent app + const remainingShortcuts = filteredShortcuts.filter( + ({ item }) => !matchedAppNames.has(item.appName) + ) + + return { appsWithShortcuts, remainingShortcuts } +} + export const filterItemsPerView = ({ appsFuse, commandsFuse, @@ -78,34 +122,14 @@ export const filterItemsPerView = ({ ({ item }) => item.type === FILTERABLE_ACTION ) - // Group each app with its shortcuts - // Filter for matched apps and return them with their shortcuts - // Append remaining shortcuts that match - const shortcutsByApp = new Map() - for (const shortcut of shortcuts) { - if (!shortcutsByApp.has(shortcut.appName)) { - shortcutsByApp.set(shortcut.appName, []) - } - shortcutsByApp.get(shortcut.appName).push(shortcut) - } - - const filteredAppsWithShortcuts = filteredApps.flatMap( - ({ item, matches }) => [ - { item, matches }, - ...wrapAsFuseResult(shortcutsByApp.get(item.displayName) ?? []), - ] - ) - - const matchedAppNames = new Set( - filteredApps.map(({ item }) => item.displayName || item.name) - ) - - const remainingShortcuts = filteredShortcuts.filter( - ({ item }) => !matchedAppNames.has(item.appName) - ) + const { appsWithShortcuts, remainingShortcuts } = groupAppsWithShortcuts({ + filteredApps, + filteredShortcuts, + shortcuts, + }) return [ - ...filteredAppsWithShortcuts, + ...appsWithShortcuts, ...remainingShortcuts, ...filteredCommands, ...filteredActions, From 37ad200d4a7c5fb60b7cafa3bfab897c098340cb Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 20 May 2026 02:54:59 +0300 Subject: [PATCH 04/11] feat: only highlight the characters that best match the filter - use the matched characters and number of words in filter to define highlighting range - use the longest contiguous range, or total number of matched characters - create separate files for fuzzy matching, highlighting, and filtering functions --- .../command-palette/command-palette.jsx | 8 +-- .../command-palette/utils/filter.js | 17 +---- .../command-palette/utils/fuzzy-matching.js | 18 ++++++ .../command-palette/utils/highlighting.js | 64 +++++++++++++++++++ .../command-palette/views/list-view.jsx | 27 +++++--- 5 files changed, 105 insertions(+), 29 deletions(-) create mode 100644 src/components/header-bar/command-palette/utils/fuzzy-matching.js create mode 100644 src/components/header-bar/command-palette/utils/highlighting.js diff --git a/src/components/header-bar/command-palette/command-palette.jsx b/src/components/header-bar/command-palette/command-palette.jsx index 3935e94..dd9af7c 100755 --- a/src/components/header-bar/command-palette/command-palette.jsx +++ b/src/components/header-bar/command-palette/command-palette.jsx @@ -13,11 +13,8 @@ import ModalContainer from './sections/modal-container.jsx' import NavigationKeysLegend from './sections/navigation-keys-legend.jsx' import SearchFilter from './sections/search-filter.jsx' import { ACTION, APP, HOME_VIEW, SHORTCUT } from './utils/constants.js' -import { - filterItemsPerView, - fuseOptions, - wrapAsFuseResult, -} from './utils/filter.js' +import { filterItemsPerView } from './utils/filter.js' +import { fuseOptions, wrapAsFuseResult } from './utils/fuzzy-matching.js' import HomeView from './views/home-view.jsx' import ListView from './views/list-view.jsx' @@ -238,6 +235,7 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { grid={grid} currentItem={currentItem} resetModal={resetModal} + filter={filter} /> )}
diff --git a/src/components/header-bar/command-palette/utils/filter.js b/src/components/header-bar/command-palette/utils/filter.js index 308b3a7..44e38f8 100644 --- a/src/components/header-bar/command-palette/utils/filter.js +++ b/src/components/header-bar/command-palette/utils/filter.js @@ -7,22 +7,7 @@ import { FILTERABLE_ACTION, SHORTCUT, } from './constants.js' - -export const fuseOptions = { - includeScore: true, - threshold: 0.3, - ignoreDiacritics: true, - shouldSort: true, - keys: ['displayName', 'name', 'appName'], - includeMatches: true, -} - -export const filterItemsArray = (fuse, filter) => - fuse.search(filter).map(({ item, matches }) => ({ item, matches })) - -export const wrapAsFuseResult = (list) => - list.map((item) => ({ item, matches: undefined })) - +import { filterItemsArray, wrapAsFuseResult } from './fuzzy-matching.js' const groupAppsWithShortcuts = ({ filteredApps, diff --git a/src/components/header-bar/command-palette/utils/fuzzy-matching.js b/src/components/header-bar/command-palette/utils/fuzzy-matching.js new file mode 100644 index 0000000..67d8ce7 --- /dev/null +++ b/src/components/header-bar/command-palette/utils/fuzzy-matching.js @@ -0,0 +1,18 @@ +// Fuzzy matching helpers and options +// Library: FuseJS +// Link: https://www.fusejs.io/fuzzy-search.html + +export const fuseOptions = { + includeScore: true, + threshold: 0.3, + ignoreDiacritics: true, + shouldSort: true, + keys: ['displayName', 'name', 'appName'], + includeMatches: true, +} + +export const filterItemsArray = (fuse, filter) => + fuse.search(filter).map(({ item, matches }) => ({ item, matches })) + +export const wrapAsFuseResult = (list) => + list.map((item) => ({ item, matches: undefined })) diff --git a/src/components/header-bar/command-palette/utils/highlighting.js b/src/components/header-bar/command-palette/utils/highlighting.js new file mode 100644 index 0000000..b30f013 --- /dev/null +++ b/src/components/header-bar/command-palette/utils/highlighting.js @@ -0,0 +1,64 @@ +// Highlighting functionality: +// For every returned search result, we only highlight the parts of the word/phrase that match most with the filter/query + +// TODO: see if new TokenSearch feature in fuse js library can be used - https://www.fusejs.io/token-search.html + +export const pickHighlightRanges = ({ + textToHighlight, + matchedIndices, + query, +}) => { + if (!matchedIndices?.length || !textToHighlight) { + return matchedIndices + } + + // for every word in the text to highlight, find its matched ranges + // find the longest range or the one with the most matched characters to determine what to highlight + const wordsWithMatches = [] + const allWordsInText = textToHighlight.matchAll(/\S+/g) + + for (const word of allWordsInText) { + const wordStart = word.index + const wordEnd = wordStart + word[0].length - 1 + + const rangesInWord = matchedIndices + .map(([rangeStart, rangeEnd]) => [ + Math.max(rangeStart, wordStart), + Math.min(rangeEnd, wordEnd), + ]) + .filter(([rangeStart, rangeEnd]) => rangeStart <= rangeEnd) + if (rangesInWord.length === 0) { + continue + } + + // longestRangeLength = length of the longest contiguous matched range + // totalMatchedChars = sum of all matched character lengths in this word + let longestRangeLength = 0 + let totalMatchedChars = 0 + for (const [rangeStart, rangeEnd] of rangesInWord) { + const rangeLength = rangeEnd - rangeStart + 1 + if (rangeLength > longestRangeLength) { + longestRangeLength = rangeLength + } + totalMatchedChars += rangeLength + } + wordsWithMatches.push({ + ranges: rangesInWord, + longestRangeLength, + totalMatchedChars, + }) + } + + // ranking words: longest range > total matched characters + wordsWithMatches.sort( + (a, b) => + b.longestRangeLength - a.longestRangeLength || + b.totalMatchedChars - a.totalMatchedChars + ) + + // highlight as many words as those entered into the filter/query + const queryWordCount = query?.match(/\S+/g)?.length || 1 + const wordsToHighlight = wordsWithMatches.slice(0, queryWordCount) + + return wordsToHighlight.flatMap((word) => word.ranges) +} diff --git a/src/components/header-bar/command-palette/views/list-view.jsx b/src/components/header-bar/command-palette/views/list-view.jsx index e3fd80d..5d46148 100644 --- a/src/components/header-bar/command-palette/views/list-view.jsx +++ b/src/components/header-bar/command-palette/views/list-view.jsx @@ -2,8 +2,9 @@ import PropTypes from 'prop-types' import React from 'react' import EmptySearchResults from '../sections/empty-search-results.jsx' import ListItem from '../sections/list-item.jsx' +import { pickHighlightRanges } from '../utils/highlighting.js' -const ListView = ({ grid, currentItem, resetModal }) => { +const ListView = ({ grid, currentItem, resetModal, filter }) => { const listItems = grid.reduce((acc, arr) => { acc.push(arr[0]) return acc @@ -30,12 +31,21 @@ const ListView = ({ grid, currentItem, resetModal }) => { const isIcon = React.isValidElement(icon) const titleKey = displayName ? 'displayName' : 'name' - const titleMatchIndices = matches?.find( - (match) => match.key === titleKey - )?.indices - const appNameMatchIndices = matches?.find( - (match) => match.key === 'appName' - )?.indices + const title = displayName || name + const titleMatchIndices = pickHighlightRanges({ + textToHighlight: title, + matchedIndices: matches?.find( + (match) => match.key === titleKey + )?.indices, + query: filter, + }) + const appNameMatchIndices = pickHighlightRanges({ + textToHighlight: appName, + matchedIndices: matches?.find( + (match) => match.key === 'appName' + )?.indices, + query: filter, + }) return ( { key={`list-item-${idx}-${name}`} appName={appName} path={path} - title={displayName || name} + title={title} titleMatchIndices={titleMatchIndices} appNameMatchIndices={appNameMatchIndices} image={isImage ? icon : undefined} @@ -66,6 +76,7 @@ const ListView = ({ grid, currentItem, resetModal }) => { ListView.propTypes = { currentItem: PropTypes.object, + filter: PropTypes.string, grid: PropTypes.array, resetModal: PropTypes.func, } From 4f4e161dd417fca4ff1ac003b2db46f37a211896 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 20 May 2026 03:41:28 +0300 Subject: [PATCH 05/11] test: update failing tests --- .../command-palette/__tests__/home-view.test.jsx | 9 ++++++--- .../header-bar/command-palette/utils/filter.test.js | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/header-bar/command-palette/__tests__/home-view.test.jsx b/src/components/header-bar/command-palette/__tests__/home-view.test.jsx index f2c5033..b3e9b56 100644 --- a/src/components/header-bar/command-palette/__tests__/home-view.test.jsx +++ b/src/components/header-bar/command-palette/__tests__/home-view.test.jsx @@ -82,9 +82,12 @@ describe('Command Palette - Home View', () => { const listItems = queryAllByTestId('headerbar-list-item') // 9 apps + 1 command + 1 shortcut expect(listItems.length).toBe(11) - expect(queryAllByText(/Test App/).length).toBe(9) - expect(queryByText(/Test Command/)).toBeInTheDocument() - expect(queryByText(/Test Shortcut/)).toBeInTheDocument() + const testApps = listItems.filter((item) => /Test App/.test(item.textContent)) + expect(testApps).toHaveLength(9) + const testCommand = listItems.filter((item) => /Test Command/.test(item.textContent)) + expect(testCommand).toHaveLength(1) + const testShortcut = listItems.filter((item) => /Test Shortcut/.test(item.textContent)) + expect(testShortcut).toHaveLength(1) // clear field const clearButton = getAllByRole('button')[1] diff --git a/src/components/header-bar/command-palette/utils/filter.test.js b/src/components/header-bar/command-palette/utils/filter.test.js index 4a6917a..5eeeca5 100644 --- a/src/components/header-bar/command-palette/utils/filter.test.js +++ b/src/components/header-bar/command-palette/utils/filter.test.js @@ -1,5 +1,5 @@ import Fuse from 'fuse.js' -import { filterItemsArray, fuseOptions } from './filter.js' +import { filterItemsArray, fuseOptions } from './fuzzy-matching.js' describe('filter helper functions', () => { const itemsToSearch = [ From cf9bf74c3c5484c37ace5c8385cbf8c07b5528c8 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 20 May 2026 03:42:32 +0300 Subject: [PATCH 06/11] style: linting --- .../command-palette/__tests__/home-view.test.jsx | 12 +++++++++--- .../command-palette/sections/highlighted-text.jsx | 4 +--- .../command-palette/sections/list-item.jsx | 5 +---- .../header-bar/command-palette/utils/filter.js | 5 ++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/header-bar/command-palette/__tests__/home-view.test.jsx b/src/components/header-bar/command-palette/__tests__/home-view.test.jsx index b3e9b56..1fc0de6 100644 --- a/src/components/header-bar/command-palette/__tests__/home-view.test.jsx +++ b/src/components/header-bar/command-palette/__tests__/home-view.test.jsx @@ -82,11 +82,17 @@ describe('Command Palette - Home View', () => { const listItems = queryAllByTestId('headerbar-list-item') // 9 apps + 1 command + 1 shortcut expect(listItems.length).toBe(11) - const testApps = listItems.filter((item) => /Test App/.test(item.textContent)) + const testApps = listItems.filter((item) => + /Test App/.test(item.textContent) + ) expect(testApps).toHaveLength(9) - const testCommand = listItems.filter((item) => /Test Command/.test(item.textContent)) + const testCommand = listItems.filter((item) => + /Test Command/.test(item.textContent) + ) expect(testCommand).toHaveLength(1) - const testShortcut = listItems.filter((item) => /Test Shortcut/.test(item.textContent)) + const testShortcut = listItems.filter((item) => + /Test Shortcut/.test(item.textContent) + ) expect(testShortcut).toHaveLength(1) // clear field diff --git a/src/components/header-bar/command-palette/sections/highlighted-text.jsx b/src/components/header-bar/command-palette/sections/highlighted-text.jsx index a592865..23bab26 100644 --- a/src/components/header-bar/command-palette/sections/highlighted-text.jsx +++ b/src/components/header-bar/command-palette/sections/highlighted-text.jsx @@ -32,9 +32,7 @@ function HighlightedText({ text, indices }) { } HighlightedText.propTypes = { - indices: PropTypes.arrayOf( - PropTypes.arrayOf(PropTypes.number.isRequired) - ), + indices: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number.isRequired)), text: PropTypes.string, } diff --git a/src/components/header-bar/command-palette/sections/list-item.jsx b/src/components/header-bar/command-palette/sections/list-item.jsx index 1121a30..6f82f0e 100644 --- a/src/components/header-bar/command-palette/sections/list-item.jsx +++ b/src/components/header-bar/command-palette/sections/list-item.jsx @@ -58,10 +58,7 @@ function ListItem({ )} - + {showDescription && ( {description} diff --git a/src/components/header-bar/command-palette/utils/filter.js b/src/components/header-bar/command-palette/utils/filter.js index 44e38f8..b06a0e9 100644 --- a/src/components/header-bar/command-palette/utils/filter.js +++ b/src/components/header-bar/command-palette/utils/filter.js @@ -14,7 +14,6 @@ const groupAppsWithShortcuts = ({ filteredShortcuts, shortcuts, }) => { - // Group each app with its shortcuts const shortcutsByApp = new Map() for (const shortcut of shortcuts) { @@ -23,7 +22,7 @@ const groupAppsWithShortcuts = ({ } shortcutsByApp.get(shortcut.appName).push(shortcut) } - + const matchesByShortcut = new Map( // retain all the fuse matches for each filtered shortcut filteredShortcuts.map(({ item, matches }) => [item, matches]) @@ -32,7 +31,7 @@ const groupAppsWithShortcuts = ({ // For all matched apps, return them with their shortcuts and their matches const appsWithShortcuts = filteredApps.flatMap(({ item, matches }) => { const appShortcuts = shortcutsByApp.get(item.displayName) ?? [] - + const appShortcutResults = appShortcuts.map((shortcut) => ({ item: shortcut, matches: matchesByShortcut.get(shortcut), From d77b1aecb86cc7d948a91cedd79d42f9d026df41 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 20 May 2026 04:21:59 +0300 Subject: [PATCH 07/11] chore: sonarqube fix to remove unused variables --- .../header-bar/command-palette/__tests__/home-view.test.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/header-bar/command-palette/__tests__/home-view.test.jsx b/src/components/header-bar/command-palette/__tests__/home-view.test.jsx index 1fc0de6..74a654e 100644 --- a/src/components/header-bar/command-palette/__tests__/home-view.test.jsx +++ b/src/components/header-bar/command-palette/__tests__/home-view.test.jsx @@ -36,8 +36,6 @@ describe('Command Palette - Home View', () => { queryByTestId, getAllByText, getByPlaceholderText, - queryAllByText, - queryByText, getAllByRole, queryAllByTestId, } = render() From da6862358a2783850e5033893cca848c6d8c859e Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Fri, 22 May 2026 03:50:03 +0300 Subject: [PATCH 08/11] feat: bump fuse threshold to 0.4 for more fuzzy matches - update failing test to cater for results returned with new threshold - refactor returned variable for highlighting ranges --- .../__tests__/search-results.test.jsx | 4 +- .../command-palette/utils/fuzzy-matching.js | 2 +- .../command-palette/utils/highlighting.js | 4 +- .../utils/highlighting.test.js | 144 ++++++++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 src/components/header-bar/command-palette/utils/highlighting.test.js diff --git a/src/components/header-bar/command-palette/__tests__/search-results.test.jsx b/src/components/header-bar/command-palette/__tests__/search-results.test.jsx index 084f3fa..f085cd6 100644 --- a/src/components/header-bar/command-palette/__tests__/search-results.test.jsx +++ b/src/components/header-bar/command-palette/__tests__/search-results.test.jsx @@ -143,7 +143,9 @@ describe('Command Palette - List View - Search Results', () => { // go to shortcuts await user.type(searchField, 'Browse shortcuts') - await user.keyboard('{Enter}') + // click the returned "Browse Shortcuts" action directly + await user.click(getByTestId('headerbar-browse-shortcuts')) + // redirect to the shortcuts page expect(queryByPlaceholderText('Search shortcuts')).toBeInTheDocument() // back action const backActionListItem = getByTestId('headerbar-back-action') diff --git a/src/components/header-bar/command-palette/utils/fuzzy-matching.js b/src/components/header-bar/command-palette/utils/fuzzy-matching.js index 67d8ce7..ed21d27 100644 --- a/src/components/header-bar/command-palette/utils/fuzzy-matching.js +++ b/src/components/header-bar/command-palette/utils/fuzzy-matching.js @@ -4,7 +4,7 @@ export const fuseOptions = { includeScore: true, - threshold: 0.3, + threshold: 0.4, ignoreDiacritics: true, shouldSort: true, keys: ['displayName', 'name', 'appName'], diff --git a/src/components/header-bar/command-palette/utils/highlighting.js b/src/components/header-bar/command-palette/utils/highlighting.js index b30f013..53c14eb 100644 --- a/src/components/header-bar/command-palette/utils/highlighting.js +++ b/src/components/header-bar/command-palette/utils/highlighting.js @@ -60,5 +60,7 @@ export const pickHighlightRanges = ({ const queryWordCount = query?.match(/\S+/g)?.length || 1 const wordsToHighlight = wordsWithMatches.slice(0, queryWordCount) - return wordsToHighlight.flatMap((word) => word.ranges) + const rangesToHighlight = wordsToHighlight.flatMap((word) => word.ranges) + + return rangesToHighlight } diff --git a/src/components/header-bar/command-palette/utils/highlighting.test.js b/src/components/header-bar/command-palette/utils/highlighting.test.js new file mode 100644 index 0000000..fcc3e9b --- /dev/null +++ b/src/components/header-bar/command-palette/utils/highlighting.test.js @@ -0,0 +1,144 @@ +import { pickHighlightRanges } from './highlighting.js' + +describe('pickHighlightRanges', () => { + describe('early returns', () => { + it('returns matchedIndices unchanged when textToHighlight is empty', () => { + expect( + pickHighlightRanges({ + textToHighlight: '', + matchedIndices: [[0, 1]], + query: 'a', + }) + ).toEqual([[0, 1]]) + }) + + it('returns matchedIndices unchanged when matchedIndices is empty', () => { + expect( + pickHighlightRanges({ + textToHighlight: 'Hello', + matchedIndices: [], + query: 'a', + }) + ).toEqual([]) + }) + + it('returns undefined when matchedIndices is undefined', () => { + expect( + pickHighlightRanges({ + textToHighlight: 'Hello', + matchedIndices: undefined, + query: 'a', + }) + ).toBeUndefined() + }) + }) + + describe('single-word query', () => { + it('keeps only the winning word and drops matches in other words', () => { + // text: "Rust settings" + // "Rust" positions 0-3 + // "settings" positions 5-12 + // Simulated Fuse indices: "st" in Rust, "sett" in settings. + // Top-1 winner: "settings" (longest range 4 > "Rust" 2). + const result = pickHighlightRanges({ + textToHighlight: 'Rust settings', + matchedIndices: [ + [2, 3], + [5, 8], + ], + query: 'sett', + }) + expect(result).toEqual([[5, 8]]) + }) + + it('skips words with no overlapping ranges entirely', () => { + // Indices only fall inside "World"; "Hello" and "Foo" are skipped. + const result = pickHighlightRanges({ + textToHighlight: 'Hello World Foo', + matchedIndices: [[6, 7]], + query: 'wo', + }) + expect(result).toEqual([[6, 7]]) + }) + }) + + describe('multi-word query', () => { + it('keeps the top N words where N is the number of words in query', () => { + // Both words match — "Notifications" wins on length, "App" still kept. + const result = pickHighlightRanges({ + textToHighlight: 'App Notifications', + matchedIndices: [ + [0, 2], + [4, 8], + ], + query: 'app notif', + }) + expect(result).toEqual([ + [4, 8], + [0, 2], + ]) + }) + + it('clips ranges that span word boundaries', () => { + // text: "Notification Settings" (space at index 12) + // Fuse returns one big span [0,20] covering both words. + // The single span must be clipped into per-word pieces. + const result = pickHighlightRanges({ + textToHighlight: 'Notification Settings', + matchedIndices: [[0, 20]], + query: 'notification settings', + }) + expect(result).toEqual([ + [0, 11], + [13, 20], + ]) + }) + }) + + describe('tie-breaking', () => { + // Regression test: when two words have the same total matched + // characters, the word with the longest contiguous range wins. + // Without this rule, "Notification" would win for query "setting" + // (it sorts first in the text) even though "Settings" has the + // clearly-superior 7-char contiguous match. + it('prefers longest contiguous range when totals are tied', () => { + // Real Fuse output for "setting" against "Notification Settings": + // "Notification" (0-11): [[0,0],[2,3],[5,5],[8,9],[11,11]] — total 7, longest 2 + // "Settings" (13-19): [[13,19]] — total 7, longest 7 + const result = pickHighlightRanges({ + textToHighlight: 'Notification Settings', + matchedIndices: [ + [0, 0], + [2, 3], + [5, 5], + [8, 9], + [11, 11], + [13, 19], + ], + query: 'setting', + }) + expect(result).toEqual([[13, 19]]) + }) + }) + + describe('query word count defaults', () => { + // Without these defaults, queryWordCount would be undefined and + // slice(0, undefined) would keep every word — re-introducing the + // "highlight scattered characters everywhere" bug. + test.each([ + { query: undefined, label: 'undefined' }, + { query: '', label: 'empty string' }, + { query: ' ', label: 'whitespace only' }, + ])('defaults to 1 word when query is $label', ({ query }) => { + const result = pickHighlightRanges({ + textToHighlight: 'Hello World', + matchedIndices: [ + [0, 4], + [6, 10], + ], + query, + }) + expect(result).toHaveLength(1) + }) + }) +}) From 27d353d47f37cb25964949b539b92da42c952aa4 Mon Sep 17 00:00:00 2001 From: Joseph John Aas Cooper <33054985+cooper-joe@users.noreply.github.com> Date: Fri, 22 May 2026 09:41:27 +0200 Subject: [PATCH 09/11] fix: highlight style --- .../header-bar/command-palette/sections/highlighted-text.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/header-bar/command-palette/sections/highlighted-text.jsx b/src/components/header-bar/command-palette/sections/highlighted-text.jsx index 23bab26..654cb08 100644 --- a/src/components/header-bar/command-palette/sections/highlighted-text.jsx +++ b/src/components/header-bar/command-palette/sections/highlighted-text.jsx @@ -4,10 +4,10 @@ import React from 'react' import Highlighter from 'react-highlight-words' const highlightStyle = { - background: colors.yellow100, + background: 'transparent', color: 'inherit', + fontWeight: '600', padding: 0, - borderRadius: '2px', } function HighlightedText({ text, indices }) { From e666589c0c32f1927a4324ddc3e86e8748f6cc02 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Thu, 28 May 2026 07:37:10 +0300 Subject: [PATCH 10/11] test: add unit tests for range highlighting util --- .../utils/highlighting.test.js | 179 +++++------------- 1 file changed, 51 insertions(+), 128 deletions(-) diff --git a/src/components/header-bar/command-palette/utils/highlighting.test.js b/src/components/header-bar/command-palette/utils/highlighting.test.js index fcc3e9b..5d2bbe0 100644 --- a/src/components/header-bar/command-palette/utils/highlighting.test.js +++ b/src/components/header-bar/command-palette/utils/highlighting.test.js @@ -1,144 +1,67 @@ import { pickHighlightRanges } from './highlighting.js' -describe('pickHighlightRanges', () => { - describe('early returns', () => { - it('returns matchedIndices unchanged when textToHighlight is empty', () => { - expect( - pickHighlightRanges({ - textToHighlight: '', - matchedIndices: [[0, 1]], - query: 'a', - }) - ).toEqual([[0, 1]]) - }) - - it('returns matchedIndices unchanged when matchedIndices is empty', () => { - expect( - pickHighlightRanges({ - textToHighlight: 'Hello', - matchedIndices: [], - query: 'a', - }) - ).toEqual([]) - }) - - it('returns undefined when matchedIndices is undefined', () => { - expect( - pickHighlightRanges({ - textToHighlight: 'Hello', - matchedIndices: undefined, - query: 'a', - }) - ).toBeUndefined() - }) - }) - - describe('single-word query', () => { - it('keeps only the winning word and drops matches in other words', () => { - // text: "Rust settings" - // "Rust" positions 0-3 - // "settings" positions 5-12 - // Simulated Fuse indices: "st" in Rust, "sett" in settings. - // Top-1 winner: "settings" (longest range 4 > "Rust" 2). - const result = pickHighlightRanges({ - textToHighlight: 'Rust settings', - matchedIndices: [ - [2, 3], - [5, 8], - ], - query: 'sett', - }) - expect(result).toEqual([[5, 8]]) - }) - - it('skips words with no overlapping ranges entirely', () => { - // Indices only fall inside "World"; "Hello" and "Foo" are skipped. - const result = pickHighlightRanges({ - textToHighlight: 'Hello World Foo', - matchedIndices: [[6, 7]], - query: 'wo', +describe('pickHighlightRanges function', () => { + it('returns matchedIndices unchanged when text to highlight is empty', () => { + expect( + pickHighlightRanges({ + textToHighlight: '', + matchedIndices: [[0, 1]], + query: 'a', }) - expect(result).toEqual([[6, 7]]) - }) + ).toEqual([[0, 1]]) }) - describe('multi-word query', () => { - it('keeps the top N words where N is the number of words in query', () => { - // Both words match — "Notifications" wins on length, "App" still kept. - const result = pickHighlightRanges({ - textToHighlight: 'App Notifications', - matchedIndices: [ - [0, 2], - [4, 8], - ], - query: 'app notif', + it('returns matchedIndices unchanged when matchedIndices is empty', () => { + expect( + pickHighlightRanges({ + textToHighlight: 'Hello', + matchedIndices: [], + query: 'a', }) - expect(result).toEqual([ - [4, 8], - [0, 2], - ]) - }) + ).toEqual([]) + }) - it('clips ranges that span word boundaries', () => { - // text: "Notification Settings" (space at index 12) - // Fuse returns one big span [0,20] covering both words. - // The single span must be clipped into per-word pieces. - const result = pickHighlightRanges({ - textToHighlight: 'Notification Settings', - matchedIndices: [[0, 20]], - query: 'notification settings', + it('returns undefined when matchedIndices is undefined', () => { + expect( + pickHighlightRanges({ + textToHighlight: 'Hello', + matchedIndices: undefined, + query: 'a', }) - expect(result).toEqual([ - [0, 11], - [13, 20], - ]) - }) + ).toBeUndefined() }) - describe('tie-breaking', () => { - // Regression test: when two words have the same total matched - // characters, the word with the longest contiguous range wins. - // Without this rule, "Notification" would win for query "setting" - // (it sorts first in the text) even though "Settings" has the - // clearly-superior 7-char contiguous match. - it('prefers longest contiguous range when totals are tied', () => { - // Real Fuse output for "setting" against "Notification Settings": - // "Notification" (0-11): [[0,0],[2,3],[5,5],[8,9],[11,11]] — total 7, longest 2 - // "Settings" (13-19): [[13,19]] — total 7, longest 7 - const result = pickHighlightRanges({ - textToHighlight: 'Notification Settings', - matchedIndices: [ - [0, 0], - [2, 3], - [5, 5], - [8, 9], - [11, 11], - [13, 19], - ], - query: 'setting', - }) - expect(result).toEqual([[13, 19]]) + it('matches the same number of words in the query as in the text to highlight', () => { + const result = pickHighlightRanges({ + textToHighlight: 'App Notifications', + matchedIndices: [ + [0, 2], + [4, 8], + ], + query: 'app notif', }) + expect(result).toEqual([ + [4, 8], + [0, 2], + ]) }) - describe('query word count defaults', () => { - // Without these defaults, queryWordCount would be undefined and - // slice(0, undefined) would keep every word — re-introducing the - // "highlight scattered characters everywhere" bug. - test.each([ - { query: undefined, label: 'undefined' }, - { query: '', label: 'empty string' }, - { query: ' ', label: 'whitespace only' }, - ])('defaults to 1 word when query is $label', ({ query }) => { - const result = pickHighlightRanges({ - textToHighlight: 'Hello World', - matchedIndices: [ - [0, 4], - [6, 10], - ], - query, - }) - expect(result).toHaveLength(1) + it('picks the longest contiguous range of matches and drops the rest', () => { + // Fuse output for "setting" against "Notification Settings": + // "Notification" (0-11): [[0,0],[2,3],[5,5],[8,9],[11,11]] — total matches: 7, longest contiguous match: 2 + // "Settings" (13-19): [[13,19]] — total matches: 7, longest contiguous match: 7 + const result = pickHighlightRanges({ + textToHighlight: 'Notification Settings', + matchedIndices: [ + [0, 0], + [2, 3], + [5, 5], + [8, 9], + [11, 11], + [13, 19], + ], + query: 'setting', }) + expect(result).toEqual([[13, 19]]) }) }) From 54b23079561c420faa0827b39776d92cc7a1db0e Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Thu, 28 May 2026 07:44:17 +0300 Subject: [PATCH 11/11] chore: remove unused colors import --- .../header-bar/command-palette/sections/highlighted-text.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/header-bar/command-palette/sections/highlighted-text.jsx b/src/components/header-bar/command-palette/sections/highlighted-text.jsx index 654cb08..5f28e9c 100644 --- a/src/components/header-bar/command-palette/sections/highlighted-text.jsx +++ b/src/components/header-bar/command-palette/sections/highlighted-text.jsx @@ -1,4 +1,3 @@ -import { colors } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React from 'react' import Highlighter from 'react-highlight-words'