From 610a044f5f4f71329d27d0e8b2955455615ba6a2 Mon Sep 17 00:00:00 2001
From: Matiss Janis Aboltins <matiss@mja.lv>
Date: Fri, 17 Mar 2023 21:20:20 +0000
Subject: [PATCH] :recycle: (TransactionsTable) port to react hooks (#769)

---
 .../components/accounts/TransactionsTable.js  | 290 ++++++++----------
 .../accounts/TransactionsTable.test.js        | 129 +++-----
 .../desktop-client/src/hooks/usePrevious.js   |  11 +
 packages/loot-design/src/components/table.js  |  11 +-
 packages/loot-design/src/setupTests.js        |  15 -
 upcoming-release-notes/769.md                 |   6 +
 6 files changed, 192 insertions(+), 270 deletions(-)
 create mode 100644 packages/desktop-client/src/hooks/usePrevious.js
 create mode 100644 upcoming-release-notes/769.md

diff --git a/packages/desktop-client/src/components/accounts/TransactionsTable.js b/packages/desktop-client/src/components/accounts/TransactionsTable.js
index 52f89c1b7..83dbe1c1a 100644
--- a/packages/desktop-client/src/components/accounts/TransactionsTable.js
+++ b/packages/desktop-client/src/components/accounts/TransactionsTable.js
@@ -66,13 +66,14 @@ import ArrowsSynchronize from 'loot-design/src/svg/v2/ArrowsSynchronize';
 import CalendarIcon from 'loot-design/src/svg/v2/Calendar';
 import Hyperlink2 from 'loot-design/src/svg/v2/Hyperlink2';
 
+import usePrevious from '../../hooks/usePrevious';
 import { getStatusProps } from '../schedules/StatusBadge';
 
 function getDisplayValue(obj, name) {
   return obj ? obj[name] : '';
 }
 
-function serializeTransaction(transaction, showZeroInDeposit, dateFormat) {
+function serializeTransaction(transaction, showZeroInDeposit) {
   let { amount, date } = transaction;
 
   if (isPreviewId(transaction.id)) {
@@ -107,7 +108,7 @@ function serializeTransaction(transaction, showZeroInDeposit, dateFormat) {
   };
 }
 
-function deserializeTransaction(transaction, originalTransaction, dateFormat) {
+function deserializeTransaction(transaction, originalTransaction) {
   let { debit, credit, date, ...realTransaction } = transaction;
 
   let amount;
@@ -128,20 +129,6 @@ function deserializeTransaction(transaction, originalTransaction, dateFormat) {
   return { ...realTransaction, date, amount };
 }
 
-function getParentTransaction(transactions, fromIndex) {
-  let trans = transactions[fromIndex];
-  let parentIdx = fromIndex;
-  while (parentIdx >= 0) {
-    if (transactions[parentIdx].id === trans.parent_id) {
-      // Found the parent
-      return transactions[parentIdx];
-    }
-    parentIdx--;
-  }
-
-  return null;
-}
-
 function isLastChild(transactions, index) {
   let trans = transactions[index];
   return (
@@ -538,7 +525,7 @@ export const Transaction = React.memo(function Transaction(props) {
   let [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit);
   let [prevTransaction, setPrevTransaction] = useState(originalTransaction);
   let [transaction, setTransaction] = useState(
-    serializeTransaction(originalTransaction, showZeroInDeposit, dateFormat),
+    serializeTransaction(originalTransaction, showZeroInDeposit),
   );
   let isPreview = isPreviewId(transaction.id);
 
@@ -547,7 +534,7 @@ export const Transaction = React.memo(function Transaction(props) {
     showZeroInDeposit !== prevShowZero
   ) {
     setTransaction(
-      serializeTransaction(originalTransaction, showZeroInDeposit, dateFormat),
+      serializeTransaction(originalTransaction, showZeroInDeposit),
     );
     setPrevTransaction(originalTransaction);
     setPrevShowZero(showZeroInDeposit);
@@ -588,13 +575,10 @@ export const Transaction = React.memo(function Transaction(props) {
         let deserialized = deserializeTransaction(
           newTransaction,
           originalTransaction,
-          dateFormat,
         );
         // Run the transaction through the formatting so that we know
         // it's always showing the formatted result
-        setTransaction(
-          serializeTransaction(deserialized, showZeroInDeposit, dateFormat),
-        );
+        setTransaction(serializeTransaction(deserialized, showZeroInDeposit));
         onSave(deserialized);
       }
     }
@@ -1228,46 +1212,26 @@ function NewTransaction({
   );
 }
 
-class TransactionTable_ extends React.Component {
-  container = React.createRef();
-  state = { highlightedRows: null };
-
-  componentDidMount() {
-    this.highlight = ids => {
-      this.setState({ highlightedRows: new Set(ids) }, () => {
-        this.setState({ highlightedRows: null });
-      });
-    };
-  }
-
-  componentWillReceiveProps(nextProps) {
-    const { isAdding } = this.props;
-    if (!isAdding && nextProps.isAdding) {
-      this.props.newNavigator.onEdit('temp', 'date');
-    }
-  }
-
-  componentDidUpdate() {
-    this._cachedParent = null;
-  }
-
-  getParent(trans, index) {
-    let { transactions } = this.props;
-
-    if (this._cachedParent && this._cachedParent.id === trans.parent_id) {
-      return this._cachedParent;
-    }
+function TransactionTableInner({
+  tableNavigator,
+  tableRef,
+  dateFormat = 'MM/dd/yyyy',
+  newNavigator,
+  renderEmpty,
+  onHover,
+  onScroll,
+  ...props
+}) {
+  const containerRef = React.createRef();
+  const isAddingPrev = usePrevious(props.isAdding);
 
-    if (trans.parent_id) {
-      this._cachedParent = getParentTransaction(transactions, index);
-      return this._cachedParent;
+  useEffect(() => {
+    if (!isAddingPrev && props.isAdding) {
+      newNavigator.onEdit('temp', 'date');
     }
+  }, [isAddingPrev, props.isAdding, newNavigator]);
 
-    return null;
-  }
-
-  renderRow = ({ item, index, position, editing, focusedFied, onEdit }) => {
-    const { highlightedRows } = this.state;
+  const renderRow = ({ item, index, position, editing }) => {
     const {
       transactions,
       selectedItems,
@@ -1279,20 +1243,16 @@ class TransactionTable_ extends React.Component {
       showAccount,
       showCategory,
       balances,
-      dateFormat = 'MM/dd/yyyy',
-      tableNavigator,
       isNew,
       isMatched,
       isExpanded,
-    } = this.props;
+    } = props;
 
     let trans = item;
     let hovered = hoveredTransaction === trans.id;
     let selected = selectedItems.has(trans.id);
-    let highlighted =
-      !selected && (highlightedRows ? highlightedRows.has(trans.id) : false);
 
-    let parent = this.getParent(trans, index);
+    let parent = props.transactionMap.get(trans.parent_id);
     let isChildDeposit = parent && parent.amount > 0;
     let expanded = isExpanded && isExpanded((parent || trans).id);
 
@@ -1318,7 +1278,7 @@ class TransactionTable_ extends React.Component {
               <TransactionError
                 error={error}
                 isDeposit={isChildDeposit}
-                onAddSplit={() => this.props.onAddSplit(trans.id)}
+                onAddSplit={() => props.onAddSplit(trans.id)}
               />
             </Tooltip>
           )}
@@ -1331,7 +1291,7 @@ class TransactionTable_ extends React.Component {
           showCleared={showCleared}
           hovered={hovered}
           selected={selected}
-          highlighted={highlighted}
+          highlighted={false}
           added={isNew && isNew(trans.id)}
           expanded={isExpanded && isExpanded(trans.id)}
           matched={isMatched && isMatched(trans.id)}
@@ -1347,117 +1307,104 @@ class TransactionTable_ extends React.Component {
               : new Set()
           }
           dateFormat={dateFormat}
-          onHover={this.props.onHover}
+          onHover={props.onHover}
           onEdit={tableNavigator.onEdit}
-          onSave={this.props.onSave}
-          onDelete={this.props.onDelete}
-          onSplit={this.props.onSplit}
-          onManagePayees={this.props.onManagePayees}
-          onCreatePayee={this.props.onCreatePayee}
-          onToggleSplit={this.props.onToggleSplit}
+          onSave={props.onSave}
+          onDelete={props.onDelete}
+          onSplit={props.onSplit}
+          onManagePayees={props.onManagePayees}
+          onCreatePayee={props.onCreatePayee}
+          onToggleSplit={props.onToggleSplit}
         />
       </>
     );
   };
 
-  render() {
-    let { props } = this;
-    let {
-      tableNavigator,
-      tableRef,
-      dateFormat = 'MM/dd/yyyy',
-      newNavigator,
-      renderEmpty,
-      onHover,
-      onScroll,
-    } = props;
+  return (
+    <View
+      innerRef={containerRef}
+      style={[{ flex: 1, cursor: 'default' }, props.style]}
+    >
+      <View>
+        <TransactionHeader
+          hasSelected={props.selectedItems.size > 0}
+          showAccount={props.showAccount}
+          showCategory={props.showCategory}
+          showBalance={!!props.balances}
+          showCleared={props.showCleared}
+        />
+
+        {props.isAdding && (
+          <View
+            {...newNavigator.getNavigatorProps({
+              onKeyDown: e => props.onCheckNewEnter(e),
+            })}
+          >
+            <NewTransaction
+              transactions={props.newTransactions}
+              editingTransaction={newNavigator.editingId}
+              hoveredTransaction={props.hoveredTransaction}
+              focusedField={newNavigator.focusedField}
+              accounts={props.accounts}
+              currentAccountId={props.currentAccountId}
+              categoryGroups={props.categoryGroups}
+              payees={props.payees || []}
+              showAccount={props.showAccount}
+              showCategory={props.showCategory}
+              showBalance={!!props.balances}
+              showCleared={props.showCleared}
+              dateFormat={dateFormat}
+              onClose={props.onCloseAddTransaction}
+              onAdd={props.onAddTemporary}
+              onAddSplit={props.onAddSplit}
+              onSplit={props.onSplit}
+              onEdit={newNavigator.onEdit}
+              onSave={props.onSave}
+              onDelete={props.onDelete}
+              onHover={props.onHover}
+              onManagePayees={props.onManagePayees}
+              onCreatePayee={props.onCreatePayee}
+            />
+          </View>
+        )}
+      </View>
+      {/*// * On Windows, makes the scrollbar always appear
+         //   the full height of the container ??? */}
 
-    return (
       <View
-        innerRef={this.container}
-        style={[{ flex: 1, cursor: 'default' }, props.style]}
+        style={[{ flex: 1, overflow: 'hidden' }]}
+        data-testid="transaction-table"
+        onMouseLeave={() => onHover(null)}
       >
-        <View>
-          <TransactionHeader
-            hasSelected={props.selectedItems.size > 0}
-            showAccount={props.showAccount}
-            showCategory={props.showCategory}
-            showBalance={!!props.balances}
-            showCleared={props.showCleared}
-          />
-
-          {props.isAdding && (
-            <View
-              {...newNavigator.getNavigatorProps({
-                onKeyDown: e => props.onCheckNewEnter(e),
-              })}
-            >
-              <NewTransaction
-                transactions={props.newTransactions}
-                editingTransaction={newNavigator.editingId}
-                hoveredTransaction={props.hoveredTransaction}
-                focusedField={newNavigator.focusedField}
-                accounts={props.accounts}
-                currentAccountId={props.currentAccountId}
-                categoryGroups={props.categoryGroups}
-                payees={this.props.payees || []}
-                showAccount={props.showAccount}
-                showCategory={props.showCategory}
-                showBalance={!!props.balances}
-                showCleared={props.showCleared}
-                dateFormat={dateFormat}
-                onClose={props.onCloseAddTransaction}
-                onAdd={this.props.onAddTemporary}
-                onAddSplit={this.props.onAddSplit}
-                onSplit={this.props.onSplit}
-                onEdit={newNavigator.onEdit}
-                onSave={this.props.onSave}
-                onDelete={this.props.onDelete}
-                onHover={this.props.onHover}
-                onManagePayees={this.props.onManagePayees}
-                onCreatePayee={this.props.onCreatePayee}
-              />
-            </View>
-          )}
-        </View>
-        {/*// * On Windows, makes the scrollbar always appear
-         //   the full height of the container ??? */}
+        <Table
+          navigator={tableNavigator}
+          ref={tableRef}
+          items={props.transactions}
+          renderItem={renderRow}
+          renderEmpty={renderEmpty}
+          loadMore={props.loadMoreTransactions}
+          isSelected={id => props.selectedItems.has(id)}
+          onKeyDown={e => props.onCheckEnter(e)}
+          onScroll={onScroll}
+        />
 
-        <View
-          style={[{ flex: 1, overflow: 'hidden' }]}
-          data-testid="transaction-table"
-          onMouseLeave={() => onHover(null)}
-        >
-          <Table
-            navigator={tableNavigator}
-            ref={tableRef}
-            items={props.transactions}
-            renderItem={this.renderRow}
-            renderEmpty={renderEmpty}
-            loadMore={props.loadMoreTransactions}
-            isSelected={id => props.selectedItems.has(id)}
-            onKeyDown={e => props.onCheckEnter(e)}
-            onScroll={onScroll}
+        {props.isAdding && (
+          <div
+            key="shadow"
+            style={{
+              position: 'absolute',
+              top: -20,
+              left: 0,
+              right: 0,
+              height: 20,
+              backgroundColor: 'red',
+              boxShadow: '0 0 6px rgba(0, 0, 0, .20)',
+            }}
           />
-
-          {props.isAdding && (
-            <div
-              key="shadow"
-              style={{
-                position: 'absolute',
-                top: -20,
-                left: 0,
-                right: 0,
-                height: 20,
-                backgroundColor: 'red',
-                boxShadow: '0 0 6px rgba(0, 0, 0, .20)',
-              }}
-            />
-          )}
-        </View>
+        )}
       </View>
-    );
-  }
+    </View>
+  );
 }
 
 export let TransactionTable = React.forwardRef((props, ref) => {
@@ -1509,6 +1456,9 @@ export let TransactionTable = React.forwardRef((props, ref) => {
     prevSplitsExpanded.current = splitsExpanded;
     return result;
   }, [props.transactions, splitsExpanded]);
+  const transactionMap = useMemo(() => {
+    return new Map(transactions.map(trans => [trans.id, trans]));
+  }, [transactions]);
 
   useEffect(() => {
     // If it's anchored that means we've also disabled animations. To
@@ -1666,10 +1616,10 @@ export let TransactionTable = React.forwardRef((props, ref) => {
     if (e.code === 'Enter' && !e.shiftKey) {
       let { editingId: id, focusedField } = tableNavigator;
 
-      afterSave(props => {
+      afterSave(() => {
         let transactions = latestState.current.transactions;
         let idx = transactions.findIndex(t => t.id === id);
-        let parent = getParentTransaction(transactions, idx);
+        let parent = transactionMap.get(transactions[idx]?.parent_id);
 
         if (
           isLastChild(transactions, idx) &&
@@ -1794,11 +1744,11 @@ export let TransactionTable = React.forwardRef((props, ref) => {
   );
 
   return (
-    // eslint-disable-next-line react/jsx-pascal-case
-    <TransactionTable_
+    <TransactionTableInner
       tableRef={mergedRef}
       {...props}
       transactions={transactions}
+      transactionMap={transactionMap}
       selectedItems={selectedItems}
       hoveredTransaction={hoveredTransaction}
       isExpanded={splitsExpanded.expanded}
diff --git a/packages/desktop-client/src/components/accounts/TransactionsTable.test.js b/packages/desktop-client/src/components/accounts/TransactionsTable.test.js
index cb58fdfee..08c5da6ee 100644
--- a/packages/desktop-client/src/components/accounts/TransactionsTable.test.js
+++ b/packages/desktop-client/src/components/accounts/TransactionsTable.test.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
 
 import { render, screen, fireEvent } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
@@ -76,99 +76,66 @@ function generateTransactions(count, splitAtIndexes = [], showError = false) {
   return transactions;
 }
 
-class LiveTransactionTable extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = { transactions: props.transactions };
-  }
+function LiveTransactionTable(props) {
+  const [transactions, setTransactions] = useState(props.transactions);
 
-  componentWillReceiveProps(nextProps) {
-    if (this.state.transactions !== nextProps.transactions) {
-      this.setState({ transactions: nextProps.transactions });
-    }
-  }
-
-  notifyChange = () => {
-    const { onTransactionsChange } = this.props;
-    onTransactionsChange && onTransactionsChange(this.state.transactions);
-  };
+  useEffect(() => {
+    if (transactions === props.transactions) return;
+    props.onTransactionsChange && props.onTransactionsChange(transactions);
+  }, [transactions]);
 
-  onSplit = id => {
-    let { state } = this;
-    let { data, diff } = splitTransaction(state.transactions, id);
-    this.setState({ transactions: data }, this.notifyChange);
+  const onSplit = id => {
+    let { data, diff } = splitTransaction(transactions, id);
+    setTransactions(data);
     return diff.added[0].id;
   };
 
-  // onDelete = id => {
-  //   let { state } = this;
-  //   this.setState(
-  //     {
-  //       transactions: applyChanges(
-  //         deleteTransaction(state.transactions, id),
-  //         state.transactions
-  //       )
-  //     },
-  //     this.notifyChange
-  //   );
-  // };
-
-  onSave = transaction => {
-    let { state } = this;
-    let { data } = updateTransaction(state.transactions, transaction);
-    this.setState({ transactions: data }, this.notifyChange);
+  const onSave = transaction => {
+    let { data } = updateTransaction(transactions, transaction);
+    setTransactions(data);
   };
 
-  onAdd = newTransactions => {
-    let { state } = this;
+  const onAdd = newTransactions => {
     newTransactions = realizeTempTransactions(newTransactions);
-    this.setState(
-      { transactions: [...newTransactions, ...state.transactions] },
-      this.notifyChange,
-    );
+    setTransactions(trans => [...newTransactions, ...trans]);
   };
 
-  onAddSplit = id => {
-    let { state } = this;
-    let { data, diff } = addSplitTransaction(state.transactions, id);
-    this.setState({ transactions: data }, this.notifyChange);
+  const onAddSplit = id => {
+    let { data, diff } = addSplitTransaction(transactions, id);
+    setTransactions(data);
     return diff.added[0].id;
   };
 
-  onCreatePayee = name => 'id';
-
-  render() {
-    const { state } = this;
-
-    // It's important that these functions are they same instances
-    // across renders. Doing so tests that the transaction table
-    // implementation properly uses the right latest state even if the
-    // hook dependencies haven't changed
-    return (
-      <TestProvider>
-        <SelectedProviderWithItems
-          name="transactions"
-          items={state.transactions}
-          fetchAllIds={() => state.transactions.map(t => t.id)}
-        >
-          <SplitsExpandedProvider>
-            <TransactionTable
-              {...this.props}
-              transactions={state.transactions}
-              loadMoreTransactions={() => {}}
-              payees={payees}
-              addNotification={n => console.log(n)}
-              onSave={this.onSave}
-              onSplit={this.onSplit}
-              onAdd={this.onAdd}
-              onAddSplit={this.onAddSplit}
-              onCreatePayee={this.onCreatePayee}
-            />
-          </SplitsExpandedProvider>
-        </SelectedProviderWithItems>
-      </TestProvider>
-    );
-  }
+  const onCreatePayee = () => 'id';
+
+  // It's important that these functions are they same instances
+  // across renders. Doing so tests that the transaction table
+  // implementation properly uses the right latest state even if the
+  // hook dependencies haven't changed
+  return (
+    <TestProvider>
+      <SelectedProviderWithItems
+        name="transactions"
+        items={transactions}
+        fetchAllIds={() => transactions.map(t => t.id)}
+      >
+        <SplitsExpandedProvider>
+          <TransactionTable
+            {...props}
+            transactions={transactions}
+            loadMoreTransactions={() => {}}
+            payees={payees}
+            addNotification={n => console.log(n)}
+            onSave={onSave}
+            onSplit={onSplit}
+            onAdd={onAdd}
+            onAddSplit={onAddSplit}
+            onCreatePayee={onCreatePayee}
+          />
+        </SplitsExpandedProvider>
+      </SelectedProviderWithItems>
+    </TestProvider>
+  );
 }
 
 function initBasicServer() {
diff --git a/packages/desktop-client/src/hooks/usePrevious.js b/packages/desktop-client/src/hooks/usePrevious.js
new file mode 100644
index 000000000..ed46581cb
--- /dev/null
+++ b/packages/desktop-client/src/hooks/usePrevious.js
@@ -0,0 +1,11 @@
+import { useEffect, useRef } from 'react';
+
+export default function usePrevious(value) {
+  const ref = useRef();
+
+  useEffect(() => {
+    ref.current = value;
+  }, [value]);
+
+  return ref.current;
+}
diff --git a/packages/loot-design/src/components/table.js b/packages/loot-design/src/components/table.js
index 1603065cf..311c25e0f 100644
--- a/packages/loot-design/src/components/table.js
+++ b/packages/loot-design/src/components/table.js
@@ -1011,10 +1011,13 @@ export function useTableNavigator(data, fields, opts = {}) {
   let modalStackLength = useRef(0);
 
   // onEdit is passed to children, so make sure it maintains identity
-  let onEdit = useCallback((id, field) => {
-    setEditingId(id);
-    setFocusedField(id ? field : null);
-  }, []);
+  let onEdit = useCallback(
+    (id, field) => {
+      setEditingId(id);
+      setFocusedField(id ? field : null);
+    },
+    [setEditingId, setFocusedField],
+  );
 
   useEffect(() => {
     modalStackLength.current = store.getState().modals.modalStack.length;
diff --git a/packages/loot-design/src/setupTests.js b/packages/loot-design/src/setupTests.js
index a33a44c42..6b025e72a 100644
--- a/packages/loot-design/src/setupTests.js
+++ b/packages/loot-design/src/setupTests.js
@@ -23,18 +23,3 @@ process.on('unhandledRejection', reason => {
 global.afterEach(() => {
   global.__resetWorld();
 });
-
-// https://github.com/testing-library/react-testing-library#suppressing-unnecessary-warnings-on-react-dom-168
-const originalError = console.error;
-global.beforeAll(() => {
-  console.error = (...args) => {
-    if (/Warning.*not wrapped in act/.test(args[0])) {
-      return;
-    }
-    originalError.call(console, ...args);
-  };
-});
-
-global.afterAll(() => {
-  console.error = originalError;
-});
diff --git a/upcoming-release-notes/769.md b/upcoming-release-notes/769.md
new file mode 100644
index 000000000..221c6c259
--- /dev/null
+++ b/upcoming-release-notes/769.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [MatissJanis]
+---
+
+Refactor `TransactionsTable` to react-hook component
-- 
GitLab