diff --git a/packages/desktop-client/src/components/Page.js b/packages/desktop-client/src/components/Page.tsx similarity index 78% rename from packages/desktop-client/src/components/Page.js rename to packages/desktop-client/src/components/Page.tsx index 8038a87db5cd893b767c3bd855d1e023757dea6a..aa2d8999d5cb184aa7e8a2527c394def5b114f4e 100644 --- a/packages/desktop-client/src/components/Page.js +++ b/packages/desktop-client/src/components/Page.tsx @@ -1,12 +1,14 @@ -import React, { createContext, useContext } from 'react'; +import React, { createContext, type ReactNode, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; +import { type CSSProperties } from 'glamor'; + import { useResponsive } from '../ResponsiveProvider'; import { colors, styles } from '../style'; import { Modal, View, Text } from './common'; -let PageTypeContext = createContext({ type: 'page' }); +let PageTypeContext = createContext({ type: 'page', current: undefined }); export function PageTypeProvider({ type, current, children }) { return ( @@ -63,18 +65,29 @@ function PageTitle({ name, style }) { ); } -export function Page({ title, modalSize, children, titleStyle }) { +export function Page({ + title, + modalSize, + children, + titleStyle, +}: { + title: string; + modalSize?: string | { width: number; height?: number }; + children: ReactNode; + titleStyle?: CSSProperties; +}) { let { type, current } = usePageType(); let navigate = useNavigate(); let { isNarrowWidth } = useResponsive(); let HORIZONTAL_PADDING = isNarrowWidth ? 10 : 20; if (type === 'modal') { - let size = modalSize; - if (typeof modalSize === 'string') { - size = - modalSize === 'medium' ? { width: 750, height: 600 } : { width: 600 }; - } + let size = + typeof modalSize === 'string' + ? modalSize === 'medium' + ? { width: 750, height: 600 } + : { width: 600 } + : modalSize; return ( <Modal diff --git a/packages/desktop-client/src/components/common/Select.tsx b/packages/desktop-client/src/components/common/Select.tsx index f328d8479a0669ed9ecb8aed9f69dfc48280fb7a..cbc01a993349e2423162c9f9277f36f54748cf62 100644 --- a/packages/desktop-client/src/components/common/Select.tsx +++ b/packages/desktop-client/src/components/common/Select.tsx @@ -10,15 +10,15 @@ import { type CSSProperties, css } from 'glamor'; import ExpandArrow from '../../icons/v0/ExpandArrow'; import { colors } from '../../style'; -type CustomSelectProps = { - options: Array<[string, string]>; - value: string; +type SelectProps<Value extends string> = { + options: Array<[Value, string]>; + value: Value; defaultLabel?: string; - onChange?: (newValue: string) => void; + onChange?: (newValue: Value) => void; style?: CSSProperties; wrapperStyle?: CSSProperties; - line: number; - disabledKeys?: string[]; + line?: number; + disabledKeys?: Value[]; }; /** @@ -36,7 +36,7 @@ type CustomSelectProps = { * // <Select options={[['1', 'Option 1'], ['2', 'Option 2']]} value="3" defaultLabel="Select an option" onChange={handleOnChange} /> */ -export default function Select({ +export default function Select<Value extends string>({ options, value, defaultLabel = '', @@ -45,7 +45,7 @@ export default function Select({ wrapperStyle, line, disabledKeys = [], -}: CustomSelectProps) { +}: SelectProps<Value>) { const arrowSize = 7; const targetOption = options.filter(option => option[0] === value); return ( diff --git a/packages/desktop-client/src/components/settings/Encryption.js b/packages/desktop-client/src/components/settings/Encryption.tsx similarity index 97% rename from packages/desktop-client/src/components/settings/Encryption.js rename to packages/desktop-client/src/components/settings/Encryption.tsx index bbedaa05292666abf2712a7cf5f0340eb7cd0b84..4f419ee5cdabe1955819aa9b20e1b992bc6f1aef 100644 --- a/packages/desktop-client/src/components/settings/Encryption.js +++ b/packages/desktop-client/src/components/settings/Encryption.tsx @@ -16,6 +16,7 @@ export default function EncryptionSettings() { const missingCryptoAPI = !(window.crypto && crypto.subtle); function onChangeKey() { + // @ts-expect-error useActions() type does not properly handle overloads pushModal('create-encryption-key', { recreate: true }); } diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index d0fe855623ac1067e7dcefd2afa29ab580f0dc47..0b6d1908a661721330c8b71b81abb34535f7a78d 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -32,6 +32,7 @@ function FeatureToggle({ <Checkbox checked={enabled} onChange={() => { + // @ts-expect-error key type is not correctly inferred savePrefs({ [`flags.${flag}`]: !enabled, }); diff --git a/packages/desktop-client/src/components/settings/Export.js b/packages/desktop-client/src/components/settings/Export.tsx similarity index 94% rename from packages/desktop-client/src/components/settings/Export.js rename to packages/desktop-client/src/components/settings/Export.tsx index 1417b5ff37747f3e096040afe3231b9b86895f65..bc966988c4ccf79ee61c446b839e959dd4ba9b23 100644 --- a/packages/desktop-client/src/components/settings/Export.js +++ b/packages/desktop-client/src/components/settings/Export.tsx @@ -10,7 +10,7 @@ import { Text, Button } from '../common'; import { Setting } from './UI'; export default function ExportBudget() { - let budgetId = useSelector(state => state.prefs.local.budgetId); + let budgetId = useSelector(state => state.prefs.local.id); let encryptKeyId = useSelector(state => state.prefs.local.encryptKeyId); async function onExport() { diff --git a/packages/desktop-client/src/components/settings/FixSplits.js b/packages/desktop-client/src/components/settings/FixSplits.tsx similarity index 92% rename from packages/desktop-client/src/components/settings/FixSplits.js rename to packages/desktop-client/src/components/settings/FixSplits.tsx index fa3eeb0db10ced059ae4088a481395f8761b5bc4..28892095fd2bbf2dabc3bface6e0f4564441fb85 100644 --- a/packages/desktop-client/src/components/settings/FixSplits.js +++ b/packages/desktop-client/src/components/settings/FixSplits.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { send } from 'loot-core/src/platform/client/fetch'; +import { type Handlers } from 'loot-core/src/types/handlers'; import { colors } from '../../style'; import { View, Text, ButtonWithLoading } from '../common'; @@ -8,7 +9,9 @@ import Paragraph from '../common/Paragraph'; import { Setting } from './UI'; -function renderResults(results) { +type Results = Awaited<ReturnType<Handlers['tools/fix-split-transactions']>>; + +function renderResults(results: Results) { let { numBlankPayees, numCleared, numDeleted } = results; let result = ''; if (numBlankPayees === 0 && numCleared === 0 && numDeleted === 0) { @@ -48,7 +51,7 @@ function renderResults(results) { export default function FixSplitsTool() { let [loading, setLoading] = useState(false); - let [results, setResults] = useState(null); + let [results, setResults] = useState<Results>(null); async function onFix() { setLoading(true); diff --git a/packages/desktop-client/src/components/settings/Format.js b/packages/desktop-client/src/components/settings/Format.tsx similarity index 83% rename from packages/desktop-client/src/components/settings/Format.js rename to packages/desktop-client/src/components/settings/Format.tsx index c92034ed75f945753f8e64f6116a69602ef263ad..666e330c93be39a79cf818fb0a9375d396c440a8 100644 --- a/packages/desktop-client/src/components/settings/Format.js +++ b/packages/desktop-client/src/components/settings/Format.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { type ReactNode } from 'react'; import { useSelector } from 'react-redux'; import { numberFormats } from 'loot-core/src/shared/util'; +import { type LocalPrefs } from 'loot-core/src/types/prefs'; import { useActions } from '../../hooks/useActions'; import tokens from '../../tokens'; @@ -13,7 +14,7 @@ import { Setting } from './UI'; // Follows Pikaday 'firstDay' numbering // https://github.com/Pikaday/Pikaday -let daysOfWeek = [ +let daysOfWeek: { value: LocalPrefs['firstDayOfWeekIdx']; label: string }[] = [ { value: '0', label: 'Sunday' }, { value: '1', label: 'Monday' }, { value: '2', label: 'Tuesday' }, @@ -23,7 +24,7 @@ let daysOfWeek = [ { value: '6', label: 'Saturday' }, ]; -let dateFormats = [ +let dateFormats: { value: LocalPrefs['dateFormat']; label: string }[] = [ { value: 'MM/dd/yyyy', label: 'MM/DD/YYYY' }, { value: 'dd/MM/yyyy', label: 'DD/MM/YYYY' }, { value: 'yyyy-MM-dd', label: 'YYYY-MM-DD' }, @@ -31,7 +32,7 @@ let dateFormats = [ { value: 'dd.MM.yyyy', label: 'DD.MM.YYYY' }, ]; -function Column({ title, children }) { +function Column({ title, children }: { title: string; children: ReactNode }) { return ( <View style={{ @@ -50,23 +51,6 @@ function Column({ title, children }) { export default function FormatSettings() { let { savePrefs } = useActions(); - function onFirstDayOfWeek(idx) { - savePrefs({ firstDayOfWeekIdx: idx }); - } - - function onDateFormat(format) { - savePrefs({ dateFormat: format }); - } - - function onNumberFormat(format) { - savePrefs({ numberFormat: format }); - } - - function onHideFraction(e) { - let hideFraction = e.target.checked; - savePrefs({ hideFraction }); - } - let sidebar = useSidebar(); let firstDayOfWeekIdx = useSelector( state => state.prefs.local.firstDayOfWeekIdx || '0', // Sunday @@ -99,9 +83,9 @@ export default function FormatSettings() { <Column title="Numbers"> <Button bounce={false} style={{ padding: 0 }}> <Select - key={hideFraction} // needed because label does not update + key={String(hideFraction)} // needed because label does not update value={numberFormat} - onChange={onNumberFormat} + onChange={format => savePrefs({ numberFormat: format })} options={numberFormats.map(f => [ f.value, hideFraction ? f.labelNoFraction : f.label, @@ -114,7 +98,9 @@ export default function FormatSettings() { <Checkbox id="settings-textDecimal" checked={!!hideFraction} - onChange={onHideFraction} + onChange={e => + savePrefs({ hideFraction: e.currentTarget.checked }) + } /> <label htmlFor="settings-textDecimal">Hide decimal places</label> </Text> @@ -124,7 +110,7 @@ export default function FormatSettings() { <Button bounce={false} style={{ padding: 0 }}> <Select value={dateFormat} - onChange={onDateFormat} + onChange={format => savePrefs({ dateFormat: format })} options={dateFormats.map(f => [f.value, f.label])} style={{ padding: '2px 10px', fontSize: 15 }} /> @@ -135,7 +121,7 @@ export default function FormatSettings() { <Button bounce={false} style={{ padding: 0 }}> <Select value={firstDayOfWeekIdx} - onChange={onFirstDayOfWeek} + onChange={idx => savePrefs({ firstDayOfWeekIdx: idx })} options={daysOfWeek.map(f => [f.value, f.label])} style={{ padding: '2px 10px', fontSize: 15 }} /> diff --git a/packages/desktop-client/src/components/settings/Global.js b/packages/desktop-client/src/components/settings/Global.tsx similarity index 97% rename from packages/desktop-client/src/components/settings/Global.js rename to packages/desktop-client/src/components/settings/Global.tsx index 318332e8c88c252c30228266eddf3d108851ec3d..1ea218976b204831eb714ea6b5db8c0bfda8f36c 100644 --- a/packages/desktop-client/src/components/settings/Global.js +++ b/packages/desktop-client/src/components/settings/Global.tsx @@ -13,7 +13,7 @@ export default function GlobalSettings() { let { saveGlobalPrefs } = useActions(); let [documentDirChanged, setDirChanged] = useState(false); - let dirScrolled = useRef(null); + let dirScrolled = useRef<HTMLSpanElement>(null); useEffect(() => { if (dirScrolled.current) { diff --git a/packages/desktop-client/src/components/settings/Reset.js b/packages/desktop-client/src/components/settings/Reset.tsx similarity index 100% rename from packages/desktop-client/src/components/settings/Reset.js rename to packages/desktop-client/src/components/settings/Reset.tsx diff --git a/packages/desktop-client/src/components/settings/Themes.js b/packages/desktop-client/src/components/settings/Themes.tsx similarity index 100% rename from packages/desktop-client/src/components/settings/Themes.js rename to packages/desktop-client/src/components/settings/Themes.tsx diff --git a/packages/desktop-client/src/components/settings/UI.tsx b/packages/desktop-client/src/components/settings/UI.tsx index d5f9bdd9cab489bcb3593381b23d38c97c8bb7cc..b3052256e1d432874fb4858bfd3c2200bde7c041 100644 --- a/packages/desktop-client/src/components/settings/UI.tsx +++ b/packages/desktop-client/src/components/settings/UI.tsx @@ -9,7 +9,7 @@ import tokens from '../../tokens'; import { View, LinkButton } from '../common'; type SettingProps = { - primaryAction: ReactNode; + primaryAction?: ReactNode; style?: CSSProperties; children: ReactNode; }; diff --git a/packages/desktop-client/src/components/settings/index.js b/packages/desktop-client/src/components/settings/index.tsx similarity index 97% rename from packages/desktop-client/src/components/settings/index.js rename to packages/desktop-client/src/components/settings/index.tsx index e0571dd2d1619f7e0958a9b47ed57f849bfe49bc..def408102834e6f0798ea5db24dae059d44e0d65 100644 --- a/packages/desktop-client/src/components/settings/index.js +++ b/packages/desktop-client/src/components/settings/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { type ReactNode, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { media } from 'glamor'; @@ -75,7 +75,7 @@ function About() { ); } -function IDName({ children }) { +function IDName({ children }: { children: ReactNode }) { return <Text style={{ fontWeight: 500 }}>{children}</Text>; } diff --git a/packages/loot-core/src/client/actions/modals.ts b/packages/loot-core/src/client/actions/modals.ts index bcb71af529e7fc74b9958afb7b7ff14aabe51214..efc1a0ae124d05d6787f1d2721b8c76da4830673 100644 --- a/packages/loot-core/src/client/actions/modals.ts +++ b/packages/loot-core/src/client/actions/modals.ts @@ -1,24 +1,37 @@ import * as constants from '../constants'; import type { + OptionlessModal, CloseModalAction, - Modal, PopModalAction, PushModalAction, ReplaceModalAction, + ModalWithOptions, + ModalType, + FinanceModals, } from '../state-types/modals'; -export function pushModal<M extends Modal>( - name: M['name'], - options: M['options'], +export function pushModal<M extends keyof ModalWithOptions>( + name: M, + options: ModalWithOptions[M], +): PushModalAction; +export function pushModal(name: OptionlessModal): PushModalAction; +export function pushModal<M extends ModalType>( + name: M, + options?: FinanceModals[M], ): PushModalAction { // @ts-expect-error TS is unable to determine that `name` and `options` match let modal: M = { name, options }; return { type: constants.PUSH_MODAL, modal }; } -export function replaceModal<M extends Modal>( - name: M['name'], - options: M['options'], +export function replaceModal<M extends keyof ModalWithOptions>( + name: M, + options: ModalWithOptions[M], +): ReplaceModalAction; +export function replaceModal(name: OptionlessModal): ReplaceModalAction; +export function replaceModal<M extends ModalType>( + name: M, + options?: FinanceModals[M], ): ReplaceModalAction { // @ts-expect-error TS is unable to determine that `name` and `options` match let modal: M = { name, options }; diff --git a/packages/loot-core/src/client/actions/prefs.ts b/packages/loot-core/src/client/actions/prefs.ts index 3521e89aefd3a1ccb571577131cb19efd507a10b..d309e017327ff53e75a5d8c6b257b76a21543433 100644 --- a/packages/loot-core/src/client/actions/prefs.ts +++ b/packages/loot-core/src/client/actions/prefs.ts @@ -1,4 +1,5 @@ import { send } from '../../platform/client/fetch'; +import type * as prefs from '../../types/prefs'; import * as constants from '../constants'; import { closeModal } from './modals'; @@ -24,7 +25,7 @@ export function loadPrefs() { }; } -export function savePrefs(prefs) { +export function savePrefs(prefs: Partial<prefs.LocalPrefs>) { return async (dispatch: Dispatch) => { await send('save-prefs', prefs); dispatch({ @@ -46,7 +47,7 @@ export function loadGlobalPrefs() { }; } -export function saveGlobalPrefs(prefs) { +export function saveGlobalPrefs(prefs: Partial<prefs.GlobalPrefs>) { return async (dispatch: Dispatch) => { await send('save-global-prefs', prefs); dispatch({ 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 6065b00265e8a2f341eddf1ca61244d4ce9c887e..c4f1fac446d90a84ead792ee912c70361dc00345 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -1,13 +1,19 @@ import type { AccountEntity } from '../../types/models'; import type { RuleEntity } from '../../types/models/rule'; +import type { EmptyObject, StripNever } from '../../types/util'; import type * as constants from '../constants'; -type Modal = { - [K in keyof FinanceModals]: { - name: K; - options: FinanceModals[K]; - }; -}[keyof FinanceModals]; +export type ModalType = keyof FinanceModals; + +export type OptionlessModal = { + [K in ModalType]: EmptyObject extends FinanceModals[K] ? K : never; +}[ModalType]; + +export type ModalWithOptions = StripNever<{ + [K in ModalType]: keyof FinanceModals[K] extends never + ? never + : FinanceModals[K]; +}>; // There is a separate (overlapping!) set of modals for the management app. Fun! type FinanceModals = { @@ -17,8 +23,8 @@ type FinanceModals = { onImported: (didChange: boolean) => void; }; - 'add-account': null; - 'add-local-account': null; + 'add-account': EmptyObject; + 'add-local-account': EmptyObject; 'close-account': { account: AccountEntity; balance: number; @@ -29,16 +35,15 @@ type FinanceModals = { requisitionId: string; upgradingAccountId: string; }; - 'configure-linked-accounts': never; 'confirm-category-delete': { onDelete: () => void } & ( | { category: string } | { group: string } ); - 'load-backup': null; + 'load-backup': EmptyObject; - 'manage-rules': { payeeId: string } | null; + 'manage-rules': { payeeId?: string }; 'edit-rule': { rule: RuleEntity; onSave: (rule: RuleEntity) => void; @@ -65,10 +70,10 @@ type FinanceModals = { onSuccess: (data: unknown) => Promise<void>; }; - 'create-encryption-key': { recreate: boolean } | null; + 'create-encryption-key': { recreate?: boolean }; 'fix-encryption-key': { - hasExistingKey: boolean; - cloudFileId: string; + hasExistingKey?: boolean; + cloudFileId?: string; onSuccess?: () => void; }; diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index e070e1a2e8a7e5861d7edb6532b9a4ee025aae66..83e8e6f961894dbab258ef99f4b19683f1552878 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -1903,9 +1903,9 @@ handlers['download-budget'] = async function ({ fileId }) { // open and sync, but don’t close handlers['sync-budget'] = async function () { setSyncingMode('enabled'); - await initialFullSync(); + let result = await initialFullSync(); - return {}; + return result; }; handlers['load-budget'] = async function ({ id }) { diff --git a/packages/loot-core/src/server/sync/index.ts b/packages/loot-core/src/server/sync/index.ts index 34dceb7dd1ff988138db3ab39e83dc824a7d524f..114c337906b02e8e8b5c79d59fb92e152d0f543d 100644 --- a/packages/loot-core/src/server/sync/index.ts +++ b/packages/loot-core/src/server/sync/index.ts @@ -538,12 +538,16 @@ function getTablesFromMessages(messages: Message[]): string[] { // spreadsheet to finish any processing. This is useful if we want to // perform a full sync and wait for everything to finish, usually if // you're doing an initial sync before working with a file. -export async function initialFullSync(): Promise<void> { +export async function initialFullSync(): Promise<{ + error?: { message: string; reason: string; meta: unknown }; +}> { let result = await fullSync(); if (isError(result)) { // Make sure to wait for anything in the spreadsheet to process await sheet.waitOnSpreadsheet(); + return result; } + return {}; } export const fullSync = once(async function (): Promise< diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index 4dcd19128b5a0d5897fc4a3e530d4fbfd414184b..4906c326e9df8f54ff4980845e6c35dbac41d3b0 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -9,7 +9,12 @@ export type FeatureFlag = export type LocalPrefs = Partial< { firstDayOfWeekIdx: `${0 | 1 | 2 | 3 | 4 | 5 | 6}`; - dateFormat: string; + dateFormat: + | 'MM/dd/yyyy' + | 'dd/MM/yyyy' + | 'yyyy-MM-dd' + | 'MM.dd.yyyy' + | 'dd.MM.yyyy'; numberFormat: (typeof numberFormats)[number]['value']; hideFraction: boolean; hideClosedAccounts: boolean; diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 32b56f1c1206545776dcfac66342f6774c8a9a50..8ce437c00631f071a71cd0e80a108f4989fee14e 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -301,7 +301,9 @@ export interface ServerHandlers { 'download-budget': (arg: { fileId; replace? }) => Promise<{ error; id }>; - 'sync-budget': () => Promise<EmptyObject>; + 'sync-budget': () => Promise<{ + error?: { message: string; reason: string; meta: unknown }; + }>; 'load-budget': (arg: { id }) => Promise<{ error }>; diff --git a/packages/loot-core/src/types/util.d.ts b/packages/loot-core/src/types/util.d.ts index 253e96456b8eafdbb7e9e15e51b35409cad47b68..f478d90061fd1ff1b75e7307f5197d99b2f38f43 100644 --- a/packages/loot-core/src/types/util.d.ts +++ b/packages/loot-core/src/types/util.d.ts @@ -1 +1,5 @@ -export type EmptyObject = Record<string, never>; +export type EmptyObject = Record<never, never>; + +export type StripNever<T> = { + [K in keyof T as T[K] extends never ? never : K]: T[K]; +}; diff --git a/packages/loot-core/typings/window.d.ts b/packages/loot-core/typings/window.d.ts index 001ef57094cabe797c513caf3176e4ca2ede9716..00401f26741be289a3a20571b51055e84d880ded 100644 --- a/packages/loot-core/typings/window.d.ts +++ b/packages/loot-core/typings/window.d.ts @@ -8,6 +8,14 @@ declare global { IS_FAKE_WEB: boolean; ACTUAL_VERSION: string; openURLInBrowser: (url: string) => void; + saveFile: ( + contents: Buffer, + filename: string, + dialogTitle: string, + ) => void; + openFileDialog: ( + opts: Parameters<import('electron').Dialog['showOpenDialogSync']>[0], + ) => Promise<string[]>; }; __navigate?: import('react-router').NavigateFunction; diff --git a/upcoming-release-notes/1405.md b/upcoming-release-notes/1405.md new file mode 100644 index 0000000000000000000000000000000000000000..cf194652c76cfc7bcb69a4c026b96cf062c3dbe0 --- /dev/null +++ b/upcoming-release-notes/1405.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [j-f1] +--- + +Port the settings-related code to TypeScript