diff --git a/packages/desktop-client/src/components/sidebar/Account.tsx b/packages/desktop-client/src/components/sidebar/Account.tsx index b882d1cf8b2..28c4420a8aa 100644 --- a/packages/desktop-client/src/components/sidebar/Account.tsx +++ b/packages/desktop-client/src/components/sidebar/Account.tsx @@ -21,7 +21,7 @@ import { type OnDragChangeCallback, type OnDropCallback, } from '../sort'; -import { type Binding } from '../spreadsheet'; +import { type SheetFields, type Binding } from '../spreadsheet'; import { CellValue } from '../spreadsheet/CellValue'; export const accountNameStyle: CSSProperties = { @@ -37,10 +37,10 @@ export const accountNameStyle: CSSProperties = { ...styles.smallText, }; -type AccountProps = { +type AccountProps> = { name: string; to: string; - query: Binding; + query: Binding<'account', FieldName>; account?: AccountEntity; connected?: boolean; pending?: boolean; @@ -52,7 +52,7 @@ type AccountProps = { onDrop?: OnDropCallback; }; -export function Account({ +export function Account>({ name, account, connected, @@ -65,7 +65,7 @@ export function Account({ outerStyle, onDragChange, onDrop, -}: AccountProps) { +}: AccountProps) { const type = account ? account.closed ? 'account-closed' diff --git a/packages/desktop-client/src/components/spreadsheet/index.ts b/packages/desktop-client/src/components/spreadsheet/index.ts index 0a069c8c65f..5e3df0052be 100644 --- a/packages/desktop-client/src/components/spreadsheet/index.ts +++ b/packages/desktop-client/src/components/spreadsheet/index.ts @@ -1,4 +1,40 @@ -// @ts-strict-ignore import { type Query } from 'loot-core/src/shared/query'; -export type Binding = string | { name: string; value?; query?: Query }; +export type Spreadsheets = { + account: { + // Common fields + 'uncategorized-amount': number; + 'uncategorized-balance': number; + + // Account fields + balance: number; + 'accounts-balance': number; + 'budgeted-accounts-balance': number; + 'offbudget-accounts-balance': number; + balanceCleared: number; + balanceUncleared: number; + }; +}; + +export type SheetNames = keyof Spreadsheets & string; + +export type SheetFields = + keyof Spreadsheets[SheetName] & string; + +export type Binding< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + SheetName extends SheetNames = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + SheetFieldName extends SheetFields = any, +> = + | SheetFieldName + | { + name: SheetFieldName; + value?: Spreadsheets[SheetName][SheetFieldName]; + query?: Query; + }; +export const parametrizedField = + () => + >(field: SheetFieldName) => + (id: string): SheetFieldName => + `${field}-${id}` as SheetFieldName; diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 7a7002410bd..0d3317d9a8d 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -40,7 +40,12 @@ import { ConditionalPrivacyFilter, mergeConditionalPrivacyFilterProps, } from './PrivacyFilter'; -import { type Binding } from './spreadsheet'; +import { + type Spreadsheets, + type SheetFields, + type SheetNames, + type Binding, +} from './spreadsheet'; import { type FormatType, useFormat } from './spreadsheet/useFormat'; import { useSheetValue } from './spreadsheet/useSheetValue'; @@ -311,7 +316,7 @@ const readonlyInputStyle = { '::selection': { backgroundColor: theme.formInputTextReadOnlySelection }, }; -type InputValueProps = ComponentProps & { +type InputValueProps = Omit, 'value'> & { value?: string; }; @@ -671,31 +676,47 @@ export function SelectCell({ ); } -type SheetCellValueProps = { - binding: Binding; +type SheetCellValueProps< + SheetName extends SheetNames, + FieldName extends SheetFields, +> = { + binding: Binding; type: FormatType; - getValueStyle?: (value: string | number) => CSSProperties; - formatExpr?: (value) => string; + getValueStyle?: (value: Spreadsheets[SheetName][FieldName]) => CSSProperties; + formatExpr?: (value: Spreadsheets[SheetName][FieldName]) => string; unformatExpr?: (value: string) => unknown; privacyFilter?: ComponentProps< typeof ConditionalPrivacyFilter >['privacyFilter']; }; -type SheetCellProps = ComponentProps & { - valueProps: SheetCellValueProps; - inputProps?: Omit, 'value' | 'onUpdate'>; +type SheetCellProps< + SheetName extends SheetNames, + FieldName extends SheetFields, +> = ComponentProps & { + valueProps: SheetCellValueProps; + inputProps?: Omit< + ComponentProps, + 'value' | 'onUpdate' | 'onBlur' + > & { + onBlur?: () => void; + }; onSave?: (value) => void; textAlign?: CSSProperties['textAlign']; }; -export function SheetCell({ +export function SheetCell< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + SheetName extends SheetNames = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FieldName extends SheetFields = any, +>({ valueProps, valueStyle, inputProps, textAlign, onSave, ...props -}: SheetCellProps) { +}: SheetCellProps) { const { binding, type, @@ -705,10 +726,10 @@ export function SheetCell({ privacyFilter, } = valueProps; - const sheetValue = useSheetValue(binding, e => { + const sheetValue = useSheetValue(binding, () => { // "close" the cell if it's editing if (props.exposed && inputProps && inputProps.onBlur) { - inputProps.onBlur(e); + inputProps.onBlur(); } }); const format = useFormat(); @@ -722,7 +743,7 @@ export function SheetCell({ } textAlign={textAlign} {...props} - value={sheetValue} + value={String(sheetValue ?? '')} formatter={value => props.formatter ? props.formatter(value, type) : format(value, type) } @@ -738,7 +759,7 @@ export function SheetCell({ {() => { return ( { onSave(unformatExpr ? unformatExpr(value) : value); }} diff --git a/packages/loot-core/src/client/queries.ts b/packages/loot-core/src/client/queries.ts index 06279fd3360..3c6402249a9 100644 --- a/packages/loot-core/src/client/queries.ts +++ b/packages/loot-core/src/client/queries.ts @@ -1,6 +1,11 @@ // @ts-strict-ignore import { parse as parseDate, isValid as isDateValid } from 'date-fns'; +import { + parametrizedField, + type Binding, + type SheetNames, +} from '../../../desktop-client/src/components/spreadsheet'; import { dayFromDate, getDayMonthRegex, @@ -8,10 +13,14 @@ import { getShortYearRegex, getShortYearFormat, } from '../shared/months'; -import { q } from '../shared/query'; +import { q, type Query } from '../shared/query'; import { currencyToAmount, amountToInteger } from '../shared/util'; +import { type CategoryEntity, type AccountEntity } from '../types/models'; +import { type LocalPrefs } from '../types/prefs'; + +const accountParametrizedField = parametrizedField<'account'>(); -export function getAccountFilter(accountId, field = 'account') { +export function getAccountFilter(accountId: string, field = 'account') { if (accountId) { if (accountId === 'budgeted') { return { @@ -47,7 +56,7 @@ export function getAccountFilter(accountId, field = 'account') { return null; } -export function makeTransactionsQuery(accountId) { +export function makeTransactionsQuery(accountId: string) { let query = q('transactions').options({ splits: 'grouped' }); const filter = getAccountFilter(accountId); @@ -58,7 +67,11 @@ export function makeTransactionsQuery(accountId) { return query; } -export function makeTransactionSearchQuery(currentQuery, search, dateFormat) { +export function makeTransactionSearchQuery( + currentQuery: Query, + search: string, + dateFormat: LocalPrefs['dateFormat'], +) { const amount = currencyToAmount(search); // Support various date formats @@ -94,9 +107,11 @@ export function makeTransactionSearchQuery(currentQuery, search, dateFormat) { }); } -export function accountBalance(acct) { +export function accountBalance( + acct: AccountEntity, +): Binding<'account', 'balance'> { return { - name: `balance-${acct.id}`, + name: accountParametrizedField('balance')(acct.id), query: q('transactions') .filter({ account: acct.id }) .options({ splits: 'none' }) @@ -104,9 +119,11 @@ export function accountBalance(acct) { }; } -export function accountBalanceCleared(acct) { +export function accountBalanceCleared( + acct: AccountEntity, +): Binding<'account', 'balanceCleared'> { return { - name: `balanceCleared-${acct.id}`, + name: accountParametrizedField('balanceCleared')(acct.id), query: q('transactions') .filter({ account: acct.id, cleared: true }) .options({ splits: 'none' }) @@ -114,9 +131,11 @@ export function accountBalanceCleared(acct) { }; } -export function accountBalanceUncleared(acct) { +export function accountBalanceUncleared( + acct: AccountEntity, +): Binding<'account', 'balanceUncleared'> { return { - name: `balanceUncleared-${acct.id}`, + name: accountParametrizedField('balanceUncleared')(acct.id), query: q('transactions') .filter({ account: acct.id, cleared: false }) .options({ splits: 'none' }) @@ -124,7 +143,7 @@ export function accountBalanceUncleared(acct) { }; } -export function allAccountBalance() { +export function allAccountBalance(): Binding<'account', 'accounts-balance'> { return { query: q('transactions') .filter({ 'account.closed': false }) @@ -133,7 +152,10 @@ export function allAccountBalance() { }; } -export function budgetedAccountBalance() { +export function budgetedAccountBalance(): Binding< + 'account', + 'budgeted-accounts-balance' +> { return { name: `budgeted-accounts-balance`, query: q('transactions') @@ -142,7 +164,10 @@ export function budgetedAccountBalance() { }; } -export function offbudgetAccountBalance() { +export function offbudgetAccountBalance(): Binding< + 'account', + 'offbudget-accounts-balance' +> { return { name: `offbudget-accounts-balance`, query: q('transactions') @@ -151,7 +176,7 @@ export function offbudgetAccountBalance() { }; } -export function categoryBalance(category, month) { +export function categoryBalance(category: CategoryEntity, month: string) { return { name: `balance-${category.id}`, query: q('transactions') @@ -164,7 +189,10 @@ export function categoryBalance(category, month) { }; } -export function categoryBalanceCleared(category, month) { +export function categoryBalanceCleared( + category: CategoryEntity, + month: string, +) { return { name: `balanceCleared-${category.id}`, query: q('transactions') @@ -178,7 +206,10 @@ export function categoryBalanceCleared(category, month) { }; } -export function categoryBalanceUncleared(category, month) { +export function categoryBalanceUncleared( + category: CategoryEntity, + month: string, +) { return { name: `balanceUncleared-${category.id}`, query: q('transactions') @@ -210,7 +241,10 @@ export function uncategorizedBalance() { }; } -export function uncategorizedCount() { +export function uncategorizedCount(): Binding< + SheetName, + 'uncategorized-amount' +> { return { name: 'uncategorized-amount', query: uncategorizedQuery.calculate({ $count: '$id' }), diff --git a/upcoming-release-notes/3093.md b/upcoming-release-notes/3093.md new file mode 100644 index 00000000000..10a21f26ecc --- /dev/null +++ b/upcoming-release-notes/3093.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [jfdoming] +--- + +Support type-checking on spreadsheet fields (part 1)