diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-loads-the-budget-page-with-budgeted-amounts-1-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-loads-the-budget-page-with-budgeted-amounts-1-chromium-linux.png index fa4ff40ee97..e1ed15d720d 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-loads-the-budget-page-with-budgeted-amounts-1-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-loads-the-budget-page-with-budgeted-amounts-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-loads-the-budget-page-with-budgeted-amounts-2-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-loads-the-budget-page-with-budgeted-amounts-2-chromium-linux.png index 4ac36791ac1..866d234e40e 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-loads-the-budget-page-with-budgeted-amounts-2-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-loads-the-budget-page-with-budgeted-amounts-2-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index 696c7633ea1..8323f724721 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -7,17 +7,15 @@ import { } from 'react-error-boundary'; import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type AppState } from 'loot-core/client/state-types/app'; -import { type PrefsState } from 'loot-core/client/state-types/prefs'; import * as Platform from 'loot-core/src/client/platform'; +import { type State } from 'loot-core/src/client/state-types'; import { init as initConnection, send, } from 'loot-core/src/platform/client/fetch'; -import { type GlobalPrefs } from 'loot-core/src/types/prefs'; import { useActions } from '../hooks/useActions'; +import { useLocalPref } from '../hooks/useLocalPref'; import { installPolyfills } from '../polyfills'; import { ResponsiveProvider } from '../ResponsiveProvider'; import { styles, hasHiddenScrollbars, ThemeStyle } from '../style'; @@ -34,26 +32,13 @@ import { UpdateNotification } from './UpdateNotification'; type AppInnerProps = { budgetId: string; cloudFileId: string; - loadingText: string; - loadBudget: ( - id: string, - loadingText?: string, - options?: object, - ) => Promise; - closeBudget: () => Promise; - loadGlobalPrefs: () => Promise; }; -function AppInner({ - budgetId, - cloudFileId, - loadingText, - loadBudget, - closeBudget, - loadGlobalPrefs, -}: AppInnerProps) { +function AppInner({ budgetId, cloudFileId }: AppInnerProps) { const [initializing, setInitializing] = useState(true); const { showBoundary: showErrorBoundary } = useErrorBoundary(); + const loadingText = useSelector((state: State) => state.app.loadingText); + const { loadBudget, closeBudget, loadGlobalPrefs } = useActions(); async function init() { const socketName = await global.Actual.getServerSocket(); @@ -126,16 +111,9 @@ function ErrorFallback({ error }: FallbackProps) { } export function App() { - const budgetId = useSelector( - state => state.prefs.local && state.prefs.local.id, - ); - const cloudFileId = useSelector( - state => state.prefs.local && state.prefs.local.cloudFileId, - ); - const loadingText = useSelector( - state => state.app.loadingText, - ); - const { loadBudget, closeBudget, loadGlobalPrefs, sync } = useActions(); + const [budgetId] = useLocalPref('id'); + const [cloudFileId] = useLocalPref('cloudFileId'); + const { sync } = useActions(); const [hiddenScrollbars, setHiddenScrollbars] = useState( hasHiddenScrollbars(), ); @@ -184,14 +162,7 @@ export function App() { {process.env.REACT_APP_REVIEW_ID && !Platform.isPlaywright && ( )} - + diff --git a/packages/desktop-client/src/components/BankSyncStatus.tsx b/packages/desktop-client/src/components/BankSyncStatus.tsx index 3fde066fa4c..f49b9200749 100644 --- a/packages/desktop-client/src/components/BankSyncStatus.tsx +++ b/packages/desktop-client/src/components/BankSyncStatus.tsx @@ -2,8 +2,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { useTransition, animated } from 'react-spring'; -import { type State } from 'loot-core/client/state-types'; -import { type AccountState } from 'loot-core/client/state-types/account'; +import { type State } from 'loot-core/src/client/state-types'; import { theme, styles } from '../style'; @@ -12,8 +11,8 @@ import { Text } from './common/Text'; import { View } from './common/View'; export function BankSyncStatus() { - const accountsSyncing = useSelector( - state => state.account.accountsSyncing, + const accountsSyncing = useSelector( + (state: State) => state.account.accountsSyncing, ); const name = accountsSyncing diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 29c241a14da..8024afe7957 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -2,6 +2,7 @@ import React, { type ReactElement, useEffect, useMemo } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend as Backend } from 'react-dnd-html5-backend'; +import { useSelector } from 'react-redux'; import { Route, Routes, @@ -13,12 +14,12 @@ import { import hotkeys from 'hotkeys-js'; -import { AccountsProvider } from 'loot-core/src/client/data-hooks/accounts'; -import { PayeesProvider } from 'loot-core/src/client/data-hooks/payees'; import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider'; +import { type State } from 'loot-core/src/client/state-types'; import { checkForUpdateNotification } from 'loot-core/src/client/update-notification'; import * as undo from 'loot-core/src/platform/client/undo'; +import { useAccounts } from '../hooks/useAccounts'; import { useActions } from '../hooks/useActions'; import { useNavigate } from '../hooks/useNavigate'; import { useResponsive } from '../ResponsiveProvider'; @@ -39,7 +40,8 @@ import { Reports } from './reports'; import { NarrowAlternate, WideComponent } from './responsive'; import { ScrollProvider } from './ScrollProvider'; import { Settings } from './settings'; -import { FloatableSidebar, SidebarProvider } from './sidebar'; +import { FloatableSidebar } from './sidebar'; +import { SidebarProvider } from './sidebar/SidebarProvider'; import { Titlebar, TitlebarProvider } from './Titlebar'; import { TransactionEdit } from './transactions/MobileTransaction'; @@ -71,18 +73,19 @@ function WideNotSupported({ children, redirectTo = '/budget' }) { return isNarrowWidth ? children : null; } -function RouterBehaviors({ getAccounts }) { +function RouterBehaviors() { const navigate = useNavigate(); + const accounts = useAccounts(); + const accountsLoaded = useSelector( + (state: State) => state.queries.accountsLoaded, + ); useEffect(() => { - // Get the accounts and check if any exist. If there are no - // accounts, we want to redirect the user to the All Accounts - // screen which will prompt them to add an account - getAccounts().then(accounts => { - if (accounts.length === 0) { - navigate('/accounts'); - } - }); - }, []); + // If there are no accounts, we want to redirect the user to + // the All Accounts screen which will prompt them to add an account + if (accountsLoaded && accounts.length === 0) { + navigate('/accounts'); + } + }, [accountsLoaded, accounts]); const location = useLocation(); const href = useHref(location); @@ -116,7 +119,7 @@ function FinancesAppWithoutContext() { return ( - + @@ -265,13 +268,9 @@ export function FinancesApp() { - - - - {app} - - - + + {app} + diff --git a/packages/desktop-client/src/components/LoggedInUser.tsx b/packages/desktop-client/src/components/LoggedInUser.tsx index bfd037ad437..f1043b764db 100644 --- a/packages/desktop-client/src/components/LoggedInUser.tsx +++ b/packages/desktop-client/src/components/LoggedInUser.tsx @@ -2,8 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type UserState } from 'loot-core/client/state-types/user'; +import { type State } from 'loot-core/src/client/state-types'; import { useActions } from '../hooks/useActions'; import { theme, styles, type CSSProperties } from '../style'; @@ -25,9 +24,7 @@ export function LoggedInUser({ style, color, }: LoggedInUserProps) { - const userData = useSelector( - state => state.user.data, - ); + const userData = useSelector((state: State) => state.user.data); const { getUserData, signOut, closeBudget } = useActions(); const [loading, setLoading] = useState(true); const [menuOpen, setMenuOpen] = useState(false); diff --git a/packages/desktop-client/src/components/ManageRules.tsx b/packages/desktop-client/src/components/ManageRules.tsx index 38634a9899a..2459fcb44a1 100644 --- a/packages/desktop-client/src/components/ManageRules.tsx +++ b/packages/desktop-client/src/components/ManageRules.tsx @@ -7,10 +7,8 @@ import React, { type SetStateAction, type Dispatch, } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type QueriesState } from 'loot-core/client/state-types/queries'; import { pushModal } from 'loot-core/src/client/actions/modals'; import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries'; import { send } from 'loot-core/src/platform/client/fetch'; @@ -19,7 +17,9 @@ import { mapField, friendlyOp } from 'loot-core/src/shared/rules'; import { describeSchedule } from 'loot-core/src/shared/schedules'; import { type RuleEntity } from 'loot-core/src/types/models'; +import { useAccounts } from '../hooks/useAccounts'; import { useCategories } from '../hooks/useCategories'; +import { usePayees } from '../hooks/usePayees'; import { useSelected, SelectedProvider } from '../hooks/useSelected'; import { theme } from '../style'; @@ -105,18 +105,13 @@ function ManageRulesContent({ const { data: schedules } = SchedulesQuery.useQuery(); const { list: categories } = useCategories(); - const state = useSelector< - State, - { - payees: QueriesState['payees']; - accounts: QueriesState['accounts']; - schedules: ReturnType<(typeof SchedulesQuery)['useQuery']>; - } - >(state => ({ - payees: state.queries.payees, - accounts: state.queries.accounts, + const payees = usePayees(); + const accounts = useAccounts(); + const state = { + payees, + accounts, schedules, - })); + }; const filterData = useMemo( () => ({ ...state, diff --git a/packages/desktop-client/src/components/MobileWebMessage.tsx b/packages/desktop-client/src/components/MobileWebMessage.tsx index bc3e0c7ac93..c54da6eedf1 100644 --- a/packages/desktop-client/src/components/MobileWebMessage.tsx +++ b/packages/desktop-client/src/components/MobileWebMessage.tsx @@ -1,10 +1,6 @@ import React, { useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -import { savePrefs } from 'loot-core/src/client/actions'; -import { type State } from 'loot-core/src/client/state-types'; -import { type PrefsState } from 'loot-core/src/client/state-types/prefs'; +import { useLocalPref } from '../hooks/useLocalPref'; import { useResponsive } from '../ResponsiveProvider'; import { theme, styles } from '../style'; @@ -16,30 +12,24 @@ import { Checkbox } from './forms'; const buttonStyle = { border: 0, fontSize: 15, padding: '10px 13px' }; export function MobileWebMessage() { - const hideMobileMessagePref = useSelector< - State, - PrefsState['local']['hideMobileMessage'] - >(state => { - return (state.prefs.local && state.prefs.local.hideMobileMessage) || true; - }); + const [hideMobileMessage = true, setHideMobileMessagePref] = + useLocalPref('hideMobileMessage'); const { isNarrowWidth } = useResponsive(); const [show, setShow] = useState( isNarrowWidth && - !hideMobileMessagePref && + !hideMobileMessage && !document.cookie.match(/hideMobileMessage=true/), ); const [requestDontRemindMe, setRequestDontRemindMe] = useState(false); - const dispatch = useDispatch(); - function onTry() { setShow(false); if (requestDontRemindMe) { // remember the pref indefinitely - dispatch(savePrefs({ hideMobileMessage: true })); + setHideMobileMessagePref(true); } else { // Set a cookie for 5 minutes const d = new Date(); diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 93ae5559218..56b599861d5 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -4,16 +4,10 @@ import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { type State } from 'loot-core/src/client/state-types'; -import { - type ModalsState, - type PopModalAction, -} from 'loot-core/src/client/state-types/modals'; -import { type PrefsState } from 'loot-core/src/client/state-types/prefs'; -import { type QueriesState } from 'loot-core/src/client/state-types/queries'; +import { type PopModalAction } from 'loot-core/src/client/state-types/modals'; import { send } from 'loot-core/src/platform/client/fetch'; import { useActions } from '../hooks/useActions'; -import { useCategories } from '../hooks/useCategories'; import { useSyncServerStatus } from '../hooks/useSyncServerStatus'; import { CategoryGroupMenu } from './modals/CategoryGroupMenu'; @@ -56,19 +50,8 @@ export type CommonModalProps = { }; export function Modals() { - const modalStack = useSelector( - state => state.modals.modalStack, - ); - const isHidden = useSelector( - state => state.modals.isHidden, - ); - const accounts = useSelector( - state => state.queries.accounts, - ); - const { grouped: categoryGroups, list: categories } = useCategories(); - const budgetId = useSelector( - state => state.prefs.local && state.prefs.local.id, - ); + const modalStack = useSelector((state: State) => state.modals.modalStack); + const isHidden = useSelector((state: State) => state.modals.isHidden); const actions = useActions(); const location = useLocation(); @@ -118,8 +101,6 @@ export function Modals() { account={options.account} balance={options.balance} canDelete={options.canDelete} - accounts={accounts.filter(acct => acct.closed === 0)} - categoryGroups={categoryGroups} actions={actions} /> ); @@ -130,7 +111,6 @@ export function Modals() { modalProps={modalProps} externalAccounts={options.accounts} requisitionId={options.requisitionId} - localAccounts={accounts.filter(acct => acct.closed === 0)} actions={actions} syncSource={options.syncSource} /> @@ -140,15 +120,8 @@ export function Modals() { return ( c.id === options.category) - } - group={ - 'group' in options && - categoryGroups.find(g => g.id === options.group) - } - categoryGroups={categoryGroups} + category={options.category} + group={options.group} onDelete={options.onDelete} /> ); @@ -166,7 +139,7 @@ export function Modals() { return ( ( - state => state.notifications.notifications, + const notifications = useSelector( + (state: State) => state.notifications.notifications, ); return ( (state => state.prefs.local?.isPrivacyEnabled); - const { savePrefs } = useActions(); + const [isPrivacyEnabled, setPrivacyEnabledPref] = + useLocalPref('isPrivacyEnabled'); const privacyIconStyle = { width: 15, height: 15 }; @@ -132,7 +128,7 @@ function PrivacyButton({ style }: PrivacyButtonProps) { )} - {(show3Cols || !showBudgetedCol) && ( + {(show3Cols || showSpentColumn) && ( @@ -125,7 +130,7 @@ export function LoadBackup({ ) : ( actions.loadBackup(budgetId, id)} + onSelect={id => actions.loadBackup(budgetIdToLoad, id)} /> )} diff --git a/packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx b/packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx index 5e36e4f9d99..33ca3d70328 100644 --- a/packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx +++ b/packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { replaceModal } from 'loot-core/src/client/actions/modals'; import { send } from 'loot-core/src/platform/client/fetch'; +import { usePayees } from '../../hooks/usePayees'; import { theme } from '../../style'; import { Information } from '../alerts'; import { Button } from '../common/Button'; @@ -15,10 +16,8 @@ import { View } from '../common/View'; const highlightStyle = { color: theme.pageTextPositive }; export function MergeUnusedPayees({ modalProps, payeeIds, targetPayeeId }) { - const { payees: allPayees, modalStack } = useSelector(state => ({ - payees: state.queries.payees, - modalStack: state.modals.modalStack, - })); + const allPayees = usePayees(); + const modalStack = useSelector(state => state.modals.modalStack); const isEditingRule = !!modalStack.find(m => m.name === 'edit-rule'); const dispatch = useDispatch(); const [shouldCreateRule, setShouldCreateRule] = useState(true); diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccounts.jsx b/packages/desktop-client/src/components/modals/SelectLinkedAccounts.jsx index 8d8d82475bc..8f74791de53 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccounts.jsx +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccounts.jsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; +import { useAccounts } from '../../hooks/useAccounts'; import { theme } from '../../style'; import { Autocomplete } from '../autocomplete/Autocomplete'; import { Button } from '../common/Button'; @@ -14,10 +15,10 @@ export function SelectLinkedAccounts({ modalProps, requisitionId, externalAccounts, - localAccounts, actions, syncSource, }) { + const localAccounts = useAccounts().filter(a => a.closed === 0); const [chosenAccounts, setChosenAccounts] = useState(() => { return Object.fromEntries( localAccounts diff --git a/packages/desktop-client/src/components/modals/SwitchBudgetType.tsx b/packages/desktop-client/src/components/modals/SwitchBudgetType.tsx index 80aada7a8d5..eb04d7e86fe 100644 --- a/packages/desktop-client/src/components/modals/SwitchBudgetType.tsx +++ b/packages/desktop-client/src/components/modals/SwitchBudgetType.tsx @@ -1,10 +1,7 @@ // @ts-strict-ignore import React from 'react'; -import { useSelector } from 'react-redux'; - -import { type State } from 'loot-core/src/client/state-types'; -import { type PrefsState } from 'loot-core/src/client/state-types/prefs'; +import { useLocalPref } from '../../hooks/useLocalPref'; import { Button } from '../common/Button'; import { ExternalLink } from '../common/ExternalLink'; import { Modal } from '../common/Modal'; @@ -21,9 +18,7 @@ export function SwitchBudgetType({ modalProps, onSwitch, }: SwitchBudgetTypeProps) { - const budgetType = useSelector( - state => state.prefs.local.budgetType, - ); + const [budgetType] = useLocalPref('budgetType'); return ( {() => ( diff --git a/packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx b/packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx index a828c5e73f2..55ea8bc78ae 100644 --- a/packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx +++ b/packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx @@ -6,11 +6,12 @@ import { applyChanges } from 'loot-core/src/shared/util'; import { useActions } from '../../hooks/useActions'; import { useCategories } from '../../hooks/useCategories'; +import { usePayees } from '../../hooks/usePayees'; import { ManagePayees } from './ManagePayees'; export function ManagePayeesWithData({ initialSelectedIds }) { - const initialPayees = useSelector(state => state.queries.payees); + const initialPayees = usePayees(); const lastUndoState = useSelector(state => state.app.lastUndoState); const { grouped: categoryGroups } = useCategories(); diff --git a/packages/desktop-client/src/components/reports/Overview.jsx b/packages/desktop-client/src/components/reports/Overview.jsx index e0b8163d6da..539aeda890f 100644 --- a/packages/desktop-client/src/components/reports/Overview.jsx +++ b/packages/desktop-client/src/components/reports/Overview.jsx @@ -1,8 +1,8 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { useReports } from 'loot-core/src/client/data-hooks/reports'; +import { useAccounts } from '../../hooks/useAccounts'; import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { styles } from '../../style'; import { View } from '../common/View'; @@ -18,7 +18,7 @@ export function Overview() { const customReportsFeatureFlag = useFeatureFlag('customReports'); - const accounts = useSelector(state => state.queries.accounts); + const accounts = useAccounts(); return ( state.prefs.local?.reportsViewLegend) || false; - const viewSummary = - useSelector(state => state.prefs.local?.reportsViewSummary) || false; - const viewLabels = - useSelector(state => state.prefs.local?.reportsViewLabel) || false; - const { savePrefs } = useActions(); + const [viewLegend = false, setViewLegendPref] = + useLocalPref('reportsViewLegend'); + const [viewSummary = false, setViewSummaryPref] = + useLocalPref('reportsViewSummary'); + const [viewLabels = false, setViewLabelsPref] = + useLocalPref('reportsViewLabel'); const { filters, @@ -126,8 +124,8 @@ export function CustomReport() { }, []); const balanceTypeOp = ReportOptions.balanceTypeMap.get(balanceType); - const payees = useCachedPayees(); - const accounts = useCachedAccounts(); + const payees = usePayees(); + const accounts = useAccounts(); const getGroupData = useMemo(() => { return createGroupedSpreadsheet({ @@ -235,13 +233,13 @@ export function CustomReport() { const onChangeViews = (viewType, status) => { if (viewType === 'viewLegend') { - savePrefs({ reportsViewLegend: status ?? !viewLegend }); + setViewLegendPref(status ?? !viewLegend); } if (viewType === 'viewSummary') { - savePrefs({ reportsViewSummary: !viewSummary }); + setViewSummaryPref(!viewSummary); } if (viewType === 'viewLabels') { - savePrefs({ reportsViewLabel: !viewLabels }); + setViewLabelsPref(!viewLabels); } }; diff --git a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx index 5bdd779b24f..4dbec9a8e05 100644 --- a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx +++ b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx @@ -1,5 +1,4 @@ import React, { useState, useEffect, useMemo } from 'react'; -import { useSelector } from 'react-redux'; import * as d from 'date-fns'; @@ -7,6 +6,7 @@ import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { integerToCurrency } from 'loot-core/src/shared/util'; +import { useAccounts } from '../../../hooks/useAccounts'; import { useFilters } from '../../../hooks/useFilters'; import { theme, styles } from '../../../style'; import { Paragraph } from '../../common/Paragraph'; @@ -20,7 +20,7 @@ import { useReport } from '../useReport'; import { fromDateRepr } from '../util'; export function NetWorth() { - const accounts = useSelector(state => state.queries.accounts); + const accounts = useAccounts(); const { filters, saved, diff --git a/packages/desktop-client/src/components/reports/spreadsheets/filterEmptyRows.ts b/packages/desktop-client/src/components/reports/spreadsheets/filterEmptyRows.ts index 45efef476f4..c0b6e8f3443 100644 --- a/packages/desktop-client/src/components/reports/spreadsheets/filterEmptyRows.ts +++ b/packages/desktop-client/src/components/reports/spreadsheets/filterEmptyRows.ts @@ -1,5 +1,5 @@ // @ts-strict-ignore -import { type GroupedEntity } from 'loot-core/types/models/reports'; +import { type GroupedEntity } from 'loot-core/src/types/models/reports'; export function filterEmptyRows( showEmpty: boolean, diff --git a/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts index 0adc9b11936..f44cddf8ca4 100644 --- a/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts +++ b/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts @@ -3,7 +3,7 @@ import { runQuery } from 'loot-core/src/client/query-helpers'; import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { integerToAmount } from 'loot-core/src/shared/util'; -import { type GroupedEntity } from 'loot-core/types/models/reports'; +import { type GroupedEntity } from 'loot-core/src/types/models/reports'; import { categoryLists } from '../ReportOptions'; diff --git a/packages/desktop-client/src/components/rules/ScheduleValue.tsx b/packages/desktop-client/src/components/rules/ScheduleValue.tsx index 036d0a2ed37..fb05b0b8f2d 100644 --- a/packages/desktop-client/src/components/rules/ScheduleValue.tsx +++ b/packages/desktop-client/src/components/rules/ScheduleValue.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type QueriesState } from 'loot-core/client/state-types/queries'; import { getPayeesById } from 'loot-core/src/client/reducers/queries'; import { describeSchedule } from 'loot-core/src/shared/schedules'; import { type ScheduleEntity } from 'loot-core/src/types/models'; +import { usePayees } from '../../hooks/usePayees'; + import { SchedulesQuery } from './SchedulesQuery'; import { Value } from './Value'; @@ -15,9 +14,7 @@ type ScheduleValueProps = { }; export function ScheduleValue({ value }: ScheduleValueProps) { - const payees = useSelector( - state => state.queries.payees, - ); + const payees = usePayees(); const byId = getPayeesById(payees); const { data: schedules } = SchedulesQuery.useQuery(); diff --git a/packages/desktop-client/src/components/rules/Value.tsx b/packages/desktop-client/src/components/rules/Value.tsx index fe9ccc73c2f..d8b184b39d5 100644 --- a/packages/desktop-client/src/components/rules/Value.tsx +++ b/packages/desktop-client/src/components/rules/Value.tsx @@ -1,17 +1,16 @@ // @ts-strict-ignore import React, { useState } from 'react'; -import { useSelector } from 'react-redux'; import { format as formatDate, parseISO } from 'date-fns'; -import { type State } from 'loot-core/client/state-types'; -import { type PrefsState } from 'loot-core/client/state-types/prefs'; -import { type QueriesState } from 'loot-core/client/state-types/queries'; import { getMonthYearFormat } from 'loot-core/src/shared/months'; import { getRecurringDescription } from 'loot-core/src/shared/schedules'; import { integerToCurrency } from 'loot-core/src/shared/util'; +import { useAccounts } from '../../hooks/useAccounts'; import { useCategories } from '../../hooks/useCategories'; +import { useDateFormat } from '../../hooks/useDateFormat'; +import { usePayees } from '../../hooks/usePayees'; import { type CSSProperties, theme } from '../../style'; import { LinkButton } from '../common/LinkButton'; import { Text } from '../common/Text'; @@ -36,16 +35,10 @@ export function Value({ describe = x => x.name, style, }: ValueProps) { - const dateFormat = useSelector( - state => state.prefs.local.dateFormat || 'MM/dd/yyyy', - ); - const payees = useSelector( - state => state.queries.payees, - ); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; + const payees = usePayees(); const { list: categories } = useCategories(); - const accounts = useSelector( - state => state.queries.accounts, - ); + const accounts = useAccounts(); const valueStyle = { color: theme.pageTextPositive, ...style, diff --git a/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx b/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx index 03a0aebaf00..1f62f40c8f4 100644 --- a/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx +++ b/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx @@ -1,9 +1,6 @@ // @ts-strict-ignore import React, { useState } from 'react'; -import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type PrefsState } from 'loot-core/client/state-types/prefs'; import { runQuery } from 'loot-core/src/client/query-helpers'; import { send } from 'loot-core/src/platform/client/fetch'; import { q } from 'loot-core/src/shared/query'; @@ -11,6 +8,7 @@ import { getRecurringDescription } from 'loot-core/src/shared/schedules'; import type { DiscoverScheduleEntity } from 'loot-core/src/types/models'; import type { BoundActions } from '../../hooks/useActions'; +import { useDateFormat } from '../../hooks/useDateFormat'; import { useSelected, useSelectedDispatch, @@ -41,9 +39,7 @@ function DiscoverSchedulesTable({ }) { const selectedItems = useSelectedItems(); const dispatchSelected = useSelectedDispatch(); - const dateFormat = useSelector( - state => state.prefs.local.dateFormat || 'MM/dd/yyyy', - ); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; function renderItem({ item }: { item: DiscoverScheduleEntity }) { const selected = selectedItems.has(item.id); diff --git a/packages/desktop-client/src/components/schedules/ScheduleDetails.jsx b/packages/desktop-client/src/components/schedules/ScheduleDetails.jsx index 85446055b92..56d20b44265 100644 --- a/packages/desktop-client/src/components/schedules/ScheduleDetails.jsx +++ b/packages/desktop-client/src/components/schedules/ScheduleDetails.jsx @@ -1,14 +1,15 @@ import React, { useEffect, useReducer } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { pushModal } from 'loot-core/src/client/actions/modals'; -import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees'; import { runQuery, liveQuery } from 'loot-core/src/client/query-helpers'; import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { q } from 'loot-core/src/shared/query'; import { extractScheduleConds } from 'loot-core/src/shared/schedules'; +import { useDateFormat } from '../../hooks/useDateFormat'; +import { usePayees } from '../../hooks/usePayees'; import { useSelected, SelectedProvider } from '../../hooks/useSelected'; import { theme } from '../../style'; import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete'; @@ -70,11 +71,10 @@ function updateScheduleConditions(schedule, fields) { export function ScheduleDetails({ modalProps, actions, id, transaction }) { const adding = id == null; const fromTrans = transaction != null; - const payees = useCachedPayees({ idKey: true }); + const payees = usePayees({ idKey: true }); const globalDispatch = useDispatch(); - const dateFormat = useSelector(state => { - return state.prefs.local.dateFormat || 'MM/dd/yyyy'; - }); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; + const [state, dispatch] = useReducer( (state, action) => { switch (action.type) { diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx index b2ec6ea021c..ba34e2df104 100644 --- a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx +++ b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx @@ -1,11 +1,6 @@ // @ts-strict-ignore import React, { useState, useMemo, type CSSProperties } from 'react'; -import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type PrefsState } from 'loot-core/client/state-types/prefs'; -import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts'; -import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees'; import { type ScheduleStatusType, type ScheduleStatuses, @@ -15,6 +10,9 @@ import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { integerToCurrency } from 'loot-core/src/shared/util'; import { type ScheduleEntity } from 'loot-core/src/types/models'; +import { useAccounts } from '../../hooks/useAccounts'; +import { useDateFormat } from '../../hooks/useDateFormat'; +import { usePayees } from '../../hooks/usePayees'; import { SvgDotsHorizontalTriple } from '../../icons/v1'; import { SvgCheck } from '../../icons/v2'; import { theme } from '../../style'; @@ -196,16 +194,11 @@ export function SchedulesTable({ onAction, tableStyle, }: SchedulesTableProps) { - const dateFormat = useSelector( - state => { - return state.prefs.local.dateFormat || 'MM/dd/yyyy'; - }, - ); - + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const [showCompleted, setShowCompleted] = useState(false); - const payees = useCachedPayees(); - const accounts = useCachedAccounts(); + const payees = usePayees(); + const accounts = useAccounts(); const filteredSchedules = useMemo(() => { if (!filter) { @@ -240,7 +233,7 @@ export function SchedulesTable({ filterIncludes(dateStr) ); }); - }, [schedules, filter, statuses]); + }, [payees, accounts, schedules, filter, statuses]); const items: SchedulesTableItem[] = useMemo(() => { const unCompletedSchedules = filteredSchedules.filter(s => !s.completed); diff --git a/packages/desktop-client/src/components/select/DateSelect.tsx b/packages/desktop-client/src/components/select/DateSelect.tsx index a6b83fc65f9..6893edc8d26 100644 --- a/packages/desktop-client/src/components/select/DateSelect.tsx +++ b/packages/desktop-client/src/components/select/DateSelect.tsx @@ -10,15 +10,12 @@ import React, { type MutableRefObject, type KeyboardEvent, } from 'react'; -import { useSelector } from 'react-redux'; import { parse, parseISO, format, subDays, addDays, isValid } from 'date-fns'; import Pikaday from 'pikaday'; import 'pikaday/css/pikaday.css'; -import { type State } from 'loot-core/client/state-types'; -import { type PrefsState } from 'loot-core/client/state-types/prefs'; import { getDayMonthFormat, getDayMonthRegex, @@ -28,6 +25,7 @@ import { } from 'loot-core/src/shared/months'; import { stringToInteger } from 'loot-core/src/shared/util'; +import { useLocalPref } from '../../hooks/useLocalPref'; import { type CSSProperties, theme } from '../../style'; import { Input, type InputProps } from '../common/Input'; import { View, type ViewProps } from '../common/View'; @@ -233,14 +231,8 @@ export function DateSelect({ const [selectedValue, setSelectedValue] = useState(value); const userSelectedValue = useRef(selectedValue); - const firstDayOfWeekIdx = useSelector< - State, - PrefsState['local']['firstDayOfWeekIdx'] - >(state => - state.prefs.local?.firstDayOfWeekIdx - ? state.prefs.local.firstDayOfWeekIdx - : '0', - ); + const [_firstDayOfWeekIdx] = useLocalPref('firstDayOfWeekIdx'); + const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0'; useEffect(() => { userSelectedValue.current = value; diff --git a/packages/desktop-client/src/components/select/RecurringSchedulePicker.jsx b/packages/desktop-client/src/components/select/RecurringSchedulePicker.jsx index df10237168f..17ee82e87c2 100644 --- a/packages/desktop-client/src/components/select/RecurringSchedulePicker.jsx +++ b/packages/desktop-client/src/components/select/RecurringSchedulePicker.jsx @@ -1,10 +1,10 @@ import React, { useEffect, useReducer, useState } from 'react'; -import { useSelector } from 'react-redux'; import { sendCatch } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { getRecurringDescription } from 'loot-core/src/shared/schedules'; +import { useDateFormat } from '../../hooks/useDateFormat'; import { SvgAdd, SvgSubtract } from '../../icons/v0'; import { theme } from '../../style'; import { Button } from '../common/Button'; @@ -159,11 +159,9 @@ function reducer(state, action) { } function SchedulePreview({ previewDates }) { - const dateFormat = useSelector(state => - (state.prefs.local.dateFormat || 'MM/dd/yyyy') - .replace('MM', 'M') - .replace('dd', 'd'), - ); + const dateFormat = (useDateFormat() || 'MM/dd/yyyy') + .replace('MM', 'M') + .replace('dd', 'd'); if (!previewDates) { return null; @@ -281,9 +279,7 @@ function RecurringScheduleTooltip({ config: currentConfig, onClose, onSave }) { const skipWeekend = state.config.hasOwnProperty('skipWeekend') ? state.config.skipWeekend : false; - const dateFormat = useSelector( - state => state.prefs.local.dateFormat || 'MM/dd/yyyy', - ); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; useEffect(() => { dispatch({ @@ -481,9 +477,7 @@ function RecurringScheduleTooltip({ config: currentConfig, onClose, onSave }) { export function RecurringSchedulePicker({ value, buttonStyle, onChange }) { const { isOpen, close, getOpenEvents } = useTooltip(); - const dateFormat = useSelector( - state => state.prefs.local.dateFormat || 'MM/dd/yyyy', - ); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; function onSave(config) { onChange(config); diff --git a/packages/desktop-client/src/components/settings/Encryption.tsx b/packages/desktop-client/src/components/settings/Encryption.tsx index cfe994b13a3..97a7660ce2f 100644 --- a/packages/desktop-client/src/components/settings/Encryption.tsx +++ b/packages/desktop-client/src/components/settings/Encryption.tsx @@ -1,11 +1,8 @@ // @ts-strict-ignore import React from 'react'; -import { useSelector } from 'react-redux'; - -import { type State } from 'loot-core/client/state-types'; -import { type PrefsState } from 'loot-core/client/state-types/prefs'; import { useActions } from '../../hooks/useActions'; +import { useLocalPref } from '../../hooks/useLocalPref'; import { theme } from '../../style'; import { Button } from '../common/Button'; import { ExternalLink } from '../common/ExternalLink'; @@ -17,9 +14,7 @@ import { Setting } from './UI'; export function EncryptionSettings() { const { pushModal } = useActions(); const serverURL = useServerURL(); - const encryptKeyId = useSelector( - state => state.prefs.local.encryptKeyId, - ); + const [encryptKeyId] = useLocalPref('encryptKeyId'); const missingCryptoAPI = !(window.crypto && crypto.subtle); diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index fc1a942c521..15f20989fec 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -1,12 +1,9 @@ import { type ReactNode, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/src/client/state-types'; -import { type PrefsState } from 'loot-core/src/client/state-types/prefs'; import type { FeatureFlag } from 'loot-core/src/types/prefs'; -import { useActions } from '../../hooks/useActions'; import { useFeatureFlag } from '../../hooks/useFeatureFlag'; +import { useLocalPref } from '../../hooks/useLocalPref'; import { theme } from '../../style'; import { LinkButton } from '../common/LinkButton'; import { Text } from '../common/Text'; @@ -23,23 +20,20 @@ type FeatureToggleProps = { }; function FeatureToggle({ - flag, + flag: flagName, disableToggle = false, error, children, }: FeatureToggleProps) { - const { savePrefs } = useActions(); - const enabled = useFeatureFlag(flag); + const enabled = useFeatureFlag(flagName); + const [_, setFlagPref] = useLocalPref(`flags.${flagName}`); return ( ); } diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx b/packages/desktop-client/src/components/transactions/MobileTransaction.jsx index bda688058a8..bbce43aee6d 100644 --- a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx +++ b/packages/desktop-client/src/components/transactions/MobileTransaction.jsx @@ -46,9 +46,12 @@ import { groupById, } from 'loot-core/src/shared/util'; +import { useAccounts } from '../../hooks/useAccounts'; import { useActions } from '../../hooks/useActions'; import { useCategories } from '../../hooks/useCategories'; +import { useDateFormat } from '../../hooks/useDateFormat'; import { useNavigate } from '../../hooks/useNavigate'; +import { usePayees } from '../../hooks/usePayees'; import { useSetThemeColor } from '../../hooks/useSetThemeColor'; import { SingleActiveEditFormProvider, @@ -939,11 +942,6 @@ function TransactionEditUnconnected(props) { useSetThemeColor(theme.mobileViewTheme); useEffect(() => { - // May as well update categories / accounts when transaction ID changes - props.getCategories(); - props.getAccounts(); - props.getPayees(); - async function fetchTransaction() { // Query for the transaction based on the ID with grouped splits. // @@ -1110,12 +1108,10 @@ function TransactionEditUnconnected(props) { export const TransactionEdit = props => { const { list: categories } = useCategories(); - const payees = useSelector(state => state.queries.payees); + const payees = usePayees(); const lastTransaction = useSelector(state => state.queries.lastTransaction); - const accounts = useSelector(state => state.queries.accounts); - const dateFormat = useSelector( - state => state.prefs.local.dateFormat || 'MM/dd/yyyy', - ); + const accounts = useAccounts(); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const actions = useActions(); return ( diff --git a/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx b/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx index ea43f6adbd6..736df89d280 100644 --- a/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx @@ -1,5 +1,4 @@ -import React, { memo, useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import React, { memo, useMemo, useCallback } from 'react'; import { format as formatDate, @@ -13,8 +12,11 @@ import { } from 'loot-core/src/client/reducers/queries'; import { integerToCurrency } from 'loot-core/src/shared/util'; +import { useAccounts } from '../../hooks/useAccounts'; import { useCategories } from '../../hooks/useCategories'; -import { useSelectedDispatch, useSelectedItems } from '../../hooks/useSelected'; +import { useDateFormat } from '../../hooks/useDateFormat'; +import { usePayees } from '../../hooks/usePayees'; +import { useSelectedItems, useSelectedDispatch } from '../../hooks/useSelected'; import { SvgArrowsSynchronize } from '../../icons/v2'; import { styles, theme } from '../../style'; import { Cell, Field, Row, SelectCell, Table } from '../table'; @@ -141,13 +143,9 @@ export function SimpleTransactionsTable({ style, }) { const { grouped: categories } = useCategories(); - const { payees, accounts, dateFormat } = useSelector(state => { - return { - payees: state.queries.payees, - accounts: state.queries.accounts, - dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy', - }; - }); + const payees = usePayees(); + const accounts = useAccounts(); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const selectedItems = useSelectedItems(); const dispatchSelected = useSelectedDispatch(); const memoFields = useMemo(() => fields, [JSON.stringify(fields)]); diff --git a/packages/desktop-client/src/components/util/DisplayId.tsx b/packages/desktop-client/src/components/util/DisplayId.tsx index db3982cbeac..34c5f593acc 100644 --- a/packages/desktop-client/src/components/util/DisplayId.tsx +++ b/packages/desktop-client/src/components/util/DisplayId.tsx @@ -1,9 +1,8 @@ // @ts-strict-ignore import React from 'react'; -import { CachedAccounts } from 'loot-core/src/client/data-hooks/accounts'; -import { CachedPayees } from 'loot-core/src/client/data-hooks/payees'; - +import { useAccount } from '../../hooks/useAccount'; +import { usePayee } from '../../hooks/usePayee'; import { theme } from '../../style'; import { Text } from '../common/Text'; @@ -18,33 +17,33 @@ export function DisplayId({ id, noneColor = theme.pageTextSubdued, }: DisplayIdProps) { - let DataComponent; - - switch (type) { - case 'payees': - DataComponent = CachedPayees; - break; - case 'accounts': - DataComponent = CachedAccounts; - break; - default: - throw new Error('DisplayId: unknown object type: ' + type); - } + return type === 'accounts' ? ( + + ) : ( + + ); +} +function AccountDisplayId({ id, noneColor }) { + const account = useAccount(id); return ( - - {data => { - const item = data[id]; + + {account ? account.name : 'None'} + + ); +} - return ( - - {item ? item.name : 'None'} - - ); - }} - +function PayeeDisplayId({ id, noneColor }) { + const payee = usePayee(id); + return ( + + {payee ? payee.name : 'None'} + ); } diff --git a/packages/desktop-client/src/components/util/GenericInput.jsx b/packages/desktop-client/src/components/util/GenericInput.jsx index b42826e01ba..bf2e3c53d30 100644 --- a/packages/desktop-client/src/components/util/GenericInput.jsx +++ b/packages/desktop-client/src/components/util/GenericInput.jsx @@ -4,6 +4,7 @@ import { useSelector } from 'react-redux'; import { getMonthYearFormat } from 'loot-core/src/shared/months'; import { useCategories } from '../../hooks/useCategories'; +import { useDateFormat } from '../../hooks/useDateFormat'; import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete'; import { Autocomplete } from '../autocomplete/Autocomplete'; import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete'; @@ -27,9 +28,7 @@ export function GenericInput({ }) { const { grouped: categoryGroups } = useCategories(); const saved = useSelector(state => state.queries.saved); - const dateFormat = useSelector( - state => state.prefs.local.dateFormat || 'MM/dd/yyyy', - ); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; // This makes the UI more resilient in case of faulty data if (multi && !Array.isArray(value)) { diff --git a/packages/desktop-client/src/hooks/useAccount.ts b/packages/desktop-client/src/hooks/useAccount.ts new file mode 100644 index 00000000000..e3da8f35e6b --- /dev/null +++ b/packages/desktop-client/src/hooks/useAccount.ts @@ -0,0 +1,8 @@ +import { useMemo } from 'react'; + +import { useAccounts } from './useAccounts'; + +export function useAccount(id: string) { + const accounts = useAccounts(); + return useMemo(() => accounts.find(a => a.id === id), [id, accounts]); +} diff --git a/packages/desktop-client/src/hooks/useAccounts.ts b/packages/desktop-client/src/hooks/useAccounts.ts new file mode 100644 index 00000000000..4c44e9cec93 --- /dev/null +++ b/packages/desktop-client/src/hooks/useAccounts.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { getAccounts } from 'loot-core/src/client/actions'; +import { type State } from 'loot-core/src/client/state-types'; + +export function useAccounts() { + const dispatch = useDispatch(); + const accountsLoaded = useSelector( + (state: State) => state.queries.accountsLoaded, + ); + + useEffect(() => { + if (!accountsLoaded) { + dispatch(getAccounts()); + } + }, []); + + return useSelector(state => state.queries.accounts); +} diff --git a/packages/desktop-client/src/hooks/useBudgetedAccounts.ts b/packages/desktop-client/src/hooks/useBudgetedAccounts.ts new file mode 100644 index 00000000000..dbd8e1f53db --- /dev/null +++ b/packages/desktop-client/src/hooks/useBudgetedAccounts.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; + +import { useAccounts } from './useAccounts'; + +export function useBudgetedAccounts() { + const accounts = useAccounts(); + return useMemo( + () => + accounts.filter( + account => account.closed === 0 && account.offbudget === 0, + ), + [accounts], + ); +} diff --git a/packages/desktop-client/src/hooks/useCategories.ts b/packages/desktop-client/src/hooks/useCategories.ts index 8e273091541..4c85fdfaff2 100644 --- a/packages/desktop-client/src/hooks/useCategories.ts +++ b/packages/desktop-client/src/hooks/useCategories.ts @@ -1,25 +1,20 @@ import { useEffect } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type QueriesState } from 'loot-core/client/state-types/queries'; - -import { useActions } from './useActions'; +import { getCategories } from 'loot-core/src/client/actions'; +import { type State } from 'loot-core/src/client/state-types'; export function useCategories() { - const { getCategories } = useActions(); - - const categories = useSelector( - state => state.queries.categories.list, + const dispatch = useDispatch(); + const categoriesLoaded = useSelector( + (state: State) => state.queries.categoriesLoaded, ); useEffect(() => { - if (categories.length === 0) { - getCategories(); + if (!categoriesLoaded) { + dispatch(getCategories()); } }, []); - return useSelector( - state => state.queries.categories, - ); + return useSelector(state => state.queries.categories); } diff --git a/packages/desktop-client/src/hooks/useClosedAccounts.ts b/packages/desktop-client/src/hooks/useClosedAccounts.ts new file mode 100644 index 00000000000..85aa5b92040 --- /dev/null +++ b/packages/desktop-client/src/hooks/useClosedAccounts.ts @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; + +import { useAccounts } from './useAccounts'; + +export function useClosedAccounts() { + const accounts = useAccounts(); + return useMemo( + () => accounts.filter(account => account.closed === 1), + [accounts], + ); +} diff --git a/packages/desktop-client/src/hooks/useDateFormat.ts b/packages/desktop-client/src/hooks/useDateFormat.ts new file mode 100644 index 00000000000..258f156e1fc --- /dev/null +++ b/packages/desktop-client/src/hooks/useDateFormat.ts @@ -0,0 +1,7 @@ +import { useSelector } from 'react-redux'; + +import { type State } from 'loot-core/src/client/state-types'; + +export function useDateFormat() { + return useSelector((state: State) => state.prefs.local?.dateFormat); +} diff --git a/packages/desktop-client/src/hooks/useFailedAccounts.ts b/packages/desktop-client/src/hooks/useFailedAccounts.ts new file mode 100644 index 00000000000..86aeb89959d --- /dev/null +++ b/packages/desktop-client/src/hooks/useFailedAccounts.ts @@ -0,0 +1,7 @@ +import { useSelector } from 'react-redux'; + +import { type State } from 'loot-core/src/client/state-types'; + +export function useFailedAccounts() { + return useSelector((state: State) => state.account.failedAccounts); +} diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts index 40b79b84dba..5550cfed2cd 100644 --- a/packages/desktop-client/src/hooks/useFeatureFlag.ts +++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts @@ -1,8 +1,7 @@ // @ts-strict-ignore import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type PrefsState } from 'loot-core/client/state-types/prefs'; +import { type State } from 'loot-core/src/client/state-types'; import type { FeatureFlag } from 'loot-core/src/types/prefs'; const DEFAULT_FEATURE_FLAG_STATE: Record = { @@ -15,13 +14,11 @@ const DEFAULT_FEATURE_FLAG_STATE: Record = { }; export function useFeatureFlag(name: FeatureFlag): boolean { - return useSelector( - state => { - const value = state.prefs.local[`flags.${name}`]; + return useSelector((state: State) => { + const value = state.prefs.local[`flags.${name}`]; - return value === undefined - ? DEFAULT_FEATURE_FLAG_STATE[name] || false - : value; - }, - ); + return value === undefined + ? DEFAULT_FEATURE_FLAG_STATE[name] || false + : value; + }); } diff --git a/packages/desktop-client/src/hooks/useGlobalPref.ts b/packages/desktop-client/src/hooks/useGlobalPref.ts new file mode 100644 index 00000000000..02d5773ac62 --- /dev/null +++ b/packages/desktop-client/src/hooks/useGlobalPref.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { saveGlobalPrefs } from 'loot-core/src/client/actions'; +import { type State } from 'loot-core/src/client/state-types'; +import { type GlobalPrefs } from 'loot-core/src/types/prefs'; + +type SetGlobalPrefAction = ( + value: GlobalPrefs[K], +) => void; + +export function useGlobalPref( + prefName: K, +): [GlobalPrefs[K], SetGlobalPrefAction] { + const dispatch = useDispatch(); + const setGlobalPref = useCallback>( + value => { + dispatch(saveGlobalPrefs({ [prefName]: value } as GlobalPrefs)); + }, + [prefName, dispatch], + ); + const globalPref = useSelector( + (state: State) => state.prefs.global?.[prefName] as GlobalPrefs[K], + ); + + return [globalPref, setGlobalPref]; +} diff --git a/packages/desktop-client/src/hooks/useLocalPref.ts b/packages/desktop-client/src/hooks/useLocalPref.ts new file mode 100644 index 00000000000..70a50cb5545 --- /dev/null +++ b/packages/desktop-client/src/hooks/useLocalPref.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { savePrefs } from 'loot-core/src/client/actions'; +import { type State } from 'loot-core/src/client/state-types'; +import { type LocalPrefs } from 'loot-core/src/types/prefs'; + +type SetLocalPrefAction = ( + value: LocalPrefs[K], +) => void; + +export function useLocalPref( + prefName: K, +): [LocalPrefs[K], SetLocalPrefAction] { + const dispatch = useDispatch(); + const setLocalPref = useCallback>( + value => { + dispatch(savePrefs({ [prefName]: value } as LocalPrefs)); + }, + [prefName, dispatch], + ); + const localPref = useSelector( + (state: State) => state.prefs.local?.[prefName] as LocalPrefs[K], + ); + + return [localPref, setLocalPref]; +} diff --git a/packages/desktop-client/src/hooks/useLocalPrefs.ts b/packages/desktop-client/src/hooks/useLocalPrefs.ts new file mode 100644 index 00000000000..870bef808e3 --- /dev/null +++ b/packages/desktop-client/src/hooks/useLocalPrefs.ts @@ -0,0 +1,7 @@ +import { useSelector } from 'react-redux'; + +import { type State } from 'loot-core/src/client/state-types'; + +export function useLocalPrefs() { + return useSelector((state: State) => state.prefs.local); +} diff --git a/packages/desktop-client/src/hooks/useOffBudgetAccounts.ts b/packages/desktop-client/src/hooks/useOffBudgetAccounts.ts new file mode 100644 index 00000000000..71a5db919bf --- /dev/null +++ b/packages/desktop-client/src/hooks/useOffBudgetAccounts.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; + +import { useAccounts } from './useAccounts'; + +export function useOffBudgetAccounts() { + const accounts = useAccounts(); + return useMemo( + () => + accounts.filter( + account => account.closed === 0 && account.offbudget === 1, + ), + [accounts], + ); +} diff --git a/packages/desktop-client/src/hooks/usePayee.ts b/packages/desktop-client/src/hooks/usePayee.ts new file mode 100644 index 00000000000..2606c60a875 --- /dev/null +++ b/packages/desktop-client/src/hooks/usePayee.ts @@ -0,0 +1,8 @@ +import { useMemo } from 'react'; + +import { usePayees } from './usePayees'; + +export function usePayee(id: string) { + const payees = usePayees(); + return useMemo(() => payees.find(p => p.id === id), [id, payees]); +} diff --git a/packages/desktop-client/src/hooks/usePayees.ts b/packages/desktop-client/src/hooks/usePayees.ts new file mode 100644 index 00000000000..cf51d6b9a7f --- /dev/null +++ b/packages/desktop-client/src/hooks/usePayees.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { getPayees } from 'loot-core/src/client/actions'; +import { type State } from 'loot-core/src/client/state-types'; + +export function usePayees() { + const dispatch = useDispatch(); + const payeesLoaded = useSelector( + (state: State) => state.queries.payeesLoaded, + ); + + useEffect(() => { + if (!payeesLoaded) { + dispatch(getPayees()); + } + }, []); + + return useSelector(state => state.queries.payees); +} diff --git a/packages/desktop-client/src/hooks/usePrivacyMode.ts b/packages/desktop-client/src/hooks/usePrivacyMode.ts new file mode 100644 index 00000000000..ffa633bc04f --- /dev/null +++ b/packages/desktop-client/src/hooks/usePrivacyMode.ts @@ -0,0 +1,9 @@ +import { useSelector } from 'react-redux'; + +import { type State } from 'loot-core/src/client/state-types'; + +export function usePrivacyMode() { + return useSelector( + (state: State) => state.prefs.local?.isPrivacyEnabled ?? false, + ); +} diff --git a/packages/desktop-client/src/hooks/useSelected.tsx b/packages/desktop-client/src/hooks/useSelected.tsx index 398350e0dd1..63a0437a815 100644 --- a/packages/desktop-client/src/hooks/useSelected.tsx +++ b/packages/desktop-client/src/hooks/useSelected.tsx @@ -12,8 +12,7 @@ import React, { } from 'react'; import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type AppState } from 'loot-core/client/state-types/app'; +import { type State } from 'loot-core/src/client/state-types'; import { listen } from 'loot-core/src/platform/client/fetch'; import * as undo from 'loot-core/src/platform/client/undo'; import { type UndoState } from 'loot-core/src/server/undo'; @@ -210,9 +209,7 @@ export function useSelected( return () => undo.setUndoState('selectedItems', prevState); }, [state.selectedItems]); - const lastUndoState = useSelector( - state => state.app.lastUndoState, - ); + const lastUndoState = useSelector((state: State) => state.app.lastUndoState); useEffect(() => { function onUndo({ messages, undoTag }: UndoState) { diff --git a/packages/desktop-client/src/hooks/useSyncServerStatus.ts b/packages/desktop-client/src/hooks/useSyncServerStatus.ts index 2b48a0c6f57..d788340bee9 100644 --- a/packages/desktop-client/src/hooks/useSyncServerStatus.ts +++ b/packages/desktop-client/src/hooks/useSyncServerStatus.ts @@ -1,7 +1,6 @@ import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type UserState } from 'loot-core/client/state-types/user'; +import { type State } from 'loot-core/src/client/state-types'; import { useServerURL } from '../components/ServerContext'; @@ -9,9 +8,7 @@ export type SyncServerStatus = 'offline' | 'no-server' | 'online'; export function useSyncServerStatus(): SyncServerStatus { const serverUrl = useServerURL(); - const userData = useSelector( - state => state.user.data, - ); + const userData = useSelector((state: State) => state.user.data); if (!serverUrl) { return 'no-server'; diff --git a/packages/desktop-client/src/hooks/useUpdatedAccounts.ts b/packages/desktop-client/src/hooks/useUpdatedAccounts.ts new file mode 100644 index 00000000000..483d22c9b5d --- /dev/null +++ b/packages/desktop-client/src/hooks/useUpdatedAccounts.ts @@ -0,0 +1,7 @@ +import { useSelector } from 'react-redux'; + +import { type State } from 'loot-core/src/client/state-types'; + +export function useUpdatedAccounts() { + return useSelector((state: State) => state.queries.updatedAccounts); +} diff --git a/packages/desktop-client/src/style/theme.tsx b/packages/desktop-client/src/style/theme.tsx index 7cd3b5751fd..fe6b2374d14 100644 --- a/packages/desktop-client/src/style/theme.tsx +++ b/packages/desktop-client/src/style/theme.tsx @@ -1,12 +1,11 @@ // @ts-strict-ignore import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { type State } from 'loot-core/client/state-types'; -import { type PrefsState } from 'loot-core/client/state-types/prefs'; import { isNonProductionEnvironment } from 'loot-core/src/shared/environment'; import type { Theme } from 'loot-core/src/types/prefs'; +import { useGlobalPref } from '../hooks/useGlobalPref'; + import * as darkTheme from './themes/dark'; import * as developmentTheme from './themes/development'; import * as lightTheme from './themes/light'; @@ -24,16 +23,13 @@ export const themeOptions = Object.entries(themes).map( ([key, { name }]) => [key, name] as [Theme, string], ); -export function useTheme(): Theme { - return ( - useSelector( - state => state.prefs.global?.theme, - ) || 'light' - ); +export function useTheme() { + const [theme = 'light', setThemePref] = useGlobalPref('theme'); + return [theme, setThemePref] as const; } export function ThemeStyle() { - const theme = useTheme(); + const [theme] = useTheme(); const [themeColors, setThemeColors] = useState< typeof lightTheme | typeof darkTheme | undefined >(undefined); diff --git a/packages/loot-core/src/client/actions/account.ts b/packages/loot-core/src/client/actions/account.ts index c8349d84c57..e7f7f269b4b 100644 --- a/packages/loot-core/src/client/actions/account.ts +++ b/packages/loot-core/src/client/actions/account.ts @@ -263,3 +263,10 @@ export function markAccountRead(accountId): MarkAccountReadAction { accountId, }; } + +export function moveAccount(id, targetId) { + return async (dispatch: Dispatch) => { + await send('account-move', { id, targetId }); + dispatch(getAccounts()); + }; +} diff --git a/packages/loot-core/src/client/actions/prefs.ts b/packages/loot-core/src/client/actions/prefs.ts index b93a38eb389..5dbca103f50 100644 --- a/packages/loot-core/src/client/actions/prefs.ts +++ b/packages/loot-core/src/client/actions/prefs.ts @@ -26,7 +26,7 @@ export function loadPrefs() { }; } -export function savePrefs(prefs: Partial) { +export function savePrefs(prefs: prefs.LocalPrefs) { return async (dispatch: Dispatch) => { await send('save-prefs', prefs); dispatch({ @@ -48,7 +48,7 @@ export function loadGlobalPrefs() { }; } -export function saveGlobalPrefs(prefs: Partial) { +export function saveGlobalPrefs(prefs: prefs.GlobalPrefs) { return async (dispatch: Dispatch) => { await send('save-global-prefs', prefs); dispatch({ diff --git a/packages/loot-core/src/client/data-hooks/accounts.tsx b/packages/loot-core/src/client/data-hooks/accounts.tsx deleted file mode 100644 index c2460477fa6..00000000000 --- a/packages/loot-core/src/client/data-hooks/accounts.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-strict-ignore -import React, { createContext, useContext } from 'react'; - -import { q } from '../../shared/query'; -import { type AccountEntity } from '../../types/models'; -import { useLiveQuery } from '../query-hooks'; -import { getAccountsById } from '../reducers/queries'; - -function useAccounts(): AccountEntity[] { - return useLiveQuery(() => q('accounts').select('*'), []); -} - -const AccountsContext = createContext(null); - -export function AccountsProvider({ children }) { - const data = useAccounts(); - return ( - {children} - ); -} - -export function CachedAccounts({ children, idKey }) { - const data = useCachedAccounts({ idKey }); - return children(data); -} - -export function useCachedAccounts(): AccountEntity[]; -export function useCachedAccounts({ - idKey, -}: { - idKey: boolean; -}): Record; -export function useCachedAccounts({ idKey }: { idKey?: boolean } = {}) { - const data = useContext(AccountsContext); - return idKey && data ? getAccountsById(data) : data; -} diff --git a/packages/loot-core/src/client/data-hooks/payees.tsx b/packages/loot-core/src/client/data-hooks/payees.tsx deleted file mode 100644 index 4299aa9a31f..00000000000 --- a/packages/loot-core/src/client/data-hooks/payees.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-strict-ignore -import React, { createContext, useContext } from 'react'; - -import { q } from '../../shared/query'; -import { type PayeeEntity } from '../../types/models'; -import { useLiveQuery } from '../query-hooks'; -import { getPayeesById } from '../reducers/queries'; - -function usePayees(): PayeeEntity[] { - return useLiveQuery(() => q('payees').select('*'), []); -} - -const PayeesContext = createContext(null); - -export function PayeesProvider({ children }) { - const data = usePayees(); - return ( - {children} - ); -} - -export function CachedPayees({ children, idKey }) { - const data = useCachedPayees({ idKey }); - return children(data); -} - -export function useCachedPayees(): PayeeEntity[]; -export function useCachedPayees({ - idKey, -}: { - idKey: boolean; -}): Record; -export function useCachedPayees({ idKey }: { idKey?: boolean } = {}) { - const data = useContext(PayeesContext); - return idKey && data ? getPayeesById(data) : data; -} diff --git a/packages/loot-core/src/client/privacy.ts b/packages/loot-core/src/client/privacy.ts deleted file mode 100644 index 295bba5e510..00000000000 --- a/packages/loot-core/src/client/privacy.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useSelector } from 'react-redux'; - -export function usePrivacyMode() { - return useSelector(state => state.prefs?.local?.isPrivacyEnabled ?? false); -} diff --git a/packages/loot-core/src/client/reducers/queries.ts b/packages/loot-core/src/client/reducers/queries.ts index 470c3591692..b0c38817a24 100644 --- a/packages/loot-core/src/client/reducers/queries.ts +++ b/packages/loot-core/src/client/reducers/queries.ts @@ -13,11 +13,14 @@ const initialState: QueriesState = { lastTransaction: null, updatedAccounts: [], accounts: [], + accountsLoaded: false, categories: { grouped: [], list: [], }, + categoriesLoaded: false, payees: [], + payeesLoaded: false, earliestTransaction: null, }; @@ -56,6 +59,7 @@ export function update(state = initialState, action: Action): QueriesState { return { ...state, accounts: action.accounts, + accountsLoaded: true, }; case constants.UPDATE_ACCOUNT: { return { @@ -72,11 +76,13 @@ export function update(state = initialState, action: Action): QueriesState { return { ...state, categories: action.categories, + categoriesLoaded: true, }; case constants.LOAD_PAYEES: return { ...state, payees: action.payees, + payeesLoaded: true, }; default: } 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 e25e38feb32..72cd5dbc54b 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -42,7 +42,7 @@ type FinanceModals = { syncSource?: AccountSyncSource; }; - 'confirm-category-delete': { onDelete: () => void } & ( + 'confirm-category-delete': { onDelete: (categoryId: string) => void } & ( | { category: string } | { group: string } ); diff --git a/packages/loot-core/src/client/state-types/queries.d.ts b/packages/loot-core/src/client/state-types/queries.d.ts index a7f18526fe0..0d511868b39 100644 --- a/packages/loot-core/src/client/state-types/queries.d.ts +++ b/packages/loot-core/src/client/state-types/queries.d.ts @@ -8,8 +8,11 @@ export type QueriesState = { lastTransaction: unknown | null; updatedAccounts: string[]; accounts: AccountEntity[]; + accountsLoaded: boolean; categories: Awaited>; + categoriesLoaded: boolean; payees: Awaited>; + payeesLoaded: boolean; earliestTransaction: unknown | null; }; diff --git a/packages/loot-core/src/shared/categories.ts b/packages/loot-core/src/shared/categories.ts deleted file mode 100644 index 9e2c4c190aa..00000000000 --- a/packages/loot-core/src/shared/categories.ts +++ /dev/null @@ -1,106 +0,0 @@ -// @ts-strict-ignore -export function addCategory(categoryGroups, cat) { - return categoryGroups.map(group => { - if (group.id === cat.cat_group) { - group.categories = [cat, ...group.categories]; - } - return { ...group }; - }); -} - -export function updateCategory(categoryGroups, category) { - return categoryGroups.map(group => { - if (group.id === category.cat_group) { - group.categories = group.categories.map(c => { - if (c.id === category.id) { - return { ...c, ...category }; - } - return c; - }); - } - return group; - }); -} - -export function moveCategory(categoryGroups, id, groupId, targetId) { - if (id === targetId) { - return categoryGroups; - } - - let moveCat = categoryGroups.reduce((value, group) => { - return value || group.categories.find(cat => cat.id === id); - }, null); - - // Update the group id on the category - moveCat = { ...moveCat, cat_group: groupId }; - - return categoryGroups.map(group => { - if (group.id === groupId) { - group.categories = group.categories.reduce((cats, cat) => { - if (cat.id === targetId) { - cats.push(moveCat); - cats.push(cat); - } else if (cat.id !== id) { - cats.push(cat); - } - return cats; - }, []); - - if (!targetId) { - group.categories.push(moveCat); - } - } else { - group.categories = group.categories.filter(cat => cat.id !== id); - } - - return { ...group }; - }); -} - -export function moveCategoryGroup(categoryGroups, id, targetId) { - if (id === targetId) { - return categoryGroups; - } - - const moveGroup = categoryGroups.find(g => g.id === id); - - categoryGroups = categoryGroups.reduce((groups, group) => { - if (group.id === targetId) { - groups.push(moveGroup); - groups.push(group); - } else if (group.id !== id) { - groups.push(group); - } - return groups; - }, []); - - if (!targetId) { - categoryGroups.push(moveGroup); - } - - return categoryGroups; -} - -export function deleteCategory(categoryGroups, id) { - return categoryGroups.map(group => { - group.categories = group.categories.filter(c => c.id !== id); - return group; - }); -} - -export function addGroup(categoryGroups, group) { - return [...categoryGroups, group]; -} - -export function updateGroup(categoryGroups, group) { - return categoryGroups.map(g => { - if (g.id === group.id) { - return { ...g, ...group }; - } - return g; - }); -} - -export function deleteGroup(categoryGroups, id) { - return categoryGroups.filter(g => g.id !== id); -} diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index df63517dd8d..251b6e5dd6a 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -53,6 +53,7 @@ export type LocalPrefs = Partial< reportsViewLegend: boolean; reportsViewSummary: boolean; reportsViewLabel: boolean; + 'mobile.showSpentColumn': boolean; } & Record<`flags.${FeatureFlag}`, boolean> >; diff --git a/upcoming-release-notes/2293.md b/upcoming-release-notes/2293.md new file mode 100644 index 00000000000..d65818af8a0 --- /dev/null +++ b/upcoming-release-notes/2293.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Add hooks for frequently-made operations in the codebase.