diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx index 157c31c89ac9458c167da161a62ef917176f7754..c9abf7956f16a04d14f8ea1b17da36804339df68 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx @@ -31,6 +31,9 @@ vi.mock('loot-core/src/platform/client/fetch'); vi.mock('../../hooks/useFeatureFlag', () => ({ default: vi.fn().mockReturnValue(false), })); +vi.mock('../../hooks/useSyncedPref', () => ({ + useSyncedPref: vi.fn().mockReturnValue([undefined, vi.fn()]), +})); const accounts = [generateAccount('Bank of America')]; const payees = [ diff --git a/packages/desktop-client/src/hooks/useLocalPrefs.ts b/packages/desktop-client/src/hooks/useLocalPrefs.ts deleted file mode 100644 index 870bef808e340661745e538aa3b619e91c60e34d..0000000000000000000000000000000000000000 --- a/packages/desktop-client/src/hooks/useLocalPrefs.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/useSyncedPref.ts b/packages/desktop-client/src/hooks/useSyncedPref.ts index e9e8572300e7cdd2bac64af07108978942a86717..5e226e6069298cb55e83a2e62ee3d3a77c6991dd 100644 --- a/packages/desktop-client/src/hooks/useSyncedPref.ts +++ b/packages/desktop-client/src/hooks/useSyncedPref.ts @@ -1,6 +1,9 @@ -import { type SyncedPrefs } from 'loot-core/src/types/prefs'; +import { useCallback } from 'react'; -import { useLocalPref } from './useLocalPref'; +import { useQuery } from 'loot-core/client/query-hooks'; +import { send } from 'loot-core/platform/client/fetch'; +import { q } from 'loot-core/shared/query'; +import { type SyncedPrefs } from 'loot-core/src/types/prefs'; type SetSyncedPrefAction<K extends keyof SyncedPrefs> = ( value: SyncedPrefs[K], @@ -9,7 +12,21 @@ type SetSyncedPrefAction<K extends keyof SyncedPrefs> = ( export function useSyncedPref<K extends keyof SyncedPrefs>( prefName: K, ): [SyncedPrefs[K], SetSyncedPrefAction<K>] { - // TODO: implement logic for fetching the pref exclusively from the - // database (in follow-up PR) - return useLocalPref(prefName); + const { data: queryData, overrideData: setQueryData } = useQuery< + [{ value: string | undefined }] + >( + () => q('preferences').filter({ id: prefName }).select('value'), + [prefName], + ); + + const setLocalPref = useCallback<SetSyncedPrefAction<K>>( + newValue => { + const value = String(newValue); + setQueryData([{ value }]); + send('preferences/save', { id: prefName, value }); + }, + [prefName, setQueryData], + ); + + return [queryData?.[0]?.value, setLocalPref]; } diff --git a/packages/desktop-client/src/hooks/useSyncedPrefs.ts b/packages/desktop-client/src/hooks/useSyncedPrefs.ts index 7d4497da649258f2a1c929228506239c43f1d313..60478520229ee32084b3085865154492ff820fd7 100644 --- a/packages/desktop-client/src/hooks/useSyncedPrefs.ts +++ b/packages/desktop-client/src/hooks/useSyncedPrefs.ts @@ -1,22 +1,39 @@ -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import { useCallback, useMemo } from 'react'; -import { savePrefs } from 'loot-core/client/actions'; +import { useQuery } from 'loot-core/client/query-hooks'; +import { send } from 'loot-core/platform/client/fetch'; +import { q } from 'loot-core/shared/query'; import { type SyncedPrefs } from 'loot-core/src/types/prefs'; -import { useLocalPrefs } from './useLocalPrefs'; - type SetSyncedPrefsAction = (value: Partial<SyncedPrefs>) => void; +/** @deprecated: please use `useSyncedPref` (singular) */ export function useSyncedPrefs(): [SyncedPrefs, SetSyncedPrefsAction] { - // TODO: implement real logic (follow-up PR) - const dispatch = useDispatch(); - const setPrefs = useCallback<SetSyncedPrefsAction>( - newPrefs => { - dispatch(savePrefs(newPrefs)); - }, - [dispatch], + const { data: queryData } = useQuery<{ id: string; value: string }[]>( + () => q('preferences').select(['id', 'value']), + [], + ); + + const prefs = useMemo<SyncedPrefs>( + () => + queryData.reduce( + (carry, { id, value }) => ({ + ...carry, + [id]: value, + }), + {}, + ), + [queryData], ); - return [useLocalPrefs(), setPrefs]; + const setPrefs = useCallback<SetSyncedPrefsAction>(newValue => { + Object.entries(newValue).forEach(([id, value]) => { + send('preferences/save', { + id: id as keyof SyncedPrefs, + value: String(value), + }); + }); + }, []); + + return [prefs, setPrefs]; } diff --git a/packages/loot-core/migrations/1723665565000_prefs.js b/packages/loot-core/migrations/1723665565000_prefs.js new file mode 100644 index 0000000000000000000000000000000000000000..cd52e78a226d883dc085f8aa1631afd6caffa51f --- /dev/null +++ b/packages/loot-core/migrations/1723665565000_prefs.js @@ -0,0 +1,59 @@ +const SYNCED_PREF_KEYS = [ + 'firstDayOfWeekIdx', + 'dateFormat', + 'numberFormat', + 'hideFraction', + 'isPrivacyEnabled', + /^show-extra-balances-/, + /^hide-cleared-/, + /^parse-date-/, + /^csv-mappings-/, + /^csv-delimiter-/, + /^csv-has-header-/, + /^ofx-fallback-missing-payee-/, + /^flip-amount-/, + // 'budgetType', // TODO: uncomment when `budgetType` moves from metadata to synced prefs + /^flags\./, +]; + +export default async function runMigration(db, { fs, fileId }) { + await db.execQuery(` + CREATE TABLE preferences + (id TEXT PRIMARY KEY, + value TEXT); + `); + + try { + const budgetDir = fs.getBudgetDir(fileId); + const fullpath = fs.join(budgetDir, 'metadata.json'); + + const prefs = JSON.parse(await fs.readFile(fullpath)); + + if (typeof prefs !== 'object') { + return; + } + + await Promise.all( + Object.keys(prefs).map(async key => { + // Check if the current key is of synced-keys type + if ( + !SYNCED_PREF_KEYS.find(keyMatcher => + keyMatcher instanceof RegExp + ? keyMatcher.test(key) + : keyMatcher === key, + ) + ) { + return; + } + + // insert the synced prefs in the new table + await db.runQuery('INSERT INTO preferences (id, value) VALUES (?, ?)', [ + key, + String(prefs[key]), + ]); + }), + ); + } catch (e) { + // Do nothing + } +} diff --git a/packages/loot-core/src/client/query-helpers.ts b/packages/loot-core/src/client/query-helpers.ts index 0f8963c0181582437e2706163ef8880c9b6f3d2e..92f058b9f5e79956987e801e5a4787423d735af8 100644 --- a/packages/loot-core/src/client/query-helpers.ts +++ b/packages/loot-core/src/client/query-helpers.ts @@ -7,8 +7,12 @@ export async function runQuery(query) { return send('query', query.serialize()); } -export function liveQuery(query, onData?, opts?): LiveQuery { - const q = new LiveQuery(query, onData, opts); +export function liveQuery<Response = unknown>( + query, + onData?: (response: Response) => void, + opts?, +): LiveQuery { + const q = new LiveQuery<Response>(query, onData, opts); q.run(); return q; } @@ -20,7 +24,7 @@ export function pagedQuery(query, onData?, opts?): PagedQuery { } // Subscribe and refetch -export class LiveQuery { +export class LiveQuery<Response = unknown> { _unsubscribe; data; dependencies; @@ -36,7 +40,11 @@ export class LiveQuery { inflightRequestId; restart; - constructor(query, onData?, opts: { mapper?; onlySync?: boolean } = {}) { + constructor( + query, + onData?: (response: Response) => void, + opts: { mapper?; onlySync?: boolean } = {}, + ) { this.error = new Error(); this.query = query; this.data = null; diff --git a/packages/loot-core/src/client/query-hooks.tsx b/packages/loot-core/src/client/query-hooks.tsx index ecfeddfd4d3df67defaf21867ab9fe3aeef61412..930acfc58751c7d474f69de508f9281b82053fd7 100644 --- a/packages/loot-core/src/client/query-hooks.tsx +++ b/packages/loot-core/src/client/query-hooks.tsx @@ -74,21 +74,42 @@ export function useLiveQuery<Response = unknown>( makeQuery: () => Query, deps: DependencyList, ): Response { - const [data, setData] = useState(null); + const { data } = useQuery<Response>(makeQuery, deps); + return data; +} + +export function useQuery<Response = unknown>( + makeQuery: () => Query, + deps: DependencyList, +): { + data: Response; + overrideData: (newData: Response) => void; + isLoading: boolean; +} { + const [data, setData] = useState<null | Response>(null); + const [isLoading, setIsLoading] = useState(true); const query = useMemo(makeQuery, deps); useEffect(() => { - let live = liveQuery(query, async data => { + setIsLoading(true); + + let live = liveQuery<Response>(query, async data => { if (live) { + setIsLoading(false); setData(data); } }); return () => { + setIsLoading(false); live.unsubscribe(); live = null; }; }, [query]); - return data; + return { + data, + overrideData: setData, + isLoading, + }; } diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts index b35e2a25a5fac4ef9f766d333e6f3ea9387f3e58..ddcac48056a480b2112fc4e21334d4f9b57617b5 100644 --- a/packages/loot-core/src/server/aql/schema/index.ts +++ b/packages/loot-core/src/server/aql/schema/index.ts @@ -122,6 +122,10 @@ export const schema = { id: f('id'), note: f('string'), }, + preferences: { + id: f('id'), + value: f('string'), + }, transaction_filters: { id: f('id'), name: f('string'), diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index c8dcd9f687455d51d2107a5536e58ce21d254734..f3b84fa9a3527825f07c1a58bef4f3f4ab1c5dd7 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -53,6 +53,7 @@ import { mutator, runHandler } from './mutators'; import { app as notesApp } from './notes/app'; import * as Platform from './platform'; import { get, post } from './post'; +import { app as preferencesApp } from './preferences/app'; import * as prefs from './prefs'; import { app as reportsApp } from './reports/app'; import { app as rulesApp } from './rules/app'; @@ -2079,6 +2080,7 @@ app.combine( budgetApp, dashboardApp, notesApp, + preferencesApp, toolsApp, filtersApp, reportsApp, diff --git a/packages/loot-core/src/server/migrate/migrations.ts b/packages/loot-core/src/server/migrate/migrations.ts index 4243b6a48357d34de72c0652e103f0e9b09a4d8d..3d933e7952407b9147a8190525df96a800c8c2ca 100644 --- a/packages/loot-core/src/server/migrate/migrations.ts +++ b/packages/loot-core/src/server/migrate/migrations.ts @@ -3,13 +3,14 @@ // them which doesn't play well with CSP. There isn't great, and eventually // we can remove this migration. import { Database } from '@jlongster/sql.js'; -import { v4 as uuidv4 } from 'uuid'; import m1632571489012 from '../../../migrations/1632571489012_remove_cache'; import m1722717601000 from '../../../migrations/1722717601000_reports_move_selected_categories'; import m1722804019000 from '../../../migrations/1722804019000_create_dashboard_table'; +import m1723665565000 from '../../../migrations/1723665565000_prefs'; import * as fs from '../../platform/server/fs'; import * as sqlite from '../../platform/server/sqlite'; +import * as prefs from '../prefs'; let MIGRATIONS_DIR = fs.migrationsPath; @@ -17,6 +18,7 @@ const javascriptMigrations = { 1632571489012: m1632571489012, 1722717601000: m1722717601000, 1722804019000: m1722804019000, + 1723665565000: m1723665565000, }; export async function withMigrationsDir( @@ -107,7 +109,10 @@ async function applyJavaScript(db, id) { } const run = javascriptMigrations[id]; - return run(dbInterface, () => uuidv4()); + return run(dbInterface, { + fs, + fileId: prefs.getPrefs()?.id, + }); } async function applySql(db, sql) { diff --git a/packages/loot-core/src/server/preferences/app.ts b/packages/loot-core/src/server/preferences/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d4f6517e8d63a40e55425c75c73c333887cef8b --- /dev/null +++ b/packages/loot-core/src/server/preferences/app.ts @@ -0,0 +1,21 @@ +import { type SyncedPrefs } from '../../types/prefs'; +import { createApp } from '../app'; +import * as db from '../db'; +import { mutator } from '../mutators'; +import { undoable } from '../undo'; + +import { PreferencesHandlers } from './types/handlers'; + +export const app = createApp<PreferencesHandlers>(); + +const savePreferences = async ({ + id, + value, +}: { + id: keyof SyncedPrefs; + value: string | undefined; +}) => { + await db.update('preferences', { id, value }); +}; + +app.method('preferences/save', mutator(undoable(savePreferences))); diff --git a/packages/loot-core/src/server/preferences/types/handlers.d.ts b/packages/loot-core/src/server/preferences/types/handlers.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..78e1a811867db17af1e355e4d7096f62a45ba085 --- /dev/null +++ b/packages/loot-core/src/server/preferences/types/handlers.d.ts @@ -0,0 +1,8 @@ +import { type SyncedPrefs } from '../../../types/prefs'; + +export interface PreferencesHandlers { + 'preferences/save': (arg: { + id: keyof SyncedPrefs; + value: string | undefined; + }) => Promise<void>; +} diff --git a/packages/loot-core/src/server/prefs.ts b/packages/loot-core/src/server/prefs.ts index e18d107d3c4a96be96b985b76da53b2eb0e5057d..b353acfbe34c87dafbeac7bf6df5108c5320630d 100644 --- a/packages/loot-core/src/server/prefs.ts +++ b/packages/loot-core/src/server/prefs.ts @@ -28,19 +28,6 @@ export async function loadPrefs(id?: string): Promise<MetadataPrefs> { prefs = { id, budgetName: id }; } - // delete released feature flags - const releasedFeatures = ['syncAccount']; - for (const feature of releasedFeatures) { - delete prefs[`flags.${feature}`]; - } - - // delete legacy notifications - for (const key of Object.keys(prefs)) { - if (key.startsWith('notifications.')) { - delete prefs[key]; - } - } - // No matter what is in `id` field, force it to be the current id. // This makes it resilient to users moving around folders, etc prefs.id = id; diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts index 39d69edbd19840436cc966203573ca03e1f49b7f..ef880e2515b51f6432acaab65706225760122bcb 100644 --- a/packages/loot-core/src/types/handlers.d.ts +++ b/packages/loot-core/src/types/handlers.d.ts @@ -2,6 +2,7 @@ import type { BudgetHandlers } from '../server/budget/types/handlers'; import type { DashboardHandlers } from '../server/dashboard/types/handlers'; import type { FiltersHandlers } from '../server/filters/types/handlers'; import type { NotesHandlers } from '../server/notes/types/handlers'; +import type { PreferencesHandlers } from '../server/preferences/types/handlers'; import type { ReportsHandlers } from '../server/reports/types/handlers'; import type { RulesHandlers } from '../server/rules/types/handlers'; import type { SchedulesHandlers } from '../server/schedules/types/handlers'; @@ -17,6 +18,7 @@ export interface Handlers DashboardHandlers, FiltersHandlers, NotesHandlers, + PreferencesHandlers, ReportsHandlers, RulesHandlers, SchedulesHandlers, diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index b500745a78c60f05a588c1c187be9c6d492ab3bb..eb0700a57020357c76f818d8d017ebc09f6054b0 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -55,11 +55,10 @@ export type MetadataPrefs = Partial<{ /** * Local preferences applicable to a single device. Stored in local storage. - * TODO: eventually `LocalPrefs` type should not use `SyncedPrefs` or `MetadataPrefs`; + * TODO: eventually `LocalPrefs` type should not use `MetadataPrefs`; * this is only a stop-gap solution. */ -export type LocalPrefs = SyncedPrefs & - MetadataPrefs & +export type LocalPrefs = MetadataPrefs & Partial<{ 'ui.showClosedAccounts': boolean; 'expand-splits': boolean; diff --git a/upcoming-release-notes/3423.md b/upcoming-release-notes/3423.md new file mode 100644 index 0000000000000000000000000000000000000000..366c1281f3ea9721ec77d7e5cf88d32cffe4c951 --- /dev/null +++ b/upcoming-release-notes/3423.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +SyncedPrefs: move synced-preferences from metadata.json to the database.