Compare commits

...

32 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
6fa4d673cf Fix refetch happening when switching budget files 2026-02-23 23:26:25 +00:00
Joel Jeremy Marquez
7995d659ab Use budgetId metadata pref to check if a budget has been loaded or not instead of synced prefs 2026-02-23 19:51:24 +00:00
Joel Jeremy Marquez
52c4586051 Update useSyncedPrefs 2026-02-23 19:50:58 +00:00
Joel Jeremy Marquez
a6873cd5c7 Update pref hooks 2026-02-23 19:00:35 +00:00
Joel Jeremy Marquez
6001c37285 Merge remote-tracking branch 'origin/master' into react-query-prefs 2026-02-23 18:54:08 +00:00
Joel Jeremy Marquez
82743c6f90 Remove refetchType 2026-02-18 21:54:02 +00:00
Joel Jeremy Marquez
aca0293750 Remove unused import 2026-02-18 21:47:54 +00:00
autofix-ci[bot]
f24a9023c5 [autofix.ci] apply automated fixes 2026-02-18 21:38:20 +00:00
Joel Jeremy Marquez
d0a653cdae Cleanup 2026-02-18 21:35:49 +00:00
Joel Jeremy Marquez
158c79281d Revert unrelated changes 2026-02-18 21:31:32 +00:00
Joel Jeremy Marquez
470fb13d37 Merge remote-tracking branch 'origin/master' into react-query-prefs 2026-02-18 21:23:45 +00:00
Joel Jeremy Marquez
e993862c5a Replace __actionsFotMenu.saveGlobalPrefs with setTheme 2026-02-18 21:20:58 +00:00
autofix-ci[bot]
329556b56d [autofix.ci] apply automated fixes 2026-02-18 15:15:19 +00:00
Joel Jeremy Marquez
635ae01ea4 Update to useAccounts 2026-02-18 15:12:53 +00:00
autofix-ci[bot]
3f35379244 [autofix.ci] apply automated fixes 2026-02-18 15:12:13 +00:00
Joel Jeremy Marquez
d21aa62186 Fix lint errors 2026-02-18 15:12:13 +00:00
Joel Jeremy Marquez
29d9507254 Fix typecheck errors 2026-02-18 15:12:13 +00:00
Joel Jeremy Marquez
5606216b0d Fix error 2026-02-18 15:12:13 +00:00
Joel Jeremy Marquez
4c7b15d1a4 Fix imports 2026-02-18 15:12:13 +00:00
Joel Jeremy Marquez
6fcf383243 Fix lint errors 2026-02-18 15:12:13 +00:00
Joel Jeremy Marquez
83fb65f413 Fix lint errors 2026-02-18 15:12:12 +00:00
Joel Jeremy Marquez
9665945a69 Move redux state to react-query - prefs states 2026-02-18 15:12:12 +00:00
Joel Jeremy Marquez
f039864a64 Merge remote-tracking branch 'origin/master' into react-query-payees 2026-02-18 13:41:47 +00:00
Joel Jeremy Marquez
1bdb052107 Cleanup and simplify AccountEntity definition to fix satisfies syntax 2026-02-17 17:43:52 +00:00
Joel Jeremy Marquez
696d1df508 Update empty payees list test 2026-02-17 17:13:21 +00:00
Joel Jeremy Marquez
8d4086ad75 Fix imports 2026-02-17 16:33:31 +00:00
Copilot
fedef15cf2 Address feedback on adding default data to usePayees (#6931)
* Initial plan

* Add default data to usePayees usages using inline destructuring

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>
2026-02-17 16:33:31 +00:00
Joel Jeremy Marquez
5ae1f539c4 Replace usage of logger in desktop-client with console 2026-02-17 16:33:31 +00:00
github-actions[bot]
40d45a5c9e Add release notes for PR #6880 2026-02-17 16:33:31 +00:00
Joel Jeremy Marquez
5263424a77 Move redux state to react-query - payees states 2026-02-17 16:33:31 +00:00
Joel Jeremy Marquez
757c93afe2 Fix onbudget and offbudget displaying closed accounts 2026-02-17 16:31:38 +00:00
Joel Jeremy Marquez
6ca922f4a4 Move redux state to react-query - account states 2026-02-17 16:27:40 +00:00
25 changed files with 575 additions and 302 deletions

View File

@@ -6,7 +6,7 @@ import { getUploadError } from 'loot-core/shared/errors';
import type { AtLeastOne } from 'loot-core/types/util'; import type { AtLeastOne } from 'loot-core/types/util';
import { pushModal } from '@desktop-client/modals/modalsSlice'; import { pushModal } from '@desktop-client/modals/modalsSlice';
import { loadPrefs } from '@desktop-client/prefs/prefsSlice'; import { prefQueries } from '@desktop-client/prefs';
import { createAppAsyncThunk } from '@desktop-client/redux'; import { createAppAsyncThunk } from '@desktop-client/redux';
import { getIsOutdated, getLatestVersion } from '@desktop-client/util/versions'; import { getIsOutdated, getLatestVersion } from '@desktop-client/util/versions';
@@ -89,8 +89,8 @@ export const resetSync = createAppAsyncThunk(
export const sync = createAppAsyncThunk( export const sync = createAppAsyncThunk(
`${sliceName}/sync`, `${sliceName}/sync`,
async (_, { dispatch, getState }) => { async (_, { extra: { queryClient } }) => {
const prefs = getState().prefs.local; const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
if (prefs && prefs.id) { if (prefs && prefs.id) {
const result = await send('sync'); const result = await send('sync');
if (result && 'error' in result) { if (result && 'error' in result) {
@@ -98,7 +98,9 @@ export const sync = createAppAsyncThunk(
} }
// Update the prefs // Update the prefs
await dispatch(loadPrefs()); queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
} }
return {}; return {};
@@ -107,8 +109,10 @@ export const sync = createAppAsyncThunk(
export const getLatestAppVersion = createAppAsyncThunk( export const getLatestAppVersion = createAppAsyncThunk(
`${sliceName}/getLatestAppVersion`, `${sliceName}/getLatestAppVersion`,
async (_, { dispatch, getState }) => { async (_, { dispatch, extra: { queryClient } }) => {
const globalPrefs = getState().prefs.global; const globalPrefs = await queryClient.ensureQueryData(
prefQueries.listGlobal(),
);
if (globalPrefs && globalPrefs.notifyWhenUpdateIsAvailable) { if (globalPrefs && globalPrefs.notifyWhenUpdateIsAvailable) {
const theLatestVersion = await getLatestVersion(); const theLatestVersion = await getLatestVersion();
dispatch( dispatch(

View File

@@ -219,8 +219,8 @@ global.Actual = {
return worker; return worker;
}, },
setTheme: theme => { setTheme: async theme => {
window.__actionsForMenu.saveGlobalPrefs({ prefs: { theme } }); await window.__actionsForMenu.setTheme(theme);
}, },
moveBudgetDirectory: () => { moveBudgetDirectory: () => {

View File

@@ -1,5 +1,6 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { type QueryClient } from '@tanstack/react-query';
import { t } from 'i18next'; import { t } from 'i18next';
import { send } from 'loot-core/platform/client/connection'; import { send } from 'loot-core/platform/client/connection';
@@ -11,7 +12,7 @@ import type { Handlers } from 'loot-core/types/handlers';
import { resetApp, setAppState } from '@desktop-client/app/appSlice'; import { resetApp, setAppState } from '@desktop-client/app/appSlice';
import { closeModal, pushModal } from '@desktop-client/modals/modalsSlice'; import { closeModal, pushModal } from '@desktop-client/modals/modalsSlice';
import { loadGlobalPrefs, loadPrefs } from '@desktop-client/prefs/prefsSlice'; import { prefQueries } from '@desktop-client/prefs';
import { createAppAsyncThunk } from '@desktop-client/redux'; import { createAppAsyncThunk } from '@desktop-client/redux';
import { signOut } from '@desktop-client/users/usersSlice'; import { signOut } from '@desktop-client/users/usersSlice';
@@ -55,7 +56,7 @@ type LoadBudgetPayload = {
export const loadBudget = createAppAsyncThunk( export const loadBudget = createAppAsyncThunk(
`${sliceName}/loadBudget`, `${sliceName}/loadBudget`,
async ({ id, options = {} }: LoadBudgetPayload, { dispatch }) => { async ({ id, options = {} }: LoadBudgetPayload, { dispatch, extra }) => {
dispatch(setAppState({ loadingText: t('Loading...') })); dispatch(setAppState({ loadingText: t('Loading...') }));
// Loading a budget may fail // Loading a budget may fail
@@ -89,23 +90,43 @@ export const loadBudget = createAppAsyncThunk(
} }
} else { } else {
dispatch(closeModal()); dispatch(closeModal());
await dispatch(loadPrefs()); extra.queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
} }
dispatch(setAppState({ loadingText: null })); dispatch(setAppState({ loadingText: null }));
}, },
); );
function invalidateClosedBudgetQueries(queryClient: QueryClient) {
// Invalidate all queries but do not cause a refetch.
// This is because we want to clear out all the budget data from the queries,
// but we don't want to trigger a bunch of error states from the queries trying
// to fetch data for a budget that is now closed. The next time a budget is loaded,
// the queries will refetch with the correct budget id.
queryClient.invalidateQueries({
refetchType: 'none',
});
// Invalidate the metadata query since the budget is now closed.
// We want to cause a refetch so that the app can update to show the correct state
// (e.g. show the manager page if no budget is open).
queryClient.invalidateQueries({
queryKey: prefQueries.listMetadata().queryKey,
});
}
export const closeBudget = createAppAsyncThunk( export const closeBudget = createAppAsyncThunk(
`${sliceName}/closeBudget`, `${sliceName}/closeBudget`,
async (_, { dispatch, getState, extra: { queryClient } }) => { async (_, { dispatch, extra: { queryClient } }) => {
const prefs = getState().prefs.local; const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
if (prefs && prefs.id) { if (prefs && prefs.id) {
dispatch(resetApp()); dispatch(resetApp());
queryClient.clear();
dispatch(setAppState({ loadingText: t('Closing...') })); dispatch(setAppState({ loadingText: t('Closing...') }));
await send('close-budget'); await send('close-budget');
dispatch(setAppState({ loadingText: null })); dispatch(setAppState({ loadingText: null }));
invalidateClosedBudgetQueries(queryClient);
if (localStorage.getItem('SharedArrayBufferOverride')) { if (localStorage.getItem('SharedArrayBufferOverride')) {
window.location.reload(); window.location.reload();
} }
@@ -115,11 +136,11 @@ export const closeBudget = createAppAsyncThunk(
export const closeBudgetUI = createAppAsyncThunk( export const closeBudgetUI = createAppAsyncThunk(
`${sliceName}/closeBudgetUI`, `${sliceName}/closeBudgetUI`,
async (_, { dispatch, getState, extra: { queryClient } }) => { async (_, { dispatch, extra: { queryClient } }) => {
const prefs = getState().prefs.local; const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
if (prefs && prefs.id) { if (prefs && prefs.id) {
dispatch(resetApp()); dispatch(resetApp());
queryClient.clear(); invalidateClosedBudgetQueries(queryClient);
} }
}, },
); );
@@ -146,7 +167,7 @@ export const createBudget = createAppAsyncThunk(
`${sliceName}/createBudget`, `${sliceName}/createBudget`,
async ( async (
{ testMode = false, demoMode = false }: CreateBudgetPayload, { testMode = false, demoMode = false }: CreateBudgetPayload,
{ dispatch }, { dispatch, extra: { queryClient } },
) => { ) => {
dispatch( dispatch(
setAppState({ setAppState({
@@ -164,7 +185,9 @@ export const createBudget = createAppAsyncThunk(
dispatch(closeModal()); dispatch(closeModal());
await dispatch(loadAllFiles()); await dispatch(loadAllFiles());
await dispatch(loadPrefs()); queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
// Set the loadingText to null after we've loaded the budget prefs // Set the loadingText to null after we've loaded the budget prefs
// so that the existing manager page doesn't flash // so that the existing manager page doesn't flash
@@ -243,14 +266,19 @@ type ImportBudgetPayload = {
export const importBudget = createAppAsyncThunk( export const importBudget = createAppAsyncThunk(
`${sliceName}/importBudget`, `${sliceName}/importBudget`,
async ({ filepath, type }: ImportBudgetPayload, { dispatch }) => { async (
{ filepath, type }: ImportBudgetPayload,
{ dispatch, extra: { queryClient } },
) => {
const { error } = await send('import-budget', { filepath, type }); const { error } = await send('import-budget', { filepath, type });
if (error) { if (error) {
throw new Error(error); throw new Error(error);
} }
dispatch(closeModal()); dispatch(closeModal());
await dispatch(loadPrefs()); queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
}, },
); );
@@ -304,7 +332,7 @@ export const downloadBudget = createAppAsyncThunk(
`${sliceName}/downloadBudget`, `${sliceName}/downloadBudget`,
async ( async (
{ cloudFileId, replace = false }: DownloadBudgetPayload, { cloudFileId, replace = false }: DownloadBudgetPayload,
{ dispatch }, { dispatch, extra: { queryClient } },
): Promise<string | null> => { ): Promise<string | null> => {
dispatch( dispatch(
setAppState({ setAppState({
@@ -368,8 +396,10 @@ export const downloadBudget = createAppAsyncThunk(
if (!id) { if (!id) {
throw new Error('No id returned from download.'); throw new Error('No id returned from download.');
} }
queryClient.invalidateQueries({
queryKey: prefQueries.listGlobal().queryKey,
});
await Promise.all([ await Promise.all([
dispatch(loadGlobalPrefs()),
dispatch(loadAllFiles()), dispatch(loadAllFiles()),
dispatch(loadBudget({ id })), dispatch(loadBudget({ id })),
]); ]);
@@ -387,8 +417,11 @@ type LoadBackupPayload = {
// Take in the budget id so that backups can be loaded when a budget isn't opened // Take in the budget id so that backups can be loaded when a budget isn't opened
export const loadBackup = createAppAsyncThunk( export const loadBackup = createAppAsyncThunk(
`${sliceName}/loadBackup`, `${sliceName}/loadBackup`,
async ({ budgetId, backupId }: LoadBackupPayload, { dispatch, getState }) => { async (
const prefs = getState().prefs.local; { budgetId, backupId }: LoadBackupPayload,
{ dispatch, extra: { queryClient } },
) => {
const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
if (prefs && prefs.id) { if (prefs && prefs.id) {
await dispatch(closeBudget()); await dispatch(closeBudget());
} }
@@ -400,8 +433,8 @@ export const loadBackup = createAppAsyncThunk(
export const makeBackup = createAppAsyncThunk( export const makeBackup = createAppAsyncThunk(
`${sliceName}/makeBackup`, `${sliceName}/makeBackup`,
async (_, { getState }) => { async (_, { extra: { queryClient } }) => {
const prefs = getState().prefs.local; const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
if (prefs && prefs.id) { if (prefs && prefs.id) {
await send('backup-make', { id: prefs.id }); await send('backup-make', { id: prefs.id });
} }

View File

@@ -38,10 +38,9 @@ import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
import { setI18NextLanguage } from '@desktop-client/i18n'; import { setI18NextLanguage } from '@desktop-client/i18n';
import { addNotification } from '@desktop-client/notifications/notificationsSlice'; import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { installPolyfills } from '@desktop-client/polyfills'; import { installPolyfills } from '@desktop-client/polyfills';
import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice'; import { prefQueries } from '@desktop-client/prefs';
import { useDispatch, useSelector, useStore } from '@desktop-client/redux'; import { useDispatch, useSelector, useStore } from '@desktop-client/redux';
import { import {
CustomThemeStyle,
hasHiddenScrollbars, hasHiddenScrollbars,
ThemeStyle, ThemeStyle,
useTheme, useTheme,
@@ -61,6 +60,8 @@ function AppInner() {
setI18NextLanguage(null); setI18NextLanguage(null);
}, []); }, []);
const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
const maybeUpdate = async <T,>(cb?: () => T): Promise<T | void> => { const maybeUpdate = async <T,>(cb?: () => T): Promise<T | void> => {
if (global.Actual.isUpdateReadyForDownload()) { if (global.Actual.isUpdateReadyForDownload()) {
@@ -91,7 +92,7 @@ function AppInner() {
loadingText: t('Loading global preferences...'), loadingText: t('Loading global preferences...'),
}), }),
); );
await dispatch(loadGlobalPrefs()); void queryClient.prefetchQuery(prefQueries.listGlobal());
// Open the last opened budget, if any // Open the last opened budget, if any
dispatch( dispatch(
@@ -239,7 +240,6 @@ export function App() {
<AppInner /> <AppInner />
</ErrorBoundary> </ErrorBoundary>
<ThemeStyle /> <ThemeStyle />
<CustomThemeStyle />
<ErrorBoundary FallbackComponent={FatalError}> <ErrorBoundary FallbackComponent={FatalError}>
<Modals /> <Modals />
</ErrorBoundary> </ErrorBoundary>

View File

@@ -6,7 +6,7 @@ import { Navigate, Route, Routes, useHref, useLocation } from 'react-router';
import { useResponsive } from '@actual-app/components/hooks/useResponsive'; import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { theme } from '@actual-app/components/theme'; import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view'; import { View } from '@actual-app/components/view';
import { useQuery } from '@tanstack/react-query'; import { usePrefetchQuery } from '@tanstack/react-query';
import * as undo from 'loot-core/platform/client/undo'; import * as undo from 'loot-core/platform/client/undo';
@@ -28,17 +28,19 @@ import { FloatableSidebar } from './sidebar';
import { ManageTagsPage } from './tags/ManageTagsPage'; import { ManageTagsPage } from './tags/ManageTagsPage';
import { Titlebar } from './Titlebar'; import { Titlebar } from './Titlebar';
import { accountQueries } from '@desktop-client/accounts';
import { getLatestAppVersion, sync } from '@desktop-client/app/appSlice'; import { getLatestAppVersion, sync } from '@desktop-client/app/appSlice';
import { ProtectedRoute } from '@desktop-client/auth/ProtectedRoute'; import { ProtectedRoute } from '@desktop-client/auth/ProtectedRoute';
import { Permissions } from '@desktop-client/auth/types'; import { Permissions } from '@desktop-client/auth/types';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor'; import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
import { useNavigate } from '@desktop-client/hooks/useNavigate'; import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { ScrollProvider } from '@desktop-client/hooks/useScrollListener'; import { ScrollProvider } from '@desktop-client/hooks/useScrollListener';
import { addNotification } from '@desktop-client/notifications/notificationsSlice'; import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { prefQueries } from '@desktop-client/prefs';
import { useDispatch, useSelector } from '@desktop-client/redux'; import { useDispatch, useSelector } from '@desktop-client/redux';
import { CustomThemeStyle } from '@desktop-client/style';
function NarrowNotSupported({ function NarrowNotSupported({
redirectTo = '/budget', redirectTo = '/budget',
@@ -91,10 +93,7 @@ export function FinancesApp() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
// TODO: Replace with `useAccounts` hook once it's updated to return the useQuery results. const { data: accounts, isFetching: isAccountsFetching } = useAccounts();
const { data: accounts, isFetching: isAccountsFetching } = useQuery(
accountQueries.list(),
);
const versionInfo = useSelector(state => state.app.versionInfo); const versionInfo = useSelector(state => state.app.versionInfo);
const [notifyWhenUpdateIsAvailable] = useGlobalPref( const [notifyWhenUpdateIsAvailable] = useGlobalPref(
@@ -198,6 +197,7 @@ export function FinancesApp() {
<RouterBehaviors /> <RouterBehaviors />
<GlobalKeys /> <GlobalKeys />
<CommandBar /> <CommandBar />
<CustomThemeStyle />
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',

View File

@@ -10,6 +10,7 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text'; import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme'; import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view'; import { View } from '@actual-app/components/view';
import { useQuery } from '@tanstack/react-query';
import { listen } from 'loot-core/platform/client/connection'; import { listen } from 'loot-core/platform/client/connection';
import type { RemoteFile, SyncedLocalFile } from 'loot-core/types/file'; import type { RemoteFile, SyncedLocalFile } from 'loot-core/types/file';
@@ -55,7 +56,6 @@ export function LoggedInUser({
f => f.state === 'remote' || f.state === 'synced' || f.state === 'detached', f => f.state === 'remote' || f.state === 'synced' || f.state === 'detached',
) as (SyncedLocalFile | RemoteFile)[]; ) as (SyncedLocalFile | RemoteFile)[];
const currentFile = remoteFiles.find(f => f.cloudFileId === cloudFileId); const currentFile = remoteFiles.find(f => f.cloudFileId === cloudFileId);
const hasSyncedPrefs = useSelector(state => state.prefs.synced);
const initializeUserData = useCallback(async () => { const initializeUserData = useCallback(async () => {
try { try {
@@ -235,7 +235,7 @@ export function LoggedInUser({
multiuserEnabled && multiuserEnabled &&
userData && userData &&
userData?.displayName && userData?.displayName &&
!hasSyncedPrefs && ( !budgetId && (
<small> <small>
( (
<Trans> <Trans>
@@ -251,7 +251,7 @@ export function LoggedInUser({
multiuserEnabled && multiuserEnabled &&
userData && userData &&
userData?.displayName && userData?.displayName &&
hasSyncedPrefs && ( budgetId && (
<small> <small>
( (
<Trans> <Trans>

View File

@@ -28,6 +28,7 @@ import {
useSaveCategoryGroupMutation, useSaveCategoryGroupMutation,
useSaveCategoryMutation, useSaveCategoryMutation,
} from '@desktop-client/budget'; } from '@desktop-client/budget';
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { useCategories } from '@desktop-client/hooks/useCategories'; import { useCategories } from '@desktop-client/hooks/useCategories';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
@@ -175,7 +176,7 @@ export function Budget() {
}; };
if (!initialized || !categoryGroups) { if (!initialized || !categoryGroups) {
return null; return <LoadingIndicator />;
} }
let table; let table;

View File

@@ -25,7 +25,7 @@ import {
} from '@desktop-client/components/ServerContext'; } from '@desktop-client/components/ServerContext';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useNavigate } from '@desktop-client/hooks/useNavigate'; import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { saveGlobalPrefs } from '@desktop-client/prefs/prefsSlice'; import { useSaveGlobalPrefsMutation } from '@desktop-client/prefs';
import { useDispatch } from '@desktop-client/redux'; import { useDispatch } from '@desktop-client/redux';
import { loggedIn, signOut } from '@desktop-client/users/usersSlice'; import { loggedIn, signOut } from '@desktop-client/users/usersSlice';
@@ -40,7 +40,6 @@ export function ElectronServerConfig({
const navigate = useNavigate(); const navigate = useNavigate();
const setServerUrl = useSetServerURL(); const setServerUrl = useSetServerURL();
const currentUrl = useServerURL(); const currentUrl = useServerURL();
const dispatch = useDispatch();
const [syncServerConfig, setSyncServerConfig] = const [syncServerConfig, setSyncServerConfig] =
useGlobalPref('syncServerConfig'); useGlobalPref('syncServerConfig');
@@ -54,6 +53,7 @@ export function ElectronServerConfig({
const hasInternalServerConfig = syncServerConfig?.port; const hasInternalServerConfig = syncServerConfig?.port;
const [startingSyncServer, setStartingSyncServer] = useState(false); const [startingSyncServer, setStartingSyncServer] = useState(false);
const saveGlobalPrefs = useSaveGlobalPrefsMutation();
const onConfigureSyncServer = async () => { const onConfigureSyncServer = async () => {
if (startingSyncServer) { if (startingSyncServer) {
@@ -73,17 +73,13 @@ export function ElectronServerConfig({
setConfigError(null); setConfigError(null);
setStartingSyncServer(true); setStartingSyncServer(true);
// Ensure config is saved before starting the server // Ensure config is saved before starting the server
await dispatch( await saveGlobalPrefs.mutateAsync({
saveGlobalPrefs({ syncServerConfig: {
prefs: { ...syncServerConfig,
syncServerConfig: { port: electronServerPort,
...syncServerConfig, autoStart: true,
port: electronServerPort, },
autoStart: true, });
},
},
}),
).unwrap();
await window.globalThis.Actual.stopSyncServer(); await window.globalThis.Actual.stopSyncServer();
await window.globalThis.Actual.startSyncServer(); await window.globalThis.Actual.startSyncServer();

View File

@@ -13,6 +13,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme'; import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view'; import { View } from '@actual-app/components/view';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useQueryClient } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection'; import { send } from 'loot-core/platform/client/connection';
import { getCreateKeyError } from 'loot-core/shared/errors'; import { getCreateKeyError } from 'loot-core/shared/errors';
@@ -27,7 +28,7 @@ import {
ModalHeader, ModalHeader,
} from '@desktop-client/components/common/Modal'; } from '@desktop-client/components/common/Modal';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice'; import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice'; import { prefQueries } from '@desktop-client/prefs';
import { useDispatch } from '@desktop-client/redux'; import { useDispatch } from '@desktop-client/redux';
type CreateEncryptionKeyModalProps = Extract< type CreateEncryptionKeyModalProps = Extract<
@@ -45,6 +46,7 @@ export function CreateEncryptionKeyModal({
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const { isNarrowWidth } = useResponsive(); const { isNarrowWidth } = useResponsive();
const dispatch = useDispatch(); const dispatch = useDispatch();
const queryClient = useQueryClient();
const isRecreating = recreate; const isRecreating = recreate;
@@ -60,7 +62,9 @@ export function CreateEncryptionKeyModal({
return; return;
} }
void dispatch(loadGlobalPrefs()); void queryClient.invalidateQueries({
queryKey: prefQueries.listGlobal().queryKey,
});
void dispatch(loadAllFiles()); void dispatch(loadAllFiles());
void dispatch(sync()); void dispatch(sync());

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect, useEffectEvent } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
@@ -11,7 +11,6 @@ import { tokens } from '@actual-app/components/tokens';
import { View } from '@actual-app/components/view'; import { View } from '@actual-app/components/view';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { listen } from 'loot-core/platform/client/connection';
import { isElectron } from 'loot-core/shared/environment'; import { isElectron } from 'loot-core/shared/environment';
import { AuthSettings } from './AuthSettings'; import { AuthSettings } from './AuthSettings';
@@ -43,7 +42,6 @@ import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref'; import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref'; import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { loadPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux'; import { useDispatch, useSelector } from '@desktop-client/redux';
function About() { function About() {
@@ -183,20 +181,17 @@ export function Settings() {
void dispatch(closeBudget()); void dispatch(closeBudget());
}; };
useEffect(() => { const onSetDefaultCurrencyCodePref = useEffectEvent(
const unlisten = listen('prefs-updated', () => { (isCurrencyExperimentalEnabled: boolean) => {
void dispatch(loadPrefs()); if (!isCurrencyExperimentalEnabled) {
}); setDefaultCurrencyCodePref('');
}
void dispatch(loadPrefs()); },
return () => unlisten(); );
}, [dispatch]);
useEffect(() => { useEffect(() => {
if (!isCurrencyExperimentalEnabled) { onSetDefaultCurrencyCodePref(isCurrencyExperimentalEnabled);
setDefaultCurrencyCodePref(''); }, [isCurrencyExperimentalEnabled]);
}
}, [isCurrencyExperimentalEnabled, setDefaultCurrencyCodePref]);
const { isNarrowWidth } = useResponsive(); const { isNarrowWidth } = useResponsive();

View File

@@ -14,7 +14,7 @@ import {
addNotification, addNotification,
} from './notifications/notificationsSlice'; } from './notifications/notificationsSlice';
import { payeeQueries } from './payees'; import { payeeQueries } from './payees';
import { loadPrefs } from './prefs/prefsSlice'; import { prefQueries } from './prefs';
import type { AppStore } from './redux/store'; import type { AppStore } from './redux/store';
import * as syncEvents from './sync-events'; import * as syncEvents from './sync-events';
@@ -141,7 +141,9 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
const unlistenFinishLoad = listen('finish-load', () => { const unlistenFinishLoad = listen('finish-load', () => {
store.dispatch(closeModal()); store.dispatch(closeModal());
store.dispatch(setAppState({ loadingText: null })); store.dispatch(setAppState({ loadingText: null }));
void store.dispatch(loadPrefs()); void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
}); });
const unlistenStartImport = listen('start-import', () => { const unlistenStartImport = listen('start-import', () => {
@@ -151,7 +153,9 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
const unlistenFinishImport = listen('finish-import', () => { const unlistenFinishImport = listen('finish-import', () => {
store.dispatch(closeModal()); store.dispatch(closeModal());
store.dispatch(setAppState({ loadingText: null })); store.dispatch(setAppState({ loadingText: null }));
void store.dispatch(loadPrefs()); void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
}); });
const unlistenShowBudgets = listen('show-budgets', () => { const unlistenShowBudgets = listen('show-budgets', () => {
@@ -163,6 +167,12 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
void window.Actual.reload(); void window.Actual.reload();
}); });
const unlistenPrefsUpdated = listen('prefs-updated', () => {
void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
});
return () => { return () => {
unlistenServerError(); unlistenServerError();
unlistenOrphanedPayees(); unlistenOrphanedPayees();
@@ -176,5 +186,6 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
unlistenFinishImport(); unlistenFinishImport();
unlistenShowBudgets(); unlistenShowBudgets();
unlistenApiFetchRedirected(); unlistenApiFetchRedirected();
unlistenPrefsUpdated();
}; };
} }

View File

@@ -1,9 +1,8 @@
import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query';
import type { GlobalPrefs } from 'loot-core/types/prefs'; import type { GlobalPrefs } from 'loot-core/types/prefs';
import { saveGlobalPrefs } from '@desktop-client/prefs/prefsSlice'; import { prefQueries, useSaveGlobalPrefsMutation } from '@desktop-client/prefs';
import { useDispatch, useSelector } from '@desktop-client/redux';
type SetGlobalPrefAction<K extends keyof GlobalPrefs> = ( type SetGlobalPrefAction<K extends keyof GlobalPrefs> = (
value: GlobalPrefs[K], value: GlobalPrefs[K],
@@ -13,23 +12,24 @@ export function useGlobalPref<K extends keyof GlobalPrefs>(
prefName: K, prefName: K,
onSaveGlobalPrefs?: () => void, onSaveGlobalPrefs?: () => void,
): [GlobalPrefs[K], SetGlobalPrefAction<K>] { ): [GlobalPrefs[K], SetGlobalPrefAction<K>] {
const dispatch = useDispatch(); const { mutate: saveGlobalPrefs } = useSaveGlobalPrefsMutation();
const setGlobalPref = useCallback<SetGlobalPrefAction<K>>( const saveGlobalPref: SetGlobalPrefAction<K> = value => {
value => { saveGlobalPrefs(
void dispatch( {
saveGlobalPrefs({ [prefName]: value,
prefs: { },
[prefName]: value, {
}, onSuccess: onSaveGlobalPrefs,
onSaveGlobalPrefs, },
}), );
); };
},
[prefName, dispatch, onSaveGlobalPrefs],
);
const globalPref = useSelector(
state => state.prefs.global?.[prefName] as GlobalPrefs[K],
);
return [globalPref, setGlobalPref]; const { data: globalPref } = useQuery({
...prefQueries.listGlobal(),
select: prefs => prefs?.[prefName],
enabled: !!prefName,
notifyOnChangeProps: ['data'],
});
return [globalPref as GlobalPrefs[K], saveGlobalPref];
} }

View File

@@ -1,9 +1,11 @@
import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query';
import type { MetadataPrefs } from 'loot-core/types/prefs'; import type { MetadataPrefs } from 'loot-core/types/prefs';
import { savePrefs } from '@desktop-client/prefs/prefsSlice'; import {
import { useDispatch, useSelector } from '@desktop-client/redux'; prefQueries,
useSaveMetadataPrefsMutation,
} from '@desktop-client/prefs';
type SetMetadataPrefAction<K extends keyof MetadataPrefs> = ( type SetMetadataPrefAction<K extends keyof MetadataPrefs> = (
value: MetadataPrefs[K], value: MetadataPrefs[K],
@@ -12,14 +14,17 @@ type SetMetadataPrefAction<K extends keyof MetadataPrefs> = (
export function useMetadataPref<K extends keyof MetadataPrefs>( export function useMetadataPref<K extends keyof MetadataPrefs>(
prefName: K, prefName: K,
): [MetadataPrefs[K], SetMetadataPrefAction<K>] { ): [MetadataPrefs[K], SetMetadataPrefAction<K>] {
const dispatch = useDispatch(); const { mutate: saveMetadataPrefs } = useSaveMetadataPrefsMutation();
const setLocalPref = useCallback<SetMetadataPrefAction<K>>( const saveMetadataPref: SetMetadataPrefAction<K> = value => {
value => { saveMetadataPrefs({ [prefName]: value });
void dispatch(savePrefs({ prefs: { [prefName]: value } })); };
},
[prefName, dispatch],
);
const localPref = useSelector(state => state.prefs.local?.[prefName]);
return [localPref, setLocalPref]; const { data: metadataPref } = useQuery({
...prefQueries.listMetadata(),
select: prefs => prefs?.[prefName],
enabled: !!prefName,
notifyOnChangeProps: ['data'],
});
return [metadataPref as MetadataPrefs[K], saveMetadataPref];
} }

View File

@@ -1,9 +1,8 @@
import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query';
import type { SyncedPrefs } from 'loot-core/types/prefs'; import type { SyncedPrefs } from 'loot-core/types/prefs';
import { saveSyncedPrefs } from '@desktop-client/prefs/prefsSlice'; import { prefQueries, useSaveSyncedPrefsMutation } from '@desktop-client/prefs';
import { useDispatch, useSelector } from '@desktop-client/redux';
type SetSyncedPrefAction<K extends keyof SyncedPrefs> = ( type SetSyncedPrefAction<K extends keyof SyncedPrefs> = (
value: SyncedPrefs[K], value: SyncedPrefs[K],
@@ -12,18 +11,17 @@ type SetSyncedPrefAction<K extends keyof SyncedPrefs> = (
export function useSyncedPref<K extends keyof SyncedPrefs>( export function useSyncedPref<K extends keyof SyncedPrefs>(
prefName: K, prefName: K,
): [SyncedPrefs[K], SetSyncedPrefAction<K>] { ): [SyncedPrefs[K], SetSyncedPrefAction<K>] {
const dispatch = useDispatch(); const { mutate: saveSyncedPrefs } = useSaveSyncedPrefsMutation();
const setPref = useCallback<SetSyncedPrefAction<K>>( const saveSyncedPref: SetSyncedPrefAction<K> = value => {
value => { saveSyncedPrefs({ [prefName]: value });
void dispatch( };
saveSyncedPrefs({
prefs: { [prefName]: value },
}),
);
},
[prefName, dispatch],
);
const pref = useSelector(state => state.prefs.synced[prefName]);
return [pref, setPref]; const { data: syncedPref } = useQuery({
...prefQueries.listSynced(),
select: prefs => prefs?.[prefName],
enabled: !!prefName,
notifyOnChangeProps: ['data'],
});
return [syncedPref as SyncedPrefs[K], saveSyncedPref];
} }

View File

@@ -1,22 +1,15 @@
import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query';
import type { SyncedPrefs } from 'loot-core/types/prefs'; import type { SyncedPrefs } from 'loot-core/types/prefs';
import { saveSyncedPrefs } from '@desktop-client/prefs/prefsSlice'; import { prefQueries, useSaveSyncedPrefsMutation } from '@desktop-client/prefs';
import { useDispatch, useSelector } from '@desktop-client/redux';
type SetSyncedPrefsAction = (value: Partial<SyncedPrefs>) => void; type SetSyncedPrefsAction = (value: Partial<SyncedPrefs>) => void;
/** @deprecated: please use `useSyncedPref` (singular) */ /** @deprecated: please use `useSyncedPref` (singular) */
export function useSyncedPrefs(): [SyncedPrefs, SetSyncedPrefsAction] { export function useSyncedPrefs(): [SyncedPrefs, SetSyncedPrefsAction] {
const dispatch = useDispatch(); const { mutate: saveSyncedPrefs } = useSaveSyncedPrefsMutation();
const setPrefs = useCallback<SetSyncedPrefsAction>( const { data: syncedPrefs } = useQuery(prefQueries.listSynced());
newValue => {
void dispatch(saveSyncedPrefs({ prefs: newValue }));
},
[dispatch],
);
const prefs = useSelector(state => state.prefs.synced);
return [prefs, setPrefs]; return [syncedPrefs as SyncedPrefs, saveSyncedPrefs];
} }

View File

@@ -13,6 +13,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection'; import { send } from 'loot-core/platform/client/connection';
import { q } from 'loot-core/shared/query'; import { q } from 'loot-core/shared/query';
import type { Theme } from 'loot-core/types/prefs';
import * as accountsSlice from './accounts/accountsSlice'; import * as accountsSlice from './accounts/accountsSlice';
import * as appSlice from './app/appSlice'; import * as appSlice from './app/appSlice';
@@ -22,6 +23,7 @@ import { App } from './components/App';
import { ServerProvider } from './components/ServerContext'; import { ServerProvider } from './components/ServerContext';
import * as modalsSlice from './modals/modalsSlice'; import * as modalsSlice from './modals/modalsSlice';
import * as notificationsSlice from './notifications/notificationsSlice'; import * as notificationsSlice from './notifications/notificationsSlice';
import { prefQueries } from './prefs';
import * as prefsSlice from './prefs/prefsSlice'; import * as prefsSlice from './prefs/prefsSlice';
import { aqlQuery } from './queries/aqlQuery'; import { aqlQuery } from './queries/aqlQuery';
import { configureAppStore } from './redux/store'; import { configureAppStore } from './redux/store';
@@ -68,6 +70,16 @@ function inputFocused(e: KeyboardEvent) {
); );
} }
async function setTheme(theme: Theme) {
// TODO: Tests and `toMatchThemeScreenshots` needs to be updated
// to change the theme via UI navigation instead of going through
// this method because this feels a bit hacky.
await send('save-global-prefs', { theme });
void queryClient.invalidateQueries({
queryKey: prefQueries.listGlobal().queryKey,
});
}
// Expose this to the main process to menu items can access it // Expose this to the main process to menu items can access it
window.__actionsForMenu = { window.__actionsForMenu = {
...boundActions, ...boundActions,
@@ -75,6 +87,7 @@ window.__actionsForMenu = {
redo, redo,
appFocused, appFocused,
uploadFile, uploadFile,
setTheme,
}; };
// Expose send for fun! // Expose send for fun!
@@ -108,6 +121,7 @@ declare global {
redo: typeof redo; redo: typeof redo;
appFocused: typeof appFocused; appFocused: typeof appFocused;
uploadFile: typeof uploadFile; uploadFile: typeof uploadFile;
setTheme: typeof setTheme;
}; };
$send: typeof send; $send: typeof send;

View File

@@ -0,0 +1,2 @@
export * from './queries';
export * from './mutations';

View File

@@ -0,0 +1,250 @@
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import type {
GlobalPrefs,
MetadataPrefs,
SyncedPrefs,
} from 'loot-core/types/prefs';
import { prefQueries } from './queries';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
function dispatchErrorNotification(
dispatch: AppDispatch,
message: string,
error?: Error,
) {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
pre: error ? error.message : undefined,
},
}),
);
}
type SaveMetadataPrefsPayload = MetadataPrefs;
export function useSaveMetadataPrefsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (metadataPrefs: SaveMetadataPrefsPayload) => {
const existing = await queryClient.ensureQueryData(
prefQueries.listMetadata(),
);
const prefsToSave = diff(metadataPrefs, existing);
if (Object.keys(prefsToSave).length > 0) {
await send('save-prefs', prefsToSave);
}
return prefsToSave;
},
onSuccess: changedPrefs => {
if (changedPrefs && Object.keys(changedPrefs).length > 0) {
queryClient.setQueryData(
prefQueries.listMetadata().queryKey,
oldData => {
return oldData
? {
...oldData,
...changedPrefs,
}
: oldData;
},
);
// Invalidate individual pref caches in case any components are subscribed to those directly
// const queryKeys = Object.keys(changedPrefs).map(
// prefName =>
// prefQueries.detailMetadata(prefName as keyof MetadataPrefs)
// .queryKey,
// );
// queryKeys.forEach(key => invalidateQueries(queryClient, key));
}
},
onError: error => {
console.error('Error saving metadata preferences:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error saving the metadata preferences. Please try again.',
),
error,
);
throw error;
},
});
}
type SaveGlobalPrefsPayload = GlobalPrefs;
export function useSaveGlobalPrefsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (globalPrefs: SaveGlobalPrefsPayload) => {
const existing = await queryClient.ensureQueryData(
prefQueries.listGlobal(),
);
const prefsToSave = diff(globalPrefs, existing);
if (Object.keys(prefsToSave).length > 0) {
await send('save-global-prefs', prefsToSave);
}
return prefsToSave;
},
onSuccess: changedPrefs => {
if (changedPrefs && Object.keys(changedPrefs).length > 0) {
queryClient.setQueryData(prefQueries.listGlobal().queryKey, oldData => {
return oldData
? {
...oldData,
...changedPrefs,
}
: oldData;
});
// Invalidate individual pref caches in case any components are subscribed to those directly
// const queryKeys = Object.keys(changedPrefs).map(
// prefName =>
// prefQueries.detailGlobal(prefName as keyof GlobalPrefs).queryKey,
// );
// queryKeys.forEach(key => invalidateQueries(queryClient, key));
}
},
onError: error => {
console.error('Error saving global preferences:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error saving the global preferences. Please try again.',
),
error,
);
throw error;
},
});
}
type SaveSyncedPrefsPayload = SyncedPrefs;
export function useSaveSyncedPrefsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (syncedPrefs: SaveSyncedPrefsPayload) => {
const existing = await queryClient.ensureQueryData(
prefQueries.listSynced(),
);
const prefsToSave = diff(syncedPrefs, existing);
if (Object.keys(prefsToSave).length > 0) {
await Promise.all(
Object.entries(prefsToSave).map(([syncedPrefName, value]) =>
send('preferences/save', {
id: syncedPrefName as keyof SyncedPrefs,
value,
}),
),
);
}
return prefsToSave;
},
onSuccess: changedPrefs => {
if (changedPrefs && Object.keys(changedPrefs).length > 0) {
queryClient.setQueryData(prefQueries.listSynced().queryKey, oldData => {
return oldData
? {
...oldData,
...changedPrefs,
}
: oldData;
});
// Invalidate individual pref caches in case any components are subscribed to those directly
// const queryKeys = Object.keys(changedPrefs).map(
// prefName =>
// prefQueries.detailSynced(prefName as keyof SyncedPrefs).queryKey,
// );
// queryKeys.forEach(key => invalidateQueries(queryClient, key));
}
},
onError: error => {
console.error('Error saving synced preferences:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error saving the synced preferences. Please try again.',
),
error,
);
throw error;
},
});
}
// type SaveServerPrefsPayload = ServerPrefs;
// export function useSaveServerPrefsMutation() {
// const queryClient = useQueryClient();
// const dispatch = useDispatch();
// const { t } = useTranslation();
// return useMutation({
// mutationFn: async (serverPrefs: SaveServerPrefsPayload) => {
// const result = await send('save-server-prefs', {
// prefs: serverPrefs,
// });
// if (result && 'error' in result) {
// return { error: result.error };
// }
// },
// onSuccess: () => invalidateQueries(queryClient, prefQueries.listServer().queryKey),
// onError: error => {
// console.error('Error saving server preferences:', error);
// dispatchErrorNotification(
// dispatch,
// t(
// 'There was an error saving the server preferences. Please try again.',
// ),
// error,
// );
// throw error;
// },
// });
// }
function diff<T extends object>(incoming: T, existing?: T | null): Partial<T> {
const changed: Partial<T> = {};
for (const [key, value] of Object.entries(incoming) as Array<
[keyof T, T[keyof T]]
>) {
if (!existing || existing[key] !== value) {
(changed as Record<keyof T, T[keyof T]>)[key] = value;
}
}
return changed;
}

View File

@@ -2,134 +2,21 @@ import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { send } from 'loot-core/platform/client/connection'; import { send } from 'loot-core/platform/client/connection';
import { parseNumberFormat, setNumberFormat } from 'loot-core/shared/util'; import type { ServerPrefs } from 'loot-core/types/prefs';
import type {
GlobalPrefs,
MetadataPrefs,
ServerPrefs,
SyncedPrefs,
} from 'loot-core/types/prefs';
import { resetApp } from '@desktop-client/app/appSlice'; import { resetApp } from '@desktop-client/app/appSlice';
import { setI18NextLanguage } from '@desktop-client/i18n';
import { closeModal } from '@desktop-client/modals/modalsSlice';
import { createAppAsyncThunk } from '@desktop-client/redux'; import { createAppAsyncThunk } from '@desktop-client/redux';
import { getUserData } from '@desktop-client/users/usersSlice'; import { getUserData } from '@desktop-client/users/usersSlice';
const sliceName = 'prefs'; const sliceName = 'prefs';
type PrefsState = { type PrefsState = {
local: MetadataPrefs;
global: GlobalPrefs;
synced: SyncedPrefs;
server: ServerPrefs; server: ServerPrefs;
}; };
const initialState: PrefsState = { const initialState: PrefsState = {
local: {},
global: {},
synced: {},
server: {}, server: {},
}; };
export const loadPrefs = createAppAsyncThunk(
`${sliceName}/loadPrefs`,
async (_, { dispatch, getState }) => {
const prefs = await send('load-prefs');
// Remove any modal state if switching between budgets
const currentPrefs = getState().prefs.local;
if (prefs && prefs.id && !currentPrefs) {
dispatch(closeModal());
}
const [globalPrefs, syncedPrefs] = await Promise.all([
send('load-global-prefs'),
send('preferences/get'),
]);
dispatch(
setPrefs({ local: prefs, global: globalPrefs, synced: syncedPrefs }),
);
// Certain loot-core utils depend on state outside of the React tree, update them
setNumberFormat(
parseNumberFormat({
format: syncedPrefs.numberFormat,
hideFraction: syncedPrefs.hideFraction,
}),
);
// We need to load translations before the app renders
setI18NextLanguage(globalPrefs.language ?? '');
return prefs;
},
);
type SavePrefsPayload = {
prefs: MetadataPrefs;
};
export const savePrefs = createAppAsyncThunk(
`${sliceName}/savePrefs`,
async ({ prefs }: SavePrefsPayload, { dispatch }) => {
await send('save-prefs', prefs);
dispatch(mergeLocalPrefs(prefs));
},
);
export const loadGlobalPrefs = createAppAsyncThunk(
`${sliceName}/loadGlobalPrefs`,
async (_, { dispatch, getState }) => {
const globalPrefs = await send('load-global-prefs');
dispatch(
setPrefs({
local: getState().prefs.local,
global: globalPrefs,
synced: getState().prefs.synced,
}),
);
return globalPrefs;
},
);
type SaveGlobalPrefsPayload = {
prefs: GlobalPrefs;
onSaveGlobalPrefs?: () => void;
};
export const saveGlobalPrefs = createAppAsyncThunk(
`${sliceName}/saveGlobalPrefs`,
async (
{ prefs, onSaveGlobalPrefs }: SaveGlobalPrefsPayload,
{ dispatch },
) => {
await send('save-global-prefs', prefs);
dispatch(mergeGlobalPrefs(prefs));
onSaveGlobalPrefs?.();
},
);
type SaveSyncedPrefsPayload = {
prefs: SyncedPrefs;
};
export const saveSyncedPrefs = createAppAsyncThunk(
`${sliceName}/saveSyncedPrefs`,
async ({ prefs }: SaveSyncedPrefsPayload, { dispatch }) => {
await Promise.all(
Object.entries(prefs).map(([prefName, value]) =>
send('preferences/save', {
id: prefName as keyof SyncedPrefs,
value,
}),
),
);
dispatch(mergeSyncedPrefs(prefs));
},
);
type SaveServerPrefsPayload = { type SaveServerPrefsPayload = {
prefs: ServerPrefs; prefs: ServerPrefs;
}; };
@@ -147,35 +34,12 @@ export const saveServerPrefs = createAppAsyncThunk(
}, },
); );
type SetPrefsPayload = {
local: MetadataPrefs;
global: GlobalPrefs;
synced: SyncedPrefs;
};
type MergeLocalPrefsPayload = MetadataPrefs;
type MergeGlobalPrefsPayload = GlobalPrefs;
type MergeSyncedPrefsPayload = SyncedPrefs;
type MergeServerPrefsPayload = ServerPrefs; type MergeServerPrefsPayload = ServerPrefs;
const prefsSlice = createSlice({ const prefsSlice = createSlice({
name: sliceName, name: sliceName,
initialState, initialState,
reducers: { reducers: {
setPrefs(state, action: PayloadAction<SetPrefsPayload>) {
state.local = action.payload.local;
state.global = action.payload.global;
state.synced = action.payload.synced;
},
mergeLocalPrefs(state, action: PayloadAction<MergeLocalPrefsPayload>) {
state.local = { ...state.local, ...action.payload };
},
mergeGlobalPrefs(state, action: PayloadAction<MergeGlobalPrefsPayload>) {
state.global = { ...state.global, ...action.payload };
},
mergeSyncedPrefs(state, action: PayloadAction<MergeSyncedPrefsPayload>) {
state.synced = { ...state.synced, ...action.payload };
},
mergeServerPrefs(state, action: PayloadAction<MergeServerPrefsPayload>) { mergeServerPrefs(state, action: PayloadAction<MergeServerPrefsPayload>) {
state.server = { ...state.server, ...action.payload }; state.server = { ...state.server, ...action.payload };
}, },
@@ -183,7 +47,6 @@ const prefsSlice = createSlice({
extraReducers: builder => { extraReducers: builder => {
builder.addCase(resetApp, state => ({ builder.addCase(resetApp, state => ({
...initialState, ...initialState,
global: state.global || initialState.global,
server: state.server || initialState.server, server: state.server || initialState.server,
})); }));
builder.addCase(getUserData.fulfilled, (state, action) => { builder.addCase(getUserData.fulfilled, (state, action) => {
@@ -211,18 +74,7 @@ export const { name, reducer, getInitialState } = prefsSlice;
export const actions = { export const actions = {
...prefsSlice.actions, ...prefsSlice.actions,
loadPrefs,
savePrefs,
loadGlobalPrefs,
saveGlobalPrefs,
saveSyncedPrefs,
saveServerPrefs, saveServerPrefs,
}; };
export const { export const { mergeServerPrefs } = actions;
mergeGlobalPrefs,
mergeLocalPrefs,
mergeServerPrefs,
mergeSyncedPrefs,
setPrefs,
} = actions;

View File

@@ -0,0 +1,104 @@
import { queryOptions } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection';
import { parseNumberFormat, setNumberFormat } from 'loot-core/shared/util';
import type {
GlobalPrefs,
MetadataPrefs,
ServerPrefs,
SyncedPrefs,
} from 'loot-core/types/prefs';
import { setI18NextLanguage } from '@desktop-client/i18n';
export type AllPrefs = {
metadata: MetadataPrefs;
global: GlobalPrefs;
synced: SyncedPrefs;
server: ServerPrefs;
};
export const prefQueries = {
all: () => ['prefs'],
lists: () => [...prefQueries.all(), 'lists'],
list: () =>
queryOptions<AllPrefs>({
queryKey: [...prefQueries.lists(), 'all'],
queryFn: async ({ client }) => {
const [metadataPrefs, globalPrefs, syncedPrefs] = await Promise.all([
client.ensureQueryData(prefQueries.listMetadata()),
client.ensureQueryData(prefQueries.listGlobal()),
client.ensureQueryData(prefQueries.listSynced()),
]);
// Certain loot-core utils depend on state outside of the React tree, update them
setNumberFormat(
parseNumberFormat({
format: syncedPrefs.numberFormat,
hideFraction: syncedPrefs.hideFraction,
}),
);
// We need to load translations before the app renders
setI18NextLanguage(globalPrefs.language ?? '');
return {
metadata: metadataPrefs,
global: globalPrefs,
synced: syncedPrefs,
server: {}, // Server prefs are loaded separately
};
},
placeholderData: {
metadata: {},
global: {},
synced: {},
server: {},
},
// Manually invalidated when preferences change
staleTime: Infinity,
}),
listMetadata: () =>
queryOptions<MetadataPrefs>({
queryKey: [...prefQueries.lists(), 'metadata'],
queryFn: async () => {
return await send('load-prefs');
},
placeholderData: {},
// Manually invalidated when local preferences change
staleTime: Infinity,
}),
listGlobal: () =>
queryOptions<GlobalPrefs>({
queryKey: [...prefQueries.lists(), 'global'],
queryFn: async () => {
return await send('load-global-prefs');
},
placeholderData: {},
// Manually invalidated when global preferences change
staleTime: Infinity,
}),
listSynced: () =>
queryOptions<SyncedPrefs>({
queryKey: [...prefQueries.lists(), 'synced'],
queryFn: async ({ client }) => {
const metadataPrefs = await client.getQueryData(
prefQueries.listMetadata().queryKey,
);
// Synced prefs are budget-specific, so if we don't have
// a budget loaded, just return an empty object.
if (!metadataPrefs?.id) {
return {};
}
return await send('preferences/get');
},
placeholderData: {},
// Manually invalidated when synced preferences change
staleTime: Infinity,
}),
listServer: () =>
queryOptions({
...prefQueries.list(),
select: data => data.server,
}),
};

View File

@@ -14,7 +14,7 @@ import { pushModal } from './modals/modalsSlice';
import { addNotification } from './notifications/notificationsSlice'; import { addNotification } from './notifications/notificationsSlice';
import type { Notification } from './notifications/notificationsSlice'; import type { Notification } from './notifications/notificationsSlice';
import { payeeQueries } from './payees'; import { payeeQueries } from './payees';
import { loadPrefs } from './prefs/prefsSlice'; import { prefQueries } from './prefs';
import type { AppStore } from './redux/store'; import type { AppStore } from './redux/store';
import { signOut } from './users/usersSlice'; import { signOut } from './users/usersSlice';
@@ -37,8 +37,8 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
let attemptedSyncRepair = false; let attemptedSyncRepair = false;
const unlistenSuccess = listen('sync-event', event => { const unlistenSuccess = listen('sync-event', async event => {
const prefs = store.getState().prefs.local; const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
if (!prefs || !prefs.id) { if (!prefs || !prefs.id) {
// Do nothing if no budget is loaded // Do nothing if no budget is loaded
return; return;
@@ -62,7 +62,9 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
const tables = event.tables; const tables = event.tables;
if (tables.includes('prefs')) { if (tables.includes('prefs')) {
void store.dispatch(loadPrefs()); void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
} }
if ( if (
@@ -219,8 +221,10 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
title: t('Register'), title: t('Register'),
action: async () => { action: async () => {
await store.dispatch(uploadBudget({})); await store.dispatch(uploadBudget({}));
void store.dispatch(sync()); store.dispatch(sync());
void store.dispatch(loadPrefs()); void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
}, },
}, },
}; };
@@ -253,7 +257,9 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
// the server does not match the local one. This can mean a // the server does not match the local one. This can mean a
// few things depending on the state, and we try to show an // few things depending on the state, and we try to show an
// appropriate message and call to action to fix it. // appropriate message and call to action to fix it.
const { cloudFileId } = store.getState().prefs.local; const { cloudFileId } = await queryClient.ensureQueryData(
prefQueries.listMetadata(),
);
if (!cloudFileId) { if (!cloudFileId) {
console.error( console.error(
'Received file-has-reset or file-has-new-key error but no cloudFileId in prefs', 'Received file-has-reset or file-has-new-key error but no cloudFileId in prefs',

View File

@@ -9,7 +9,7 @@ import {
closeBudget, closeBudget,
loadAllFiles, loadAllFiles,
} from '@desktop-client/budgetfiles/budgetfilesSlice'; } from '@desktop-client/budgetfiles/budgetfilesSlice';
import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice'; import { prefQueries } from '@desktop-client/prefs';
import { createAppAsyncThunk } from '@desktop-client/redux'; import { createAppAsyncThunk } from '@desktop-client/redux';
const sliceName = 'user'; const sliceName = 'user';
@@ -48,11 +48,13 @@ export const loggedIn = createAppAsyncThunk(
export const signOut = createAppAsyncThunk( export const signOut = createAppAsyncThunk(
`${sliceName}/signOut`, `${sliceName}/signOut`,
async (_, { dispatch }) => { async (_, { dispatch, extra: { queryClient } }) => {
await send('subscribe-sign-out'); await send('subscribe-sign-out');
void dispatch(getUserData()); void dispatch(getUserData());
void dispatch(loadGlobalPrefs()); void queryClient.invalidateQueries({
queryKey: prefQueries.listGlobal().queryKey,
});
void dispatch(closeBudget()); void dispatch(closeBudget());
// Handled in budgetSlice // Handled in budgetSlice
// dispatch({ type: constants.SIGN_OUT }); // dispatch({ type: constants.SIGN_OUT });

View File

@@ -25,7 +25,7 @@ import type {
import { copy, exists, mkdir, remove } from 'fs-extra'; import { copy, exists, mkdir, remove } from 'fs-extra';
import promiseRetry from 'promise-retry'; import promiseRetry from 'promise-retry';
import type { GlobalPrefsJson } from '../loot-core/src/types/prefs'; import type { GlobalPrefsJson, Theme } from '../loot-core/src/types/prefs';
import { getMenu } from './menu'; import { getMenu } from './menu';
import { import {
@@ -625,11 +625,10 @@ ipcMain.on('message', (_event, msg) => {
serverProcess.postMessage(msg.args); serverProcess.postMessage(msg.args);
}); });
ipcMain.on('set-theme', (_event, theme: string) => { ipcMain.on('set-theme', async (_event, theme: Theme) => {
const obj = { theme };
if (clientWin) { if (clientWin) {
void clientWin.webContents.executeJavaScript( await clientWin.webContents.executeJavaScript(
`window.__actionsForMenu && window.__actionsForMenu.saveGlobalPrefs({ prefs: ${JSON.stringify(obj)} })`, `window.__actionsForMenu && window.__actionsForMenu.setTheme('${theme}')`,
); );
} }
}); });

View File

@@ -1,6 +1,8 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import type { IpcRenderer } from 'electron'; import type { IpcRenderer } from 'electron';
import type { Theme } from 'loot-core/types/prefs';
import type { import type {
GetBootstrapDataPayload, GetBootstrapDataPayload,
OpenFileDialogPayload, OpenFileDialogPayload,
@@ -85,7 +87,7 @@ contextBridge.exposeInMainWorld('Actual', {
return null; return null;
}, },
setTheme: (theme: string) => { setTheme: async (theme: Theme) => {
ipcRenderer.send('set-theme', theme); ipcRenderer.send('set-theme', theme);
}, },

View File

@@ -1,5 +1,7 @@
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import type { Theme } from 'loot-core/types/prefs';
export type IpcClient = { export type IpcClient = {
on: EventEmitter['on']; on: EventEmitter['on'];
emit: (name: string, data: unknown) => void; emit: (name: string, data: unknown) => void;
@@ -34,7 +36,7 @@ type Actual = {
applyAppUpdate: () => Promise<void>; applyAppUpdate: () => Promise<void>;
ipcConnect: (callback: (client: IpcClient) => void) => void; ipcConnect: (callback: (client: IpcClient) => void) => void;
getServerSocket: () => Promise<Worker | null>; getServerSocket: () => Promise<Worker | null>;
setTheme: (theme: string) => void; setTheme: (theme: Theme) => Promise<void>;
logToTerminal: (...args: unknown[]) => void; logToTerminal: (...args: unknown[]) => void;
onEventFromMain: ( onEventFromMain: (
event: string, event: string,