[AI] Replace payee and category autocomplete filter/sort with fzf fuzy search (#7261)
* [AI] feat(web): replace custom autocomplete ranking with fzf Replace substring-based filter/sort in PayeeAutocomplete and CategoryAutocomplete with fzf fuzzy search. Remove deprecated autocompleteRanking utility. Closes #7261 * Update #7261 release notes Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk> * Update VRT screenshots Auto-generated by VRT workflow PR: #7261 * Regenerate yarn.lock file * Update VRT screenshots Auto-generated by VRT workflow PR: #7261 * Restore e2e snapshots * Update VRT screenshots Auto-generated by VRT workflow PR: #7261 --------- Co-authored-by: Nadir Miralimov <riid@pm.me> Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -161,6 +161,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "9.3.2",
|
||||
"fzf": "^0.5.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"hyperformula": "^3.2.0",
|
||||
"i18next": "^25.10.10",
|
||||
|
||||
@@ -17,13 +17,13 @@ import { Text } from '@actual-app/components/text';
|
||||
import { TextOneLine } from '@actual-app/components/text-one-line';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { getNormalisedString } from '@actual-app/core/shared/normalisation';
|
||||
import { integerToCurrency } from '@actual-app/core/shared/util';
|
||||
import type {
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Fzf } from 'fzf';
|
||||
|
||||
import { useEnvelopeSheetValue } from '#components/budget/envelope/EnvelopeBudgetComponents';
|
||||
import { makeAmountFullStyle } from '#components/budget/util';
|
||||
@@ -33,8 +33,7 @@ import { useSheetValue } from '#hooks/useSheetValue';
|
||||
import { useSyncedPref } from '#hooks/useSyncedPref';
|
||||
import { envelopeBudget, trackingBudget } from '#spreadsheet/bindings';
|
||||
|
||||
import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
|
||||
import { rankAutocompleteMatch } from './autocompleteRanking';
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
type CategoryAutocompleteItem = Omit<CategoryEntity, 'group'> & {
|
||||
@@ -186,22 +185,6 @@ function CategoryList({
|
||||
);
|
||||
}
|
||||
|
||||
function customSort(obj: CategoryAutocompleteItem, value: string): number {
|
||||
if (obj.id === 'split') {
|
||||
return -6;
|
||||
}
|
||||
const nameRank = rankAutocompleteMatch(obj.name, value);
|
||||
if (nameRank < 0) {
|
||||
return nameRank;
|
||||
}
|
||||
// Group name matching: ranks above no-match but below all name tiers.
|
||||
const groupName = obj.group ? getNormalisedString(obj.group.name) : '';
|
||||
if (groupName.includes(getNormalisedString(value))) {
|
||||
return -0.5;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
type CategoryAutocompleteProps = ComponentProps<
|
||||
typeof Autocomplete<CategoryAutocompleteItem>
|
||||
> & {
|
||||
@@ -271,30 +254,22 @@ export function CategoryAutocomplete({
|
||||
suggestions: CategoryAutocompleteItem[],
|
||||
value: string,
|
||||
): CategoryAutocompleteItem[] => {
|
||||
const normalizedValue = getNormalisedString(value);
|
||||
return suggestions
|
||||
.filter(suggestion => {
|
||||
if (suggestion.id === 'split') {
|
||||
return true;
|
||||
}
|
||||
const splitItem = suggestions.find(s => s.id === 'split');
|
||||
const realSuggestions = suggestions.filter(s => s.id !== 'split');
|
||||
|
||||
if (suggestion.group) {
|
||||
return (
|
||||
getNormalisedString(suggestion.group.name).includes(
|
||||
normalizedValue,
|
||||
) ||
|
||||
getNormalisedString(
|
||||
suggestion.group.name + ' ' + suggestion.name,
|
||||
).includes(normalizedValue)
|
||||
);
|
||||
}
|
||||
if (!value) {
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
return defaultFilterSuggestion(suggestion, value);
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
customSort(a, normalizedValue) - customSort(b, normalizedValue),
|
||||
);
|
||||
const filtered = new Fzf(realSuggestions, {
|
||||
selector: item =>
|
||||
item.group ? item.group.name + ' ' + item.name : item.name,
|
||||
limit: 100,
|
||||
})
|
||||
.find(value)
|
||||
.map(result => result.item);
|
||||
|
||||
return splitItem ? [splitItem, ...filtered] : filtered;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
PayeeEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Fzf } from 'fzf';
|
||||
|
||||
import { useAccounts } from '#hooks/useAccounts';
|
||||
import { useCommonPayees } from '#hooks/useCommonPayees';
|
||||
@@ -41,12 +42,7 @@ import {
|
||||
useDeletePayeeLocationMutation,
|
||||
} from '#payees';
|
||||
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteFooter,
|
||||
defaultFilterSuggestion,
|
||||
} from './Autocomplete';
|
||||
import { rankAutocompleteMatch } from './autocompleteRanking';
|
||||
import { Autocomplete, AutocompleteFooter } from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
type PayeeAutocompleteItem = PayeeEntity &
|
||||
@@ -342,13 +338,6 @@ function PayeeList({
|
||||
);
|
||||
}
|
||||
|
||||
function customSort(obj: PayeeAutocompleteItem, value: string): number {
|
||||
if (obj.id === 'new') {
|
||||
return -5;
|
||||
}
|
||||
return rankAutocompleteMatch(obj.name, value);
|
||||
}
|
||||
|
||||
export type PayeeAutocompleteProps = ComponentProps<
|
||||
typeof Autocomplete<PayeeAutocompleteItem>
|
||||
> & {
|
||||
@@ -464,9 +453,12 @@ export function PayeeAutocomplete({
|
||||
return nearbyPayeesWithType;
|
||||
}
|
||||
|
||||
return nearbyPayeesWithType.filter(payee => {
|
||||
return defaultFilterSuggestion(payee, rawPayee);
|
||||
});
|
||||
return new Fzf(nearbyPayeesWithType, {
|
||||
selector: item => item.name ?? '',
|
||||
limit: 100,
|
||||
})
|
||||
.find(rawPayee)
|
||||
.map(result => result.item);
|
||||
}, [nearbyPayeesWithType, rawPayee]);
|
||||
|
||||
const handleForgetLocation = useCallback(
|
||||
@@ -506,34 +498,38 @@ export function PayeeAutocomplete({
|
||||
suggestions: PayeeAutocompleteItem[],
|
||||
value: string,
|
||||
) => {
|
||||
const normalizedValue = getNormalisedString(value);
|
||||
const filtered = suggestions
|
||||
.filter(suggestion => {
|
||||
if (suggestion.id === 'new') {
|
||||
return !value || value === '' || focusTransferPayees ? false : true;
|
||||
}
|
||||
// Separate the 'new' sentinel from real payees
|
||||
const newItem = suggestions.find(s => s.id === 'new');
|
||||
const realSuggestions = suggestions.filter(s => s.id !== 'new');
|
||||
|
||||
return defaultFilterSuggestion(suggestion, value);
|
||||
let filtered: PayeeAutocompleteItem[];
|
||||
if (!value) {
|
||||
filtered = realSuggestions.slice(0, 100);
|
||||
} else {
|
||||
filtered = new Fzf(realSuggestions, {
|
||||
selector: item => item.name ?? '',
|
||||
limit: 100,
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
customSort(a, normalizedValue) - customSort(b, normalizedValue),
|
||||
)
|
||||
// Only show the first 100 results, users can search to find more.
|
||||
// If user want to view all payees, it can be done via the manage payees page.
|
||||
.slice(0, 100);
|
||||
.find(value)
|
||||
.map(result => result.item);
|
||||
}
|
||||
|
||||
if (filtered.length >= 2 && filtered[0].id === 'new') {
|
||||
const firstFiltered = filtered[1];
|
||||
// Re-prepend 'new' when the user has typed something
|
||||
const showNew = newItem && value && value !== '' && !focusTransferPayees;
|
||||
const results = showNew ? [newItem, ...filtered] : filtered;
|
||||
|
||||
if (results.length >= 2 && results[0].id === 'new') {
|
||||
const firstFiltered = results[1];
|
||||
if (
|
||||
getNormalisedString(firstFiltered.name) === normalizedValue &&
|
||||
getNormalisedString(firstFiltered.name) ===
|
||||
getNormalisedString(value) &&
|
||||
!firstFiltered.transfer_acct
|
||||
) {
|
||||
// Exact match found, remove the 'Create payee` option.
|
||||
return filtered.slice(1);
|
||||
// Exact match found, remove the 'Create payee' option.
|
||||
return results.slice(1);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
return results;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { rankAutocompleteMatch } from './autocompleteRanking';
|
||||
|
||||
describe('rankAutocompleteMatch', () => {
|
||||
test('exact match returns -4', () => {
|
||||
expect(rankAutocompleteMatch('Me', 'me')).toBe(-4);
|
||||
expect(rankAutocompleteMatch('me', 'Me')).toBe(-4);
|
||||
expect(rankAutocompleteMatch('Groceries', 'groceries')).toBe(-4);
|
||||
});
|
||||
|
||||
test('prefix match returns -3', () => {
|
||||
expect(rankAutocompleteMatch('Memory Express', 'me')).toBe(-3);
|
||||
expect(rankAutocompleteMatch('Merchant', 'me')).toBe(-3);
|
||||
});
|
||||
|
||||
test('word-boundary match returns -2', () => {
|
||||
expect(rankAutocompleteMatch('French Meadow', 'me')).toBe(-2);
|
||||
expect(rankAutocompleteMatch('Self-medicate', 'me')).toBe(-2);
|
||||
});
|
||||
|
||||
test('contains match returns -1', () => {
|
||||
expect(rankAutocompleteMatch('Framework', 'me')).toBe(-1);
|
||||
expect(rankAutocompleteMatch('Homestead', 'me')).toBe(-1);
|
||||
expect(rankAutocompleteMatch('Gamestop', 'me')).toBe(-1);
|
||||
});
|
||||
|
||||
test('no match returns 0', () => {
|
||||
expect(rankAutocompleteMatch('Apple Store', 'me')).toBe(0);
|
||||
expect(rankAutocompleteMatch('Target', 'me')).toBe(0);
|
||||
});
|
||||
|
||||
test('empty input returns 0', () => {
|
||||
expect(rankAutocompleteMatch('Anything', '')).toBe(0);
|
||||
});
|
||||
|
||||
test('diacritics are normalised', () => {
|
||||
expect(rankAutocompleteMatch('Cafe', 'café')).toBe(-4);
|
||||
expect(rankAutocompleteMatch('Café', 'cafe')).toBe(-4);
|
||||
expect(rankAutocompleteMatch('Résumé', 're')).toBe(-3);
|
||||
});
|
||||
|
||||
test('case insensitive', () => {
|
||||
expect(rankAutocompleteMatch('METRO', 'me')).toBe(-3);
|
||||
expect(rankAutocompleteMatch('metro', 'ME')).toBe(-3);
|
||||
});
|
||||
|
||||
test('realistic payee scenario for "me"', () => {
|
||||
const payees = [
|
||||
'Me',
|
||||
'Memory Express',
|
||||
'Merchant',
|
||||
'French Meadow',
|
||||
'Self-medicate',
|
||||
'Framework',
|
||||
'Homestead',
|
||||
'Gamestop',
|
||||
'Apple Store',
|
||||
'Target',
|
||||
];
|
||||
|
||||
const ranked = payees
|
||||
.map(name => ({ name, rank: rankAutocompleteMatch(name, 'me') }))
|
||||
.sort((a, b) => a.rank - b.rank);
|
||||
|
||||
// Exact match first
|
||||
expect(ranked[0]).toEqual({ name: 'Me', rank: -4 });
|
||||
|
||||
// Prefix matches next
|
||||
const prefixMatches = ranked.filter(r => r.rank === -3);
|
||||
expect(prefixMatches.map(r => r.name)).toEqual(
|
||||
expect.arrayContaining(['Memory Express', 'Merchant']),
|
||||
);
|
||||
|
||||
// Word-boundary matches
|
||||
const wordBoundaryMatches = ranked.filter(r => r.rank === -2);
|
||||
expect(wordBoundaryMatches.map(r => r.name)).toEqual(
|
||||
expect.arrayContaining(['French Meadow', 'Self-medicate']),
|
||||
);
|
||||
|
||||
// Contains matches
|
||||
const containsMatches = ranked.filter(r => r.rank === -1);
|
||||
expect(containsMatches.map(r => r.name)).toEqual(
|
||||
expect.arrayContaining(['Framework', 'Homestead', 'Gamestop']),
|
||||
);
|
||||
|
||||
// No matches
|
||||
const noMatches = ranked.filter(r => r.rank === 0);
|
||||
expect(noMatches.map(r => r.name)).toEqual(
|
||||
expect.arrayContaining(['Apple Store', 'Target']),
|
||||
);
|
||||
});
|
||||
|
||||
test('single character input', () => {
|
||||
expect(rankAutocompleteMatch('A', 'a')).toBe(-4);
|
||||
expect(rankAutocompleteMatch('Apple', 'a')).toBe(-3);
|
||||
expect(rankAutocompleteMatch('Big Apple', 'a')).toBe(-2);
|
||||
expect(rankAutocompleteMatch('Banana', 'a')).toBe(-1);
|
||||
});
|
||||
|
||||
test('hyphenated word boundaries', () => {
|
||||
expect(rankAutocompleteMatch('Co-op', 'op')).toBe(-2);
|
||||
expect(rankAutocompleteMatch('Self-checkout', 'check')).toBe(-2);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getNormalisedString } from '@actual-app/core/shared/normalisation';
|
||||
|
||||
/**
|
||||
* Returns a numeric rank for how well `name` matches `input`.
|
||||
* Lower values = better match (convention used by `.sort()`).
|
||||
*
|
||||
* -4 Exact match (normalised)
|
||||
* -3 Prefix match (name starts with input)
|
||||
* -2 Word-boundary match (a non-first word starts with input)
|
||||
* -1 Contains match (input found anywhere)
|
||||
* 0 No match
|
||||
*/
|
||||
export function rankAutocompleteMatch(name: string, input: string): number {
|
||||
if (!input) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normName = getNormalisedString(name);
|
||||
const normInput = getNormalisedString(input);
|
||||
|
||||
if (normName === normInput) {
|
||||
return -4;
|
||||
}
|
||||
|
||||
if (normName.startsWith(normInput)) {
|
||||
return -3;
|
||||
}
|
||||
|
||||
// Check if any non-first word starts with the input.
|
||||
// Words are split on whitespace and hyphens.
|
||||
const words = normName.split(/[\s-]/);
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
if (words[i].startsWith(normInput)) {
|
||||
return -2;
|
||||
}
|
||||
}
|
||||
|
||||
if (normName.includes(normInput)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
6
upcoming-release-notes/7261.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [riid]
|
||||
---
|
||||
|
||||
Improve the search algorithm when selecting a payee or category
|
||||
@@ -260,6 +260,7 @@ __metadata:
|
||||
cross-env: "npm:^10.1.0"
|
||||
date-fns: "npm:^4.1.0"
|
||||
downshift: "npm:9.3.2"
|
||||
fzf: "npm:^0.5.2"
|
||||
html-to-image: "npm:^1.11.13"
|
||||
hyperformula: "npm:^3.2.0"
|
||||
i18next: "npm:^25.10.10"
|
||||
@@ -16738,6 +16739,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fzf@npm:^0.5.2":
|
||||
version: 0.5.2
|
||||
resolution: "fzf@npm:0.5.2"
|
||||
checksum: 10/820f9fd068792e792a4c4ab79c719889ee61b2f152089e11a3ba7e9ea081b06bde3b5b6a18131fe4aac61f189640e426a269ccb8b41e946fac9f8c1cc8e98635
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"generator-function@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "generator-function@npm:2.0.1"
|
||||
|
||||