Skip to content

Commit

Permalink
feat(popup-menu): add fuzzy search
Browse files Browse the repository at this point in the history
  • Loading branch information
philippfromme committed May 28, 2024
1 parent c2d3219 commit c9459fa
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 171 deletions.
26 changes: 2 additions & 24 deletions lib/features/popup-menu/PopupMenuComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {

import PopupMenuHeader from './PopupMenuHeader';
import PopupMenuList from './PopupMenuList';
import { searchEntries } from './PopupMenuSearchUtil';
import classNames from 'clsx';
import { isDefined, isFunction } from 'min-dash';

Expand Down Expand Up @@ -70,34 +71,11 @@ export default function PopupMenuComponent(props) {
const [ value, setValue ] = useState('');

const filterEntries = useCallback((originalEntries, value) => {

if (!searchable) {
return originalEntries;
}

const filter = entry => {
if (!value) {
return (entry.rank || 0) >= 0;
}

if (entry.searchable === false) {
return false;
}

const searchableFields = [
entry.description || '',
entry.label || '',
entry.search || ''
].map(string => string.toLowerCase());

// every word of `value` should be included in one of the searchable fields
return value
.toLowerCase()
.split(/\s/g)
.every(word => searchableFields.some(field => field.includes(word)));
};

return originalEntries.filter(filter);
return searchEntries(originalEntries, value);
}, [ searchable ]);

const [ entries, setEntries ] = useState(filterEntries(originalEntries, value));
Expand Down
23 changes: 23 additions & 0 deletions lib/features/popup-menu/PopupMenuSearchUtil.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Fuse from 'fuse.js/min-basic';

Check failure on line 1 in lib/features/popup-menu/PopupMenuSearchUtil.js

View workflow job for this annotation

GitHub Actions / Build (ubuntu-20.04)

Unable to resolve path to module 'fuse.js/min-basic'

const options = {
includeScore: true,
threshold: 0.25,
keys: [
'description',
'label',
'search'
]
};

export function searchEntries(entries, value) {
if (!value) {
return entries.filter(entry => (entry.rank || 0) >= 0);
}

const fuse = new Fuse(entries.filter(entry => entry.searchable !== false), options);

const result = fuse.search(value);

return result.map(({ item }) => item);
}
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@bpmn-io/diagram-js-ui": "^0.2.3",
"clsx": "^2.1.0",
"didi": "^10.2.2",
"fuse.js": "^7.0.0",
"inherits-browser": "^0.1.0",
"min-dash": "^4.1.0",
"min-dom": "^4.1.0",
Expand Down
104 changes: 18 additions & 86 deletions test/spec/features/popup-menu/PopupMenuComponentSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,49 +383,51 @@ describe('features/popup-menu - <PopupMenu>', function() {
describe('search', function() {

const entries = [
{ id: '1', label: 'Entry 1', description: 'Entry 1 description' },
{ id: '2', label: 'Entry 2' },
{ id: '3', label: 'Entry 3' },
{ id: '4', label: 'Entry 4' },
{ id: '5', label: 'Entry 5', search: 'foo' },
{ id: 'some_entry_id', label: 'Last' },
{ id: '7', label: 'Entry 7' , searchable: false }
{ id: 1, label: 'Apple' },
{ id: 2, label: 'Banana' },
{ id: 3, label: 'Cherry' },
{ id: 4, label: 'Orange' },
{ id: 5, label: 'Pineapple' },
{ id: 6, label: 'Watermelon' }
];


it('should filter entries + select first', async function() {
it('should filter entries & select first', async function() {

// given
await createPopupMenu({ container, entries, search: true });

var searchInput = domQuery('.djs-popup-search input', container);
searchInput.value = 'Entry 3';
searchInput.value = 'orange';

// when
await trigger(searchInput, keyDown('ArrowUp'));
await trigger(searchInput, keyUp('ArrowUp'));

// then
expect(domQueryAll('.entry', container)).to.have.length(1);
expect(domQuery('.entry', container).textContent).to.eql('Entry 3');
expect(domQuery('.selected', container).textContent).to.eql('Entry 3');
expect(domQuery('.entry', container).textContent).to.eql('Orange');
expect(domQuery('.selected', container).textContent).to.eql('Orange');
expect(domQuery('.djs-popup-no-results', container)).not.to.exist;
});


it('should allow partial search', async function() {
it('should filter entries (substring)', async function() {

// given
await createPopupMenu({ container, entries, search: true });

var searchInput = domQuery('.djs-popup-search input', container);
searchInput.value = 'Entry';
searchInput.value = 'ora';

// when
await trigger(searchInput, keyDown('ArrowDown'));
await trigger(searchInput, keyUp('ArrowDown'));

// then
expect(domQueryAll('.entry', container)).to.have.length(5);
expect(domQueryAll('.entry', container)).to.have.length(1);
expect(domQuery('.entry', container).textContent).to.eql('Orange');
expect(domQuery('.selected', container).textContent).to.eql('Orange');
expect(domQuery('.djs-popup-no-results', container)).not.to.exist;
});

Expand All @@ -441,7 +443,7 @@ describe('features/popup-menu - <PopupMenu>', function() {
});

var searchInput = domQuery('.djs-popup-search input', container);
searchInput.value = 'Foo bar';
searchInput.value = 'Blueberry';

// when
await trigger(searchInput, keyDown('ArrowDown'));
Expand All @@ -453,76 +455,6 @@ describe('features/popup-menu - <PopupMenu>', function() {
});


it('should search description', async function() {

// given
await createPopupMenu({ container, entries, search: true });

var searchInput = domQuery('.djs-popup-search input', container);
searchInput.value = entries[0].description;

// when
await trigger(searchInput, keyDown('ArrowUp'));
await trigger(searchInput, keyUp('ArrowUp'));

// then
expect(domQueryAll('.entry', container)).to.have.length(1);
expect(domQuery('.entry .djs-popup-label', container).textContent).to.eql('Entry 1');
});


it('should search additional "search" terms', async function() {

// given
await createPopupMenu({ container, entries, search: true });

var searchInput = domQuery('.djs-popup-search input', container);
searchInput.value = entries[4].search;

// when
await trigger(searchInput, keyDown('ArrowUp'));
await trigger(searchInput, keyUp('ArrowUp'));

// then
expect(domQueryAll('.entry', container)).to.have.length(1);
expect(domQuery('.entry .djs-popup-label', container).textContent).to.eql('Entry 5');
});


it('should not search id', async function() {

// given
await createPopupMenu({ container, entries, search: true });

var searchInput = domQuery('.djs-popup-search input', container);
searchInput.value = entries[5].id;

// when
await trigger(searchInput, keyDown('ArrowUp'));
await trigger(searchInput, keyUp('ArrowUp'));

// then
expect(domQueryAll('.entry', container)).to.have.length(0);
});


it('should not search non-searchable entries', async function() {

// given
await createPopupMenu({ container, entries, search: true });

var searchInput = domQuery('.djs-popup-search input', container);
searchInput.value = 'entry';

// when
await trigger(searchInput, keyDown('ArrowUp'));
await trigger(searchInput, keyUp('ArrowUp'));

// then
expect(domQuery('.entry[data-id="7"]', container)).to.not.exist;
});


describe('render', function() {

const otherEntries = [
Expand Down Expand Up @@ -552,7 +484,7 @@ describe('features/popup-menu - <PopupMenu>', function() {
});


it('should be hidden (less than 5 entries)', async function() {
it('should be hidden (5 entries or less)', async function() {

// given
await createPopupMenu({ container, entries: otherEntries, search: true });
Expand Down
89 changes: 89 additions & 0 deletions test/spec/features/popup-menu/PopupMenuSearchUtilSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { searchEntries } from '../../../../lib/features/popup-menu/PopupMenuSearchUtil';


describe('features/popup-menu - PopupMenuSearchUtil', function() {

describe('ranking', function() {

it('should hide rank < 0 if not searching', function() {

// given
const entries = [
{ id: 'entry-1', rank: -1 },
{ id: 'entry-2' },
{ id: 'entry-3' }
];

// when
const result = searchEntries(entries, '');

// then
expect(result).to.have.length(2);
expect(result[0]).to.equal(entries[1]);
expect(result[1]).to.equal(entries[2]);
});

});


describe('searching', function() {

const entries = [
{ id: 1, label: 'Apple' },
{ id: 2, label: 'Banana' },
{ id: 3, label: 'Cherry' },
{ id: 4, label: 'Orange' },
{ id: 5, label: 'Clementine', search: 'Mandarine Tangerine' },
{ id: 6, label: 'Pineapple', description: 'Tropical fruit' },
{ id: 7, label: 'Watermelon' }
];

expectEntries('should find entries by <label> (substring)', entries, [
'Banana'
], 'ban');

expectEntries('should find entries by <label>', entries, [
'Banana'
], 'banana');

expectEntries('should find entries by <label> (superstring)', entries, [
'Banana'
], 'bananas');

expectEntries('should find entries by <label> (below threshold)', entries, [
'Banana'
], 'ananas');

expectEntries('should not find entries by <label> (above threshold)', entries, [], 'panama');

expectEntries('should find entries by <label> (rank by location)', entries, [
'Apple',
'Pineapple'
], 'apple');

expectEntries('shoud find entries by <description>', entries, [
'Pineapple'
], 'tropical');

expectEntries('should find entries by <search>', entries, [
'Clementine'
], 'mandarine');

});

});

function expectEntries(title, entries, expected, search) {
it(title, function() {

// when
const result = searchEntries(entries, search);

// then
expect(result).to.have.length(expected.length);

expected.forEach((label, index) => {
expect(result[ index ].label).to.equal(label);
});
});
}
Loading

0 comments on commit c9459fa

Please sign in to comment.