diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index da2b2e6790ffe9d87721b44d0e154eb6243e5d31..112df73b7aae98de32e20aaec21bee3d744f7fac 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -20,6 +20,7 @@ "@swc/plugin-react-remove-properties": "^1.5.121", "@testing-library/react": "14.1.2", "@testing-library/user-event": "14.5.2", + "@types/debounce": "^1.2.4", "@types/lodash": "^4", "@types/promise-retry": "^1.1.6", "@types/react": "^18.2.0", diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.tsx similarity index 77% rename from packages/desktop-client/src/components/accounts/Account.jsx rename to packages/desktop-client/src/components/accounts/Account.tsx index ece252ea1b19ce0cfbc644fa3aca9103dc5afb1e..9c5c9387789708a1d1dfb13db7b9cfe0a82ad7bf 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -1,4 +1,11 @@ -import React, { PureComponent, createRef, useMemo } from 'react'; +// @ts-strict-ignore +import React, { + PureComponent, + type MutableRefObject, + createRef, + useMemo, + type ReactElement, +} from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { Navigate, useParams, useLocation } from 'react-router-dom'; @@ -8,6 +15,7 @@ import { t } from 'i18next'; import { v4 as uuidv4 } from 'uuid'; import { validForTransfer } from 'loot-core/client/transfer'; +import { type UndoState } from 'loot-core/server/undo'; import { useFilters } from 'loot-core/src/client/data-hooks/filters'; import { SchedulesProvider, @@ -17,7 +25,7 @@ import * as queries from 'loot-core/src/client/queries'; import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers'; import { send, listen } from 'loot-core/src/platform/client/fetch'; import { currentDay } from 'loot-core/src/shared/months'; -import { q } from 'loot-core/src/shared/query'; +import { q, type Query } from 'loot-core/src/shared/query'; import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { updateTransaction, @@ -28,15 +36,28 @@ import { makeAsNonChildTransactions, } from 'loot-core/src/shared/transactions'; import { applyChanges, groupById } from 'loot-core/src/shared/util'; +import { + type NewRuleEntity, + type RuleActionEntity, + type AccountEntity, + type PayeeEntity, + type RuleConditionEntity, + type TransactionEntity, + type TransactionFilterEntity, +} from 'loot-core/src/types/models'; import { useAccounts } from '../../hooks/useAccounts'; import { useActions } from '../../hooks/useActions'; import { useCategories } from '../../hooks/useCategories'; import { useDateFormat } from '../../hooks/useDateFormat'; import { useFailedAccounts } from '../../hooks/useFailedAccounts'; +import { useLocalPref } from '../../hooks/useLocalPref'; import { usePayees } from '../../hooks/usePayees'; import { usePreviewTransactions } from '../../hooks/usePreviewTransactions'; -import { SelectedProviderWithItems } from '../../hooks/useSelected'; +import { + SelectedProviderWithItems, + type Actions, +} from '../../hooks/useSelected'; import { SplitsExpandedProvider, useSplitsExpanded, @@ -51,7 +72,19 @@ import { TransactionList } from '../transactions/TransactionList'; import { AccountHeader } from './Header'; -function EmptyMessage({ onAdd }) { +type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity; + +function isTransactionFilterEntity( + filter: ConditionEntity, +): filter is TransactionFilterEntity { + return 'id' in filter; +} + +type EmptyMessageProps = { + onAdd: () => void; +}; + +function EmptyMessage({ onAdd }: EmptyMessageProps) { return ( <View style={{ @@ -98,22 +131,34 @@ function EmptyMessage({ onAdd }) { ); } +type AllTransactionsProps = { + account?: AccountEntity; + transactions: TransactionEntity[]; + balances: Record<string, { balance: number }> | null; + showBalances?: boolean; + filtered?: boolean; + children: ( + transactions: TransactionEntity[], + balances: Record<string, { balance: number }> | null, + ) => ReactElement; + collapseTransactions: (ids: string[]) => void; +}; + function AllTransactions({ - account = {}, + account, transactions, balances, showBalances, filtered, children, collapseTransactions, -}) { - const accountId = account.id; - const prependTransactions = usePreviewTransactions(collapseTransactions).map( - trans => ({ +}: AllTransactionsProps) { + const accountId = account?.id; + const prependTransactions: (TransactionEntity & { _inverse?: boolean })[] = + usePreviewTransactions(collapseTransactions).map(trans => ({ ...trans, _inverse: accountId ? accountId !== trans.account : false, - }), - ); + })); transactions ??= []; @@ -140,6 +185,8 @@ function AllTransactions({ (scheduledTransaction._inverse ? -1 : 1) * getScheduledAmount(scheduledTransaction.amount); return { + // TODO: fix me + // eslint-disable-next-line react-hooks/exhaustive-deps balance: (runningBalance += amount), id: scheduledTransaction.id, }; @@ -169,7 +216,11 @@ function AllTransactions({ return children(allTransactions, allBalances); } -function getField(field) { +function getField(field?: string) { + if (!field) { + return 'date'; + } + switch (field) { case 'account': return 'account.name'; @@ -186,37 +237,120 @@ function getField(field) { } } -class AccountInternal extends PureComponent { - constructor(props) { +type AccountInternalProps = { + accountId?: string; + filterConditions: RuleConditionEntity[]; + showBalances?: boolean; + setShowBalances: (newValue: boolean) => void; + showCleared?: boolean; + setShowCleared: (newValue: boolean) => void; + showReconciled: boolean; + setShowReconciled: (newValue: boolean) => void; + showExtraBalances?: boolean; + setShowExtraBalances: (newValue: boolean) => void; + modalShowing?: boolean; + setLastUndoState: (state: null) => void; + lastUndoState: { current: UndoState | null }; + accounts: AccountEntity[]; + getPayees: () => Promise<PayeeEntity[]>; + updateAccount: (newAccount: AccountEntity) => void; + newTransactions: string[]; + matchedTransactions: string[]; + splitsExpandedDispatch: ReturnType<typeof useSplitsExpanded>['dispatch']; + expandSplits?: boolean; + savedFilters: TransactionFilterEntity[]; + onBatchEdit: ReturnType<typeof useTransactionBatchActions>['onBatchEdit']; + onBatchDuplicate: ReturnType< + typeof useTransactionBatchActions + >['onBatchDuplicate']; + onBatchLinkSchedule: ReturnType< + typeof useTransactionBatchActions + >['onBatchLinkSchedule']; + onBatchUnlinkSchedule: ReturnType< + typeof useTransactionBatchActions + >['onBatchUnlinkSchedule']; + onBatchDelete: ReturnType<typeof useTransactionBatchActions>['onBatchDelete']; + categoryId?: string; + location: ReturnType<typeof useLocation>; + failedAccounts: ReturnType<typeof useFailedAccounts>; + dateFormat: ReturnType<typeof useDateFormat>; + payees: ReturnType<typeof usePayees>; + categoryGroups: ReturnType<typeof useCategories>['grouped']; + hideFraction: boolean; + accountsSyncing: string[]; +} & ReturnType<typeof useActions>; +type AccountInternalState = { + search: string; + filterConditions: ConditionEntity[]; + filterId: Record<string, unknown>; + filterConditionsOp: 'and' | 'or'; + loading: boolean; + workingHard: boolean; + reconcileAmount: null | number; + transactions: TransactionEntity[]; + transactionCount: number; + transactionsFiltered?: boolean; + showBalances?: boolean; + balances: Record<string, { balance: number }> | null; + showCleared?: boolean; + prevShowCleared?: boolean; + showReconciled: boolean; + editingName: boolean; + isAdding: boolean; + modalShowing?: boolean; + sort: { + ascDesc: 'asc' | 'desc'; + field: string; + prevField?: string; + prevAscDesc?: 'asc' | 'desc'; + } | null; + filteredAmount: null | number; +}; + +class AccountInternal extends PureComponent< + AccountInternalProps, + AccountInternalState +> { + paged: ReturnType<typeof pagedQuery> | null; + rootQuery: Query; + currentQuery: Query; + table: MutableRefObject<{ + edit: (updatedId: string | null, op?: string, someBool?: boolean) => void; + setRowAnimation: (animation: boolean) => void; + scrollTo: (focusId: string) => void; + scrollToTop: () => void; + } | null>; + unlisten?: () => void; + dispatchSelected?: (action: Actions) => void; + + constructor(props: AccountInternalProps) { super(props); this.paged = null; this.table = createRef(); - this.animated = true; this.state = { search: '', filterConditions: props.filterConditions || [], - filterId: [], + filterId: {}, filterConditionsOp: 'and', loading: true, workingHard: false, reconcileAmount: null, transactions: [], - transactionsCount: 0, + transactionCount: 0, showBalances: props.showBalances, balances: null, showCleared: props.showCleared, showReconciled: props.showReconciled, editingName: false, isAdding: false, - latestDate: null, - sort: [], + sort: null, filteredAmount: null, }; } async componentDidMount() { - const maybeRefetch = tables => { + const maybeRefetch = (tables: string[]) => { if ( tables.includes('transactions') || tables.includes('category_mapping') || @@ -226,14 +360,14 @@ class AccountInternal extends PureComponent { } }; - const onUndo = async ({ tables, messages }) => { + const onUndo = async ({ tables, messages }: UndoState) => { await maybeRefetch(tables); // If all the messages are dealing with transactions, find the // first message referencing a non-deleted row so that we can // highlight the row // - let focusId; + let focusId: null | string; if ( messages.every(msg => msg.dataset === 'transactions') && !messages.find(msg => msg.column === 'tombstone') @@ -282,7 +416,7 @@ class AccountInternal extends PureComponent { } } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: AccountInternalProps) { // If the active account changes - close the transaction entry mode if (this.state.isAdding && this.props.accountId !== prevProps.accountId) { this.setState({ isAdding: false }); @@ -302,7 +436,7 @@ class AccountInternal extends PureComponent { //Resest sort/filter/search on account change if (this.props.accountId !== prevProps.accountId) { - this.setState({ sort: [], search: '', filterConditions: [] }); + this.setState({ sort: null, search: '', filterConditions: [] }); } } @@ -316,12 +450,12 @@ class AccountInternal extends PureComponent { } fetchAllIds = async () => { - const { data } = await runQuery(this.paged.getQuery().select('id')); + const { data } = await runQuery(this.paged?.getQuery().select('id')); // Remember, this is the `grouped` split type so we need to deal // with the `subtransactions` property - return data.reduce((arr, t) => { + return data.reduce((arr: string[], t: TransactionEntity) => { arr.push(t.id); - t.subtransactions.forEach(sub => arr.push(sub.id)); + t.subtransactions?.forEach(sub => arr.push(sub.id)); return arr; }, []); }; @@ -330,7 +464,7 @@ class AccountInternal extends PureComponent { this.paged?.run(); }; - fetchTransactions = filterConditions => { + fetchTransactions = (filterConditions?: ConditionEntity[]) => { const query = this.makeRootQuery(); this.rootQuery = this.currentQuery = query; if (filterConditions) this.applyFilters(filterConditions); @@ -347,7 +481,7 @@ class AccountInternal extends PureComponent { return queries.makeTransactionsQuery(accountId); }; - updateQuery(query, isFiltered) { + updateQuery(query: Query, isFiltered: boolean = false) { if (this.paged) { this.paged.unsubscribe(); } @@ -359,7 +493,10 @@ class AccountInternal extends PureComponent { this.paged = pagedQuery( query.select('*'), - async (data, prevData) => { + async ( + data: TransactionEntity[], + prevData: TransactionEntity[] | null, + ) => { const firstLoad = prevData == null; if (firstLoad) { @@ -381,7 +518,7 @@ class AccountInternal extends PureComponent { this.setState( { transactions: data, - transactionCount: this.paged.getTotalCount(), + transactionCount: this.paged?.getTotalCount(), transactionsFiltered: isFiltered, loading: false, workingHard: false, @@ -409,7 +546,7 @@ class AccountInternal extends PureComponent { ); } - UNSAFE_componentWillReceiveProps(nextProps) { + UNSAFE_componentWillReceiveProps(nextProps: AccountInternalProps) { if (this.props.accountId !== nextProps.accountId) { this.setState( { @@ -429,8 +566,8 @@ class AccountInternal extends PureComponent { } } - onSearch = value => { - this.paged.unsubscribe(); + onSearch = (value: string) => { + this.paged?.unsubscribe(); this.setState({ search: value }, this.onSearchDone); }; @@ -481,7 +618,7 @@ class AccountInternal extends PureComponent { accountId, categories, filename: res[0], - onImported: didChange => { + onImported: (didChange: boolean) => { if (didChange) { this.fetchTransactions(); } @@ -491,7 +628,7 @@ class AccountInternal extends PureComponent { } }; - onExport = async accountName => { + onExport = async (accountName: string) => { const { t } = useTranslation(); const exportedTransactions = await send('transactions-export-query', { @@ -508,10 +645,13 @@ class AccountInternal extends PureComponent { ); }; - onTransactionsChange = (newTransaction, data) => { + onTransactionsChange = ( + newTransaction: TransactionEntity, + data: TransactionEntity[], + ) => { // Apply changes to pagedQuery data - this.paged.optimisticUpdate( - data => { + this.paged?.optimisticUpdate( + (data: TransactionEntity[]) => { if (newTransaction._deleted) { return data.filter(t => t.id !== newTransaction.id); } else { @@ -537,7 +677,7 @@ class AccountInternal extends PureComponent { account && this.state.search === '' && this.state.filterConditions.length === 0 && - (this.state.sort.length === 0 || + (this.state.sort === null || (this.state.sort.field === 'date' && this.state.sort.ascDesc === 'desc')) ); @@ -550,45 +690,53 @@ class AccountInternal extends PureComponent { const { data } = await runQuery( this.paged - .getQuery() + ?.getQuery() .options({ splits: 'none' }) .select([{ balance: { $sumOver: '$amount' } }]), ); - return groupById(data); + return groupById<{ id: string; balance: number }>(data); } onAddTransaction = () => { this.setState({ isAdding: true }); }; - onExposeName = flag => { + onExposeName = (flag: boolean) => { this.setState({ editingName: flag }); }; - onSaveName = name => { + onSaveName = (name: string) => { if (name.trim().length) { const accountId = this.props.accountId; const account = this.props.accounts.find( account => account.id === accountId, - ); + )!; this.props.updateAccount({ ...account, name }); this.setState({ editingName: false }); } }; onToggleExtraBalances = () => { - const { accountId, showExtraBalances } = this.props; - const key = 'show-extra-balances-' + accountId || 'all-accounts'; - - this.props.savePrefs({ [key]: !showExtraBalances }); + this.props.setShowExtraBalances(!this.props.showExtraBalances); }; - onMenuSelect = async item => { - const accountId = this.props.accountId; + onMenuSelect = async ( + item: + | 'link' + | 'unlink' + | 'close' + | 'reopen' + | 'export' + | 'toggle-balance' + | 'remove-sorting' + | 'toggle-cleared' + | 'toggle-reconciled', + ) => { + const accountId = this.props.accountId!; const account = this.props.accounts.find( account => account.id === accountId, - ); + )!; switch (item) { case 'link': @@ -616,17 +764,17 @@ class AccountInternal extends PureComponent { break; case 'toggle-balance': if (this.state.showBalances) { - this.props.savePrefs({ ['show-balances-' + accountId]: false }); + this.props.setShowBalances(false); this.setState({ showBalances: false, balances: null }); } else { - this.props.savePrefs({ ['show-balances-' + accountId]: true }); + this.props.setShowBalances(true); this.setState( { transactions: [], transactionCount: 0, filterConditions: [], search: '', - sort: [], + sort: null, showBalances: true, }, () => { @@ -636,7 +784,7 @@ class AccountInternal extends PureComponent { } break; case 'remove-sorting': { - this.setState({ sort: [] }, () => { + this.setState({ sort: null }, () => { const filterConditions = this.state.filterConditions; if (filterConditions.length > 0) { this.applyFilters([...filterConditions]); @@ -651,21 +799,21 @@ class AccountInternal extends PureComponent { } case 'toggle-cleared': if (this.state.showCleared) { - this.props.savePrefs({ ['hide-cleared-' + accountId]: true }); + this.props.setShowCleared(false); this.setState({ showCleared: false }); } else { - this.props.savePrefs({ ['hide-cleared-' + accountId]: false }); + this.props.setShowCleared(true); this.setState({ showCleared: true }); } break; case 'toggle-reconciled': if (this.state.showReconciled) { - this.props.savePrefs({ ['hide-reconciled-' + accountId]: true }); + this.props.setShowReconciled(false); this.setState({ showReconciled: false }, () => this.fetchTransactions(this.state.filterConditions), ); } else { - this.props.savePrefs({ ['hide-reconciled-' + accountId]: false }); + this.props.setShowReconciled(true); this.setState({ showReconciled: true }, () => this.fetchTransactions(this.state.filterConditions), ); @@ -675,7 +823,7 @@ class AccountInternal extends PureComponent { } }; - getAccountTitle(account, id) { + getAccountTitle(account?: AccountEntity, id?: string) { const { filterName } = this.props.location.state || {}; if (filterName) { @@ -698,7 +846,7 @@ class AccountInternal extends PureComponent { return account.name; } - getBalanceQuery(account, id) { + getBalanceQuery(id?: string) { return { name: `balance-query-${id}`, query: this.makeRootQuery().calculate({ $sum: '$amount' }), @@ -707,20 +855,20 @@ class AccountInternal extends PureComponent { getFilteredAmount = async () => { const { data: amount } = await runQuery( - this.paged.getQuery().calculate({ $sum: '$amount' }), + this.paged?.getQuery().calculate({ $sum: '$amount' }), ); return amount; }; - isNew = id => { + isNew = (id: string) => { return this.props.newTransactions.includes(id); }; - isMatched = id => { + isMatched = (id: string) => { return this.props.matchedTransactions.includes(id); }; - onCreatePayee = name => { + onCreatePayee = (name: string) => { const trimmed = name.trim(); if (trimmed !== '') { return this.props.createPayee(name); @@ -741,7 +889,9 @@ class AccountInternal extends PureComponent { ); let transactions = ungroupTransactions(data); - const changes = { updated: [] }; + const changes: { updated: Array<Partial<TransactionEntity>> } = { + updated: [], + }; transactions.forEach(trans => { const { diff } = updateTransaction(transactions, { @@ -760,7 +910,7 @@ class AccountInternal extends PureComponent { await this.refetchTransactions(); }; - onReconcile = async balance => { + onReconcile = async (balance: number) => { this.setState(({ showCleared }) => ({ reconcileAmount: balance, showCleared: true, @@ -788,7 +938,7 @@ class AccountInternal extends PureComponent { } }); - const targetDiff = reconcileAmount - cleared; + const targetDiff = (reconcileAmount || 0) - cleared; if (targetDiff === 0) { await this.lockTransactions(); @@ -800,14 +950,14 @@ class AccountInternal extends PureComponent { }); }; - onCreateReconciliationTransaction = async diff => { + onCreateReconciliationTransaction = async (diff: number) => { const { t } = useTranslation(); // Create a new reconciliation transaction const reconciliationTransactions = realizeTempTransactions([ { id: 'temp', - account: this.props.accountId, + account: this.props.accountId!, cleared: true, reconciled: false, amount: diff, @@ -828,7 +978,7 @@ class AccountInternal extends PureComponent { await this.refetchTransactions(); }; - onShowTransactions = async ids => { + onShowTransactions = async (ids: string[]) => { const { t } = useTranslation(); this.onApplyFilter({ @@ -837,7 +987,7 @@ class AccountInternal extends PureComponent { }); }; - onBatchEdit = (name, ids) => { + onBatchEdit = (name: keyof TransactionEntity, ids: string[]) => { this.props.onBatchEdit({ name, ids, @@ -851,24 +1001,26 @@ class AccountInternal extends PureComponent { }); }; - onBatchDuplicate = ids => { + onBatchDuplicate = (ids: string[]) => { this.props.onBatchDuplicate({ ids, onSuccess: this.refetchTransactions }); }; - onBatchDelete = ids => { + onBatchDelete = (ids: string[]) => { this.props.onBatchDelete({ ids, onSuccess: this.refetchTransactions }); }; - onMakeAsSplitTransaction = async ids => { + onMakeAsSplitTransaction = async (ids: string[]) => { this.setState({ workingHard: true }); - const { data: transactions } = await runQuery( + const { data } = await runQuery( q('transactions') .filter({ id: { $oneof: ids } }) .select('*') .options({ splits: 'none' }), ); + const transactions: TransactionEntity[] = data; + if (!transactions || transactions.length === 0) { return; } @@ -896,17 +1048,22 @@ class AccountInternal extends PureComponent { this.refetchTransactions(); }; - onMakeAsNonSplitTransactions = async ids => { + onMakeAsNonSplitTransactions = async (ids: string[]) => { this.setState({ workingHard: true }); - const { data: groupedTransactions } = await runQuery( + const { data } = await runQuery( q('transactions') .filter({ id: { $oneof: ids } }) .select('*') .options({ splits: 'grouped' }), ); - let changes = { + const groupedTransactions: TransactionEntity[] = data; + + let changes: { + updated: TransactionEntity[]; + deleted: TransactionEntity[]; + } = { updated: [], deleted: [], }; @@ -961,13 +1118,17 @@ class AccountInternal extends PureComponent { this.refetchTransactions(); const transactionsToSelect = changes.updated.map(t => t.id); - this.dispatchSelected({ + this.dispatchSelected?.({ type: 'select-all', ids: transactionsToSelect, }); }; - checkForReconciledTransactions = async (ids, confirmReason, onConfirm) => { + checkForReconciledTransactions = async ( + ids: string[], + confirmReason: string, + onConfirm: (ids: string[]) => void, + ) => { const { data } = await runQuery( q('transactions') .filter({ id: { $oneof: ids }, reconciled: true }) @@ -987,7 +1148,7 @@ class AccountInternal extends PureComponent { } }; - onBatchLinkSchedule = ids => { + onBatchLinkSchedule = (ids: string[]) => { this.props.onBatchLinkSchedule({ ids, account: this.props.accounts.find(a => a.id === this.props.accountId), @@ -995,14 +1156,14 @@ class AccountInternal extends PureComponent { }); }; - onBatchUnlinkSchedule = ids => { + onBatchUnlinkSchedule = (ids: string[]) => { this.props.onBatchUnlinkSchedule({ ids, onSuccess: this.refetchTransactions, }); }; - onCreateRule = async ids => { + onCreateRule = async (ids: string[]) => { const { data } = await runQuery( q('transactions') .filter({ id: { $oneof: ids } }) @@ -1017,27 +1178,27 @@ class AccountInternal extends PureComponent { ); const payeeCondition = ruleTransaction.imported_payee - ? { + ? ({ field: 'imported_payee', op: 'is', value: ruleTransaction.imported_payee, type: 'string', - } - : { + } satisfies RuleConditionEntity) + : ({ field: 'payee', op: 'is', - value: ruleTransaction.payee, + value: ruleTransaction.payee!, type: 'id', - }; + } satisfies RuleConditionEntity); const amountCondition = { field: 'amount', op: 'isapprox', value: ruleTransaction.amount, type: 'number', - }; + } satisfies RuleConditionEntity; const rule = { - stage: null, + stage: 'default', conditionsOp: 'and', conditions: [payeeCondition, amountCondition], actions: [ @@ -1051,7 +1212,7 @@ class AccountInternal extends PureComponent { options: { splitIndex: 0, }, - }, + } satisfies RuleActionEntity, ] : []), ...childTransactions.flatMap((sub, index) => [ @@ -1062,7 +1223,7 @@ class AccountInternal extends PureComponent { splitIndex: index + 1, method: 'fixed-amount', }, - }, + } satisfies RuleActionEntity, { op: 'set', field: 'category', @@ -1071,16 +1232,16 @@ class AccountInternal extends PureComponent { options: { splitIndex: index + 1, }, - }, + } satisfies RuleActionEntity, ]), ], - }; + } satisfies NewRuleEntity; this.props.pushModal('edit-rule', { rule }); }; - onSetTransfer = async ids => { - const onConfirmTransfer = async ids => { + onSetTransfer = async (ids: string[]) => { + const onConfirmTransfer = async (ids: string[]) => { this.setState({ workingHard: true }); const payees = await this.props.getPayees(); @@ -1101,12 +1262,12 @@ class AccountInternal extends PureComponent { updated: [ { ...fromTrans, - payee: toPayee.id, + payee: toPayee?.id, transfer_id: toTrans.id, }, { ...toTrans, - payee: fromPayee.id, + payee: fromPayee?.id, transfer_id: fromTrans.id, }, ], @@ -1125,7 +1286,7 @@ class AccountInternal extends PureComponent { ); }; - onConditionsOpChange = value => { + onConditionsOpChange = (value: 'and' | 'or') => { this.setState({ filterConditionsOp: value }); this.setState({ filterId: { ...this.state.filterId, status: 'changed' } }); this.applyFilters([...this.state.filterConditions]); @@ -1134,16 +1295,21 @@ class AccountInternal extends PureComponent { } }; - onReloadSavedFilter = (savedFilter, item) => { + onReloadSavedFilter = ( + savedFilter: TransactionFilterEntity & { status?: string }, + item: string, + ) => { if (item === 'reload') { const [savedFilter] = this.props.savedFilters.filter( f => f.id === this.state.filterId.id, ); - this.setState({ filterConditionsOp: savedFilter.conditionsOp }); + this.setState({ filterConditionsOp: savedFilter.conditionsOp ?? 'and' }); this.applyFilters([...savedFilter.conditions]); } else { if (savedFilter.status) { - this.setState({ filterConditionsOp: savedFilter.conditionsOp }); + this.setState({ + filterConditionsOp: savedFilter.conditionsOp ?? 'and', + }); this.applyFilters([...savedFilter.conditions]); } } @@ -1152,14 +1318,17 @@ class AccountInternal extends PureComponent { onClearFilters = () => { this.setState({ filterConditionsOp: 'and' }); - this.setState({ filterId: [] }); + this.setState({ filterId: {} }); this.applyFilters([]); if (this.state.search !== '') { this.onSearch(this.state.search); } }; - onUpdateFilter = (oldCondition, updatedCondition) => { + onUpdateFilter = ( + oldCondition: RuleConditionEntity, + updatedCondition: RuleConditionEntity, + ) => { this.applyFilters( this.state.filterConditions.map(c => c === oldCondition ? updatedCondition : c, @@ -1176,10 +1345,10 @@ class AccountInternal extends PureComponent { } }; - onDeleteFilter = condition => { + onDeleteFilter = (condition: RuleConditionEntity) => { this.applyFilters(this.state.filterConditions.filter(c => c !== condition)); if (this.state.filterConditions.length === 1) { - this.setState({ filterId: [] }); + this.setState({ filterId: {} }); this.setState({ filterConditionsOp: 'and' }); } else { this.setState({ @@ -1194,14 +1363,9 @@ class AccountInternal extends PureComponent { } }; - onApplyFilter = async conditionOrSavedFilter => { + onApplyFilter = async (conditionOrSavedFilter: ConditionEntity) => { let filterConditions = this.state.filterConditions; - if (conditionOrSavedFilter.customName) { - filterConditions = filterConditions.filter( - c => c.customName !== conditionOrSavedFilter.customName, - ); - } - if (conditionOrSavedFilter.conditions) { + if (isTransactionFilterEntity(conditionOrSavedFilter)) { // A saved filter was passed in. const savedFilter = conditionOrSavedFilter; this.setState({ @@ -1210,6 +1374,12 @@ class AccountInternal extends PureComponent { this.setState({ filterConditionsOp: savedFilter.conditionsOp }); this.applyFilters([...savedFilter.conditions]); } else { + filterConditions = filterConditions.filter( + c => + !isTransactionFilterEntity(c) && + c.customName !== conditionOrSavedFilter.customName, + ); + // A condition was passed in. const condition = conditionOrSavedFilter; this.setState({ @@ -1220,12 +1390,16 @@ class AccountInternal extends PureComponent { }); this.applyFilters([...filterConditions, condition]); } + if (this.state.search !== '') { this.onSearch(this.state.search); } }; - onScheduleAction = async (name, ids) => { + onScheduleAction = async ( + name: 'skip' | 'post-transaction', + ids: string[], + ) => { switch (name) { case 'post-transaction': for (const id of ids) { @@ -1244,15 +1418,19 @@ class AccountInternal extends PureComponent { } }; - applyFilters = async conditions => { + applyFilters = async (conditions: ConditionEntity[]) => { if (conditions.length > 0) { - const customQueryFilters = conditions - .filter(cond => !!cond.customName) - .map(f => f.queryFilter); + const filteredCustomQueryFilters: Partial<RuleConditionEntity>[] = + conditions.filter(cond => !isTransactionFilterEntity(cond)); + const customQueryFilters = filteredCustomQueryFilters.map( + f => f.queryFilter, + ); const { filters: queryFilters } = await send( 'make-filters-from-conditions', { - conditions: conditions.filter(cond => !cond.customName), + conditions: conditions.filter( + cond => isTransactionFilterEntity(cond) || !cond.customName, + ), }, ); const conditionsOpKey = @@ -1282,24 +1460,33 @@ class AccountInternal extends PureComponent { ); } - if (this.state.sort.length !== 0) { + if (this.state.sort !== null) { this.applySort(); } }; - applySort = (field, ascDesc, prevField, prevAscDesc) => { + applySort = ( + field?: string, + ascDesc?: 'asc' | 'desc', + prevField?: string, + prevAscDesc?: 'asc' | 'desc', + ) => { const filterConditions = this.state.filterConditions; const isFiltered = filterConditions.length > 0; - const sortField = getField(!field ? this.state.sort.field : field); - const sortAscDesc = !ascDesc ? this.state.sort.ascDesc : ascDesc; + const sortField = getField(!field ? this.state.sort?.field : field); + const sortAscDesc = !ascDesc ? this.state.sort?.ascDesc : ascDesc; const sortPrevField = getField( - !prevField ? this.state.sort.prevField : prevField, + !prevField ? this.state.sort?.prevField : prevField, ); const sortPrevAscDesc = !prevField - ? this.state.sort.prevAscDesc + ? this.state.sort?.prevAscDesc : prevAscDesc; - const sortCurrentQuery = function (that, sortField, sortAscDesc) { + const sortCurrentQuery = function ( + that: AccountInternal, + sortField: string, + sortAscDesc?: 'asc' | 'desc', + ) { if (sortField === 'cleared') { that.currentQuery = that.currentQuery.orderBy({ reconciled: sortAscDesc, @@ -1311,7 +1498,11 @@ class AccountInternal extends PureComponent { }); }; - const sortRootQuery = function (that, sortField, sortAscDesc) { + const sortRootQuery = function ( + that: AccountInternal, + sortField: string, + sortAscDesc?: 'asc' | 'desc', + ) { if (sortField === 'cleared') { that.currentQuery = that.rootQuery.orderBy({ reconciled: sortAscDesc, @@ -1328,9 +1519,9 @@ class AccountInternal extends PureComponent { // sort by previously used sort field, if any const maybeSortByPreviousField = function ( - that, - sortPrevField, - sortPrevAscDesc, + that: AccountInternal, + sortPrevField: string, + sortPrevAscDesc?: 'asc' | 'desc', ) { if (!sortPrevField) { return; @@ -1373,12 +1564,12 @@ class AccountInternal extends PureComponent { this.updateQuery(this.currentQuery, isFiltered); }; - onSort = (headerClicked, ascDesc) => { - let prevField; - let prevAscDesc; + onSort = (headerClicked: string, ascDesc: 'asc' | 'desc') => { + let prevField: string | undefined; + let prevAscDesc: 'asc' | 'desc' | undefined; //if staying on same column but switching asc/desc //then keep prev the same - if (headerClicked === this.state.sort.field) { + if (headerClicked === this.state.sort?.field) { prevField = this.state.sort.prevField; prevAscDesc = this.state.sort.prevAscDesc; this.setState({ @@ -1390,14 +1581,14 @@ class AccountInternal extends PureComponent { } else { //if switching to new column then capture state //of current sort column as prev - prevField = this.state.sort.field; - prevAscDesc = this.state.sort.ascDesc; + prevField = this.state.sort?.field; + prevAscDesc = this.state.sort?.ascDesc; this.setState({ sort: { field: headerClicked, ascDesc, - prevField: this.state.sort.field, - prevAscDesc: this.state.sort.ascDesc, + prevField: this.state.sort?.field, + prevAscDesc: this.state.sort?.ascDesc, }, }); } @@ -1449,7 +1640,7 @@ class AccountInternal extends PureComponent { const category = categoryGroups .flatMap(g => g.categories) - .find(category => category.id === categoryId); + .find(category => category?.id === categoryId); const showEmptyMessage = !loading && !accountId && accounts.length === 0; @@ -1459,7 +1650,7 @@ class AccountInternal extends PureComponent { accountId !== 'offbudget' && accountId !== 'uncategorized'; - const balanceQuery = this.getBalanceQuery(account, accountId); + const balanceQuery = this.getBalanceQuery(accountId); return ( <AllTransactions @@ -1489,7 +1680,6 @@ class AccountInternal extends PureComponent { account={account} filterId={filterId} savedFilters={this.props.savedFilters} - location={this.props.location} accountName={accountName} accountsSyncing={accountsSyncing} failedAccounts={failedAccounts} @@ -1504,12 +1694,11 @@ class AccountInternal extends PureComponent { canCalculateBalance={this.canCalculateBalance} filteredAmount={filteredAmount} isFiltered={transactionsFiltered} - isSorted={this.state.sort.length !== 0} + isSorted={this.state.sort !== null} reconcileAmount={reconcileAmount} search={this.state.search} filterConditions={this.state.filterConditions} filterConditionsOp={this.state.filterConditionsOp} - savePrefs={this.props.savePrefs} pushModal={this.props.pushModal} onSearch={this.onSearch} onShowTransactions={this.onShowTransactions} @@ -1545,11 +1734,11 @@ class AccountInternal extends PureComponent { <View style={{ flex: 1 }}> <TransactionList + headerContent={undefined} tableRef={this.table} account={account} transactions={transactions} allTransactions={allTransactions} - animated={this.animated} loadMoreTransactions={() => this.paged && this.paged.fetchNext() } @@ -1590,16 +1779,10 @@ class AccountInternal extends PureComponent { ) : null } onSort={this.onSort} - sortField={this.state.sort.field} - ascDesc={this.state.sort.ascDesc} + sortField={this.state.sort?.field} + ascDesc={this.state.sort?.ascDesc} onChange={this.onTransactionsChange} onRefetch={this.refetchTransactions} - onRefetchUpToRow={row => - this.paged.refetchUpToRow(row, { - field: 'date', - order: 'desc', - }) - } onCloseAddTransaction={() => this.setState({ isAdding: false }) } @@ -1615,7 +1798,17 @@ class AccountInternal extends PureComponent { } } -function AccountHack(props) { +type AccountHackProps = Omit< + AccountInternalProps, + | 'splitsExpandedDispatch' + | 'onBatchEdit' + | 'onBatchDuplicate' + | 'onBatchLinkSchedule' + | 'onBatchUnlinkSchedule' + | 'onBatchDelete' +>; + +function AccountHack(props: AccountHackProps) { const { dispatch: splitsExpandedDispatch } = useSplitsExpanded(); const { onBatchEdit, @@ -1652,11 +1845,17 @@ export function Account() { const failedAccounts = useFailedAccounts(); const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const [hideFraction = false] = useSyncedPref('hideFraction'); - const [expandSplits] = useSyncedPref('expand-splits'); - const [showBalances] = useSyncedPref(`show-balances-${params.id}`); - const [hideCleared] = useSyncedPref(`hide-cleared-${params.id}`); - const [hideReconciled] = useSyncedPref(`hide-reconciled-${params.id}`); - const [showExtraBalances] = useSyncedPref( + const [expandSplits] = useLocalPref('expand-splits'); + const [showBalances, setShowBalances] = useSyncedPref( + `show-balances-${params.id}`, + ); + const [hideCleared, setHideCleared] = useSyncedPref( + `hide-cleared-${params.id}`, + ); + const [hideReconciled, setHideReconciled] = useSyncedPref( + `hide-reconciled-${params.id}`, + ); + const [showExtraBalances, setShowExtraBalances] = useSyncedPref( `show-extra-balances-${params.id || 'all-accounts'}`, ); const modalShowing = useSelector(state => state.modals.modalStack.length > 0); @@ -1683,9 +1882,13 @@ export function Account() { hideFraction={hideFraction} expandSplits={expandSplits} showBalances={showBalances} + setShowBalances={setShowBalances} showCleared={!hideCleared} + setShowCleared={val => setHideCleared(!val)} showReconciled={!hideReconciled} + setShowReconciled={val => setHideReconciled(!val)} showExtraBalances={showExtraBalances} + setShowExtraBalances={setShowExtraBalances} payees={payees} modalShowing={modalShowing} accountsSyncing={accountsSyncing} diff --git a/packages/desktop-client/src/components/filters/FiltersStack.tsx b/packages/desktop-client/src/components/filters/FiltersStack.tsx index 02576c5de1da97841ad8d59b6cb7d957e5e1565e..ec639437e0ae80ffbfb074971436010a991fb5e9 100644 --- a/packages/desktop-client/src/components/filters/FiltersStack.tsx +++ b/packages/desktop-client/src/components/filters/FiltersStack.tsx @@ -24,7 +24,7 @@ export function FiltersStack({ onConditionsOpChange, }: { conditions: RuleConditionEntity[]; - conditionsOp: string; + conditionsOp: 'and' | 'or'; onUpdateFilter: ( filter: RuleConditionEntity, newFilter: RuleConditionEntity, diff --git a/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx b/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx index 1a2d68e525b6e5bc90f9269d56119de12d7aa1d7..3d4b3d701c09eeebc4c7d42e017a746ba08ea77a 100644 --- a/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx +++ b/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx @@ -16,7 +16,7 @@ import { NameFilter } from './NameFilter'; export type SavedFilter = { conditions?: RuleConditionEntity[]; - conditionsOp?: string; + conditionsOp?: 'and' | 'or'; id?: string; name: string; status?: string; @@ -31,7 +31,7 @@ export function SavedFilterMenuButton({ savedFilters, }: { conditions: RuleConditionEntity[]; - conditionsOp: string; + conditionsOp: 'and' | 'or'; filterId: SavedFilter; onClearFilters: () => void; onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void; diff --git a/packages/desktop-client/src/hooks/useSelected.tsx b/packages/desktop-client/src/hooks/useSelected.tsx index 15c944e60c3663b25dbf380b0c6305775f5afb1d..a2052e333db9fe47150876c3a86507427bae42d7 100644 --- a/packages/desktop-client/src/hooks/useSelected.tsx +++ b/packages/desktop-client/src/hooks/useSelected.tsx @@ -48,7 +48,7 @@ type SelectAllAction = { isRangeSelect?: boolean; }; -type Actions = SelectAction | SelectNoneAction | SelectAllAction; +export type Actions = SelectAction | SelectNoneAction | SelectAllAction; export function useSelected<T extends Item>( name: string, @@ -310,7 +310,7 @@ export function SelectedProvider<T extends Item>({ type SelectedProviderWithItemsProps<T extends Item> = { name: string; items: T[]; - initialSelectedIds: string[]; + initialSelectedIds?: string[]; fetchAllIds: () => Promise<string[]>; registerDispatch?: (dispatch: Dispatch<Actions>) => void; selectAllFilter?: (item: T) => boolean; @@ -322,7 +322,7 @@ type SelectedProviderWithItemsProps<T extends Item> = { export function SelectedProviderWithItems<T extends Item>({ name, items, - initialSelectedIds, + initialSelectedIds = [], fetchAllIds, registerDispatch, selectAllFilter, diff --git a/packages/loot-core/src/client/data-hooks/filters.ts b/packages/loot-core/src/client/data-hooks/filters.ts index e26d6fa7ec9d13f1f214541701a54136f3175460..5b77204a6b1bf262ced8d708d7b76d5cb8d16f12 100644 --- a/packages/loot-core/src/client/data-hooks/filters.ts +++ b/packages/loot-core/src/client/data-hooks/filters.ts @@ -14,7 +14,7 @@ function toJS(rows) { tombstone: row.tombstone, conditionsOp: row.conditions_op, conditions: row.conditions, - }; + } satisfies TransactionFilterEntity; }); return filters; } diff --git a/packages/loot-core/src/client/queries.ts b/packages/loot-core/src/client/queries.ts index ec5e124977ca2ccf0fe588ecb46bb7598be18b05..807ee0eca21a185cc9b208921cedb7db538fa12c 100644 --- a/packages/loot-core/src/client/queries.ts +++ b/packages/loot-core/src/client/queries.ts @@ -28,7 +28,7 @@ const accountParametrizedField = parametrizedField<'account'>(); const rolloverParametrizedField = parametrizedField<'rollover-budget'>(); const reportParametrizedField = parametrizedField<'report-budget'>(); -export function getAccountFilter(accountId: string, field = 'account') { +export function getAccountFilter(accountId?: string, field = 'account') { if (accountId) { if (accountId === 'budgeted') { return { @@ -64,7 +64,7 @@ export function getAccountFilter(accountId: string, field = 'account') { return null; } -export function makeTransactionsQuery(accountId: string) { +export function makeTransactionsQuery(accountId?: string) { let query = q('transactions').options({ splits: 'grouped' }); const filter = getAccountFilter(accountId); 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 078996b3cb9c3017749fbbc200e77398f071cd2f..785ba31cc56489438e6b824ca11d77be8d603014 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -54,7 +54,7 @@ type FinanceModals = { 'manage-rules': { payeeId?: string }; 'edit-rule': { rule: RuleEntity | NewRuleEntity; - onSave: (rule: RuleEntity) => void; + onSave?: (rule: RuleEntity) => void; }; 'merge-unused-payees': { payeeIds: string[]; @@ -270,6 +270,10 @@ type FinanceModals = { message?: string; onConfirm: () => void; }; + 'confirm-unlink-account': { + accountName: string; + onUnlink: () => void; + }; }; export type PushModalAction = { diff --git a/packages/loot-core/src/client/state-types/queries.d.ts b/packages/loot-core/src/client/state-types/queries.d.ts index 2b5138f41675357b8832042634a79866495d8857..a4a32432c5e4531e7218dd03460072de94f8855c 100644 --- a/packages/loot-core/src/client/state-types/queries.d.ts +++ b/packages/loot-core/src/client/state-types/queries.d.ts @@ -3,8 +3,8 @@ import { type AccountEntity } from '../../types/models'; import type * as constants from '../constants'; export type QueriesState = { - newTransactions: unknown[]; - matchedTransactions: unknown[]; + newTransactions: string[]; + matchedTransactions: string[]; lastTransaction: unknown | null; updatedAccounts: string[]; accounts: AccountEntity[]; @@ -20,8 +20,8 @@ export type QueriesState = { type SetNewTransactionsAction = { type: typeof constants.SET_NEW_TRANSACTIONS; - newTransactions?: unknown[]; - matchedTransactions?: unknown[]; + newTransactions?: string[]; + matchedTransactions?: string[]; updatedAccounts?: string[]; }; diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index bacb94ca5520dbe5a83bba02e9cfc80c94878f64..55fbda9c44ca019d5db17d005ccd6c7cbe4623bd 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -55,8 +55,9 @@ type BaseConditionEntity< year?: boolean; }; conditionsOp?: string; - type?: 'id' | 'boolean' | 'date' | 'number'; + type?: 'id' | 'boolean' | 'date' | 'number' | 'string'; customName?: string; + queryFilter?: Record<string, { $oneof: string[] }>; }; export type RuleConditionEntity = diff --git a/packages/loot-core/src/types/models/transaction-filter.d.ts b/packages/loot-core/src/types/models/transaction-filter.d.ts index e43d50e21e317d42cec20056822e05522d1a6eff..c688cc6fb452ca32492828c946ce4524d9e70113 100644 --- a/packages/loot-core/src/types/models/transaction-filter.d.ts +++ b/packages/loot-core/src/types/models/transaction-filter.d.ts @@ -1,7 +1,9 @@ +import { type RuleConditionEntity } from './rule'; + export interface TransactionFilterEntity { id: string; name: string; - conditions_op: string; - conditions: unknown; + conditionsOp: 'and' | 'or'; + conditions: RuleConditionEntity[]; tombstone: boolean; } diff --git a/packages/loot-core/src/types/models/transaction.d.ts b/packages/loot-core/src/types/models/transaction.d.ts index 9cb097fec87055c58f739085560f9c1729aeaff7..648eec974956bfa6483c095051a9815854dc6f99 100644 --- a/packages/loot-core/src/types/models/transaction.d.ts +++ b/packages/loot-core/src/types/models/transaction.d.ts @@ -24,4 +24,6 @@ export interface TransactionEntity { tombstone?: boolean; schedule?: ScheduleEntity['id']; subtransactions?: TransactionEntity[]; + _unmatched?: boolean; + _deleted?: boolean; } diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index 29f3486313e91102b856a97fd672495613bbb60d..a9dd5235cdb192fee3d2d23cb8d928017ce837b3 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -24,6 +24,7 @@ export type SyncedPrefs = Partial< numberFormat: (typeof numberFormats)[number]['value']; hideFraction: boolean; isPrivacyEnabled: boolean; + [key: `show-balances-${string}`]: boolean; [key: `show-extra-balances-${string}`]: boolean; [key: `hide-cleared-${string}`]: boolean; [key: `hide-reconciled-${string}`]: boolean; diff --git a/upcoming-release-notes/3311.md b/upcoming-release-notes/3311.md new file mode 100644 index 0000000000000000000000000000000000000000..c7eabab267a68c6b2c52194d16423c497f911d35 --- /dev/null +++ b/upcoming-release-notes/3311.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +TypeScript: migrate Account component. diff --git a/yarn.lock b/yarn.lock index 179f6284b9e47364f886cc78e783faea63ca85a2..ed7ca65ea403c28046895b944b3a47adb96aeaaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -73,6 +73,7 @@ __metadata: "@swc/plugin-react-remove-properties": "npm:^1.5.121" "@testing-library/react": "npm:14.1.2" "@testing-library/user-event": "npm:14.5.2" + "@types/debounce": "npm:^1.2.4" "@types/lodash": "npm:^4" "@types/promise-retry": "npm:^1.1.6" "@types/react": "npm:^18.2.0" @@ -5031,6 +5032,13 @@ __metadata: languageName: node linkType: hard +"@types/debounce@npm:^1.2.4": + version: 1.2.4 + resolution: "@types/debounce@npm:1.2.4" + checksum: 10/decef3eee65d681556d50f7fac346f1b33134f6b21f806d41326f9dfb362fa66b0282ff0640ae6791b690694c9dc3dad4e146e909e707e6f96650f3aa325b9da + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.6": version: 4.1.8 resolution: "@types/debug@npm:4.1.8"