diff --git a/packages/@n8n/utils/src/search/sublimeSearch.test.ts b/packages/@n8n/utils/src/search/sublimeSearch.test.ts index 68140c3a5c..6fee13d1aa 100644 --- a/packages/@n8n/utils/src/search/sublimeSearch.test.ts +++ b/packages/@n8n/utils/src/search/sublimeSearch.test.ts @@ -2,20 +2,20 @@ import topLevel from './snapshots/toplevel.snapshot.json'; import { sublimeSearch } from './sublimeSearch'; describe('sublimeSearch', () => { - const testCases = [{ filter: 'agent', expectedOrder: ['Magento 2', 'AI Agent'] }]; + describe('search finds at least one match', () => { + const testCases: Array<[string, string[]]> = [['agent', ['Magento 2', 'AI Agent']]]; - test.each(testCases)( - 'should return results in the correct order for filter "$filter"', - ({ filter, expectedOrder }) => { - const results = sublimeSearch(filter, topLevel, [ - { key: 'properties.displayName', weight: 1.3 }, - { key: 'properties.codex.alias', weight: 1 }, - ]); + test.each(testCases)( + 'should return at least "$expectedOrder" for filter "$filter"', + (filter, expectedOrder) => { + // These match the weights in the production use case + const results = sublimeSearch(filter, topLevel); - const resultNames = results.map((result) => result.item.properties.displayName); - expectedOrder.forEach((expectedName, index) => { - expect(resultNames[index]).toBe(expectedName); - }); - }, - ); + const resultNames = results.map((result) => result.item.properties.displayName); + expectedOrder.forEach((expectedName, index) => { + expect(resultNames[index]).toBe(expectedName); + }); + }, + ); + }); }); diff --git a/packages/@n8n/utils/src/search/sublimeSearch.ts b/packages/@n8n/utils/src/search/sublimeSearch.ts index e358352a67..539541a783 100644 --- a/packages/@n8n/utils/src/search/sublimeSearch.ts +++ b/packages/@n8n/utils/src/search/sublimeSearch.ts @@ -12,6 +12,11 @@ const LEADING_LETTER_PENALTY = -20; // penalty applied for every letter in str b const MAX_LEADING_LETTER_PENALTY = -200; // maximum penalty for leading letters const UNMATCHED_LETTER_PENALTY = -5; +export const DEFAULT_KEYS = [ + { key: 'properties.displayName', weight: 1.3 }, + { key: 'properties.codex.alias', weight: 1 }, +]; + /** * Returns true if each character in pattern is found sequentially within target * @param {*} pattern string @@ -217,7 +222,7 @@ function getValue(obj: T, prop: string): unknown { export function sublimeSearch( filter: string, data: Readonly, - keys: Array<{ key: string; weight: number }>, + keys: Array<{ key: string; weight: number }> = DEFAULT_KEYS, ): Array<{ score: number; item: T }> { const results = data.reduce((accu: Array<{ score: number; item: T }>, item: T) => { let values: Array<{ value: string; weight: number }> = []; diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.test.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.test.ts index deabf36577..a13e1aaba7 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.test.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.test.ts @@ -1,5 +1,10 @@ import type { SectionCreateElement } from '@/Interface'; -import { formatTriggerActionName, groupItemsInSections, sortNodeCreateElements } from './utils'; +import { + formatTriggerActionName, + groupItemsInSections, + removeTrailingTrigger, + sortNodeCreateElements, +} from './utils'; import { mockActionCreateElement, mockNodeCreateElement, @@ -77,4 +82,25 @@ describe('NodeCreator - utils', () => { expect(formatTriggerActionName(actionName)).toEqual(expected); }); }); + describe('removeTrailingTrigger', () => { + test.each([ + ['Telegram Trigger', 'Telegram'], + ['Trigger Telegram', 'Trigger Telegram'], + ['Telegram Tri', 'Telegram'], + ['Telegram Bot', 'Telegram Bot'], + ['Tri', 'Tri'], + ['Trigger', 'Trigger'], + ['Telegram', 'Telegram'], + ['Telegram Trigger Bot', 'Telegram Trigger Bot'], + ['Telegram Trig', 'Telegram'], + ['Telegram Bot trigger', 'Telegram Bot'], + ['Telegram TRIGGER', 'Telegram'], + ['', ''], + ['Telegram Trigger', 'Telegram Trigger'], // full-width space, + ['Telegram Trigger ', 'Telegram Trigger'], + ['Telegram Trigger', 'Telegram'], + ])('should transform "%s" to "%s"', (input, expected) => { + expect(removeTrailingTrigger(input)).toEqual(expected); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts index 3119d9460c..e96ee0221c 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts @@ -90,22 +90,34 @@ export function sortNodeCreateElements(nodes: INodeCreateElement[]) { }); } +// We remove `Trigger` from e.g. `Telegram Trigger` to show it as part of the `Telegram` group, +// but still want to show matching results when the user types `Telegram Tri` or `Telegram Trigger` +// Ideally this would be handled via metadata, but that is a larger refactor. +export function removeTrailingTrigger(searchFilter: string) { + const parts = searchFilter.split(' '); + if (parts.length > 1 && 'trigger'.startsWith(parts.slice(-1)[0].toLowerCase())) { + return parts + .slice(0, -1) + .filter((x) => x) + .join(' ') + .trimEnd(); + } + return searchFilter; +} + export function searchNodes(searchFilter: string, items: INodeCreateElement[]) { const askAiEnabled = useSettingsStore().isAskAiEnabled; if (!askAiEnabled) { items = items.filter((item) => item.key !== AI_TRANSFORM_NODE_TYPE); } - // In order to support the old search we need to remove the 'trigger' part - const trimmedFilter = searchFilter.toLowerCase().replace('trigger', '').trimEnd(); + const trimmedFilter = removeTrailingTrigger(searchFilter).toLowerCase(); + // We have a snapshot of this call in sublimeSearch.test.ts to assert practical order for some cases - // Please kindly update the test call if you modify the weights here, and ideally regenerate the snapshot as described in its file. - const result = ( - sublimeSearch(trimmedFilter, items, [ - { key: 'properties.displayName', weight: 1.3 }, - { key: 'properties.codex.alias', weight: 1 }, - ]) || [] - ).map(({ item }) => item); + // Please update the snapshots per the README next to the the snapshots if you modify items significantly. + const result = (sublimeSearch(trimmedFilter, items) || []).map( + ({ item }) => item, + ); return result; }