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 &rarr;
-                      </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} &rarr; {actualAccount.name}
-                        <Button
-                          primary
-                          style={{
-                            padding: '10px',
-                            fontSize: 15,
-                            margin: 10,
-                          }}
-                          onClick={() => removeChoose(chosenAccount)}
-                        >
-                          Remove &rarr;
-                        </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