From 77e99af297f7f3c1f1a4b3870a9f1bc55a679f97 Mon Sep 17 00:00:00 2001 From: Julian Dominguez-Schatz Date: Mon, 21 Jul 2025 09:54:27 -0400 Subject: [PATCH] Convert `SelectLinkedAccountsModal` to TypeScript (#5059) * Convert `SelectLinkedAccountsModal` to TypeScript * Add release notes * Fix typo caught by Rabbit --- .../components/autocomplete/Autocomplete.tsx | 52 ++--- ...odal.jsx => SelectLinkedAccountsModal.tsx} | 196 +++++++++++++----- .../desktop-client/src/modals/modalsSlice.ts | 9 +- .../loot-core/src/types/models/gocardless.ts | 5 +- .../loot-core/src/types/models/pluggyai.ts | 3 +- .../loot-core/src/types/models/simplefin.ts | 1 + upcoming-release-notes/5059.md | 6 + 7 files changed, 187 insertions(+), 85 deletions(-) rename packages/desktop-client/src/components/modals/{SelectLinkedAccountsModal.jsx => SelectLinkedAccountsModal.tsx} (63%) create mode 100644 upcoming-release-notes/5059.md diff --git a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx index f146bfd494..c9991d9d79 100644 --- a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx @@ -25,7 +25,7 @@ import { getNormalisedString } from 'loot-core/shared/normalisation'; import { useProperFocus } from '@desktop-client/hooks/useProperFocus'; -type CommonAutocompleteProps = { +type CommonAutocompleteProps = { focused?: boolean; embedded?: boolean; containerProps?: HTMLProps; @@ -54,14 +54,14 @@ type CommonAutocompleteProps = { onClose?: () => void; }; -type Item = { +export type AutocompleteItem = { id?: string; name: string; }; const inst: { lastChangeType?: StateChangeTypes } = {}; -function findItem( +function findItem( strict: boolean, suggestions: T[], value: T | T['id'], @@ -74,7 +74,9 @@ function findItem( return value; } -function getItemName(item: T | T['name'] | null): string { +function getItemName( + item: T | T['name'] | null, +): string { if (item == null) { return ''; } else if (typeof item === 'string') { @@ -83,14 +85,14 @@ function getItemName(item: T | T['name'] | null): string { return item.name || ''; } -function getItemId(item: T | T['id']) { +function getItemId(item: T | T['id']) { if (typeof item === 'string') { return item; } return item ? item.id : null; } -export function defaultFilterSuggestion( +export function defaultFilterSuggestion( suggestion: T, value: string, ) { @@ -98,7 +100,7 @@ export function defaultFilterSuggestion( return getNormalisedString(name).includes(getNormalisedString(value)); } -function defaultFilterSuggestions( +function defaultFilterSuggestions( suggestions: T[], value: string, ) { @@ -107,7 +109,7 @@ function defaultFilterSuggestions( ); } -function fireUpdate( +function fireUpdate( onUpdate: ((selected: string | null, value: string) => void) | undefined, strict: boolean, suggestions: T[], @@ -143,7 +145,7 @@ function defaultRenderInput(props: ComponentProps) { return ; } -function defaultRenderItems( +function defaultRenderItems( items: T[], getItemProps: (arg: { item: T }) => ComponentProps, highlightedIndex: number, @@ -199,17 +201,18 @@ function defaultShouldSaveFromKey(e: KeyboardEvent) { return e.code === 'Enter'; } -function defaultItemToString(item?: T) { +function defaultItemToString(item?: T) { return item ? getItemName(item) : ''; } -type SingleAutocompleteProps = CommonAutocompleteProps & { - type?: 'single' | never; - onSelect: (id: T['id'], value: string) => void; - value: null | T | T['id']; -}; +type SingleAutocompleteProps = + CommonAutocompleteProps & { + type?: 'single' | never; + onSelect: (id: T['id'], value: string) => void; + value: null | T | T['id']; + }; -function SingleAutocomplete({ +function SingleAutocomplete({ focused, embedded = false, containerProps, @@ -649,13 +652,14 @@ const defaultMultiAutocompleteInputClassName = css({ '&[data-focused]': { border: 0, boxShadow: 'none' }, }); -type MultiAutocompleteProps = CommonAutocompleteProps & { - type: 'multi'; - onSelect: (ids: T['id'][], id?: T['id']) => void; - value: null | T[] | T['id'][]; -}; +type MultiAutocompleteProps = + CommonAutocompleteProps & { + type: 'multi'; + onSelect: (ids: T['id'][], id?: T['id']) => void; + value: null | T[] | T['id'][]; + }; -function MultiAutocomplete({ +function MultiAutocomplete({ value: selectedItems = [], onSelect, suggestions, @@ -787,11 +791,11 @@ export function AutocompleteFooter({ ); } -type AutocompleteProps = +type AutocompleteProps = | ComponentProps> | ComponentProps>; -export function Autocomplete({ +export function Autocomplete({ ...props }: AutocompleteProps) { if (props.type === 'multi') { diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx similarity index 63% rename from packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx rename to packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx index 6f765b8e7d..52decf9ca0 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx @@ -7,13 +7,23 @@ import { theme } from '@actual-app/components/theme'; import { Tooltip } from '@actual-app/components/tooltip'; import { View } from '@actual-app/components/view'; +import { + type AccountEntity, + type SyncServerGoCardlessAccount, + type SyncServerPluggyAiAccount, + type SyncServerSimpleFinAccount, +} from 'loot-core/types/models'; + import { linkAccount, linkAccountPluggyAi, linkAccountSimpleFin, unlinkAccount, } from '@desktop-client/accounts/accountsSlice'; -import { Autocomplete } from '@desktop-client/components/autocomplete/Autocomplete'; +import { + Autocomplete, + type AutocompleteItem, +} from '@desktop-client/components/autocomplete/Autocomplete'; import { Modal, ModalCloseButton, @@ -25,6 +35,7 @@ import { Table, Row, Field, + Cell, } from '@desktop-client/components/table'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { closeModal } from '@desktop-client/modals/modalsSlice'; @@ -45,31 +56,68 @@ function useAddBudgetAccountOptions() { return { addOnBudgetAccountOption, addOffBudgetAccountOption }; } +export type SelectLinkedAccountsModalProps = + | { + requisitionId: string; + externalAccounts: SyncServerGoCardlessAccount[]; + syncSource: 'goCardless'; + } + | { + requisitionId?: undefined; + externalAccounts: SyncServerSimpleFinAccount[]; + syncSource: 'simpleFin'; + } + | { + requisitionId?: undefined; + externalAccounts: SyncServerPluggyAiAccount[]; + syncSource: 'pluggyai'; + }; + export function SelectLinkedAccountsModal({ requisitionId = undefined, externalAccounts, - syncSource = undefined, -}) { - const sortedExternalAccounts = useMemo(() => { - const toSort = externalAccounts ? [...externalAccounts] : []; - toSort.sort( - (a, b) => - getInstitutionName(a)?.localeCompare(getInstitutionName(b)) || - a.name.localeCompare(b.name), - ); - return toSort; - }, [externalAccounts]); + syncSource, +}: SelectLinkedAccountsModalProps) { + const propsWithSortedExternalAccounts = + useMemo(() => { + const toSort = externalAccounts ? [...externalAccounts] : []; + toSort.sort( + (a, b) => + getInstitutionName(a)?.localeCompare(getInstitutionName(b)) || + a.name.localeCompare(b.name), + ); + switch (syncSource) { + case 'simpleFin': + return { + syncSource: 'simpleFin', + externalAccounts: toSort as SyncServerSimpleFinAccount[], + }; + case 'pluggyai': + return { + syncSource: 'pluggyai', + externalAccounts: toSort as SyncServerPluggyAiAccount[], + }; + case 'goCardless': + return { + syncSource: 'goCardless', + requisitionId: requisitionId!, + externalAccounts: toSort as SyncServerGoCardlessAccount[], + }; + } + }, [externalAccounts, syncSource, requisitionId]); const { t } = useTranslation(); const dispatch = useDispatch(); const localAccounts = useAccounts().filter(a => a.closed === 0); - const [chosenAccounts, setChosenAccounts] = useState(() => { - return Object.fromEntries( - localAccounts - .filter(acc => acc.account_id) - .map(acc => [acc.account_id, acc.id]), - ); - }); + const [chosenAccounts, setChosenAccounts] = useState>( + () => { + return Object.fromEntries( + localAccounts + .filter(acc => acc.account_id) + .map(acc => [acc.account_id, acc.id]), + ); + }, + ); const { addOnBudgetAccountOption, addOffBudgetAccountOption } = useAddBudgetAccountOptions(); @@ -86,22 +134,26 @@ export function SelectLinkedAccountsModal({ // Link new accounts Object.entries(chosenAccounts).forEach( ([chosenExternalAccountId, chosenLocalAccountId]) => { - const externalAccount = sortedExternalAccounts.find( - account => account.account_id === chosenExternalAccountId, - ); + const externalAccountIndex = + propsWithSortedExternalAccounts.externalAccounts.findIndex( + account => account.account_id === chosenExternalAccountId, + ); const offBudget = chosenLocalAccountId === addOffBudgetAccountOption.id; // Skip linking accounts that were previously linked with // a different bank. - if (!externalAccount) { + if (externalAccountIndex === -1) { return; } // Finally link the matched account - if (syncSource === 'simpleFin') { + if (propsWithSortedExternalAccounts.syncSource === 'simpleFin') { dispatch( linkAccountSimpleFin({ - externalAccount, + externalAccount: + propsWithSortedExternalAccounts.externalAccounts[ + externalAccountIndex + ], upgradingId: chosenLocalAccountId !== addOnBudgetAccountOption.id && chosenLocalAccountId !== addOffBudgetAccountOption.id @@ -110,10 +162,13 @@ export function SelectLinkedAccountsModal({ offBudget, }), ); - } else if (syncSource === 'pluggyai') { + } else if (propsWithSortedExternalAccounts.syncSource === 'pluggyai') { dispatch( linkAccountPluggyAi({ - externalAccount, + externalAccount: + propsWithSortedExternalAccounts.externalAccounts[ + externalAccountIndex + ], upgradingId: chosenLocalAccountId !== addOnBudgetAccountOption.id && chosenLocalAccountId !== addOffBudgetAccountOption.id @@ -125,8 +180,11 @@ export function SelectLinkedAccountsModal({ } else { dispatch( linkAccount({ - requisitionId, - account: externalAccount, + requisitionId: propsWithSortedExternalAccounts.requisitionId, + account: + propsWithSortedExternalAccounts.externalAccounts[ + externalAccountIndex + ], upgradingId: chosenLocalAccountId !== addOnBudgetAccountOption.id && chosenLocalAccountId !== addOffBudgetAccountOption.id @@ -146,7 +204,13 @@ export function SelectLinkedAccountsModal({ account => !Object.values(chosenAccounts).includes(account.id), ); - function onSetLinkedAccount(externalAccount, localAccountId) { + function onSetLinkedAccount( + externalAccount: + | SyncServerGoCardlessAccount + | SyncServerSimpleFinAccount + | SyncServerPluggyAiAccount, + localAccountId: string | null | undefined, + ) { setChosenAccounts(accounts => { const updatedAccounts = { ...accounts }; @@ -184,22 +248,29 @@ export function SelectLinkedAccountsModal({ border: '1px solid ' + theme.tableBorder, }} > - + + + + + + + - + items={propsWithSortedExternalAccounts.externalAccounts.map( + account => ({ + ...account, + id: account.account_id, + }), + )} style={{ backgroundColor: theme.tableHeaderBackground }} - getItemKey={index => index} - renderItem={({ key, item }) => ( - + getItemKey={String} + renderItem={({ item }) => ( + void; +}; + function TableRow({ externalAccount, chosenAccount, unlinkedAccounts, onSetLinkedAccount, -}) { - const [focusedField, setFocusedField] = useState(null); +}: TableRowProps) { + const [focusedField, setFocusedField] = useState(null); const { addOnBudgetAccountOption, addOffBudgetAccountOption } = useAddBudgetAccountOptions(); - const availableAccountOptions = [ - ...unlinkedAccounts, - chosenAccount?.id !== addOnBudgetAccountOption.id && chosenAccount, + const availableAccountOptions: AutocompleteItem[] = [...unlinkedAccounts]; + if (chosenAccount && chosenAccount.id !== addOnBudgetAccountOption.id) { + availableAccountOptions.push(chosenAccount); + } + availableAccountOptions.push( addOnBudgetAccountOption, addOffBudgetAccountOption, - ].filter(Boolean); + ); return ( diff --git a/packages/desktop-client/src/modals/modalsSlice.ts b/packages/desktop-client/src/modals/modalsSlice.ts index 2509fd5303..12e95b5684 100644 --- a/packages/desktop-client/src/modals/modalsSlice.ts +++ b/packages/desktop-client/src/modals/modalsSlice.ts @@ -4,7 +4,6 @@ import { send } from 'loot-core/platform/client/fetch'; import { type File } from 'loot-core/types/file'; import { type AccountEntity, - type AccountSyncSource, type CategoryEntity, type CategoryGroupEntity, type GoCardlessToken, @@ -19,6 +18,7 @@ import { } from 'loot-core/types/models'; import { resetApp, setAppState } from '@desktop-client/app/appSlice'; +import { type SelectLinkedAccountsModalProps } from '@desktop-client/components/modals/SelectLinkedAccountsModal'; import { createAppAsyncThunk } from '@desktop-client/redux'; import { signOut } from '@desktop-client/users/usersSlice'; @@ -53,12 +53,7 @@ export type Modal = } | { name: 'select-linked-accounts'; - options: { - externalAccounts: unknown[]; - requisitionId?: string; - upgradingAccountId?: string | undefined; - syncSource?: AccountSyncSource; - }; + options: SelectLinkedAccountsModalProps; } | { name: 'confirm-category-delete'; diff --git a/packages/loot-core/src/types/models/gocardless.ts b/packages/loot-core/src/types/models/gocardless.ts index 365c3314c9..eb123a5bc5 100644 --- a/packages/loot-core/src/types/models/gocardless.ts +++ b/packages/loot-core/src/types/models/gocardless.ts @@ -1,6 +1,6 @@ export type GoCardlessToken = { id: string; - accounts: unknown[]; + accounts: SyncServerGoCardlessAccount[]; }; export type GoCardlessInstitution = { @@ -75,7 +75,8 @@ export type GoCardlessTransaction = { }; export type SyncServerGoCardlessAccount = { - institution: string; + balance: number; + institution: string | { name: string }; account_id: string; mask: string; name: string; diff --git a/packages/loot-core/src/types/models/pluggyai.ts b/packages/loot-core/src/types/models/pluggyai.ts index 5476bfcc01..2771566f0d 100644 --- a/packages/loot-core/src/types/models/pluggyai.ts +++ b/packages/loot-core/src/types/models/pluggyai.ts @@ -10,9 +10,10 @@ export type PluggyAiAccount = { }; export type SyncServerPluggyAiAccount = { + balance: number; account_id: string; institution?: string; - orgDomain?: string; + orgDomain?: string | null; orgId?: string; name: string; }; diff --git a/packages/loot-core/src/types/models/simplefin.ts b/packages/loot-core/src/types/models/simplefin.ts index 008ff00e07..5ce7b47fb4 100644 --- a/packages/loot-core/src/types/models/simplefin.ts +++ b/packages/loot-core/src/types/models/simplefin.ts @@ -19,6 +19,7 @@ export interface SimpleFinBatchSyncResponse { } export type SyncServerSimpleFinAccount = { + balance: number; account_id: string; institution?: string; orgDomain?: string; diff --git a/upcoming-release-notes/5059.md b/upcoming-release-notes/5059.md new file mode 100644 index 0000000000..4ba9d1c429 --- /dev/null +++ b/upcoming-release-notes/5059.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [jfdoming] +--- + +Convert `SelectLinkedAccountsModal` to TypeScript