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