diff --git a/lib/features/popup-menu/PopupMenuComponent.js b/lib/features/popup-menu/PopupMenuComponent.js index 378eeacab..012b7e2aa 100644 --- a/lib/features/popup-menu/PopupMenuComponent.js +++ b/lib/features/popup-menu/PopupMenuComponent.js @@ -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'; @@ -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)); diff --git a/lib/features/popup-menu/PopupMenuSearchUtil.js b/lib/features/popup-menu/PopupMenuSearchUtil.js new file mode 100644 index 000000000..6d949e909 --- /dev/null +++ b/lib/features/popup-menu/PopupMenuSearchUtil.js @@ -0,0 +1,23 @@ +// eslint-disable-next-line import/no-unresolved +import Fuse from 'fuse.js/min-basic'; + +const options = { + 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); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 682252b6e..d54efd1a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,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", @@ -3783,6 +3784,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -11272,6 +11281,11 @@ "version": "1.2.3", "dev": true }, + "fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==" + }, "gensync": { "version": "1.0.0-beta.2", "dev": true diff --git a/package.json b/package.json index 64ed1e7e3..be250c824 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/spec/features/popup-menu/PopupMenuComponentSpec.js b/test/spec/features/popup-menu/PopupMenuComponentSpec.js index 6ce440fea..9cd032da2 100644 --- a/test/spec/features/popup-menu/PopupMenuComponentSpec.js +++ b/test/spec/features/popup-menu/PopupMenuComponentSpec.js @@ -383,23 +383,22 @@ describe('features/popup-menu - ', 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')); @@ -407,25 +406,28 @@ describe('features/popup-menu - ', function() { // 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; }); @@ -441,7 +443,7 @@ describe('features/popup-menu - ', function() { }); var searchInput = domQuery('.djs-popup-search input', container); - searchInput.value = 'Foo bar'; + searchInput.value = 'Blueberry'; // when await trigger(searchInput, keyDown('ArrowDown')); @@ -453,76 +455,6 @@ describe('features/popup-menu - ', 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 = [ @@ -552,7 +484,7 @@ describe('features/popup-menu - ', 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 }); diff --git a/test/spec/features/popup-menu/PopupMenuSearchUtilSpec.js b/test/spec/features/popup-menu/PopupMenuSearchUtilSpec.js new file mode 100644 index 000000000..d7621eba9 --- /dev/null +++ b/test/spec/features/popup-menu/PopupMenuSearchUtilSpec.js @@ -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