From f6733cff9d88d9b3298f6afc2c59c0dea59c6464 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Mon, 24 Oct 2022 11:34:35 +0200 Subject: [PATCH] feat(editor): improve nodes panel search (#4399) * feat(editor): add relevance sort to nodes panel * handle locales --- .../Node/NodeCreator/CategorizedItems.vue | 28 +- .../components/Node/NodeCreator/sortUtils.ts | 268 ++++++++++++++++++ 2 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 packages/editor-ui/src/components/Node/NodeCreator/sortUtils.ts diff --git a/packages/editor-ui/src/components/Node/NodeCreator/CategorizedItems.vue b/packages/editor-ui/src/components/Node/NodeCreator/CategorizedItems.vue index 8887d795971ed..987861fa7bd67 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/CategorizedItems.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/CategorizedItems.vue @@ -87,9 +87,10 @@ import ItemIterator from './ItemIterator.vue'; import NoResults from './NoResults.vue'; import SearchBar from './SearchBar.vue'; import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps, ICategoriesWithNodes, ICategoryItemProps, INodeFilterType } from '@/Interface'; -import { CORE_NODES_CATEGORY, WEBHOOK_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, ALL_NODE_FILTER, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER, NODE_TYPE_COUNT_MAPPER } from '@/constants'; +import { WEBHOOK_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, ALL_NODE_FILTER, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER, NODE_TYPE_COUNT_MAPPER } from '@/constants'; import { matchesNodeType, matchesSelectType } from './helpers'; import { BaseTextKey } from '@/plugins/i18n'; +import { sublimeSearch } from './sortUtils'; export default mixins(externalHooks, globalLinkActions).extend({ name: 'CategorizedItems', @@ -175,22 +176,35 @@ export default mixins(externalHooks, globalLinkActions).extend({ searchFilter(): string { return this.nodeFilter.toLowerCase().trim(); }, + defaultLocale (): string { + return this.$store.getters.defaultLocale; + }, filteredNodeTypes(): INodeCreateElement[] { - const searchableNodes = this.subcategorizedNodes.length > 0 ? this.subcategorizedNodes : this.searchItems; const filter = this.searchFilter; - const matchedCategorizedNodes = searchableNodes.filter((el: INodeCreateElement) => { - return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter); - }); + const searchableNodes = this.subcategorizedNodes.length > 0 ? this.subcategorizedNodes : this.searchItems; + + let returnItems: INodeCreateElement[] = []; + if (this.defaultLocale !== 'en') { + returnItems = searchableNodes.filter((el: INodeCreateElement) => { + return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter); + }); + } + else { + const matchingNodes = searchableNodes.filter((el) => matchesSelectType(el, this.selectedType)); + const matchedCategorizedNodes = sublimeSearch(filter, matchingNodes, [{key: 'properties.nodeType.displayName', weight: 2}, {key: 'properties.nodeType.codex.alias', weight: 1}]); + returnItems = matchedCategorizedNodes.map(({item}) => item);; + } + setTimeout(() => { this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', { nodeFilter: this.nodeFilter, - result: matchedCategorizedNodes, + result: returnItems, selectedType: this.selectedType, }); }, 0); - return matchedCategorizedNodes; + return returnItems; }, filteredAllNodeTypes(): INodeCreateElement[] { if(this.filteredNodeTypes.length > 0) return []; diff --git a/packages/editor-ui/src/components/Node/NodeCreator/sortUtils.ts b/packages/editor-ui/src/components/Node/NodeCreator/sortUtils.ts new file mode 100644 index 0000000000000..594785b4a8a9c --- /dev/null +++ b/packages/editor-ui/src/components/Node/NodeCreator/sortUtils.ts @@ -0,0 +1,268 @@ +// based on https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js + +const SEQUENTIAL_BONUS = 30; // bonus for adjacent matches +const SEPARATOR_BONUS = 30; // bonus if match occurs after a separator +const CAMEL_BONUS = 30; // bonus if match is uppercase and prev is lower +const FIRST_LETTER_BONUS = 15; // bonus if the first letter is matched + +const LEADING_LETTER_PENALTY = -15; // penalty applied for every letter in str before the first match +const MAX_LEADING_LETTER_PENALTY = -200; // maximum penalty for leading letters +const UNMATCHED_LETTER_PENALTY = -5; + +/** + * Returns true if each character in pattern is found sequentially within target + * @param {*} pattern string + * @param {*} target string + */ +function fuzzyMatchSimple(pattern: string, target: string): boolean { + let patternIdx = 0; + let strIdx = 0; + + while (patternIdx < pattern.length && strIdx < target.length) { + const patternChar = pattern.charAt(patternIdx).toLowerCase(); + const targetChar = target.charAt(strIdx).toLowerCase(); + if (patternChar === targetChar) { + patternIdx++; + } + ++strIdx; + } + + return pattern.length !== 0 && target.length !== 0 && patternIdx === pattern.length; +} + +/** + * Does a fuzzy search to find pattern inside a string. + * @param {*} pattern string pattern to search for + * @param {*} target string string which is being searched + * @returns [boolean, number] a boolean which tells if pattern was + * found or not and a search score + */ +function fuzzyMatch(pattern: string, target: string): {matched: boolean, outScore: number} { + const recursionCount = 0; + const recursionLimit = 5; + const matches: number[] = []; + const maxMatches = 256; + + return fuzzyMatchRecursive( + pattern, + target, + 0 /* patternCurIndex */, + 0 /* strCurrIndex */, + null /* srcMatces */, + matches, + maxMatches, + 0 /* nextMatch */, + recursionCount, + recursionLimit, + ); +} + +function fuzzyMatchRecursive( + pattern: string, + target: string, + patternCurIndex: number, + targetCurrIndex: number, + targetMatches: null | number[], + matches: number[], + maxMatches: number, + nextMatch: number, + recursionCount: number, + recursionLimit: number, +): {matched: boolean, outScore: number} { + let outScore = 0; + + // Return if recursion limit is reached. + if (++recursionCount >= recursionLimit) { + return {matched: false, outScore}; + } + + // Return if we reached ends of strings. + if (patternCurIndex === pattern.length || targetCurrIndex === target.length) { + return {matched: false, outScore}; + } + + // Recursion params + let recursiveMatch = false; + let bestRecursiveMatches: number[] = []; + let bestRecursiveScore = 0; + + // Loop through pattern and str looking for a match. + let firstMatch = true; + while (patternCurIndex < pattern.length && targetCurrIndex < target.length) { + // Match found. + if ( + pattern[patternCurIndex].toLowerCase() === target[targetCurrIndex].toLowerCase() + ) { + if (nextMatch >= maxMatches) { + return {matched: false, outScore}; + } + + if (firstMatch && targetMatches) { + matches = [...targetMatches]; + firstMatch = false; + } + + const recursiveMatches: number[] = []; + const recursiveResult = fuzzyMatchRecursive( + pattern, + target, + patternCurIndex, + targetCurrIndex + 1, + matches, + recursiveMatches, + maxMatches, + nextMatch, + recursionCount, + recursionLimit, + ); + + const recursiveScore = recursiveResult.outScore; + if (recursiveResult.matched) { + // Pick best recursive score. + if (!recursiveMatch || recursiveScore > bestRecursiveScore) { + bestRecursiveMatches = [...recursiveMatches]; + bestRecursiveScore = recursiveScore; + } + recursiveMatch = true; + } + + matches[nextMatch++] = targetCurrIndex; + ++patternCurIndex; + } + ++targetCurrIndex; + } + + const matched = patternCurIndex === pattern.length; + + if (matched) { + outScore = 100; + + // Apply leading letter penalty + let penalty = LEADING_LETTER_PENALTY * matches[0]; + penalty = + penalty < MAX_LEADING_LETTER_PENALTY + ? MAX_LEADING_LETTER_PENALTY + : penalty; + outScore += penalty; + + //Apply unmatched penalty + const unmatched = target.length - nextMatch; + outScore += UNMATCHED_LETTER_PENALTY * unmatched; + + // Apply ordering bonuses + for (let i = 0; i < nextMatch; i++) { + const currIdx = matches[i]; + + if (i > 0) { + const prevIdx = matches[i - 1]; + if (currIdx === prevIdx + 1) { + outScore += SEQUENTIAL_BONUS; + } + } + + // Check for bonuses based on neighbor character value. + if (currIdx > 0) { + // Camel case + const neighbor = target[currIdx - 1]; + const curr = target[currIdx]; + if ( + neighbor !== neighbor.toUpperCase() && + curr !== curr.toLowerCase() + ) { + outScore += CAMEL_BONUS; + } + const isNeighbourSeparator = neighbor === "_" || neighbor === " "; + if (isNeighbourSeparator) { + outScore += SEPARATOR_BONUS; + } + } else { + // First letter + outScore += FIRST_LETTER_BONUS; + } + } + + // Return best result + if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) { + // Recursive score is better than "this" + matches = [...bestRecursiveMatches]; + outScore = bestRecursiveScore; + return {matched: true, outScore}; + } else if (matched) { + // "this" score is better than recursive + return {matched: true, outScore}; + } else { + return {matched: false, outScore}; + } + } + return {matched: false, outScore}; +} + +// prop = 'key' +// prop = 'key1.key2' +// prop = ['key1', 'key2'] +function getValue(obj: T, prop: string): unknown { + if (obj.hasOwnProperty(prop)) { + return obj[prop as keyof T]; + } + + const segments = prop.split('.'); + let result: any = obj; // tslint:disable-line:no-any + let i = 0; + while (result && i < segments.length) { + result = result[segments[i]]; + i++; + } + return result; +} + +export function sublimeSearch(filter: string, data: Readonly, keys: Array<{key: string, weight: number}>): Array<{score: number, item: T}> { + const results = data.reduce((accu: Array<{score: number, item: T}>, item: T) => { + let values: Array<{value: string, weight: number}> = []; + keys.forEach(({key, weight}) => { + const value = getValue(item, key); + if (Array.isArray(value)) { + values = values.concat(value.map((v) => ({value: v, weight}))); + } + else if (typeof value === 'string') { + values.push({ + value, + weight, + }); + } + }); + + // for each item, check every key and get maximum score + const itemMatch = values.reduce((accu: null | {matched: boolean, outScore: number}, {value, weight}: {value: string, weight: number}) => { + if (!fuzzyMatchSimple(filter, value)) { + return accu; + } + + const match = fuzzyMatch(filter, value); + match.outScore *= weight; + + const {matched, outScore} = match; + if (!accu && matched) { + return match; + } + if (matched && accu && outScore > accu.outScore) { + return match; + } + return accu; + }, null); + + if (itemMatch) { + accu.push({ + score: itemMatch.outScore, + item, + }); + } + + return accu; + }, []); + + results.sort((a, b) => { + return b.score - a.score; + }); + + return results; +}