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/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..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() @@ -82,9 +80,18 @@ 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/__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/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/sections/highlighted-text.jsx b/src/components/header-bar/command-palette/sections/highlighted-text.jsx new file mode 100644 index 0000000..5f28e9c --- /dev/null +++ b/src/components/header-bar/command-palette/sections/highlighted-text.jsx @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types' +import React from 'react' +import Highlighter from 'react-highlight-words' + +const highlightStyle = { + background: 'transparent', + color: 'inherit', + fontWeight: '600', + padding: 0, +} + +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..6f82f0e 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,16 @@ function ListItem({
{isShortcut && ( - <> - - {appName} + + - - - + + )} - {title} + {showDescription && ( {description} @@ -156,6 +160,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 +171,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/utils/filter.js b/src/components/header-bar/command-palette/utils/filter.js index 5c67529..b06a0e9 100644 --- a/src/components/header-bar/command-palette/utils/filter.js +++ b/src/components/header-bar/command-palette/utils/filter.js @@ -7,21 +7,49 @@ import { FILTERABLE_ACTION, SHORTCUT, } from './constants.js' +import { filterItemsArray, wrapAsFuseResult } from './fuzzy-matching.js' -export const fuseOptions = { - includeScore: true, - threshold: 0.3, - ignoreDiacritics: true, - shouldSort: true, - keys: ['displayName', 'name', 'appName'], - includeMatches: true, -} +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) + } -export const filterItemsArray = (fuse, filter) => - fuse.search(filter).map(({ item, matches }) => ({ item, matches })) + 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) + ) -export const wrapAsFuseResult = (list) => - list.map((item) => ({ item, matches: undefined })) + return { appsWithShortcuts, remainingShortcuts } +} export const filterItemsPerView = ({ appsFuse, @@ -78,34 +106,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, 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 = [ 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..ed21d27 --- /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.4, + 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..53c14eb --- /dev/null +++ b/src/components/header-bar/command-palette/utils/highlighting.js @@ -0,0 +1,66 @@ +// 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) + + 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..5d2bbe0 --- /dev/null +++ b/src/components/header-bar/command-palette/utils/highlighting.test.js @@ -0,0 +1,67 @@ +import { pickHighlightRanges } from './highlighting.js' + +describe('pickHighlightRanges function', () => { + it('returns matchedIndices unchanged when text to highlight 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() + }) + + 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], + ]) + }) + + 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]]) + }) +}) 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..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 @@ -14,7 +15,7 @@ const ListView = ({ grid, currentItem, resetModal }) => { {listItems.length > 0 ? (
{listItems.map((listItem, idx) => { - const { item } = listItem + const { item, matches } = listItem const { appName, action, @@ -29,13 +30,32 @@ const ListView = ({ grid, currentItem, resetModal }) => { const isImage = typeof icon === 'string' const isIcon = React.isValidElement(icon) + const titleKey = displayName ? 'displayName' : 'name' + 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 ( { ListView.propTypes = { currentItem: PropTypes.object, + filter: PropTypes.string, grid: PropTypes.array, resetModal: PropTypes.func, } 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"