From 8def8393dab39d0d8bc949ed93743060300f4b14 Mon Sep 17 00:00:00 2001
From: Jed Fox <git@jedfox.com>
Date: Sat, 18 Mar 2023 10:59:24 -0400
Subject: [PATCH] Remove a few unused class components, convert a few
 components to functions (#783)

---
 .../src/components/GlobalKeys.js              |  26 +-
 .../desktop-client/src/components/Tutorial.js | 274 ------------------
 .../src/components/TutorialPoints.js          |  41 ---
 .../accounts/MobileAccountDetails.js          | 100 +++----
 .../src/components/manager/ManagementApp.js   | 259 ++++++++---------
 .../modals/ConfirmCategoryDelete.js           | 182 ++++++------
 .../src/components/reports/Container.js       |  36 +--
 .../src/components/reports/index.js           |  20 +-
 .../src/components/TutorialPoint.js           |  21 --
 packages/loot-design/src/components/common.js |  79 ++---
 upcoming-release-notes/783.md                 |   6 +
 11 files changed, 329 insertions(+), 715 deletions(-)
 delete mode 100644 packages/desktop-client/src/components/Tutorial.js
 delete mode 100644 packages/desktop-client/src/components/TutorialPoints.js
 delete mode 100644 packages/loot-design/src/components/TutorialPoint.js
 create mode 100644 upcoming-release-notes/783.md

diff --git a/packages/desktop-client/src/components/GlobalKeys.js b/packages/desktop-client/src/components/GlobalKeys.js
index dc37943ec..61b413b1e 100644
--- a/packages/desktop-client/src/components/GlobalKeys.js
+++ b/packages/desktop-client/src/components/GlobalKeys.js
@@ -1,17 +1,17 @@
-import React from 'react';
-import { withRouter } from 'react-router-dom';
+import { useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
 
 import * as Platform from 'loot-core/src/client/platform';
 
-class GlobalKeys extends React.Component {
-  componentDidMount() {
+export default function GlobalKeys() {
+  let history = useHistory();
+  useEffect(() => {
     const handleKeys = e => {
       if (Platform.isBrowser) {
         return;
       }
 
       if (e.metaKey) {
-        const { history } = this.props;
         switch (e.code) {
           case 'Digit1':
             history.push('/budget');
@@ -34,18 +34,8 @@ class GlobalKeys extends React.Component {
 
     document.addEventListener('keydown', handleKeys);
 
-    this.cleanupListeners = () => {
-      document.removeEventListener('keydown', handleKeys);
-    };
-  }
-
-  componentWillUnmount() {
-    this.cleanupListeners();
-  }
+    return () => document.removeEventListener('keydown', handleKeys);
+  }, []);
 
-  render() {
-    return null;
-  }
+  return null;
 }
-
-export default withRouter(GlobalKeys);
diff --git a/packages/desktop-client/src/components/Tutorial.js b/packages/desktop-client/src/components/Tutorial.js
deleted file mode 100644
index 7c7c46fad..000000000
--- a/packages/desktop-client/src/components/Tutorial.js
+++ /dev/null
@@ -1,274 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import { connect } from 'react-redux';
-
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
-
-import * as actions from 'loot-core/src/client/actions';
-
-import BudgetCategories from './tutorial/BudgetCategories';
-import BudgetInitial from './tutorial/BudgetInitial';
-import BudgetNewIncome from './tutorial/BudgetNewIncome';
-import BudgetNextMonth from './tutorial/BudgetNextMonth';
-import BudgetSummary from './tutorial/BudgetSummary';
-import CategoryBalance from './tutorial/CategoryBalance';
-import Final from './tutorial/Final';
-import Intro from './tutorial/Intro';
-import Overspending from './tutorial/Overspending';
-import TransactionAdd from './tutorial/TransactionAdd';
-import TransactionEnter from './tutorial/TransactionEnter';
-
-function generatePath(innerRect, outerRect) {
-  const i = innerRect;
-  const o = outerRect;
-  // prettier-ignore
-  return `
-    M0,0 ${o.width},0 ${o.width},${o.height} L0,${o.height} L0,0 Z
-    M${i.left},${i.top} L${i.left+i.width},${i.top} L${i.left+i.width},${i.top+i.height} L${i.left},${i.top+i.height} L${i.left},${i.top} Z
-  `;
-}
-
-function expandRect({ top, left, width, height }, padding) {
-  if (typeof padding === 'number') {
-    return {
-      top: top - padding,
-      left: left - padding,
-      width: width + padding * 2,
-      height: height + padding * 2,
-    };
-  } else if (padding) {
-    return {
-      top: top - (padding.top || 0),
-      left: left - (padding.left || 0),
-      width: width + (padding.right || 0) + (padding.left || 0),
-      height: height + (padding.bottom || 0) + (padding.top || 0),
-    };
-  }
-
-  return { top, left, width, height };
-}
-
-function withinWindow(rect) {
-  return {
-    top: rect.top,
-    left: rect.left,
-    width: Math.min(rect.left + rect.width, window.innerWidth) - rect.left,
-    height: Math.min(rect.top + rect.height, window.innerHeight) - rect.top,
-  };
-}
-
-class MeasureNodes extends React.Component {
-  state = { measurements: null };
-
-  componentDidMount() {
-    window.addEventListener('resize', () => {
-      setTimeout(() => this.updateMeasurements(true), 0);
-    });
-    this.updateMeasurements();
-  }
-
-  componentDidUpdate(prevProps) {
-    if (prevProps.nodes !== this.props.nodes) {
-      this.updateMeasurements();
-    }
-  }
-
-  updateMeasurements() {
-    this.setState({
-      measurements: this.props.nodes.map(node => node.getBoundingClientRect()),
-    });
-  }
-
-  render() {
-    const { children } = this.props;
-    const { measurements } = this.state;
-    return measurements ? children(...measurements) : null;
-  }
-}
-
-class Tutorial extends React.Component {
-  state = { highlightRect: null, windowRect: null };
-
-  static contextTypes = {
-    getTutorialNode: PropTypes.func,
-    endTutorial: PropTypes.func,
-  };
-
-  onClose = didQuitEarly => {
-    // The difference between these is `endTutorial` permanently
-    // disable the tutorial. If the user walked all the way through
-    // it, never show it to them again. Otherwise they will see if
-    // again if they create a new budget.
-    if (didQuitEarly) {
-      this.props.closeTutorial();
-    } else {
-      this.props.endTutorial();
-    }
-  };
-
-  getContent(stage, targetRect, navigationProps) {
-    switch (stage) {
-      case 'budget-summary':
-        return (
-          <BudgetSummary
-            fromYNAB={this.props.fromYNAB}
-            targetRect={targetRect}
-            navigationProps={navigationProps}
-          />
-        );
-      case 'budget-categories':
-        return (
-          <BudgetCategories
-            targetRect={targetRect}
-            navigationProps={navigationProps}
-          />
-        );
-      case 'transaction-add':
-        return (
-          <TransactionAdd
-            targetRect={targetRect}
-            navigationProps={navigationProps}
-          />
-        );
-      case 'budget-new-income':
-        return (
-          <BudgetNewIncome
-            targetRect={targetRect}
-            navigationProps={navigationProps}
-          />
-        );
-      case 'budget-next-month':
-        return <div>hi</div>;
-      default:
-        throw new Error(
-          `Encountered an unexpected error rendering the tutorial content for ${stage}`,
-        );
-    }
-  }
-
-  render() {
-    const { stage, fromYNAB, nextTutorialStage, closeTutorial } = this.props;
-    if (stage === null) {
-      return null;
-    }
-
-    const navigationProps = {
-      nextTutorialStage: this.props.nextTutorialStage,
-      previousTutorialStage: this.props.previousTutorialStage,
-      closeTutorial: () => this.onClose(true),
-      endTutorial: () => this.onClose(false),
-    };
-
-    switch (stage) {
-      case 'intro':
-        return (
-          <Intro
-            nextTutorialStage={nextTutorialStage}
-            closeTutorial={closeTutorial}
-            fromYNAB={fromYNAB}
-          />
-        );
-      case 'budget-initial':
-        return (
-          <BudgetInitial
-            nextTutorialStage={nextTutorialStage}
-            closeTutorial={closeTutorial}
-            navigationProps={navigationProps}
-          />
-        );
-      case 'budget-next-month':
-        return (
-          <BudgetNextMonth
-            nextTutorialStage={nextTutorialStage}
-            closeTutorial={closeTutorial}
-            navigationProps={navigationProps}
-          />
-        );
-      case 'budget-next-month2':
-        return (
-          <BudgetNextMonth
-            nextTutorialStage={nextTutorialStage}
-            closeTutorial={closeTutorial}
-            navigationProps={navigationProps}
-            stepTwo={true}
-          />
-        );
-      case 'transaction-enter':
-        return (
-          <TransactionEnter
-            fromYNAB={fromYNAB}
-            navigationProps={navigationProps}
-          />
-        );
-      case 'budget-category-balance':
-        return <CategoryBalance navigationProps={navigationProps} />;
-      case 'budget-overspending':
-        return <Overspending navigationProps={navigationProps} />;
-      case 'budget-overspending2':
-        return (
-          <Overspending navigationProps={navigationProps} stepTwo={true} />
-        );
-      case 'final':
-        return (
-          <Final
-            nextTutorialStage={nextTutorialStage}
-            closeTutorial={closeTutorial}
-            navigationProps={navigationProps}
-          />
-        );
-      default:
-      // Default case defined below (outside the switch statement)
-    }
-
-    const { node: targetNode, expand } = this.context.getTutorialNode(stage);
-
-    return (
-      <MeasureNodes nodes={[targetNode.parentNode, document.body]}>
-        {(targetRect, windowRect) => {
-          targetRect = withinWindow(
-            expandRect(expandRect(targetRect, 5), expand),
-          );
-
-          return (
-            <div>
-              {ReactDOM.createPortal(
-                <svg
-                  width={windowRect.width}
-                  height={windowRect.height}
-                  viewBox={'0 0 ' + windowRect.width + ' ' + windowRect.height}
-                  version="1.1"
-                  xmlns="http://www.w3.org/2000/svg"
-                  style={{
-                    position: 'absolute',
-                    top: 0,
-                    left: 0,
-                    zIndex: 1000,
-                    pointerEvents: 'none',
-                  }}
-                >
-                  <path
-                    fill="rgba(0, 0, 0, .2)"
-                    fill-rule="evenodd"
-                    d={generatePath(targetRect, windowRect)}
-                    style={{ pointerEvents: 'fill' }}
-                  />
-                </svg>,
-                document.body,
-              )}
-              {this.getContent(stage, targetRect, navigationProps)}
-            </div>
-          );
-        }}
-      </MeasureNodes>
-    );
-  }
-}
-
-export default connect(
-  state => ({
-    stage: state.tutorial.stage,
-    fromYNAB: state.tutorial.fromYNAB,
-  }),
-  dispatch => bindActionCreators(actions, dispatch),
-)(Tutorial);
diff --git a/packages/desktop-client/src/components/TutorialPoints.js b/packages/desktop-client/src/components/TutorialPoints.js
deleted file mode 100644
index 083fa1650..000000000
--- a/packages/desktop-client/src/components/TutorialPoints.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-
-import PropTypes from 'prop-types';
-
-class Tutorial extends React.Component {
-  static childContextTypes = {
-    setTutorialNode: PropTypes.func,
-    getTutorialNode: PropTypes.func,
-    endTutorial: PropTypes.func,
-  };
-
-  constructor() {
-    super();
-    this.nodes = {};
-  }
-
-  getChildContext() {
-    return {
-      setTutorialNode: this.setTutorialNode,
-      getTutorialNode: this.getTutorialNode,
-    };
-  }
-
-  setTutorialNode = (name, node, expand) => {
-    this.nodes[name] = { node, expand };
-  };
-
-  getTutorialNode = (name, node) => {
-    return this.nodes[name];
-  };
-
-  render() {
-    const { children } = this.props;
-    return React.Children.only(children);
-  }
-}
-
-export default connect(state => ({ deactivated: state.tutorial.deactivated }))(
-  Tutorial,
-);
diff --git a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js
index 229d62986..be0e132b0 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js
+++ b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useState, useMemo } from 'react';
 import { Link } from 'react-router-dom';
 
 import {
@@ -16,62 +16,52 @@ import SearchAlternate from 'loot-design/src/svg/v2/SearchAlternate';
 
 import { TransactionList } from './MobileTransaction';
 
-class TransactionSearchInput extends React.Component {
-  state = { text: '' };
+function TransactionSearchInput({ accountName, onSearch }) {
+  const [text, setText] = useState('');
 
-  performSearch = () => {
-    this.props.onSearch(this.state.text);
-  };
-
-  onChange = text => {
-    this.setState({ text }, this.performSearch);
-  };
-
-  render() {
-    const { accountName } = this.props;
-    const { text } = this.state;
-
-    return (
-      <View
+  return (
+    <View
+      style={{
+        flexDirection: 'row',
+        alignItems: 'center',
+        backgroundColor: colors.n11,
+        margin: '11px auto 4px',
+        borderRadius: 4,
+        padding: 10,
+        width: '100%',
+      }}
+    >
+      <InputWithContent
+        leftContent={
+          <SearchAlternate
+            style={{
+              width: 13,
+              height: 13,
+              flexShrink: 0,
+              color: text ? colors.p7 : 'inherit',
+              margin: 5,
+              marginRight: 0,
+            }}
+          />
+        }
+        value={text}
+        onUpdate={text => {
+          setText(text);
+          onSearch(text);
+        }}
+        placeholder={`Search ${accountName}`}
         style={{
-          flexDirection: 'row',
-          alignItems: 'center',
           backgroundColor: colors.n11,
-          margin: '11px auto 4px',
-          borderRadius: 4,
-          padding: 10,
-          width: '100%',
+          border: `1px solid ${colors.n9}`,
+          fontSize: 15,
+          flex: 1,
+          height: 32,
+          marginLeft: 4,
+          padding: 8,
         }}
-      >
-        <InputWithContent
-          leftContent={
-            <SearchAlternate
-              style={{
-                width: 13,
-                height: 13,
-                flexShrink: 0,
-                color: text ? colors.p7 : 'inherit',
-                margin: 5,
-                marginRight: 0,
-              }}
-            />
-          }
-          value={text}
-          onUpdate={this.onChange}
-          placeholder={`Search ${accountName}`}
-          style={{
-            backgroundColor: colors.n11,
-            border: `1px solid ${colors.n9}`,
-            fontSize: 15,
-            flex: 1,
-            height: 32,
-            marginLeft: 4,
-            padding: 8,
-          }}
-        />
-      </View>
-    );
-  }
+      />
+    </View>
+  );
 }
 
 const LEFT_RIGHT_FLEX_WIDTH = 70;
@@ -149,8 +139,8 @@ export default function AccountDetails({
           >
             {account.name}
           </View>
-          {/* 
-              TODO: connect to an add transaction modal 
+          {/*
+              TODO: connect to an add transaction modal
               Only left here but hidden for flex centering of the account name.
           */}
           <Link to="transaction/new" style={{ visibility: 'hidden' }}>
diff --git a/packages/desktop-client/src/components/manager/ManagementApp.js b/packages/desktop-client/src/components/manager/ManagementApp.js
index 087a9a6fc..a5ff781e5 100644
--- a/packages/desktop-client/src/components/manager/ManagementApp.js
+++ b/packages/desktop-client/src/components/manager/ManagementApp.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect } from 'react';
 import { connect } from 'react-redux';
 import { Switch, Redirect, Router, Route } from 'react-router-dom';
 
@@ -49,170 +49,163 @@ function Version() {
   );
 }
 
-class ManagementApp extends React.Component {
-  constructor(props) {
-    super(props);
-    this.mounted = true;
-    this.history = createBrowserHistory();
-    window.__history = this.history;
-  }
-
-  async componentDidMount() {
+function ManagementApp({
+  files,
+  userData,
+  loadingText,
+  managerHasInitialized,
+  setAppState,
+  getUserData,
+  loadAllFiles,
+}) {
+  const history = createBrowserHistory();
+  window.__history = history;
+
+  // runs on mount only
+  useEffect(() => {
     // An action may have been triggered from outside, and we don't
     // want to override its loading message so we only show the
     // initial loader if there isn't already a message
-    let alreadyLoading = this.props.loadingText != null;
+    let alreadyLoading = loadingText != null;
 
     // Remember: this component is remounted every time the user
     // closes a budget. That's why we keep `managerHasInitialized` in
     // redux so that it persists across renders. This will show the
     // loading spinner on first run, but never again since we'll have
     // a cached list of files and can show them
-    if (!this.props.managerHasInitialized) {
+    if (!managerHasInitialized) {
       if (!alreadyLoading) {
-        this.props.setAppState({ loadingText: '' });
+        setAppState({ loadingText: '' });
       }
-    } else {
-      // If it's not the first time rendering, immediately show the
-      // modal since we should have cached data
-      this.showModal();
     }
 
-    let userData = await this.props.getUserData();
-    if (userData) {
-      await this.props.loadAllFiles();
+    async function fetchData() {
+      let userData = await getUserData();
+      if (userData) {
+        await loadAllFiles();
+      }
+
+      // TODO: There is a race condition here. The user could perform an
+      // action that starts loading in between where `alreadyLoading`
+      // was captured and this would clear it. We really only want to
+      // ever clear the initial loading screen, so we need a "loading
+      // id" of some kind.
+      setAppState({
+        managerHasInitialized: true,
+        ...(!alreadyLoading ? { loadingText: null } : null),
+      });
     }
 
-    // TODO: There is a race condition here. The user could perform an
-    // action that starts loading in between where `alreadyLoading`
-    // was captured and this would clear it. We really only want to
-    // ever clear the initial loading screen, so we need a "loading
-    // id" of some kind.
-    this.props.setAppState({
-      managerHasInitialized: true,
-      ...(!alreadyLoading ? { loadingText: null } : null),
-    });
-  }
+    fetchData();
+  }, []);
 
-  componentWillUnmount() {
-    this.mounted = false;
+  if (!managerHasInitialized) {
+    return null;
   }
 
-  render() {
-    let { files, userData, managerHasInitialized } = this.props;
-
-    if (!managerHasInitialized) {
-      return null;
-    }
-
-    return (
-      <Router history={this.history}>
-        <View style={{ height: '100%' }}>
-          <View
+  return (
+    <Router history={history}>
+      <View style={{ height: '100%' }}>
+        <View
+          style={{
+            position: 'absolute',
+            top: 0,
+            left: 0,
+            right: 0,
+            height: 40,
+            WebkitAppRegion: 'drag',
+          }}
+        />
+        <View
+          style={{
+            position: 'absolute',
+            bottom: 40,
+            right: 15,
+          }}
+        >
+          <Notifications
             style={{
-              position: 'absolute',
-              top: 0,
-              left: 0,
-              right: 0,
-              height: 40,
-              WebkitAppRegion: 'drag',
+              position: 'relative',
+              left: 'initial',
+              right: 'initial',
             }}
           />
+        </View>
+
+        {managerHasInitialized && (
           <View
             style={{
+              alignItems: 'center',
+              bottom: 0,
+              justifyContent: 'center',
+              left: 0,
+              padding: 20,
               position: 'absolute',
-              bottom: 40,
-              right: 15,
+              right: 0,
+              top: 0,
             }}
           >
-            <Notifications
-              style={{
-                position: 'relative',
-                left: 'initial',
-                right: 'initial',
-              }}
-            />
-          </View>
+            {userData && files ? (
+              <>
+                <Switch>
+                  <Route exact path="/config-server" component={ConfigServer} />
+                  <Route
+                    exact
+                    path="/change-password"
+                    component={ChangePassword}
+                  />
+                  {files && files.length > 0 ? (
+                    <Route exact path="/" component={BudgetList} />
+                  ) : (
+                    <Route exact path="/" component={WelcomeScreen} />
+                  )}
+                  {/* Redirect all other pages to this route */}
+                  <Route path="/" render={() => <Redirect to="/" />} />
+                </Switch>
 
-          {managerHasInitialized && (
-            <View
-              style={{
-                alignItems: 'center',
-                bottom: 0,
-                justifyContent: 'center',
-                left: 0,
-                padding: 20,
-                position: 'absolute',
-                right: 0,
-                top: 0,
-              }}
-            >
-              {userData && files ? (
-                <>
+                <View
+                  style={{
+                    position: 'absolute',
+                    top: 0,
+                    right: 0,
+                    padding: '6px 10px',
+                    zIndex: 4000,
+                  }}
+                >
                   <Switch>
+                    <Route exact path="/config-server" component={null} />
                     <Route
                       exact
-                      path="/config-server"
-                      component={ConfigServer}
-                    />
-                    <Route
-                      exact
-                      path="/change-password"
-                      component={ChangePassword}
+                      path="/"
+                      render={() => (
+                        <LoggedInUser style={{ padding: '4px 7px' }} />
+                      )}
                     />
-                    {files && files.length > 0 ? (
-                      <Route exact path="/" component={BudgetList} />
-                    ) : (
-                      <Route exact path="/" component={WelcomeScreen} />
-                    )}
-                    {/* Redirect all other pages to this route */}
-                    <Route path="/" render={() => <Redirect to="/" />} />
                   </Switch>
-
-                  <View
-                    style={{
-                      position: 'absolute',
-                      top: 0,
-                      right: 0,
-                      padding: '6px 10px',
-                      zIndex: 4000,
-                    }}
-                  >
-                    <Switch>
-                      <Route exact path="/config-server" component={null} />
-                      <Route
-                        exact
-                        path="/"
-                        render={() => (
-                          <LoggedInUser style={{ padding: '4px 7px' }} />
-                        )}
-                      />
-                    </Switch>
-                  </View>
-                </>
-              ) : (
-                <Switch>
-                  <Route exact path="/login" component={Login} />
-                  <Route exact path="/error" component={Error} />
-                  <Route exact path="/config-server" component={ConfigServer} />
-                  <Route exact path="/bootstrap" component={Bootstrap} />
-                  {/* Redirect all other pages to this route */}
-                  <Route path="/" render={() => <Redirect to="/bootstrap" />} />
-                </Switch>
-              )}
-            </View>
-          )}
-
-          <Switch>
-            <Route exact path="/config-server" component={null} />
-            <Route path="/" component={ServerURL} />
-          </Switch>
-          <Version />
-        </View>
-        <Modals history={this.history} />
-      </Router>
-    );
-  }
+                </View>
+              </>
+            ) : (
+              <Switch>
+                <Route exact path="/login" component={Login} />
+                <Route exact path="/error" component={Error} />
+                <Route exact path="/config-server" component={ConfigServer} />
+                <Route exact path="/bootstrap" component={Bootstrap} />
+                {/* Redirect all other pages to this route */}
+                <Route path="/" render={() => <Redirect to="/bootstrap" />} />
+              </Switch>
+            )}
+          </View>
+        )}
+
+        <Switch>
+          <Route exact path="/config-server" component={null} />
+          <Route path="/" component={ServerURL} />
+        </Switch>
+        <Version />
+      </View>
+      <Modals history={history} />
+    </Router>
+  );
 }
 
 export default connect(state => {
diff --git a/packages/desktop-client/src/components/modals/ConfirmCategoryDelete.js b/packages/desktop-client/src/components/modals/ConfirmCategoryDelete.js
index f03a5acd0..b187f54f3 100644
--- a/packages/desktop-client/src/components/modals/ConfirmCategoryDelete.js
+++ b/packages/desktop-client/src/components/modals/ConfirmCategoryDelete.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect, useRef } from 'react';
 
 import { NativeCategorySelect } from 'loot-design/src/components/CategorySelect';
 import {
@@ -10,29 +10,26 @@ import {
 } from 'loot-design/src/components/common';
 import { colors } from 'loot-design/src/style';
 
-class ConfirmCategoryDelete extends React.Component {
-  state = { transferCategory: null, error: null };
+export default function ConfirmCategoryDelete({
+  modalProps,
+  category,
+  group,
+  categoryGroups,
+  onDelete,
+}) {
+  const [transferCategory, setTransferCategory] = useState(null);
+  const [error, setError] = useState(null);
 
-  componentDidMount() {
+  const inputRef = useRef(null);
+
+  useEffect(() => {
     // Hack: 200ms is the timing of the modal animation
     setTimeout(() => {
-      this.input.focus();
+      inputRef.current.focus();
     }, 200);
-  }
-
-  onDelete = () => {
-    let { transferCategory } = this.state;
-    let { onDelete } = this.props;
-
-    if (!transferCategory) {
-      this.setState({ error: 'required-transfer' });
-    } else {
-      onDelete(transferCategory);
-      this.props.modalProps.onClose();
-    }
-  };
+  }, []);
 
-  renderError = error => {
+  const renderError = error => {
     let msg;
 
     switch (error) {
@@ -46,83 +43,84 @@ class ConfirmCategoryDelete extends React.Component {
     return <Text style={{ marginTop: 15, color: colors.r4 }}>{msg}</Text>;
   };
 
-  render() {
-    const { modalProps, category, group, categoryGroups } = this.props;
-    const { transferCategory, error } = this.state;
-    const isIncome = !!(category || group).is_income;
-
-    return (
-      <Modal title="Confirm Delete" {...modalProps} style={{ flex: 0 }}>
-        {() => (
-          <View style={{ lineHeight: 1.5 }}>
-            {group ? (
-              <Block>
-                Categories in the group <strong>{group.name}</strong> are used
-                by existing transaction
-                {!isIncome &&
-                  ' or it has a positive leftover balance currently'}
-                . <strong>Are you sure you want to delete it?</strong> If so,
-                you must select another category to transfer existing
-                transactions and balance to.
-              </Block>
-            ) : (
-              <Block>
-                <strong>{category.name}</strong> is used by existing
-                transactions
-                {!isIncome &&
-                  ' or it has a positive leftover balance currently'}
-                . <strong>Are you sure you want to delete it?</strong> If so,
-                you must select another category to transfer existing
-                transactions and balance to.
-              </Block>
-            )}
+  const isIncome = !!(category || group).is_income;
 
-            {error && this.renderError(error)}
+  return (
+    <Modal title="Confirm Delete" {...modalProps} style={{ flex: 0 }}>
+      {() => (
+        <View style={{ lineHeight: 1.5 }}>
+          {group ? (
+            <Block>
+              Categories in the group <strong>{group.name}</strong> are used by
+              existing transaction
+              {!isIncome &&
+                ' or it has a positive leftover balance currently'}.{' '}
+              <strong>Are you sure you want to delete it?</strong> If so, you
+              must select another category to transfer existing transactions and
+              balance to.
+            </Block>
+          ) : (
+            <Block>
+              <strong>{category.name}</strong> is used by existing transactions
+              {!isIncome &&
+                ' or it has a positive leftover balance currently'}.{' '}
+              <strong>Are you sure you want to delete it?</strong> If so, you
+              must select another category to transfer existing transactions and
+              balance to.
+            </Block>
+          )}
 
-            <View
-              style={{
-                marginTop: 20,
-                flexDirection: 'row',
-                justifyContent: 'flex-start',
-                alignItems: 'center',
-              }}
-            >
-              <Text>Transfer to:</Text>
+          {error && renderError(error)}
 
-              <View style={{ flex: 1, marginLeft: 10, marginRight: 30 }}>
-                <NativeCategorySelect
-                  ref={el => (this.input = el)}
-                  categoryGroups={
-                    group
-                      ? categoryGroups.filter(
-                          g => g.id !== group.id && !!g.is_income === isIncome,
-                        )
-                      : categoryGroups
-                          .filter(g => !!g.is_income === isIncome)
-                          .map(g => ({
-                            ...g,
-                            categories: g.categories.filter(
-                              c => c.id !== category.id,
-                            ),
-                          }))
-                  }
-                  name="category"
-                  value={transferCategory}
-                  onChange={e =>
-                    this.setState({ transferCategory: e.target.value })
-                  }
-                />
-              </View>
+          <View
+            style={{
+              marginTop: 20,
+              flexDirection: 'row',
+              justifyContent: 'flex-start',
+              alignItems: 'center',
+            }}
+          >
+            <Text>Transfer to:</Text>
 
-              <Button primary onClick={() => this.onDelete()}>
-                Delete
-              </Button>
+            <View style={{ flex: 1, marginLeft: 10, marginRight: 30 }}>
+              <NativeCategorySelect
+                ref={inputRef}
+                categoryGroups={
+                  group
+                    ? categoryGroups.filter(
+                        g => g.id !== group.id && !!g.is_income === isIncome,
+                      )
+                    : categoryGroups
+                        .filter(g => !!g.is_income === isIncome)
+                        .map(g => ({
+                          ...g,
+                          categories: g.categories.filter(
+                            c => c.id !== category.id,
+                          ),
+                        }))
+                }
+                name="category"
+                value={transferCategory}
+                onChange={e => setTransferCategory(e.target.value)}
+              />
             </View>
+
+            <Button
+              primary
+              onClick={() => {
+                if (!transferCategory) {
+                  setError('required-transfer');
+                } else {
+                  onDelete(transferCategory);
+                  modalProps.onClose();
+                }
+              }}
+            >
+              Delete
+            </Button>
           </View>
-        )}
-      </Modal>
-    );
-  }
+        </View>
+      )}
+    </Modal>
+  );
 }
-
-export default ConfirmCategoryDelete;
diff --git a/packages/desktop-client/src/components/reports/Container.js b/packages/desktop-client/src/components/reports/Container.js
index 5542081d8..31906cf5b 100644
--- a/packages/desktop-client/src/components/reports/Container.js
+++ b/packages/desktop-client/src/components/reports/Container.js
@@ -1,27 +1,21 @@
-import React from 'react';
+import React, { useRef } from 'react';
 import AutoSizer from 'react-virtualized-auto-sizer';
 
 import { View } from 'loot-design/src/components/common';
 
-class Container extends React.Component {
-  render() {
-    const { style, children } = this.props;
+export default function Container({ style, children }) {
+  const portalHost = useRef(null);
 
-    return (
-      <View
-        style={[{ height: 300, position: 'relative', flexShrink: 0 }, style]}
-      >
-        <div ref={el => (this.portalHost = el)} />
-        <AutoSizer>
-          {({ width, height }) => (
-            <div style={{ width, height }}>
-              {children(width, height, this.portalHost)}
-            </div>
-          )}
-        </AutoSizer>
-      </View>
-    );
-  }
+  return (
+    <View style={[{ height: 300, position: 'relative', flexShrink: 0 }, style]}>
+      <div ref={portalHost} />
+      <AutoSizer>
+        {({ width, height }) => (
+          <div style={{ width, height }}>
+            {children(width, height, portalHost.current)}
+          </div>
+        )}
+      </AutoSizer>
+    </View>
+  );
 }
-
-export default Container;
diff --git a/packages/desktop-client/src/components/reports/index.js b/packages/desktop-client/src/components/reports/index.js
index 9c6054282..ba204ac8d 100644
--- a/packages/desktop-client/src/components/reports/index.js
+++ b/packages/desktop-client/src/components/reports/index.js
@@ -7,16 +7,12 @@ import CashFlow from './CashFlow';
 import NetWorth from './NetWorth';
 import Overview from './Overview';
 
-class Reports extends React.Component {
-  render() {
-    return (
-      <View style={{ flex: 1 }} data-testid="reports-page">
-        <Route path="/reports" exact component={Overview} />
-        <Route path="/reports/net-worth" exact component={NetWorth} />
-        <Route path="/reports/cash-flow" exact component={CashFlow} />
-      </View>
-    );
-  }
+export default function Reports() {
+  return (
+    <View style={{ flex: 1 }} data-testid="reports-page">
+      <Route path="/reports" exact component={Overview} />
+      <Route path="/reports/net-worth" exact component={NetWorth} />
+      <Route path="/reports/cash-flow" exact component={CashFlow} />
+    </View>
+  );
 }
-
-export default Reports;
diff --git a/packages/loot-design/src/components/TutorialPoint.js b/packages/loot-design/src/components/TutorialPoint.js
deleted file mode 100644
index c2884c166..000000000
--- a/packages/loot-design/src/components/TutorialPoint.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-
-class TutorialPoint extends React.Component {
-  static contextTypes = {
-    setTutorialNode: PropTypes.func,
-  };
-
-  ref = el => {
-    if (this.context.setTutorialNode) {
-      this.context.setTutorialNode(this.props.name, el, this.props.expand);
-    }
-  };
-
-  render() {
-    return <div ref={this.ref} />;
-  }
-}
-
-export default TutorialPoint;
diff --git a/packages/loot-design/src/components/common.js b/packages/loot-design/src/components/common.js
index 1ada04125..a405235a5 100644
--- a/packages/loot-design/src/components/common.js
+++ b/packages/loot-design/src/components/common.js
@@ -1105,62 +1105,45 @@ export function InitialFocus({ children }) {
   return React.cloneElement(children, { inputRef: node });
 }
 
-export class HoverTarget extends React.Component {
-  state = { hovered: false };
+export function HoverTarget({
+  style,
+  contentStyle,
+  children,
+  renderContent,
+  disabled,
+}) {
+  let [hovered, setHovered] = useState(false);
 
-  onMouseEnter = () => {
-    if (!this.props.disabled) {
-      this.setState({ hovered: true });
+  const onMouseEnter = useCallback(() => {
+    if (!disabled) {
+      setHovered(true);
     }
-  };
+  }, [disabled]);
 
-  onMouseLeave = () => {
-    if (!this.props.disabled) {
-      this.setState({ hovered: false });
+  const onMouseLeave = useCallback(() => {
+    if (!disabled) {
+      setHovered(false);
     }
-  };
+  }, [disabled]);
 
-  componentDidUpdate(prevProps) {
-    let { disabled } = this.props;
-    if (disabled && this.state.hovered) {
-      this.setState({ hovered: false });
+  useEffect(() => {
+    if (disabled && hovered) {
+      setHovered(false);
     }
-  }
+  }, [disabled, hovered]);
 
-  render() {
-    let { style, contentStyle, children, renderContent } = this.props;
-    return (
-      <View style={style}>
-        <View
-          onMouseEnter={this.onMouseEnter}
-          onMouseLeave={this.onMouseLeave}
-          style={contentStyle}
-        >
-          {children}
-        </View>
-        {this.state.hovered && renderContent()}
-      </View>
-    );
-  }
-}
-
-export class TooltipTarget extends React.Component {
-  state = { clicked: false };
-
-  render() {
-    return (
-      <View style={[{ position: 'relative' }, this.props.style]}>
-        <View
-          style={{ flex: 1 }}
-          onClick={() => this.setState({ clicked: true })}
-        >
-          {this.props.children}
-        </View>
-        {this.state.clicked &&
-          this.props.renderContent(() => this.setState({ clicked: false }))}
+  return (
+    <View style={style}>
+      <View
+        onMouseEnter={onMouseEnter}
+        onMouseLeave={onMouseLeave}
+        style={contentStyle}
+      >
+        {children}
       </View>
-    );
-  }
+      {hovered && renderContent()}
+    </View>
+  );
 }
 
 export function Label({ title, style }) {
diff --git a/upcoming-release-notes/783.md b/upcoming-release-notes/783.md
new file mode 100644
index 000000000..68d086d9f
--- /dev/null
+++ b/upcoming-release-notes/783.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [j-f1]
+---
+
+Remove a few unused class components, convert a few components to functions
-- 
GitLab