mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): improve nodes panel search (#4399)
* feat(editor): add relevance sort to nodes panel * handle locales
This commit is contained in:
parent
cdec0f1d88
commit
f6733cff9d
|
@ -87,9 +87,10 @@ import ItemIterator from './ItemIterator.vue';
|
||||||
import NoResults from './NoResults.vue';
|
import NoResults from './NoResults.vue';
|
||||||
import SearchBar from './SearchBar.vue';
|
import SearchBar from './SearchBar.vue';
|
||||||
import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps, ICategoriesWithNodes, ICategoryItemProps, INodeFilterType } from '@/Interface';
|
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 { matchesNodeType, matchesSelectType } from './helpers';
|
||||||
import { BaseTextKey } from '@/plugins/i18n';
|
import { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
import { sublimeSearch } from './sortUtils';
|
||||||
|
|
||||||
export default mixins(externalHooks, globalLinkActions).extend({
|
export default mixins(externalHooks, globalLinkActions).extend({
|
||||||
name: 'CategorizedItems',
|
name: 'CategorizedItems',
|
||||||
|
@ -175,22 +176,35 @@ export default mixins(externalHooks, globalLinkActions).extend({
|
||||||
searchFilter(): string {
|
searchFilter(): string {
|
||||||
return this.nodeFilter.toLowerCase().trim();
|
return this.nodeFilter.toLowerCase().trim();
|
||||||
},
|
},
|
||||||
|
defaultLocale (): string {
|
||||||
|
return this.$store.getters.defaultLocale;
|
||||||
|
},
|
||||||
filteredNodeTypes(): INodeCreateElement[] {
|
filteredNodeTypes(): INodeCreateElement[] {
|
||||||
const searchableNodes = this.subcategorizedNodes.length > 0 ? this.subcategorizedNodes : this.searchItems;
|
|
||||||
const filter = this.searchFilter;
|
const filter = this.searchFilter;
|
||||||
const matchedCategorizedNodes = searchableNodes.filter((el: INodeCreateElement) => {
|
const searchableNodes = this.subcategorizedNodes.length > 0 ? this.subcategorizedNodes : this.searchItems;
|
||||||
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
|
|
||||||
});
|
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<INodeCreateElement>(filter, matchingNodes, [{key: 'properties.nodeType.displayName', weight: 2}, {key: 'properties.nodeType.codex.alias', weight: 1}]);
|
||||||
|
returnItems = matchedCategorizedNodes.map(({item}) => item);;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
|
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
|
||||||
nodeFilter: this.nodeFilter,
|
nodeFilter: this.nodeFilter,
|
||||||
result: matchedCategorizedNodes,
|
result: returnItems,
|
||||||
selectedType: this.selectedType,
|
selectedType: this.selectedType,
|
||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return matchedCategorizedNodes;
|
return returnItems;
|
||||||
},
|
},
|
||||||
filteredAllNodeTypes(): INodeCreateElement[] {
|
filteredAllNodeTypes(): INodeCreateElement[] {
|
||||||
if(this.filteredNodeTypes.length > 0) return [];
|
if(this.filteredNodeTypes.length > 0) return [];
|
||||||
|
|
268
packages/editor-ui/src/components/Node/NodeCreator/sortUtils.ts
Normal file
268
packages/editor-ui/src/components/Node/NodeCreator/sortUtils.ts
Normal file
|
@ -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<T extends object>(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<T extends object>(filter: string, data: Readonly<T[]>, 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;
|
||||||
|
}
|
Loading…
Reference in a new issue