diff --git a/packages/desktop-client/src/components/autocomplete/Autocomplete2.tsx b/packages/desktop-client/src/components/autocomplete/Autocomplete2.tsx new file mode 100644 index 0000000000..446c806e26 --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/Autocomplete2.tsx @@ -0,0 +1,194 @@ +import { + type ComponentProps, + useRef, + useContext, + type KeyboardEvent, + useEffect, + createContext, + type ReactNode, +} from 'react'; +import { + ComboBox, + ListBox, + ListBoxItem, + ListBoxSection, + type ListBoxSectionProps, + type ComboBoxProps, + type ListBoxItemProps, + ComboBoxStateContext, + type Key, +} from 'react-aria-components'; +import { type ComboBoxState } from 'react-stately'; + +import { Input } from '@actual-app/components/input'; +import { Popover } from '@actual-app/components/popover'; +import { styles } from '@actual-app/components/styles'; +import { theme } from '@actual-app/components/theme'; +import { View } from '@actual-app/components/view'; +import { css, cx } from '@emotion/css'; + +const popoverClassName = () => + css({ + ...styles.darkScrollbar, + ...styles.popover, + backgroundColor: theme.menuAutoCompleteBackground, + color: theme.menuAutoCompleteText, + padding: '5px 0', + borderRadius: 4, + }); + +const listBoxClassName = ({ width }: { width?: number }) => + css({ + width, + minWidth: 200, + maxHeight: 200, + overflow: 'auto', + '& [data-focused]': { + backgroundColor: theme.menuAutoCompleteBackgroundHover, + }, + }); + +type Autocomplete2Props = Omit< + ComboBoxProps, + 'children' +> & { + inputPlaceholder?: string; + children: ComponentProps>['children']; +}; + +export function Autocomplete2({ + children, + ...props +}: Autocomplete2Props) { + const viewRef = useRef(null); + return ( + + allowsEmptyCollection + allowsCustomValue + menuTrigger="focus" + {...props} + > + + + + + + className={listBoxClassName({ width: viewRef.current?.clientWidth })} + > + {children} + + + + ); +} + +type AutocompleteInputContextValue = { + getFocusedKey?: (state: ComboBoxState) => Key | null; +}; + +const AutocompleteInputContext = + createContext(null); + +type AutocompleteInputProviderProps = { + children: ReactNode; + getFocusedKey?: (state: ComboBoxState) => Key | null; +}; + +export function AutocompleteInputProvider({ + children, + getFocusedKey, +}: AutocompleteInputProviderProps) { + return ( + + {children} + + ); +} + +type AutocompleteInputProps = ComponentProps; + +function AutocompleteInput({ onKeyUp, ...props }: AutocompleteInputProps) { + const state = useContext(ComboBoxStateContext); + const _onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + state.revert(); + } + onKeyUp?.(e); + }; + + const autocompleteInputContext = useContext(AutocompleteInputContext); + + useEffect(() => { + if (state && state.inputValue && !state.selectionManager.focusedKey) { + const focusedKey = autocompleteInputContext?.getFocusedKey + ? autocompleteInputContext.getFocusedKey(state) + : defaultGetFocusedKey(state); + + state.selectionManager.setFocusedKey(focusedKey); + } + }, [autocompleteInputContext, state, state?.inputValue]); + + return ; +} + +function defaultGetFocusedKey(state: ComboBoxState) { + // Focus on the first suggestion item when typing. + const keys = Array.from(state.collection.getKeys()); + return keys + .map(key => state.collection.getItem(key)) + .find(i => i.type === 'item')?.key; +} + +const defaultAutocompleteSectionClassName = css({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + '& header': { + paddingTop: 5, + paddingBottom: 5, + paddingLeft: 10, + color: theme.menuAutoCompleteTextHeader, + }, +}); + +type AutocompleteSectionProps = ListBoxSectionProps; + +export function AutocompleteSection({ + className, + ...props +}: AutocompleteSectionProps) { + return ( + + ); +} + +const defaultAutocompleteItemClassName = css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + paddingTop: 5, + paddingBottom: 5, + paddingLeft: 20, +}); + +type AutocompleteItemProps = ListBoxItemProps; + +export function AutocompleteItem({ + className, + ...props +}: AutocompleteItemProps) { + return ( + + cx(defaultAutocompleteItemClassName, className(renderProps)) + : cx(defaultAutocompleteItemClassName, className) + } + {...props} + /> + ); +} diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete2.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete2.tsx new file mode 100644 index 0000000000..43c017e94b --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete2.tsx @@ -0,0 +1,346 @@ +import { type ComponentProps, useMemo, useState } from 'react'; +import { Header, type Key } from 'react-aria-components'; +import { Trans, useTranslation } from 'react-i18next'; + +import { SvgAdd } from '@actual-app/components/icons/v1'; +import { theme } from '@actual-app/components/theme'; + +import { + normalisedEquals, + normalisedIncludes, +} from 'loot-core/shared/normalisation'; +import { type AccountEntity, type PayeeEntity } from 'loot-core/types/models'; + +import { + Autocomplete2, + AutocompleteInputProvider, + AutocompleteItem, + AutocompleteSection, +} from './Autocomplete2'; + +import { useAccounts } from '@desktop-client/hooks/useAccounts'; +import { useCommonPayees, usePayees } from '@desktop-client/hooks/usePayees'; +import { usePrevious } from '@desktop-client/hooks/usePrevious'; +import { + createPayee, + getActivePayees, +} from '@desktop-client/payees/payeesSlice'; +import { useDispatch } from '@desktop-client/redux'; + +type PayeeAutocompleteItemType = { + type: 'account' | 'payee' | 'suggested'; +}; + +type PayeeAutocompleteItem = PayeeEntity & PayeeAutocompleteItemType; + +type PayeeAutocomplete2Props = Omit< + ComponentProps, + 'children' +> & { + showInactive?: boolean; +}; + +export function PayeeAutocomplete2({ + showInactive, + selectedKey, + onOpenChange, + onSelectionChange, + ...props +}: PayeeAutocomplete2Props) { + const { t } = useTranslation(); + const payees = usePayees(); + const commonPayees = useCommonPayees(); + const accounts = useAccounts(); + const [focusTransferPayees, setFocusedTransferPayees] = useState(false); + + const allPayeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => { + const suggestions = getPayeeSuggestions(commonPayees, payees); + + let filteredSuggestions: PayeeAutocompleteItem[] = [...suggestions]; + + if (!showInactive) { + filteredSuggestions = filterActivePayees(filteredSuggestions, accounts); + } + + if (focusTransferPayees) { + filteredSuggestions = filterTransferPayees(filteredSuggestions); + } + + return filteredSuggestions; + }, [commonPayees, payees, showInactive, focusTransferPayees, accounts]); + + const [inputValue, setInputValue] = useState( + getPayeeName(allPayeeSuggestions, selectedKey), + ); + const [_selectedKey, setSelectedKey] = useState( + selectedKey || null, + ); + + const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); + const previousIsAutocompeleteOpen = usePrevious(isAutocompleteOpen); + const isInitialAutocompleteOpen = + !previousIsAutocompeleteOpen && isAutocompleteOpen; + + const filter = (textValue: string, inputValue: string) => { + return ( + isInitialAutocompleteOpen || normalisedIncludes(textValue, inputValue) + ); + }; + + const filteredPayeeSuggestions = allPayeeSuggestions.filter(p => + filter(p.name, inputValue), + ); + + const suggestedPayees = filteredPayeeSuggestions.filter( + p => p.type === 'suggested', + ); + const regularPayees = filteredPayeeSuggestions.filter( + p => !p.favorite && p.type === 'payee', + ); + const accountPayees = filteredPayeeSuggestions.filter( + p => p.type === 'account', + ); + + const findExactMatchPayee = () => + filteredPayeeSuggestions.find( + p => p.id !== 'new' && normalisedEquals(p.name, inputValue), + ); + + const exactMatchPayee = findExactMatchPayee(); + + const dispatch = useDispatch(); + const onCreatePayee = () => { + return dispatch(createPayee({ name: inputValue })).unwrap(); + }; + + const _onSelectionChange = async (id: Key | null) => { + if (id === 'new') { + const newPayeeId = await onCreatePayee?.(); + setSelectedKey(newPayeeId); + onSelectionChange?.(newPayeeId); + return; + } + + setSelectedKey(id); + setInputValue(getPayeeName(filteredPayeeSuggestions, id)); + onSelectionChange?.(id); + }; + + const _onOpenChange = (isOpen: boolean) => { + setIsAutocompleteOpen(isOpen); + onOpenChange?.(isOpen); + }; + + const getFocusedKey: ComponentProps< + typeof AutocompleteInputProvider + >['getFocusedKey'] = state => { + const keys = Array.from(state.collection.getKeys()); + const found = keys + .map(key => state.collection.getItem(key)) + .find(i => i.type === 'item' && i.key !== 'new'); + + // Focus on the first suggestion item when typing. + // Otherwise, if there are no results, focus on the "new" item to allow creating a new entry. + return found?.key || 'new'; + }; + + return ( + + + + + {/* + + + */} + + + ); +} + +type PayeeListProps = { + showCreatePayee: boolean; + inputValue: string; + suggestedPayees: PayeeAutocompleteItem[]; + regularPayees: PayeeAutocompleteItem[]; + accountPayees: PayeeAutocompleteItem[]; +}; + +function PayeeList({ + showCreatePayee, + inputValue, + suggestedPayees, + regularPayees, + accountPayees, +}: PayeeListProps) { + // useAutocompleteFocusOnInput({ + // getFocusedKey: state => { + // const keys = Array.from(state.collection.getKeys()); + // return keys + // .map(key => state.collection.getItem(key)) + // .find(i => i.type === 'item' && i.key !== 'new')?.key || 'new'; + // }, + // }); + + return ( + <> + {showCreatePayee && ( + + + Create payee: {inputValue} + + )} + + {suggestedPayees.length > 0 && ( + +
+ Suggested Payees +
+ {suggestedPayees.map(payee => ( + + {payee.name} + + ))} +
+ )} + + {regularPayees.length > 0 && ( + +
+ Payees +
+ {regularPayees.map(payee => ( + + {payee.name} + + ))} +
+ )} + + {accountPayees.length > 0 && ( + +
+ Transfer To/From +
+ {accountPayees.map(payee => ( + + {payee.name} + + ))} +
+ )} + + ); +} + +const MAX_AUTO_SUGGESTIONS = 5; + +function getPayeeSuggestions( + commonPayees: PayeeEntity[], + payees: PayeeEntity[], +): PayeeAutocompleteItem[] { + const favoritePayees = payees + .filter(p => p.favorite) + .map(p => { + return { ...p, type: determineType(p, true) }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + let additionalCommonPayees: PayeeAutocompleteItem[] = []; + if (commonPayees?.length > 0) { + if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) { + additionalCommonPayees = commonPayees + .filter( + p => !(p.favorite || favoritePayees.map(fp => fp.id).includes(p.id)), + ) + .slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length) + .map(p => { + return { ...p, type: determineType(p, true) }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + } + } + + if (favoritePayees.length + additionalCommonPayees.length) { + const filteredPayees: PayeeAutocompleteItem[] = payees + .filter(p => !favoritePayees.find(fp => fp.id === p.id)) + .filter(p => !additionalCommonPayees.find(fp => fp.id === p.id)) + .map(p => { + return { ...p, type: determineType(p, false) }; + }); + + return favoritePayees.concat(additionalCommonPayees).concat(filteredPayees); + } + + return payees.map(p => { + return { ...p, type: determineType(p, false) }; + }); +} + +function filterActivePayees( + payees: T[], + accounts: AccountEntity[], +): T[] { + return accounts ? (getActivePayees(payees, accounts) as T[]) : payees; +} + +function filterTransferPayees(payees: PayeeAutocompleteItem[]) { + return payees.filter(payee => !!payee.transfer_acct); +} + +function determineType( + payee: PayeeEntity, + isCommon: boolean, +): PayeeAutocompleteItem['type'] { + if (payee.transfer_acct) { + return 'account'; + } + if (isCommon) { + return 'suggested'; + } else { + return 'payee'; + } +} + +function getPayeeName(items: T[], id: Key | null) { + return items.find(p => p.id === id)?.name || ''; +} diff --git a/packages/desktop-client/src/components/rules/RuleEditor.tsx b/packages/desktop-client/src/components/rules/RuleEditor.tsx index 8e0101e7ea..e82c6a3f6f 100644 --- a/packages/desktop-client/src/components/rules/RuleEditor.tsx +++ b/packages/desktop-client/src/components/rules/RuleEditor.tsx @@ -54,6 +54,7 @@ import { import { FormulaActionEditor } from './FormulaActionEditor'; +import { PayeeAutocomplete2 } from '@desktop-client/components/autocomplete/PayeeAutocomplete2'; import { StatusBadge } from '@desktop-client/components/schedules/StatusBadge'; import { SimpleTransactionsTable } from '@desktop-client/components/transactions/SimpleTransactionsTable'; import { BetweenAmountInput } from '@desktop-client/components/util/AmountInput'; @@ -319,6 +320,13 @@ function ConditionEditor({ onChange={v => onChange('value', v)} /> ); + } else if (field === 'payee') { + valueEditor = ( + onChange('value', id)} + /> + ); } else { valueEditor = (