Port the settings components to TS (#1405)

This commit is contained in:
Jed Fox
2023-07-30 07:24:55 -07:00
committed by GitHub
parent fd5ace58b4
commit e8b3419933
22 changed files with 130 additions and 78 deletions

View File

@@ -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

View File

@@ -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 (

View File

@@ -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 });
}

View File

@@ -32,6 +32,7 @@ function FeatureToggle({
<Checkbox
checked={enabled}
onChange={() => {
// @ts-expect-error key type is not correctly inferred
savePrefs({
[`flags.${flag}`]: !enabled,
});

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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 }}
/>

View File

@@ -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) {

View File

@@ -9,7 +9,7 @@ import tokens from '../../tokens';
import { View, LinkButton } from '../common';
type SettingProps = {
primaryAction: ReactNode;
primaryAction?: ReactNode;
style?: CSSProperties;
children: ReactNode;
};

View File

@@ -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>;
}

View File

@@ -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 };

View File

@@ -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({

View File

@@ -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;
};

View File

@@ -1903,9 +1903,9 @@ handlers['download-budget'] = async function ({ fileId }) {
// open and sync, but dont close
handlers['sync-budget'] = async function () {
setSyncingMode('enabled');
await initialFullSync();
let result = await initialFullSync();
return {};
return result;
};
handlers['load-budget'] = async function ({ id }) {

View File

@@ -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<

View File

@@ -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;

View File

@@ -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 }>;

View File

@@ -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];
};

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [j-f1]
---
Port the settings-related code to TypeScript