From 3d6e9919b2042545d67eb3d681484703662bb043 Mon Sep 17 00:00:00 2001 From: Matt Fiddaman Date: Wed, 5 Nov 2025 23:57:25 +0000 Subject: [PATCH] make SelectLinkedAccountModal responsive (#5984) * make SelectLinkedAccountModal responsive * fix table headers * note * coderabbit * fix feedback * coderabbit * cast to string * Fix for unlinking account only * Code Rabbit reviews --------- Co-authored-by: lelemm --- .../modals/SelectLinkedAccountsModal.tsx | 428 ++++++++++++++---- upcoming-release-notes/5984.md | 6 + 2 files changed, 348 insertions(+), 86 deletions(-) create mode 100644 upcoming-release-notes/5984.md diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx index 52decf9ca0..9f3faf254d 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.tsx @@ -2,6 +2,8 @@ import React, { useMemo, useState } from 'react'; import { useTranslation, Trans } from 'react-i18next'; import { Button } from '@actual-app/components/button'; +import { useResponsive } from '@actual-app/components/hooks/useResponsive'; +import { Stack } from '@actual-app/components/stack'; import { Text } from '@actual-app/components/text'; import { theme } from '@actual-app/components/theme'; import { Tooltip } from '@actual-app/components/tooltip'; @@ -38,6 +40,7 @@ import { Cell, } from '@desktop-client/components/table'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; +import { useFormat } from '@desktop-client/hooks/useFormat'; import { closeModal } from '@desktop-client/modals/modalsSlice'; import { useDispatch } from '@desktop-client/redux'; @@ -107,8 +110,12 @@ export function SelectLinkedAccountsModal({ }, [externalAccounts, syncSource, requisitionId]); const { t } = useTranslation(); + const { isNarrowWidth } = useResponsive(); const dispatch = useDispatch(); const localAccounts = useAccounts().filter(a => a.closed === 0); + const [draftLinkAccounts, setDraftLinkAccounts] = useState< + Map + >(new Map()); const [chosenAccounts, setChosenAccounts] = useState>( () => { return Object.fromEntries( @@ -216,133 +223,220 @@ export function SelectLinkedAccountsModal({ if (localAccountId) { updatedAccounts[externalAccount.account_id] = localAccountId; + setDraftLinkAccounts(prev => + new Map(prev).set(externalAccount.account_id, 'linking'), + ); } else { delete updatedAccounts[externalAccount.account_id]; + setDraftLinkAccounts(prev => + new Map(prev).set(externalAccount.account_id, 'unlinking'), + ); } return updatedAccounts; }); } + const getChosenAccount = (accountId: string) => { + const chosenId = chosenAccounts[accountId]; + if (!chosenId) return undefined; + + if (chosenId === addOnBudgetAccountOption.id) { + return addOnBudgetAccountOption; + } + if (chosenId === addOffBudgetAccountOption.id) { + return addOffBudgetAccountOption; + } + + return localAccounts.find(acc => acc.id === chosenId); + }; + + const label = useMemo(() => { + const s = new Set(draftLinkAccounts.values()); + if (s.has('linking') && s.has('unlinking')) { + return t('Link and unlink accounts'); + } else if (s.has('linking')) { + return t('Link accounts'); + } else if (s.has('unlinking')) { + return t('Unlink accounts'); + } + + return t('Link or unlink accounts'); + }, [draftLinkAccounts, t]); + return ( {({ state: { close } }) => ( - <> + } /> - - - We found the following accounts. Select which ones you want to - add: - - + - - - - - - - - - - items={propsWithSortedExternalAccounts.externalAccounts.map( - account => ({ - ...account, - id: account.account_id, - }), - )} - style={{ backgroundColor: theme.tableHeaderBackground }} - getItemKey={String} - renderItem={({ item }) => ( - - chosenAccounts[item.account_id] === acc.id, - ) - } - unlinkedAccounts={unlinkedAccounts} - onSetLinkedAccount={onSetLinkedAccount} - /> - - )} - /> + + + We found the following accounts. Select which ones you want to + add: + + + {isNarrowWidth ? ( + + {propsWithSortedExternalAccounts.externalAccounts.map(account => ( + + ))} + + ) : ( + + + + + + + + + + + items={propsWithSortedExternalAccounts.externalAccounts.map( + account => ({ + ...account, + id: account.account_id, + }), + )} + style={{ backgroundColor: theme.tableHeaderBackground }} + renderItem={({ item }) => ( + + + + )} + /> + + )} + - + )} ); } -function getInstitutionName( - externalAccount: - | SyncServerGoCardlessAccount - | SyncServerSimpleFinAccount - | SyncServerPluggyAiAccount, -) { - if (typeof externalAccount?.institution === 'string') { - return externalAccount?.institution ?? ''; - } else if (typeof externalAccount.institution?.name === 'string') { - return externalAccount?.institution?.name ?? ''; - } - return ''; -} +type ExternalAccount = + | SyncServerGoCardlessAccount + | SyncServerSimpleFinAccount + | SyncServerPluggyAiAccount; -type TableRowProps = { - externalAccount: - | SyncServerGoCardlessAccount - | SyncServerSimpleFinAccount - | SyncServerPluggyAiAccount; +type SharedAccountRowProps = { + externalAccount: ExternalAccount; chosenAccount: { id: string; name: string } | undefined; unlinkedAccounts: AccountEntity[]; onSetLinkedAccount: ( - externalAccount: - | SyncServerGoCardlessAccount - | SyncServerSimpleFinAccount - | SyncServerPluggyAiAccount, + externalAccount: ExternalAccount, localAccountId: string | null | undefined, ) => void; }; +function getAvailableAccountOptions( + unlinkedAccounts: AccountEntity[], + chosenAccount: { id: string; name: string } | undefined, + addOnBudgetAccountOption: { id: string; name: string }, + addOffBudgetAccountOption: { id: string; name: string }, +): AutocompleteItem[] { + const options: AutocompleteItem[] = [...unlinkedAccounts]; + if ( + chosenAccount && + chosenAccount.id !== addOnBudgetAccountOption.id && + chosenAccount.id !== addOffBudgetAccountOption.id + ) { + options.push(chosenAccount); + } + options.push(addOnBudgetAccountOption, addOffBudgetAccountOption); + return options; +} + +type TableRowProps = SharedAccountRowProps; + function TableRow({ externalAccount, chosenAccount, @@ -352,12 +446,12 @@ function TableRow({ const [focusedField, setFocusedField] = useState(null); const { addOnBudgetAccountOption, addOffBudgetAccountOption } = useAddBudgetAccountOptions(); + const format = useFormat(); + const { t } = useTranslation(); - const availableAccountOptions: AutocompleteItem[] = [...unlinkedAccounts]; - if (chosenAccount && chosenAccount.id !== addOnBudgetAccountOption.id) { - availableAccountOptions.push(chosenAccount); - } - availableAccountOptions.push( + const availableAccountOptions = getAvailableAccountOptions( + unlinkedAccounts, + chosenAccount, addOnBudgetAccountOption, addOffBudgetAccountOption, ); @@ -391,7 +485,11 @@ function TableRow({ - {externalAccount.balance} + + {externalAccount.balance != null + ? format(externalAccount.balance.toString(), 'financial') + : t('Unknown')} + ); } + +function getInstitutionName( + externalAccount: + | SyncServerGoCardlessAccount + | SyncServerSimpleFinAccount + | SyncServerPluggyAiAccount, +) { + if (typeof externalAccount?.institution === 'string') { + return externalAccount?.institution ?? ''; + } else if (typeof externalAccount.institution?.name === 'string') { + return externalAccount?.institution?.name ?? ''; + } + return ''; +} + +type AccountCardProps = SharedAccountRowProps; + +function AccountCard({ + externalAccount, + chosenAccount, + unlinkedAccounts, + onSetLinkedAccount, +}: AccountCardProps) { + const [focusedField, setFocusedField] = useState(null); + const { addOnBudgetAccountOption, addOffBudgetAccountOption } = + useAddBudgetAccountOptions(); + const format = useFormat(); + const { t } = useTranslation(); + + const availableAccountOptions = getAvailableAccountOptions( + unlinkedAccounts, + chosenAccount, + addOnBudgetAccountOption, + addOffBudgetAccountOption, + ); + + return ( + + + {externalAccount.name} + + + + {getInstitutionName(externalAccount)} + + + + Balance:{' '} + + {externalAccount.balance != null + ? format(externalAccount.balance.toString(), 'financial') + : t('Unknown')} + + + + + + Linked to: + + {chosenAccount ? ( + + {chosenAccount.name} + + ) : ( + + Not linked + + )} + + + {focusedField === 'account' && ( + + { + onSetLinkedAccount(externalAccount, value); + setFocusedField(null); + }} + inputProps={{ + onBlur: () => setFocusedField(null), + placeholder: t('Select account...'), + }} + value={chosenAccount?.id} + /> + + )} + + + {chosenAccount ? ( + + ) : ( + + )} + + + ); +} diff --git a/upcoming-release-notes/5984.md b/upcoming-release-notes/5984.md new file mode 100644 index 0000000000..cbe49c642c --- /dev/null +++ b/upcoming-release-notes/5984.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [matt-fidd] +--- + +Make bank sync accout linking modal mobile responsive