From f329fe21af6d055b3fb9c90adee0df1c9f782377 Mon Sep 17 00:00:00 2001
From: Jed Fox <git@jedfox.com>
Date: Tue, 18 Jul 2023 15:55:14 -0400
Subject: [PATCH] Add clear typings to the modals (#1359)

---
 .../src/components/{Modals.js => Modals.tsx}  | 59 +++++--------
 .../desktop-client/src/hooks/useActions.ts    |  1 -
 .../loot-core/src/client/actions/budgets.ts   |  1 +
 .../loot-core/src/client/actions/modals.ts    | 19 +++-
 .../loot-core/src/client/reducers/modals.ts   |  7 +-
 .../src/client/state-types/modals.d.ts        | 88 +++++++++++++++++--
 upcoming-release-notes/1359.md                |  6 ++
 7 files changed, 127 insertions(+), 54 deletions(-)
 rename packages/desktop-client/src/components/{Modals.js => Modals.tsx} (82%)
 create mode 100644 upcoming-release-notes/1359.md

diff --git a/packages/desktop-client/src/components/Modals.js b/packages/desktop-client/src/components/Modals.tsx
similarity index 82%
rename from packages/desktop-client/src/components/Modals.js
rename to packages/desktop-client/src/components/Modals.tsx
index 502201f25..b26cc34ef 100644
--- a/packages/desktop-client/src/components/Modals.js
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -1,12 +1,9 @@
 import React from 'react';
-import { connect } from 'react-redux';
+import { useSelector } from 'react-redux';
 
-import { bindActionCreators } from 'redux';
-
-import * as actions from 'loot-core/src/client/actions';
 import { send } from 'loot-core/src/platform/client/fetch';
 
-import useFeatureFlag from '../hooks/useFeatureFlag';
+import { useActions } from '../hooks/useActions';
 import useSyncServerStatus from '../hooks/useSyncServerStatus';
 
 import BudgetSummary from './modals/BudgetSummary';
@@ -27,21 +24,21 @@ import NordigenInitialise from './modals/NordigenInitialise';
 import PlaidExternalMsg from './modals/PlaidExternalMsg';
 import SelectLinkedAccounts from './modals/SelectLinkedAccounts';
 
-function Modals({
-  modalStack,
-  isHidden,
-  accounts,
-  categoryGroups,
-  categories,
-  budgetId,
-  actions,
-}) {
-  const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
+export default function Modals() {
+  const modalStack = useSelector(state => state.modals.modalStack);
+  const isHidden = useSelector(state => state.modals.isHidden);
+  const accounts = useSelector(state => state.queries.accounts);
+  const categoryGroups = useSelector(state => state.queries.categories.grouped);
+  const categories = useSelector(state => state.queries.categories.list);
+  const budgetId = useSelector(
+    state => state.prefs.local && state.prefs.local.id,
+  );
+  const actions = useActions();
 
   const syncServerStatus = useSyncServerStatus();
 
   return modalStack
-    .map(({ name, options = {} }, idx) => {
+    .map(({ name, options }, idx) => {
       const modalProps = {
         onClose: actions.popModal,
         onBack: actions.popModal,
@@ -61,7 +58,6 @@ function Modals({
           return (
             <CreateAccount
               modalProps={modalProps}
-              actions={actions}
               syncServerStatus={syncServerStatus}
             />
           );
@@ -91,7 +87,6 @@ function Modals({
               externalAccounts={options.accounts}
               requisitionId={options.requisitionId}
               localAccounts={accounts.filter(acct => acct.closed === 0)}
-              upgradingAccountId={options.upgradingAccountId}
               actions={actions}
             />
           );
@@ -100,9 +95,14 @@ function Modals({
           return (
             <ConfirmCategoryDelete
               modalProps={modalProps}
-              actions={actions}
-              category={categories.find(c => c.id === options.category)}
-              group={categoryGroups.find(g => g.id === options.group)}
+              category={
+                'category' in options &&
+                categories.find(c => c.id === options.category)
+              }
+              group={
+                'group' in options &&
+                categoryGroups.find(g => g.id === options.group)
+              }
               categoryGroups={categoryGroups}
               onDelete={options.onDelete}
             />
@@ -115,6 +115,7 @@ function Modals({
               budgetId={budgetId}
               modalProps={modalProps}
               actions={actions}
+              backupDisabled={false}
             />
           );
 
@@ -148,7 +149,6 @@ function Modals({
           return (
             <PlaidExternalMsg
               modalProps={modalProps}
-              actions={actions}
               onMoveExternal={options.onMoveExternal}
               onClose={() => {
                 options.onClose?.();
@@ -170,7 +170,6 @@ function Modals({
           return (
             <NordigenExternalMsg
               modalProps={modalProps}
-              actions={actions}
               onMoveExternal={options.onMoveExternal}
               onClose={() => {
                 options.onClose?.();
@@ -217,8 +216,6 @@ function Modals({
               key={name}
               modalProps={modalProps}
               month={options.month}
-              actions={actions}
-              isGoalTemplatesEnabled={isGoalTemplatesEnabled}
             />
           );
 
@@ -231,15 +228,3 @@ function Modals({
       <React.Fragment key={modalStack[idx].name}>{modal}</React.Fragment>
     ));
 }
-
-export default connect(
-  state => ({
-    modalStack: state.modals.modalStack,
-    isHidden: state.modals.isHidden,
-    accounts: state.queries.accounts,
-    categoryGroups: state.queries.categories.grouped,
-    categories: state.queries.categories.list,
-    budgetId: state.prefs.local && state.prefs.local.id,
-  }),
-  dispatch => ({ actions: bindActionCreators(actions, dispatch) }),
-)(Modals);
diff --git a/packages/desktop-client/src/hooks/useActions.ts b/packages/desktop-client/src/hooks/useActions.ts
index b60a9abe0..2078a4d8c 100644
--- a/packages/desktop-client/src/hooks/useActions.ts
+++ b/packages/desktop-client/src/hooks/useActions.ts
@@ -6,7 +6,6 @@ import { bindActionCreators } from 'redux';
 import * as actions from 'loot-core/src/client/actions';
 
 // https://react-redux.js.org/api/hooks#recipe-useactions
-// eslint-disable-next-line import/no-unused-modules
 export function useActions() {
   const dispatch = useDispatch();
   return useMemo(() => {
diff --git a/packages/loot-core/src/client/actions/budgets.ts b/packages/loot-core/src/client/actions/budgets.ts
index 498541c62..d9a54920c 100644
--- a/packages/loot-core/src/client/actions/budgets.ts
+++ b/packages/loot-core/src/client/actions/budgets.ts
@@ -80,6 +80,7 @@ export function loadBudget(
           );
 
           if (showBackups) {
+            // @ts-expect-error manager modals are not yet typed
             dispatch(pushModal('load-backup', { budgetId: id }));
           }
         } else {
diff --git a/packages/loot-core/src/client/actions/modals.ts b/packages/loot-core/src/client/actions/modals.ts
index 27b33df09..87c1abb1b 100644
--- a/packages/loot-core/src/client/actions/modals.ts
+++ b/packages/loot-core/src/client/actions/modals.ts
@@ -1,13 +1,24 @@
 import * as constants from '../constants';
+import type { Modal } from '../state-types/modals';
 
 import type { ActionResult } from './types';
 
-export function pushModal(name: string, options: unknown): ActionResult {
-  return { type: constants.PUSH_MODAL, name, options };
+export function pushModal<M extends Modal>(
+  name: M['name'],
+  options: M['options'],
+): ActionResult {
+  // @ts-expect-error TS is unable to determine that `name` and `options` match
+  let modal: M = { name, options };
+  return { type: constants.PUSH_MODAL, modal };
 }
 
-export function replaceModal(name: string, options: unknown): ActionResult {
-  return { type: constants.REPLACE_MODAL, name, options };
+export function replaceModal<M extends Modal>(
+  name: M['name'],
+  options: M['options'],
+): ActionResult {
+  // @ts-expect-error TS is unable to determine that `name` and `options` match
+  let modal: M = { name, options };
+  return { type: constants.REPLACE_MODAL, modal };
 }
 
 export function popModal(): ActionResult {
diff --git a/packages/loot-core/src/client/reducers/modals.ts b/packages/loot-core/src/client/reducers/modals.ts
index 2c40d0914..eeb743869 100644
--- a/packages/loot-core/src/client/reducers/modals.ts
+++ b/packages/loot-core/src/client/reducers/modals.ts
@@ -12,15 +12,12 @@ function update(state = initialState, action: Action): ModalsState {
     case constants.PUSH_MODAL:
       return {
         ...state,
-        modalStack: [
-          ...state.modalStack,
-          { name: action.name, options: action.options },
-        ],
+        modalStack: [...state.modalStack, action.modal],
       };
     case constants.REPLACE_MODAL:
       return {
         ...state,
-        modalStack: [{ name: action.name, options: action.options }],
+        modalStack: [action.modal],
       };
     case constants.POP_MODAL:
       return { ...state, modalStack: state.modalStack.slice(0, -1) };
diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts
index c3c9bbe4c..3f77b3532 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -1,21 +1,95 @@
+import type { AccountEntity } from '../../types/models';
+import type { RuleEntity } from '../../types/models/rule';
 import type * as constants from '../constants';
 
-// TODO: type this more throughly
 type Modal = {
-  name: string;
-  options: unknown;
+  [K in keyof FinanceModals]: {
+    name: K;
+    options: FinanceModals[K];
+  };
+}[keyof FinanceModals];
+
+// There is a separate (overlapping!) set of modals for the management app. Fun!
+type FinanceModals = {
+  'import-transactions': {
+    accountId: string;
+    filename: string;
+    onImported: (didChange: boolean) => void;
+  };
+
+  'add-account': null;
+  'add-local-account': null;
+  'close-account': {
+    account: AccountEntity;
+    balance: number;
+    canDelete: boolean;
+  };
+  'select-linked-accounts': {
+    accounts: unknown[];
+    requisitionId: string;
+    upgradingAccountId: string;
+  };
+  'configure-linked-accounts': never;
+
+  'confirm-category-delete': { onDelete: () => void } & (
+    | { category: string }
+    | { group: string }
+  );
+
+  'load-backup': null;
+
+  'manage-rules': { payeeId: string } | null;
+  'edit-rule': {
+    rule: RuleEntity;
+    onSave: (rule: RuleEntity) => void;
+  };
+  'merge-unused-payees': {
+    payeeIds: string[];
+    targetPayeeId: string;
+  };
+
+  'plaid-external-msg': {
+    onMoveExternal: () => Promise<void>;
+    onClose?: () => void;
+    onSuccess: (data: unknown) => Promise<void>;
+  };
+
+  'nordigen-init': {
+    onSuccess: () => void;
+  };
+  'nordigen-external-msg': {
+    onMoveExternal: (arg: {
+      institutionId: string;
+    }) => Promise<{ error: string } | { data: unknown }>;
+    onClose?: () => void;
+    onSuccess: (data: unknown) => Promise<void>;
+  };
+
+  'create-encryption-key': { recreate: boolean } | null;
+  'fix-encryption-key': {
+    hasExistingKey: boolean;
+    cloudFileId: string;
+    onSuccess?: () => void;
+  };
+
+  'edit-field': {
+    name: string;
+    onSubmit: (name: string, value: string) => void;
+  };
+
+  'budget-summary': {
+    month: string;
+  };
 };
 
 export type PushModalAction = {
   type: typeof constants.PUSH_MODAL;
-  name: string;
-  options: unknown;
+  modal: Modal;
 };
 
 export type ReplaceModalAction = {
   type: typeof constants.REPLACE_MODAL;
-  name: string;
-  options: unknown;
+  modal: Modal;
 };
 
 export type PopModalAction = {
diff --git a/upcoming-release-notes/1359.md b/upcoming-release-notes/1359.md
new file mode 100644
index 000000000..c2c17458d
--- /dev/null
+++ b/upcoming-release-notes/1359.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [j-f1]
+---
+
+Port the modal infrastructure to TypeScript
-- 
GitLab