From e0d7233b401f39948ead6d2a5c41a3f613e7bec7 Mon Sep 17 00:00:00 2001 From: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com> Date: Sat, 3 Aug 2024 11:06:47 -0400 Subject: [PATCH] Support type-checking on spreadsheet fields (part 1) (#3093) * Correct table usage of `onBlur` * Add basic spreadsheet typing structure * Move to different module * Add account typing * Add release notes * Fix lint * Remove unneeded diff * PR feedback --- .../src/components/sidebar/Account.tsx | 10 +-- .../src/components/spreadsheet/index.ts | 40 ++++++++++- .../desktop-client/src/components/table.tsx | 51 ++++++++++---- packages/loot-core/src/client/queries.ts | 68 ++++++++++++++----- upcoming-release-notes/3093.md | 6 ++ 5 files changed, 136 insertions(+), 39 deletions(-) create mode 100644 upcoming-release-notes/3093.md diff --git a/packages/desktop-client/src/components/sidebar/Account.tsx b/packages/desktop-client/src/components/sidebar/Account.tsx index b882d1cf8..28c4420a8 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<FieldName extends SheetFields<'account'>> = { 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<FieldName extends SheetFields<'account'>>({ name, account, connected, @@ -65,7 +65,7 @@ export function Account({ outerStyle, onDragChange, onDrop, -}: AccountProps) { +}: AccountProps<FieldName>) { 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 0a069c8c6..5e3df0052 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<SheetName extends SheetNames> = + 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<SheetName> = any, +> = + | SheetFieldName + | { + name: SheetFieldName; + value?: Spreadsheets[SheetName][SheetFieldName]; + query?: Query; + }; +export const parametrizedField = + <SheetName extends SheetNames>() => + <SheetFieldName extends SheetFields<SheetName>>(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 7a7002410..0d3317d9a 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<typeof Input> & { +type InputValueProps = Omit<ComponentProps<typeof Input>, 'value'> & { value?: string; }; @@ -671,31 +676,47 @@ export function SelectCell({ ); } -type SheetCellValueProps = { - binding: Binding; +type SheetCellValueProps< + SheetName extends SheetNames, + FieldName extends SheetFields<SheetName>, +> = { + binding: Binding<SheetName, FieldName>; 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<typeof Cell> & { - valueProps: SheetCellValueProps; - inputProps?: Omit<ComponentProps<typeof InputValue>, 'value' | 'onUpdate'>; +type SheetCellProps< + SheetName extends SheetNames, + FieldName extends SheetFields<SheetName>, +> = ComponentProps<typeof Cell> & { + valueProps: SheetCellValueProps<SheetName, FieldName>; + inputProps?: Omit< + ComponentProps<typeof InputValue>, + '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<SheetName> = any, +>({ valueProps, valueStyle, inputProps, textAlign, onSave, ...props -}: SheetCellProps) { +}: SheetCellProps<SheetName, FieldName>) { 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 ( <InputValue - value={formatExpr ? formatExpr(sheetValue) : sheetValue} + value={formatExpr ? formatExpr(sheetValue) : sheetValue.toString()} onUpdate={value => { onSave(unformatExpr ? unformatExpr(value) : value); }} diff --git a/packages/loot-core/src/client/queries.ts b/packages/loot-core/src/client/queries.ts index 06279fd33..3c6402249 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<SheetName extends SheetNames>(): 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 000000000..10a21f26e --- /dev/null +++ b/upcoming-release-notes/3093.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [jfdoming] +--- + +Support type-checking on spreadsheet fields (part 1) -- GitLab