Skip to content

Commit

Permalink
feat: add search feature
Browse files Browse the repository at this point in the history
  • Loading branch information
philippfromme committed Jul 18, 2024
1 parent 1d46da6 commit e62a656
Show file tree
Hide file tree
Showing 8 changed files with 535 additions and 46 deletions.
8 changes: 5 additions & 3 deletions lib/features/popup-menu/PopupMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -94,7 +95,8 @@ export default function PopupMenu(config, eventBus, canvas) {
PopupMenu.$inject = [
'config.popupMenu',
'eventBus',
'canvas'
'canvas',
'search'
];

PopupMenu.prototype._render = function() {
Expand Down Expand Up @@ -138,6 +140,7 @@ PopupMenu.prototype._render = function() {
scale=${ scale }
onOpened=${ this._onOpened.bind(this) }
onClosed=${ this._onClosed.bind(this) }
searchFn=${ this._search }
...${{ ...options }}
/>
`,
Expand Down Expand Up @@ -548,7 +551,6 @@ PopupMenu.prototype._getHeaderEntries = function(target, providers) {


PopupMenu.prototype._getEmptyPlaceholder = function(providers) {

const provider = providers.find(
provider => isFunction(provider.getEmptyPlaceholder)
);
Expand Down
32 changes: 11 additions & 21 deletions lib/features/popup-menu/PopupMenuComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default function PopupMenuComponent(props) {
scale,
search,
emptyPlaceholder,
searchFn,
entries: originalEntries,
onOpened,
onClosed
Expand All @@ -75,29 +76,18 @@ 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'
]
}).map(({ item }) => item);
}, [ searchable ]);

const [ entries, setEntries ] = useState(filterEntries(originalEntries, value));
Expand Down
3 changes: 3 additions & 0 deletions lib/features/popup-menu/index.js
Original file line number Diff line number Diff line change
@@ -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 ]
};
8 changes: 8 additions & 0 deletions lib/features/search/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import search from './search';

/**
* @type { import('didi').ModuleDeclaration }
*/
export default {
search: [ 'value', search ]
};
244 changes: 244 additions & 0 deletions lib/features/search/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/**
* @typedef { {
* index: number;
* match: boolean;
* value: string;
* } } Token
*
* @typedef {Token[]} Tokens
*
* @typedef { {
* item: Object,
* tokens: Record<string, Tokens>
* } } 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, { keys }) {
return items.reduce((results, item) => {
const tokens = getTokens(item, pattern, keys);

if (Object.keys(tokens).length) {
const result = {
item,
tokens
};

const index = getIndex(result, results, keys);

results.splice(index, 0, result);
}

return results;
}, []);
}

/**
* Get tokens for item.
*
* @param {Object} item
* @param {string} pattern
* @param {string[]} keys
*
* @returns {Record<string, Tokens>}
*/
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;
}
3 changes: 3 additions & 0 deletions test/spec/features/popup-menu/PopupMenuComponentSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down Expand Up @@ -727,6 +729,7 @@ describe('features/popup-menu - <PopupMenu>', function() {
const props = {
entries: [],
headerEntries: [],
searchFn: searchFn,
position() {
return { x: 0, y: 0 };
},
Expand Down
Loading

0 comments on commit e62a656

Please sign in to comment.