diff --git a/lib/features/popup-menu/PopupMenu.js b/lib/features/popup-menu/PopupMenu.js index e673ed72c..a70ba8b48 100644 --- a/lib/features/popup-menu/PopupMenu.js +++ b/lib/features/popup-menu/PopupMenu.js @@ -60,9 +60,10 @@ var DEFAULT_PRIORITY = 1000; * @param {EventBus} eventBus * @param {Canvas} canvas */ -export default function PopupMenu(config, eventBus, canvas) { +export default function PopupMenu(config, eventBus, canvas, search) { this._eventBus = eventBus; this._canvas = canvas; + this._search = search; this._current = null; @@ -94,7 +95,8 @@ export default function PopupMenu(config, eventBus, canvas) { PopupMenu.$inject = [ 'config.popupMenu', 'eventBus', - 'canvas' + 'canvas', + 'search' ]; PopupMenu.prototype._render = function() { @@ -138,6 +140,7 @@ PopupMenu.prototype._render = function() { scale=${ scale } onOpened=${ this._onOpened.bind(this) } onClosed=${ this._onClosed.bind(this) } + searchFn=${ this._search } ...${{ ...options }} /> `, @@ -548,7 +551,6 @@ PopupMenu.prototype._getHeaderEntries = function(target, providers) { PopupMenu.prototype._getEmptyPlaceholder = function(providers) { - const provider = providers.find( provider => isFunction(provider.getEmptyPlaceholder) ); diff --git a/lib/features/popup-menu/PopupMenuComponent.js b/lib/features/popup-menu/PopupMenuComponent.js index 378eeacab..212fa0954 100644 --- a/lib/features/popup-menu/PopupMenuComponent.js +++ b/lib/features/popup-menu/PopupMenuComponent.js @@ -54,6 +54,7 @@ export default function PopupMenuComponent(props) { scale, search, emptyPlaceholder, + searchFn, entries: originalEntries, onOpened, onClosed @@ -75,29 +76,19 @@ export default function PopupMenuComponent(props) { return originalEntries; } - const filter = entry => { - if (!value) { - return (entry.rank || 0) >= 0; - } - - if (entry.searchable === false) { - return false; - } + if (!value) { + return originalEntries.filter(({ rank = 0 }) => rank >= 0); + } - 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))); - }; + const searchableEntries = originalEntries.filter(({ searchable }) => searchable !== false); - return originalEntries.filter(filter); + return searchFn(searchableEntries, value, { + keys: [ + 'label', + 'description', + 'search' + ] + }).map(({ item }) => item); }, [ searchable ]); const [ entries, setEntries ] = useState(filterEntries(originalEntries, value)); @@ -198,7 +189,7 @@ export default function PopupMenuComponent(props) { - + ` } diff --git a/lib/features/popup-menu/index.js b/lib/features/popup-menu/index.js index 366a636d0..e93c238ce 100644 --- a/lib/features/popup-menu/index.js +++ b/lib/features/popup-menu/index.js @@ -1,10 +1,13 @@ import PopupMenu from './PopupMenu'; +import Search from '../search'; + /** * @type { import('didi').ModuleDeclaration } */ export default { + __depends__: [ Search ], __init__: [ 'popupMenu' ], popupMenu: [ 'type', PopupMenu ] }; diff --git a/lib/features/search/index.js b/lib/features/search/index.js new file mode 100644 index 000000000..4f073ef19 --- /dev/null +++ b/lib/features/search/index.js @@ -0,0 +1,8 @@ +import search from './search'; + +/** + * @type { import('didi').ModuleDeclaration } + */ +export default { + search: [ 'value', search ] +}; \ No newline at end of file diff --git a/lib/features/search/search.js b/lib/features/search/search.js new file mode 100644 index 000000000..87ef123b0 --- /dev/null +++ b/lib/features/search/search.js @@ -0,0 +1,244 @@ +/** + * @typedef { { + * index: number; + * match: boolean; + * value: string; + * } } Token + * + * @typedef {Token[]} Tokens + * + * @typedef { { + * item: Object, + * tokens: Record + * } } SearchResult + * + * @typedef {SearchResult[]} SearchResults + */ + +/** + * Search items by query. + * + * @param {Object[]} items + * @param {string} pattern + * @param { { + * keys: string[]; + * } } options + * + * @returns {SearchResults} + */ +export default function search(items, pattern, options) { + return items.reduce((results, item) => { + const tokens = getTokens(item, pattern, options.keys); + + if (Object.keys(tokens).length) { + const result = { + item, + tokens + }; + + const index = getIndex(result, results, options.keys); + + results.splice(index, 0, result); + } + + return results; + }, []); +} + +/** + * Get tokens for item. + * + * @param {Object} item + * @param {string} pattern + * @param {string[]} keys + * + * @returns {Record} + */ +function getTokens(item, pattern, keys) { + return keys.reduce((results, key) => { + const string = item[ key ]; + + const tokens = getMatchingTokens(string, pattern); + + if (hasMatch(tokens)) { + results[ key ] = tokens; + } + + return results; + }, {}); +} + +/** + * Get index of result in list of results. + * + * @param {SearchResult} result + * @param {SearchResults} results + * @param {string[]} keys + * + * @returns {number} + */ +function getIndex(result, results, keys) { + if (!results.length) { + return 0; + } + + let index = 0; + + do { + for (const key of keys) { + const tokens = result.tokens[ key ], + tokensOther = results[ index ].tokens[ key ]; + + if (tokens && !tokensOther) { + return index; + } else if (!tokens && tokensOther) { + index++; + + break; + } else if (!tokens && !tokensOther) { + continue; + } + + const tokenComparison = compareTokens(tokens, tokensOther); + + if (tokenComparison === -1) { + return index; + } else if (tokenComparison === 1) { + index++; + + break; + } else { + const stringComparison = compareStrings(result.item[ key ], results[ index ].item[ key ]); + + if (stringComparison === -1) { + return index; + } else if (stringComparison === 1) { + index++; + + break; + } else { + continue; + } + } + } + } while (index < results.length); + + return index; +} + +/** +* @param {Token} token +* +* @return {boolean} +*/ +export function isMatch(token) { + return token.match; +} + +/** +* @param {Token[]} tokens +* +* @return {boolean} +*/ +export function hasMatch(tokens) { + return tokens.find(isMatch); +} + +/** +* Compares two token arrays. +* +* @param {Token[]} tokensA +* @param {Token[]} tokensB +* +* @returns {number} +*/ +export function compareTokens(tokensA, tokensB) { + const tokensAHasMatch = hasMatch(tokensA), + tokensBHasMatch = hasMatch(tokensB); + + if (tokensAHasMatch && !tokensBHasMatch) { + return -1; + } + + if (!tokensAHasMatch && tokensBHasMatch) { + return 1; + } + + if (!tokensAHasMatch && !tokensBHasMatch) { + return 0; + } + + const tokensAFirstMatch = tokensA.find(isMatch), + tokensBFirstMatch = tokensB.find(isMatch); + + if (tokensAFirstMatch.index < tokensBFirstMatch.index) { + return -1; + } + + if (tokensAFirstMatch.index > tokensBFirstMatch.index) { + return 1; + } + + return 0; +} + +/** +* Compares two strings. +* +* @param {string} a +* @param {string} b +* +* @returns {number} +*/ +export function compareStrings(a, b) { + return a.localeCompare(b); +} + +/** +* @param {string} string +* @param {string} pattern +* +* @return {Token[]} +*/ +export function getMatchingTokens(string, pattern) { + var tokens = [], + originalString = string; + + if (!string) { + return tokens; + } + + string = string.toLowerCase(); + pattern = pattern.toLowerCase(); + + var index = string.indexOf(pattern); + + if (index > -1) { + if (index !== 0) { + tokens.push({ + value: originalString.slice(0, index), + index: 0 + }); + } + + tokens.push({ + value: originalString.slice(index, index + pattern.length), + index: index, + match: true + }); + + if (pattern.length + index < string.length) { + tokens.push({ + value: originalString.slice(index + pattern.length), + index: index + pattern.length + }); + } + } else { + tokens.push({ + value: originalString, + index: 0 + }); + } + + return tokens; +} \ No newline at end of file diff --git a/test/spec/features/popup-menu/PopupMenuComponentSpec.js b/test/spec/features/popup-menu/PopupMenuComponentSpec.js index 6ce440fea..53f6cf31e 100644 --- a/test/spec/features/popup-menu/PopupMenuComponentSpec.js +++ b/test/spec/features/popup-menu/PopupMenuComponentSpec.js @@ -15,6 +15,8 @@ import { queryAll as domQueryAll } from 'min-dom'; +import searchFn from 'lib/features/search/search'; + const TEST_IMAGE_URL = `data:image/svg+xml;utf8,${ encodeURIComponent(` @@ -727,6 +729,7 @@ describe('features/popup-menu - ', function() { const props = { entries: [], headerEntries: [], + searchFn: searchFn, position() { return { x: 0, y: 0 }; }, diff --git a/test/spec/features/popup-menu/PopupMenuSpec.js b/test/spec/features/popup-menu/PopupMenuSpec.js index 118e3ddd6..19b4f783b 100755 --- a/test/spec/features/popup-menu/PopupMenuSpec.js +++ b/test/spec/features/popup-menu/PopupMenuSpec.js @@ -1495,28 +1495,6 @@ describe('features/popup-menu', function() { })); - it('should show search results (matching label & search)', inject(async function(popupMenu) { - - // given - popupMenu.registerProvider('test-menu', testMenuProvider); - popupMenu.open({}, 'test-menu', { x: 100, y: 100 }, { search: true }); - - // when - await triggerSearch('delta search'); - - // then - var shownEntries; - - await waitFor(() => { - shownEntries = queryPopupAll('.entry'); - - expect(shownEntries).to.have.length(1); - }); - - expect(shownEntries[0].querySelector('.djs-popup-label').textContent).to.eql('Delta'); - })); - - describe('ranking', function() { it('should hide rank < 0 items', inject(async function(popupMenu) { diff --git a/test/spec/features/search/searchSpec.js b/test/spec/features/search/searchSpec.js new file mode 100644 index 000000000..1661d6dcc --- /dev/null +++ b/test/spec/features/search/searchSpec.js @@ -0,0 +1,261 @@ +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import search from '../../../../lib/features/search'; + +describe('search', function() { + + beforeEach(bootstrapDiagram({ modules: [ search ] })); + + + it('should expose search', inject(function(search) { + expect(search).to.exist; + })); + + + it('complex', inject(function(search) { + + // given + const items = [ + { + title: 'bar', + description: 'foo' + }, + { + title: 'foo', + description: 'bar' + }, + { + title: 'baz', + description: 'baz' + }, + { + title: 'baz', + description: 'bar foobar' + }, + { + title: 'baz', + description: 'bar foo' + }, + { + title: 'bar foo', + description: 'baz' + } + ]; + + // when + const results = search(items, 'foo', { + keys: [ + 'title', + 'description' + ] + }); + + // then + expect(results).to.have.length(5); + expect(results[0].item).to.eql(items[1]); + expect(results[1].item).to.eql(items[5]); + expect(results[2].item).to.eql(items[0]); + expect(results[3].item).to.eql(items[4]); + expect(results[4].item).to.eql(items[3]); + })); + + + it('should by match', inject(function(search) { + + // given + const items = [ + { + title: 'bar', + description: 'baz' + }, + { + title: 'foo', + description: 'bar' + }, + { + title: 'baz', + description: 'foo' + } + ]; + + // when + const results = search(items, 'foo', { + keys: [ + 'title', + 'description' + ] + }); + + // then + expect(results).to.have.length(2); + expect(results[0].item).to.eql(items[1]); + expect(results[1].item).to.eql(items[2]); + })); + + + it('should by match location', inject(function(search) { + + // given + const items = [ + { + title: 'bar baz foo', + description: 'bar' + }, + { + title: 'foo', + description: 'bar' + }, + { + title: 'baz foo', + description: 'bar' + } + ]; + + // when + const results = search(items, 'foo', { + keys: [ + 'title', + 'description' + ] + }); + + // then + expect(results).to.have.length(3); + expect(results[0].item).to.eql(items[1]); + expect(results[1].item).to.eql(items[2]); + expect(results[2].item).to.eql(items[0]); + })); + + + it('should sort alphabetically', inject(function(search) { + + // given + const items = [ + { + title: 'foobaz', + description: 'foo' + }, + { + title: 'foobar', + description: 'foo' + }, + { + title: 'foobazbaz', + description: 'foo' + } + ]; + + // when + const results = search(items, 'foo', { + keys: [ + 'title', + 'description' + ] + }); + + // then + expect(results).to.have.length(3); + expect(results[0].item).to.eql(items[1]); + expect(results[1].item).to.eql(items[0]); + expect(results[2].item).to.eql(items[2]); + })); + + + it('should handle missing keys', inject(function(search) { + + // given + const items = [ + { + title: 'bar', + description: 'foo' + }, + { + title: 'bar' + }, + { + title: 'foo', + description: 'bar' + } + ]; + + // when + const results = search(items, 'foo', { + keys: [ + 'title', + 'description' + ] + }); + + // then + expect(results).to.have.length(2); + expect(results[0].item).to.eql(items[2]); + expect(results[1].item).to.eql(items[0]); + })); + +}); + + +describe('overriding search', function() { + + beforeEach(bootstrapDiagram({ + modules: [ + { + search: [ + 'value', + function(items, pattern, { keys, customOption }) { + return items + .filter(item => { + return keys.some(key => { + return item[ key ].indexOf(pattern) !== -1; + }) && customOption; + }) + .map(item => { + return { + item, + tokens: [] + }; + }); + } + ] + } + ] + })); + + + it('should override search', inject(function(search) { + + // given + const items = [ + { + title: 'bar', + custom: 'foo' + }, + { + title: 'bar', + custom: 'baz' + }, + { + title: 'foo', + custom: 'bar' + } + ]; + + // when + const results = search(items, 'foo', { + keys: [ + 'title', + 'custom' + ], + customOption: true + }); + + // then + expect(results).to.have.length(2); + expect(results[0].item).to.eql(items[0]); + expect(results[1].item).to.eql(items[2]); + })); + +}); \ No newline at end of file