Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ describe('Command Palette - Home View', () => {
queryByTestId,
getAllByText,
getByPlaceholderText,
queryAllByText,
queryByText,
getAllByRole,
queryAllByTestId,
} = render(<WrappedCommandPalette />)
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 3 additions & 5 deletions src/components/header-bar/command-palette/command-palette.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -238,6 +235,7 @@ const CommandPalette = ({ apps, commands, shortcuts }) => {
grid={grid}
currentItem={currentItem}
resetModal={resetModal}
filter={filter}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Highlighter
searchWords={[]}
textToHighlight={text}
findChunks={() =>
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
24 changes: 17 additions & 7 deletions src/components/header-bar/command-palette/sections/list-item.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,15 +49,16 @@ function ListItem({
<div className="text-content">
<span className="title">
{isShortcut && (
<>
<span className="shortcut-app-name">
{appName}
<span className="shortcut-app-name">
<HighlightedText
text={appName}
indices={appNameMatchIndices}
/>

<IconChevronRight16 />
</span>
</>
<IconChevronRight16 />
</span>
)}
{title}
<HighlightedText text={title} indices={titleMatchIndices} />
</span>
{showDescription && (
<span className="description">{description}</span>
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}
Expand Down
84 changes: 46 additions & 38 deletions src/components/header-bar/command-palette/utils/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
18 changes: 18 additions & 0 deletions src/components/header-bar/command-palette/utils/fuzzy-matching.js
Original file line number Diff line number Diff line change
@@ -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 }))
66 changes: 66 additions & 0 deletions src/components/header-bar/command-palette/utils/highlighting.js
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading