diff --git a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx index 5c413b01d55..65b09844eb1 100644 --- a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx @@ -10,7 +10,7 @@ import React, { type ChangeEvent, } from 'react'; -import Downshift from 'downshift'; +import Downshift, { type StateChangeTypes } from 'downshift'; import { css } from 'glamor'; import Remove from '../../icons/v2/Remove'; @@ -20,18 +20,31 @@ import Input from '../common/Input'; import View from '../common/View'; import { Tooltip } from '../tooltips'; -const inst: { lastChangeType? } = {}; +type Item = { + id?: string; + name: string; +}; + +const inst: { lastChangeType?: StateChangeTypes } = {}; -function findItem(strict, suggestions, value) { +function findItem( + strict: boolean, + suggestions: T[], + value: T | T['id'], +): T | null { if (strict) { const idx = suggestions.findIndex(item => item.id === value); return idx === -1 ? null : suggestions[idx]; } + if (typeof value === 'string') { + throw new Error('value can be string only if strict = false'); + } + return value; } -function getItemName(item) { +function getItemName(item: null | string | Item): string { if (item == null) { return ''; } else if (typeof item === 'string') { @@ -40,24 +53,36 @@ function getItemName(item) { return item.name || ''; } -function getItemId(item) { +function getItemId(item: Item | Item['id']) { if (typeof item === 'string') { return item; } return item ? item.id : null; } -export function defaultFilterSuggestion(suggestion, value) { +export function defaultFilterSuggestion( + suggestion: T, + value: string, +) { return getItemName(suggestion).toLowerCase().includes(value.toLowerCase()); } -function defaultFilterSuggestions(suggestions, value) { +function defaultFilterSuggestions( + suggestions: T[], + value: string, +) { return suggestions.filter(suggestion => defaultFilterSuggestion(suggestion, value), ); } -function fireUpdate(onUpdate, strict, suggestions, index, value) { +function fireUpdate( + onUpdate: ((selected: string | null, value: string) => void) | undefined, + strict: boolean, + suggestions: T[], + index: number, + value: string, +) { // If the index is null, look up the id in the suggestions. If the // value is empty it will select nothing (as expected). If it's not // empty but nothing is selected, it still resolves to an id. It @@ -82,11 +107,15 @@ function fireUpdate(onUpdate, strict, suggestions, index, value) { onUpdate?.(selected, value); } -function defaultRenderInput(props) { +function defaultRenderInput(props: ComponentProps) { return ; } -function defaultRenderItems(items, getItemProps, highlightedIndex) { +function defaultRenderItems( + items: T[], + getItemProps: (arg: { item: T }) => ComponentProps, + highlightedIndex: number, +) { return (
{items.map((item, index) => { @@ -134,15 +163,15 @@ function defaultRenderItems(items, getItemProps, highlightedIndex) { ); } -function defaultShouldSaveFromKey(e) { +function defaultShouldSaveFromKey(e: KeyboardEvent) { return e.code === 'Enter'; } -function defaultItemToString(item) { +function defaultItemToString(item?: T) { return item ? getItemName(item) : ''; } -type SingleAutocompleteProps = { +type SingleAutocompleteProps = { focused?: boolean; embedded?: boolean; containerProps?: HTMLProps; @@ -150,31 +179,31 @@ type SingleAutocompleteProps = { inputProps?: Omit, 'onChange'> & { onChange?: (value: string) => void; }; - suggestions?: unknown[]; + suggestions?: T[]; tooltipStyle?: CSSProperties; tooltipProps?: ComponentProps; renderInput?: (props: ComponentProps) => ReactNode; renderItems?: ( - items, - getItemProps: (arg: { item: unknown }) => ComponentProps, + items: T[], + getItemProps: (arg: { item: T }) => ComponentProps, idx: number, - value?: unknown, + value?: string, ) => ReactNode; - itemToString?: (item) => string; + itemToString?: (item: T) => string; shouldSaveFromKey?: (e: KeyboardEvent) => boolean; - filterSuggestions?: (suggestions, value: string) => unknown[]; + filterSuggestions?: (suggestions: T[], value: string) => T[]; openOnFocus?: boolean; - getHighlightedIndex?: (suggestions) => number | null; + getHighlightedIndex?: (suggestions: T[]) => number | null; highlightFirst?: boolean; - onUpdate?: (id: unknown, value: string) => void; + onUpdate?: (id: T['id'], value: string) => void; strict?: boolean; - onSelect: (id: unknown, value: string) => void; + onSelect: (id: T['id'], value: string) => void; tableBehavior?: boolean; closeOnBlur?: boolean; - value: unknown[] | string; + value: T | T['id']; isMulti?: boolean; }; -function SingleAutocomplete({ +function SingleAutocomplete({ focused, embedded = false, containerProps, @@ -198,7 +227,7 @@ function SingleAutocomplete({ closeOnBlur = true, value: initialValue, isMulti = false, -}: SingleAutocompleteProps) { +}: SingleAutocompleteProps) { const [selectedItem, setSelectedItem] = useState(() => findItem(strict, suggestions, initialValue), ); @@ -220,9 +249,9 @@ function SingleAutocomplete({ setSelectedItem(findItem(strict, suggestions, initialValue)); }, [initialValue, suggestions, strict]); - function resetState(newValue) { + function resetState(newValue?: string) { const val = newValue === undefined ? initialValue : newValue; - const selectedItem = findItem(strict, suggestions, val); + const selectedItem = findItem(strict, suggestions, val); setSelectedItem(selectedItem); setValue(selectedItem ? getItemName(selectedItem) : ''); @@ -527,7 +556,12 @@ function SingleAutocomplete({ ); } -function MultiItem({ name, onRemove }) { +type MultiItemProps = { + name: string; + onRemove: () => void; +}; + +function MultiItem({ name, onRemove }: MultiItemProps) { return ( & { - value: unknown[]; - onSelect: (ids: unknown[], id?: string) => void; +type MultiAutocompleteProps< + T extends Item, + Value = SingleAutocompleteProps['value'], +> = Omit, 'value' | 'onSelect'> & { + value: Value[]; + onSelect: (ids: Value[], id?: string) => void; }; -function MultiAutocomplete({ +function MultiAutocomplete({ value: selectedItems, onSelect, suggestions, strict, ...props -}: MultiAutocompleteProps) { +}: MultiAutocompleteProps) { const [focused, setFocused] = useState(false); - const lastSelectedItems = useRef(); + const lastSelectedItems = useRef(); useEffect(() => { lastSelectedItems.current = selectedItems; }); - function onRemoveItem(id) { + function onRemoveItem(id: (typeof selectedItems)[0]) { const items = selectedItems.filter(i => i !== id); onSelect(items); } - function onAddItem(id) { + function onAddItem(id: string) { if (id) { id = id.trim(); onSelect([...selectedItems, id], id); } } - function onKeyDown(e, prevOnKeyDown) { + function onKeyDown( + e: KeyboardEvent, + prevOnKeyDown?: ComponentProps['onKeyDown'], + ) { + // @ts-expect-error We're missing `target.value` on KeyboardEvent if (e.key === 'Backspace' && e.target.value === '') { onRemoveItem(selectedItems[selectedItems.length - 1]); } @@ -680,31 +718,24 @@ export function AutocompleteFooter({ ); } -type AutocompleteProps = - | ComponentProps - | ComponentProps; +type AutocompleteProps = + | ComponentProps> + | ComponentProps>; -function isMultiAutocomplete( - props: AutocompleteProps, +function isMultiAutocomplete( + _props: AutocompleteProps, multi?: boolean, -): props is ComponentProps { +): _props is ComponentProps> { return multi; } -function isSingleAutocomplete( - props: AutocompleteProps, - multi?: boolean, -): props is ComponentProps { - return !multi; -} - -export default function Autocomplete({ +export default function Autocomplete({ multi, ...props -}: AutocompleteProps & { multi?: boolean }) { +}: AutocompleteProps & { multi?: boolean }) { if (isMultiAutocomplete(props, multi)) { return ; - } else if (isSingleAutocomplete(props, multi)) { - return ; } + + return ; } diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx index 6ed812c7b02..1c908d0be68 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx @@ -308,6 +308,7 @@ export default function PayeeAutocomplete({ const isf = filtered.length > 100; filtered = filtered.slice(0, 100); + // @ts-expect-error TODO: solve this somehow filtered.filtered = isf; if (filtered.length >= 2 && filtered[0].id === 'new') { diff --git a/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.tsx index a2800b85646..989e0b17123 100644 --- a/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.tsx @@ -1,25 +1,26 @@ import React, { type ComponentProps } from 'react'; import { useFilters } from 'loot-core/src/client/data-hooks/filters'; +import { type TransactionFilterEntity } from 'loot-core/src/types/models'; import { theme } from '../../style'; import View from '../common/View'; import Autocomplete from './Autocomplete'; -type FilterListProps = { - items: { id: string; name: string }[]; - getItemProps: (arg: { item: unknown }) => ComponentProps; +type FilterListProps = { + items: T[]; + getItemProps: (arg: { item: T }) => ComponentProps; highlightedIndex: number; embedded?: boolean; }; -function FilterList({ +function FilterList({ items, getItemProps, highlightedIndex, embedded, -}: FilterListProps) { +}: FilterListProps) { return ( ; +} & ComponentProps>; export default function SavedFilterAutocomplete({ embedded, @@ -73,6 +74,7 @@ export default function SavedFilterAutocomplete({ suggestions={filters} renderItems={(items, getItemProps, highlightedIndex) => ( q('transaction_filters').select('*'), []) || [], ); diff --git a/packages/loot-core/src/types/models/index.d.ts b/packages/loot-core/src/types/models/index.d.ts index d411ff64eb8..cceded549bb 100644 --- a/packages/loot-core/src/types/models/index.d.ts +++ b/packages/loot-core/src/types/models/index.d.ts @@ -6,3 +6,4 @@ export type * from './payee'; export type * from './rule'; export type * from './schedule'; export type * from './transaction'; +export type * from './transaction-filter'; diff --git a/packages/loot-core/src/types/models/transaction-filter.d.ts b/packages/loot-core/src/types/models/transaction-filter.d.ts new file mode 100644 index 00000000000..e43d50e21e3 --- /dev/null +++ b/packages/loot-core/src/types/models/transaction-filter.d.ts @@ -0,0 +1,7 @@ +export interface TransactionFilterEntity { + id: string; + name: string; + conditions_op: string; + conditions: unknown; + tombstone: boolean; +}