From 7e6b760796a3e86243eab8ba5edfcbe5540fe3ee Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins <matiss@mja.lv> Date: Fri, 31 Mar 2023 20:33:49 +0100 Subject: [PATCH] :recycle: (bank-sync) improved UX for linking nordigen accounts (#792) Improving the UX for Nordigen bank-sync account import modal. --- .../desktop-client/src/components/Modals.js | 4 +- .../components/modals/SelectLinkedAccounts.js | 391 ++++++++---------- upcoming-release-notes/792.md | 6 + 3 files changed, 185 insertions(+), 216 deletions(-) create mode 100644 upcoming-release-notes/792.md diff --git a/packages/desktop-client/src/components/Modals.js b/packages/desktop-client/src/components/Modals.js index a70800ef7..311e88be6 100644 --- a/packages/desktop-client/src/components/Modals.js +++ b/packages/desktop-client/src/components/Modals.js @@ -94,9 +94,9 @@ function Modals({ <Route path="/select-linked-accounts"> <SelectLinkedAccounts modalProps={modalProps} - accounts={options.accounts} + externalAccounts={options.accounts} requisitionId={options.requisitionId} - actualAccounts={accounts.filter(acct => acct.closed === 0)} + localAccounts={accounts.filter(acct => acct.closed === 0)} upgradingAccountId={options.upgradingAccountId} actions={actions} /> diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccounts.js b/packages/desktop-client/src/components/modals/SelectLinkedAccounts.js index 842c55244..1be08bb16 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccounts.js +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccounts.js @@ -1,254 +1,217 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; -import { View, Modal, P, Button, Strong, CustomSelect } from '../common'; +import { colors } from '../../style'; +import Autocomplete from '../autocomplete/NewAutocomplete'; +import { View, Modal, Button, Text } from '../common'; +import { TableHeader, Table, Row, Field } from '../table'; -function EmptyMessage() { - return null; -} +const addAccountOption = { value: 'new', label: 'Create new account' }; export default function SelectLinkedAccounts({ - upgradingAccountId, modalProps, requisitionId, - accounts: importedAccounts, - actualAccounts, + externalAccounts, + localAccounts, actions, }) { - let [chosenAccounts, setChosenAccounts] = useState([]); - - const addAccountOption = { id: 'new', name: 'Create new account' }; + const [chosenAccounts, setChosenAccounts] = useState(() => { + return Object.fromEntries( + localAccounts + .filter(acc => acc.account_id) + .map(acc => [acc.account_id, acc.id]), + ); + }); - const importedAccountsToSelect = importedAccounts.filter( - account => - !chosenAccounts - .map(acc => acc.chosenImportedAccountId) - .includes(account.account_id), - ); - - const actualAccountsToSelect = [ - addAccountOption, - ...actualAccounts.filter( - account => - !chosenAccounts - .map(acc => acc.chosenActualAccountId) - .includes(account.id), - ), - ]; - - useEffect(() => { - const chosenAccountsToAdd = []; - importedAccountsToSelect.forEach(importedAccount => { - // Try to auto-match accounts based on account_id or mask - // Add matched accounts to list of selected accounts - const matchedActualAccount = actualAccountsToSelect.find( - actualAccount => { - return ( - actualAccount.account_id === importedAccount.account_id || - actualAccount.mask === importedAccount.mask - ); - }, - ); - - if (matchedActualAccount) { - chosenAccountsToAdd.push({ - chosenImportedAccountId: importedAccount.account_id, - chosenActualAccountId: matchedActualAccount.id, - }); - } - }); + async function onNext() { + const chosenLocalAccountIds = Object.values(chosenAccounts); + + // Unlink accounts that were previously linked, but the user + // chose to remove the bank-sync + localAccounts + .filter(acc => acc.account_id) + .filter(acc => !chosenLocalAccountIds.includes(acc.id)) + .forEach(acc => actions.unlinkAccount(acc.id)); + + // Link new accounts + Object.entries(chosenAccounts).forEach( + ([chosenExternalAccountId, chosenLocalAccountId]) => { + const externalAccount = externalAccounts.find( + account => account.account_id === chosenExternalAccountId, + ); + + // Skip linking accounts that were previously linked with + // a different bank. + if (!externalAccount) { + return; + } + + // Finally link the matched account + actions.linkAccount( + requisitionId, + externalAccount, + chosenLocalAccountId !== addAccountOption.value + ? chosenLocalAccountId + : undefined, + ); + }, + ); - setChosenAccounts([...chosenAccounts, ...chosenAccountsToAdd]); - }, []); + actions.closeModal(); + } - let [selectedImportAccountId, setSelectedImportAccountId] = useState( - importedAccountsToSelect[0] && importedAccountsToSelect[0].account_id, - ); - let [selectedAccountId, setSelectedAccountId] = useState( - actualAccountsToSelect[0] && actualAccountsToSelect[0].id, + const unlinkedAccounts = localAccounts.filter( + account => !Object.values(chosenAccounts).includes(account.id), ); - async function onNext() { - chosenAccounts.forEach(chosenAccount => { - const importedAccount = importedAccounts.find( - account => account.account_id === chosenAccount.chosenImportedAccountId, - ); - - actions.linkAccount( - requisitionId, - importedAccount, - chosenAccount.chosenActualAccountId !== addAccountOption.id - ? chosenAccount.chosenActualAccountId - : undefined, - ); - }); + function onSetLinkedAccount(externalAccount, localAccountId) { + setChosenAccounts(accounts => { + const updatedAccounts = { ...accounts }; - actions.closeModal(); - } + if (localAccountId) { + updatedAccounts[externalAccount.account_id] = localAccountId; + } else { + delete updatedAccounts[externalAccount.account_id]; + } - function addToChosenAccounts() { - setChosenAccounts([ - ...chosenAccounts, - { - chosenImportedAccountId: selectedImportAccountId, - chosenActualAccountId: selectedAccountId, - }, - ]); + return updatedAccounts; + }); } - // Update dropbox with available accounts to select - useEffect(() => { - const newSelectedImportAccountId = - importedAccountsToSelect[0] && importedAccountsToSelect[0].account_id; - const newSelectedAccountId = - actualAccountsToSelect[0] && actualAccountsToSelect[0].id; - setSelectedImportAccountId(newSelectedImportAccountId); - setSelectedAccountId(newSelectedAccountId); - }, [chosenAccounts]); - - const removeChoose = chosenAccount => { - setChosenAccounts([...chosenAccounts.filter(acc => acc !== chosenAccount)]); - }; - return ( - <Modal title={'Link Accounts'} {...modalProps}> + <Modal title="Link Accounts" {...modalProps} style={{ width: 800 }}> {() => ( - <View style={{ maxWidth: 500 }}> - {upgradingAccountId ? ( - <P> - You allowed access to the following accounts. Select the one you - want to link with: - </P> - ) : ( - <P> - We found the following accounts. Select which ones you want to - add: - </P> - )} - + <> + <Text style={{ marginBottom: 10 }}> + We found the following accounts. Select which ones you want to add: + </Text> <View style={{ - maxHeight: 300, - overflow: 'auto', - // Allow the shadow to appear on left/right edge - paddingLeft: 5, - paddingRight: 5, - marginLeft: -5, - marginRight: -5, + flex: 'unset', + height: 300, + border: '1px solid ' + colors.border, }} > - <View> - {importedAccounts.length === 0 ? ( - <EmptyMessage /> - ) : ( - <View> - {importedAccountsToSelect.length ? ( - <View - style={{ - flexDirection: 'row', - justifyContent: 'flex-center', - margin: '30px 0', - borderBottom: 'solid 1px', - }} - > - <View> - <Strong>Imported Account:</Strong> - <CustomSelect - options={importedAccountsToSelect.map(account => [ - account.account_id, - account.name, - ])} - onChange={val => { - setSelectedImportAccountId(val); - }} - value={selectedImportAccountId} - /> - </View> - - <View> - <Strong>Actual Budget Account:</Strong> - <CustomSelect - options={actualAccountsToSelect.map(account => [ - account.id, - account.name, - ])} - onChange={val => { - setSelectedAccountId(val); - }} - value={selectedAccountId} - /> - </View> - - <Button - primary - style={{ - padding: '10px', - fontSize: 15, - margin: 10, - }} - onClick={addToChosenAccounts} - > - Link account → - </Button> - </View> - ) : ( - '' - )} - {chosenAccounts.map(chosenAccount => { - const { chosenImportedAccountId, chosenActualAccountId } = - chosenAccount; - const importedAccount = importedAccounts.find( - acc => acc.account_id === chosenImportedAccountId, - ); - const actualAccount = [ - addAccountOption, - ...actualAccounts, - ].find(acc => acc.id === chosenActualAccountId); - - return ( - <View - key={chosenImportedAccountId} - style={{ - flexDirection: 'row', - justifyContent: 'flex-center', - marginTop: 30, - }} - > - {importedAccount.name} → {actualAccount.name} - <Button - primary - style={{ - padding: '10px', - fontSize: 15, - margin: 10, - }} - onClick={() => removeChoose(chosenAccount)} - > - Remove → - </Button> - </View> - ); - })} + <TableHeader + headers={[ + { name: 'Bank Account To Sync', width: 200 }, + { name: 'Account in Actual', width: 'flex' }, + { name: 'Actions', width: 'flex' }, + ]} + /> + + <Table + items={externalAccounts} + style={{ backgroundColor: colors.n11 }} + getItemKey={index => index} + renderItem={({ key, item }) => ( + <View key={key}> + <TableRow + externalAccount={item} + chosenAccount={ + chosenAccounts[item.account_id] === addAccountOption.value + ? { + id: addAccountOption.value, + name: addAccountOption.label, + } + : localAccounts.find( + acc => chosenAccounts[item.account_id] === acc.id, + ) + } + unlinkedAccounts={unlinkedAccounts} + onSetLinkedAccount={onSetLinkedAccount} + /> </View> )} - </View> + /> </View> <View style={{ flexDirection: 'row', justifyContent: 'flex-end', - marginTop: 30, + marginTop: 10, }} > - <Button style={{ marginRight: 10 }} onClick={modalProps.onClose}> - Cancel - </Button> - <Button primary onClick={onNext} disabled={!chosenAccounts.length}> + <Button + primary + onClick={onNext} + disabled={!Object.keys(chosenAccounts).length} + > Link accounts </Button> </View> - </View> + </> )} </Modal> ); } + +function TableRow({ + externalAccount, + chosenAccount, + unlinkedAccounts, + onSetLinkedAccount, +}) { + const [focusedField, setFocusedField] = useState(null); + + const chosenAccountOption = chosenAccount && { + value: chosenAccount.id, + label: chosenAccount.name, + }; + + const availableAccountOptions = [ + ...unlinkedAccounts.map(acct => ({ + value: acct.id, + label: acct.name, + })), + chosenAccount?.id !== addAccountOption.value && chosenAccountOption, + addAccountOption, + ].filter(Boolean); + + return ( + <Row style={{ backgroundColor: 'white' }}> + <Field width={200}>{externalAccount.name}</Field> + <Field + width="flex" + truncate={focusedField !== 'account'} + onClick={() => setFocusedField('account')} + > + {focusedField === 'account' ? ( + <Autocomplete + autoFocus + options={availableAccountOptions} + onSelect={value => { + onSetLinkedAccount(externalAccount, value); + }} + onBlur={() => setFocusedField(null)} + value={chosenAccountOption} + /> + ) : ( + chosenAccount?.name + )} + </Field> + <Field width="flex"> + {chosenAccount ? ( + <Button + onClick={() => { + onSetLinkedAccount(externalAccount, null); + }} + style={{ float: 'right' }} + > + Remove bank-sync + </Button> + ) : ( + <Button + primary + onClick={() => { + setFocusedField('account'); + }} + style={{ float: 'right' }} + > + Setup bank-sync + </Button> + )} + </Field> + </Row> + ); +} diff --git a/upcoming-release-notes/792.md b/upcoming-release-notes/792.md new file mode 100644 index 000000000..25fe8efa1 --- /dev/null +++ b/upcoming-release-notes/792.md @@ -0,0 +1,6 @@ +--- +category: Feature +authors: [MatissJanis] +--- + +Improved UX when setting up account links for bank-sync via Nordigen -- GitLab