mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 10:33:02 -05:00
Port the settings components to TS (#1405)
This commit is contained in:
@@ -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
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ function FeatureToggle({
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
// @ts-expect-error key type is not correctly inferred
|
||||
savePrefs({
|
||||
[`flags.${flag}`]: !enabled,
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
@@ -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);
|
||||
@@ -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 }}
|
||||
/>
|
||||
@@ -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) {
|
||||
@@ -9,7 +9,7 @@ import tokens from '../../tokens';
|
||||
import { View, LinkButton } from '../common';
|
||||
|
||||
type SettingProps = {
|
||||
primaryAction: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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<
|
||||
|
||||
7
packages/loot-core/src/types/prefs.d.ts
vendored
7
packages/loot-core/src/types/prefs.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
|
||||
6
packages/loot-core/src/types/util.d.ts
vendored
6
packages/loot-core/src/types/util.d.ts
vendored
@@ -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];
|
||||
};
|
||||
|
||||
8
packages/loot-core/typings/window.d.ts
vendored
8
packages/loot-core/typings/window.d.ts
vendored
@@ -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;
|
||||
|
||||
6
upcoming-release-notes/1405.md
Normal file
6
upcoming-release-notes/1405.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [j-f1]
|
||||
---
|
||||
|
||||
Port the settings-related code to TypeScript
|
||||
Reference in New Issue
Block a user