Compare commits

...

25 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
c285392c3c Coderabbit 2025-01-08 13:09:33 -08:00
github-actions[bot]
3fe5b5aaf0 Update VRT 2025-01-08 19:38:17 +00:00
Joel Jeremy Marquez
4fc91c6b4c Fix typecheck 2025-01-08 11:11:42 -08:00
github-actions[bot]
45777cad8d Update VRT 2025-01-08 00:46:28 -08:00
Joel Jeremy Marquez
72644d3d51 Remove non-existent SelectedProviderWithItems 2025-01-08 00:46:28 -08:00
Joel Jeremy Marquez
5e805085b0 Ignore unused useActions 2025-01-08 00:46:28 -08:00
Joel Jeremy Marquez
4e6331c7f0 useTransactionsFilter hook 2025-01-08 00:46:28 -08:00
Joel Jeremy Marquez
2ec46d8dec Fix running balances by excluding child preview transactions 2025-01-08 00:46:12 -08:00
Joel Jeremy Marquez
bb17f6a6f1 Fix test 2025-01-08 00:46:12 -08:00
Joel Jeremy Marquez
ba70fca304 Fix test 2025-01-08 00:46:12 -08:00
Joel Jeremy Marquez
3ace7d199d Coderabbit feedback 2025-01-08 00:46:11 -08:00
Joel Jeremy Marquez
2a93b173e0 Fix default expand splits 2025-01-08 00:44:53 -08:00
Joel Jeremy Marquez
e9175951dd useTransactionsSearch 2025-01-08 00:44:53 -08:00
Joel Jeremy Marquez
3ba94642d9 Remove unused headerContent 2025-01-08 00:44:53 -08:00
Joel Jeremy Marquez
bc3b96c9b2 Release notes 2025-01-08 00:44:53 -08:00
Joel Jeremy Marquez
ccff8412d3 Fix preview transactions 2025-01-08 00:44:53 -08:00
Joel Jeremy Marquez
df61e42fda Initial commit 2025-01-08 00:44:53 -08:00
Joel Jeremy Marquez
0726760084 Code review feedback and improve schedules loading 2025-01-08 00:15:45 -08:00
Joel Jeremy Marquez
a7f65532fb Coderabbit feedback + make useSchedules consistent with query pattern used in useTransactions 2025-01-08 00:15:45 -08:00
Joel Jeremy Marquez
89059bf5da Code rabbit suggestions 2025-01-08 00:15:45 -08:00
Joel Jeremy Marquez
b9c167d5d6 Update useTransactions 2025-01-08 00:15:45 -08:00
Joel Jeremy Marquez
60dac66898 Apply coderabbit suggestions 2025-01-08 00:15:45 -08:00
Joel Jeremy Marquez
b9a43b992a Fix tests 2025-01-08 00:15:45 -08:00
Joel Jeremy Marquez
a7b90a0945 Fx flaky test 2025-01-08 00:15:45 -08:00
Joel Jeremy Marquez
fbc2ccd2e7 useTransactions hook to load transactions 2025-01-08 00:15:45 -08:00
40 changed files with 2068 additions and 2102 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 105 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import React, { useRef } from 'react'; import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHover } from 'usehooks-ts'; import { css } from '@emotion/css';
import { isPreviewId } from 'loot-core/shared/transactions'; import { isPreviewId } from 'loot-core/shared/transactions';
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
@@ -12,7 +12,7 @@ import { type AccountEntity } from 'loot-core/types/models';
import { useSelectedItems } from '../../hooks/useSelected'; import { useSelectedItems } from '../../hooks/useSelected';
import { SvgArrowButtonRight1 } from '../../icons/v2'; import { SvgArrowButtonRight1 } from '../../icons/v2';
import { theme } from '../../style'; import { theme } from '../../style';
import { Button } from '../common/Button2'; import { ButtonWithLoading } from '../common/Button2';
import { Text } from '../common/Text'; import { Text } from '../common/Text';
import { View } from '../common/View'; import { View } from '../common/View';
import { PrivacyFilter } from '../PrivacyFilter'; import { PrivacyFilter } from '../PrivacyFilter';
@@ -56,10 +56,10 @@ function DetailedBalance({
type SelectedBalanceProps = { type SelectedBalanceProps = {
selectedItems: Set<string>; selectedItems: Set<string>;
account?: AccountEntity; accountId?: AccountEntity['id'];
}; };
function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) { function SelectedBalance({ selectedItems, accountId }: SelectedBalanceProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const name = `selected-balance-${[...selectedItems].join('-')}`; const name = `selected-balance-${[...selectedItems].join('-')}`;
@@ -104,7 +104,7 @@ function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) {
isExactBalance = false; isExactBalance = false;
} }
if (!account || account.id === s._account) { if (accountId !== s._account) {
scheduleBalance += getScheduledAmount(s._amount); scheduleBalance += getScheduledAmount(s._amount);
} else { } else {
scheduleBalance -= getScheduledAmount(s._amount); scheduleBalance -= getScheduledAmount(s._amount);
@@ -128,39 +128,53 @@ function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) {
} }
type FilteredBalanceProps = { type FilteredBalanceProps = {
filteredAmount?: number | null; transactionsQuery: Query;
}; };
function FilteredBalance({ filteredAmount }: FilteredBalanceProps) { function FilteredBalance({ transactionsQuery }: FilteredBalanceProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const filteredBalance = useSheetValue<'balance', 'filtered-balance'>({
name: 'filtered-balance',
query: transactionsQuery.calculate({ $sum: '$amount' }),
value: 0,
});
return ( return (
<DetailedBalance <DetailedBalance
name={t('Filtered balance:')} name={t('Filtered balance:')}
balance={filteredAmount ?? 0} balance={filteredBalance || 0}
isExactBalance={true} isExactBalance={true}
/> />
); );
} }
type MoreBalancesProps = { type MoreBalancesProps = {
balanceQuery: { name: `balance-query-${string}`; query: Query }; accountId: AccountEntity['id'] | string;
balanceQuery: Query;
}; };
function MoreBalances({ balanceQuery }: MoreBalancesProps) { function MoreBalances({ accountId, balanceQuery }: MoreBalancesProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const clearedQuery = useMemo(
() => balanceQuery.filter({ cleared: true }),
[balanceQuery],
);
const cleared = useSheetValue<'balance', `balance-query-${string}-cleared`>({ const cleared = useSheetValue<'balance', `balance-query-${string}-cleared`>({
name: (balanceQuery.name + '-cleared') as `balance-query-${string}-cleared`, name: `balance-query-${accountId}-cleared`,
query: balanceQuery.query.filter({ cleared: true }), query: clearedQuery,
}); });
const unclearedQuery = useMemo(
() => balanceQuery.filter({ cleared: false }),
[balanceQuery],
);
const uncleared = useSheetValue< const uncleared = useSheetValue<
'balance', 'balance',
`balance-query-${string}-uncleared` `balance-query-${string}-uncleared`
>({ >({
name: (balanceQuery.name + name: `balance-query-${accountId}-uncleared`,
'-uncleared') as `balance-query-${string}-uncleared`, query: unclearedQuery,
query: balanceQuery.query.filter({ cleared: false }),
}); });
return ( return (
@@ -172,25 +186,31 @@ function MoreBalances({ balanceQuery }: MoreBalancesProps) {
} }
type BalancesProps = { type BalancesProps = {
balanceQuery: { name: `balance-query-${string}`; query: Query }; accountId?: AccountEntity['id'] | string;
showFilteredBalance: boolean;
transactionsQuery?: Query;
balanceQuery: Query;
showExtraBalances: boolean; showExtraBalances: boolean;
onToggleExtraBalances: () => void; onToggleExtraBalances: () => void;
account?: AccountEntity;
isFiltered: boolean;
filteredAmount?: number | null;
}; };
export function Balances({ export function Balances({
accountId,
balanceQuery, balanceQuery,
transactionsQuery,
showFilteredBalance,
showExtraBalances, showExtraBalances,
onToggleExtraBalances, onToggleExtraBalances,
account,
isFiltered,
filteredAmount,
}: BalancesProps) { }: BalancesProps) {
const selectedItems = useSelectedItems(); const selectedItems = useSelectedItems();
const buttonRef = useRef(null); const balanceBinding = useMemo<Binding<'balance', `balance-query-${string}`>>(
const isButtonHovered = useHover(buttonRef); () => ({
name: `balance-query-${accountId}`,
query: balanceQuery,
value: 0,
}),
[accountId, balanceQuery],
);
return ( return (
<View <View
@@ -201,25 +221,28 @@ export function Balances({
marginLeft: -5, marginLeft: -5,
}} }}
> >
<Button <ButtonWithLoading
ref={buttonRef} isLoading={!balanceQuery}
data-testid="account-balance" data-testid="account-balance"
variant="bare" variant="bare"
onPress={onToggleExtraBalances} onPress={onToggleExtraBalances}
style={{ className={css({
paddingTop: 1, paddingTop: 1,
paddingBottom: 1, paddingBottom: 1,
}} [`& svg`]: {
width: 10,
height: 10,
marginLeft: 10,
color: theme.pillText,
transform: showExtraBalances ? 'rotateZ(180deg)' : 'rotateZ(0)',
opacity: selectedItems.size > 0 || showExtraBalances ? 1 : 0,
},
[`&[data-hovered] svg`]: {
opacity: 1,
},
})}
> >
<CellValue <CellValue binding={balanceBinding} type="financial">
binding={
{ ...balanceQuery, value: 0 } as Binding<
'balance',
`balance-query-${string}`
>
}
type="financial"
>
{props => ( {props => (
<CellValueText <CellValueText
{...props} {...props}
@@ -237,26 +260,17 @@ export function Balances({
)} )}
</CellValue> </CellValue>
<SvgArrowButtonRight1 <SvgArrowButtonRight1 />
style={{ </ButtonWithLoading>
width: 10, {showExtraBalances && accountId && balanceQuery && (
height: 10, <MoreBalances accountId={accountId} balanceQuery={balanceQuery} />
marginLeft: 10, )}
color: theme.pillText, {selectedItems.size > 0 && (
transform: showExtraBalances ? 'rotateZ(180deg)' : 'rotateZ(0)', <SelectedBalance selectedItems={selectedItems} accountId={accountId} />
opacity: )}
isButtonHovered || selectedItems.size > 0 || showExtraBalances {showFilteredBalance && transactionsQuery && (
? 1 <FilteredBalance transactionsQuery={transactionsQuery} />
: 0,
}}
/>
</Button>
{showExtraBalances && <MoreBalances balanceQuery={balanceQuery} />}
{selectedItems.size > 0 && (
<SelectedBalance selectedItems={selectedItems} account={account} />
)} )}
{isFiltered && <FilteredBalance filteredAmount={filteredAmount} />}
</View> </View>
); );
} }

View File

@@ -4,10 +4,13 @@ import React, {
Fragment, Fragment,
type ReactNode, type ReactNode,
type ComponentProps, type ComponentProps,
useCallback,
useMemo,
} from 'react'; } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { type Query } from 'loot-core/shared/query';
import { import {
type AccountEntity, type AccountEntity,
type RuleConditionEntity, type RuleConditionEntity,
@@ -18,7 +21,7 @@ import {
import { useLocalPref } from '../../hooks/useLocalPref'; import { useLocalPref } from '../../hooks/useLocalPref';
import { useSplitsExpanded } from '../../hooks/useSplitsExpanded'; import { useSplitsExpanded } from '../../hooks/useSplitsExpanded';
import { useSyncServerStatus } from '../../hooks/useSyncServerStatus'; import { useSyncServerStatus } from '../../hooks/useSyncServerStatus';
import { AnimatedLoading } from '../../icons/AnimatedLoading'; // import { AnimatedLoading } from '../../icons/AnimatedLoading';
import { SvgAdd } from '../../icons/v1'; import { SvgAdd } from '../../icons/v1';
import { import {
SvgArrowsExpand3, SvgArrowsExpand3,
@@ -40,7 +43,6 @@ import { Stack } from '../common/Stack';
import { View } from '../common/View'; import { View } from '../common/View';
import { FilterButton } from '../filters/FiltersMenu'; import { FilterButton } from '../filters/FiltersMenu';
import { FiltersStack } from '../filters/FiltersStack'; import { FiltersStack } from '../filters/FiltersStack';
import { type SavedFilter } from '../filters/SavedFilterMenuButton';
import { NotesButton } from '../NotesButton'; import { NotesButton } from '../NotesButton';
import { SelectedTransactionsButton } from '../transactions/SelectedTransactionsButton'; import { SelectedTransactionsButton } from '../transactions/SelectedTransactionsButton';
@@ -52,29 +54,28 @@ type AccountHeaderProps = {
tableRef: TableRef; tableRef: TableRef;
editingName: boolean; editingName: boolean;
isNameEditable: boolean; isNameEditable: boolean;
workingHard: boolean; isLoading: boolean;
accountName: string; accountId?: AccountEntity['id'] | string;
accountName: string | null;
account?: AccountEntity; account?: AccountEntity;
filterId?: SavedFilter; activeFilter?: TransactionFilterEntity;
savedFilters: TransactionFilterEntity[]; dirtyFilter?: TransactionFilterEntity;
accountsSyncing: string[]; accountsSyncing: string[];
failedAccounts: AccountSyncSidebarProps['failedAccounts']; failedAccounts: AccountSyncSidebarProps['failedAccounts'];
accounts: AccountEntity[]; accounts: AccountEntity[];
transactions: TransactionEntity[]; transactions: readonly TransactionEntity[];
showBalances: boolean; showBalances: boolean;
showExtraBalances: boolean; showExtraBalances: boolean;
showCleared: boolean; showCleared: boolean;
showReconciled: boolean; showReconciled: boolean;
showEmptyMessage: boolean; showEmptyMessage: boolean;
balanceQuery: ComponentProps<typeof ReconcilingMessage>['balanceQuery']; balanceQuery: Query;
reconcileAmount?: number | null; transactionsQuery?: Query;
canCalculateBalance?: () => boolean; reconcileAmount: number | null;
isFiltered: boolean; showFilteredBalance: boolean;
filteredAmount?: number | null;
isSorted: boolean; isSorted: boolean;
search: string; filterConditions: readonly RuleConditionEntity[];
filterConditions: RuleConditionEntity[]; filterConditionsOp: RuleConditionEntity['conditionsOp'];
filterConditionsOp: 'and' | 'or';
onSearch: (newSearch: string) => void; onSearch: (newSearch: string) => void;
onAddTransaction: () => void; onAddTransaction: () => void;
onShowTransactions: ComponentProps< onShowTransactions: ComponentProps<
@@ -127,11 +128,12 @@ export function AccountHeader({
tableRef, tableRef,
editingName, editingName,
isNameEditable, isNameEditable,
workingHard, isLoading,
accountId,
accountName, accountName,
account, account,
filterId, activeFilter,
savedFilters, dirtyFilter,
accountsSyncing, accountsSyncing,
failedAccounts, failedAccounts,
accounts, accounts,
@@ -143,11 +145,9 @@ export function AccountHeader({
showEmptyMessage, showEmptyMessage,
balanceQuery, balanceQuery,
reconcileAmount, reconcileAmount,
canCalculateBalance, showFilteredBalance,
isFiltered, transactionsQuery,
filteredAmount,
isSorted, isSorted,
search,
filterConditions, filterConditions,
filterConditionsOp, filterConditionsOp,
onSearch, onSearch,
@@ -191,6 +191,7 @@ export function AccountHeader({
const isUsingServer = syncServerStatus !== 'no-server'; const isUsingServer = syncServerStatus !== 'no-server';
const isServerOffline = syncServerStatus === 'offline'; const isServerOffline = syncServerStatus === 'offline';
const [_, setExpandSplitsPref] = useLocalPref('expand-splits'); const [_, setExpandSplitsPref] = useLocalPref('expand-splits');
const [search, setSearch] = useState('');
let canSync = !!(account?.account_id && isUsingServer); let canSync = !!(account?.account_id && isUsingServer);
if (!account) { if (!account) {
@@ -254,6 +255,19 @@ export function AccountHeader({
[onSync], [onSync],
); );
const onSearchChange = useCallback(
(search: string) => {
setSearch(search);
onSearch?.(search);
},
[onSearch],
);
const transactionsMap = useMemo(
() => new Map(transactions.map(t => [t.id, t])),
[transactions],
);
return ( return (
<> <>
<View style={{ ...styles.pageContent, paddingBottom: 10, flexShrink: 0 }}> <View style={{ ...styles.pageContent, paddingBottom: 10, flexShrink: 0 }}>
@@ -276,7 +290,7 @@ export function AccountHeader({
)} )}
<AccountNameField <AccountNameField
account={account} account={account}
accountName={accountName} accountName={accountName || ''}
isNameEditable={isNameEditable} isNameEditable={isNameEditable}
editingName={editingName} editingName={editingName}
saveNameError={saveNameError} saveNameError={saveNameError}
@@ -287,12 +301,12 @@ export function AccountHeader({
</View> </View>
<Balances <Balances
accountId={accountId}
balanceQuery={balanceQuery} balanceQuery={balanceQuery}
showExtraBalances={showExtraBalances} transactionsQuery={transactionsQuery}
showFilteredBalance={showFilteredBalance}
showExtraBalances={!showFilteredBalance && showExtraBalances}
onToggleExtraBalances={onToggleExtraBalances} onToggleExtraBalances={onToggleExtraBalances}
account={account}
isFiltered={isFiltered}
filteredAmount={filteredAmount}
/> />
<Stack <Stack
@@ -345,30 +359,25 @@ export function AccountHeader({
<Search <Search
placeholder={t('Search')} placeholder={t('Search')}
value={search} value={search}
onChange={onSearch} onChange={onSearchChange}
inputRef={searchInput} inputRef={searchInput}
/> />
{workingHard ? ( <SelectedTransactionsButton
<View> isLoading={isLoading}
<AnimatedLoading style={{ width: 16, height: 16 }} /> getTransaction={id => transactionsMap.get(id)}
</View> onShow={onShowTransactions}
) : ( onDuplicate={onBatchDuplicate}
<SelectedTransactionsButton onDelete={onBatchDelete}
getTransaction={id => transactions.find(t => t.id === id)} onEdit={onBatchEdit}
onShow={onShowTransactions} onLinkSchedule={onBatchLinkSchedule}
onDuplicate={onBatchDuplicate} onUnlinkSchedule={onBatchUnlinkSchedule}
onDelete={onBatchDelete} onCreateRule={onCreateRule}
onEdit={onBatchEdit} onSetTransfer={onSetTransfer}
onLinkSchedule={onBatchLinkSchedule} onScheduleAction={onScheduleAction}
onUnlinkSchedule={onBatchUnlinkSchedule} showMakeTransfer={showMakeTransfer}
onCreateRule={onCreateRule} onMakeAsSplitTransaction={onMakeAsSplitTransaction}
onSetTransfer={onSetTransfer} onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
onScheduleAction={onScheduleAction} />
showMakeTransfer={showMakeTransfer}
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
/>
)}
<View style={{ flex: '0 0 auto' }}> <View style={{ flex: '0 0 auto' }}>
{account && ( {account && (
<> <>
@@ -443,9 +452,7 @@ export function AccountHeader({
<AccountMenu <AccountMenu
account={account} account={account}
canSync={canSync} canSync={canSync}
canShowBalances={ // canShowBalances={canCalculateBalance()}
canCalculateBalance ? canCalculateBalance() : false
}
isSorted={isSorted} isSorted={isSorted}
showBalances={showBalances} showBalances={showBalances}
showCleared={showCleared} showCleared={showCleared}
@@ -500,14 +507,15 @@ export function AccountHeader({
onDeleteFilter={onDeleteFilter} onDeleteFilter={onDeleteFilter}
onClearFilters={onClearFilters} onClearFilters={onClearFilters}
onReloadSavedFilter={onReloadSavedFilter} onReloadSavedFilter={onReloadSavedFilter}
filterId={filterId} filter={activeFilter}
savedFilters={savedFilters} dirtyFilter={dirtyFilter}
onConditionsOpChange={onConditionsOpChange} onConditionsOpChange={onConditionsOpChange}
/> />
)} )}
</View> </View>
{reconcileAmount != null && ( {reconcileAmount != null && (
<ReconcilingMessage <ReconcilingMessage
accountId={accountId}
targetBalance={reconcileAmount} targetBalance={reconcileAmount}
balanceQuery={balanceQuery} balanceQuery={balanceQuery}
onDone={onDoneReconciling} onDone={onDoneReconciling}
@@ -590,7 +598,7 @@ function AccountNameField({
marginLeft: -6, marginLeft: -6,
paddingTop: 2, paddingTop: 2,
paddingBottom: 2, paddingBottom: 2,
width: Math.max(20, accountName.length) + 'ch', width: Math.max(20, accountName?.length ?? 0) + 'ch',
}} }}
/> />
</InitialFocus> </InitialFocus>
@@ -671,7 +679,7 @@ type AccountMenuProps = {
account: AccountEntity; account: AccountEntity;
canSync: boolean; canSync: boolean;
showBalances: boolean; showBalances: boolean;
canShowBalances: boolean; // canShowBalances: boolean;
showCleared: boolean; showCleared: boolean;
showReconciled: boolean; showReconciled: boolean;
isSorted: boolean; isSorted: boolean;
@@ -693,7 +701,7 @@ function AccountMenu({
account, account,
canSync, canSync,
showBalances, showBalances,
canShowBalances, // canShowBalances,
showCleared, showCleared,
showReconciled, showReconciled,
isSorted, isSorted,
@@ -716,16 +724,22 @@ function AccountMenu({
} as const, } as const,
] ]
: []), : []),
...(canShowBalances // ...(canShowBalances
? [ // ? [
{ // {
name: 'toggle-balance', // name: 'toggle-balance',
text: showBalances // text: showBalances
? t('Hide running balance') // ? t('Hide running balance')
: t('Show running balance'), // : t('Show running balance'),
} as const, // } as const,
] // ]
: []), // : []),[
{
name: 'toggle-balance',
text: showBalances
? t('Hide running balance')
: t('Show running balance'),
},
{ {
name: 'toggle-cleared', name: 'toggle-cleared',
text: showCleared text: showCleared

View File

@@ -17,13 +17,15 @@ import { useFormat } from '../spreadsheet/useFormat';
import { useSheetValue } from '../spreadsheet/useSheetValue'; import { useSheetValue } from '../spreadsheet/useSheetValue';
type ReconcilingMessageProps = { type ReconcilingMessageProps = {
balanceQuery: { name: `balance-query-${string}`; query: Query }; accountId?: AccountEntity['id'] | string;
balanceQuery: Query;
targetBalance: number; targetBalance: number;
onDone: () => void; onDone: () => void;
onCreateTransaction: (targetDiff: number) => void; onCreateTransaction: (targetDiff: number) => void;
}; };
export function ReconcilingMessage({ export function ReconcilingMessage({
accountId,
balanceQuery, balanceQuery,
targetBalance, targetBalance,
onDone, onDone,
@@ -31,11 +33,10 @@ export function ReconcilingMessage({
}: ReconcilingMessageProps) { }: ReconcilingMessageProps) {
const cleared = const cleared =
useSheetValue<'balance', `balance-query-${string}-cleared`>({ useSheetValue<'balance', `balance-query-${string}-cleared`>({
name: (balanceQuery.name + name: `balance-query-${accountId}-cleared`,
'-cleared') as `balance-query-${string}-cleared`,
value: 0, value: 0,
query: balanceQuery.query.filter({ cleared: true }), query: balanceQuery.filter({ cleared: true }),
}) ?? 0; } as const) ?? 0;
const format = useFormat(); const format = useFormat();
const targetDiff = targetBalance - cleared; const targetDiff = targetBalance - cleared;

View File

@@ -8,14 +8,16 @@ import { ConditionsOpMenu } from './ConditionsOpMenu';
import { FilterExpression } from './FilterExpression'; import { FilterExpression } from './FilterExpression';
type AppliedFiltersProps = { type AppliedFiltersProps = {
conditions: RuleConditionEntity[]; conditions: readonly RuleConditionEntity[];
onUpdate: ( onUpdate: (
filter: RuleConditionEntity, filterCondition: RuleConditionEntity,
newFilter: RuleConditionEntity, newFilterCondition: RuleConditionEntity,
) => void;
onDelete: (filterCondition: RuleConditionEntity) => void;
conditionsOp: RuleConditionEntity['conditionsOp'];
onConditionsOpChange: (
filterConditionsOp: RuleConditionEntity['conditionsOp'],
) => void; ) => void;
onDelete: (filter: RuleConditionEntity) => void;
conditionsOp: string;
onConditionsOpChange: (value: 'and' | 'or') => void;
}; };
export function AppliedFilters({ export function AppliedFilters({

View File

@@ -12,9 +12,9 @@ export function ConditionsOpMenu({
onChange, onChange,
conditions, conditions,
}: { }: {
conditionsOp: string; conditionsOp: RuleConditionEntity['conditionsOp'];
onChange: (value: 'and' | 'or') => void; onChange: (op: RuleConditionEntity['conditionsOp']) => void;
conditions: RuleConditionEntity[]; conditions: readonly RuleConditionEntity[];
}) { }) {
return conditions.length > 1 ? ( return conditions.length > 1 ? (
<Text style={{ color: theme.pageText, marginTop: 11, marginRight: 5 }}> <Text style={{ color: theme.pageText, marginTop: 11, marginRight: 5 }}>

View File

@@ -1,15 +1,17 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { type TransactionFilterEntity } from 'loot-core/types/models';
import { Menu } from '../common/Menu'; import { Menu } from '../common/Menu';
import { type SavedFilter } from './SavedFilterMenuButton';
export function FilterMenu({ export function FilterMenu({
filterId, filter,
dirtyFilter,
onFilterMenuSelect, onFilterMenuSelect,
}: { }: {
filterId?: SavedFilter; filter?: TransactionFilterEntity;
dirtyFilter?: TransactionFilterEntity;
onFilterMenuSelect: (item: string) => void; onFilterMenuSelect: (item: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -20,12 +22,12 @@ export function FilterMenu({
onFilterMenuSelect(item); onFilterMenuSelect(item);
}} }}
items={ items={
!filterId?.id !filter?.id
? [ ? [
{ name: 'save-filter', text: t('Save new filter') }, { name: 'save-filter', text: t('Save new filter') },
{ name: 'clear-filter', text: t('Clear all conditions') }, { name: 'clear-filter', text: t('Clear all filter conditions') },
] ]
: filterId?.id !== null && filterId?.status === 'saved' : filter?.id !== null && !dirtyFilter
? [ ? [
{ name: 'rename-filter', text: t('Rename') }, { name: 'rename-filter', text: t('Rename') },
{ name: 'delete-filter', text: t('Delete') }, { name: 'delete-filter', text: t('Delete') },
@@ -35,16 +37,22 @@ export function FilterMenu({
text: t('Save new filter'), text: t('Save new filter'),
disabled: true, disabled: true,
}, },
{ name: 'clear-filter', text: t('Clear all conditions') }, {
name: 'clear-filter',
text: t('Clear all filter conditions'),
},
] ]
: [ : [
{ name: 'rename-filter', text: t('Rename') }, { name: 'rename-filter', text: t('Rename') },
{ name: 'update-filter', text: t('Update condtions') }, { name: 'update-filter', text: t('Update filter conditions') },
{ name: 'reload-filter', text: t('Revert changes') }, { name: 'reload-filter', text: t('Revert changes') },
{ name: 'delete-filter', text: t('Delete') }, { name: 'delete-filter', text: t('Delete') },
Menu.line, Menu.line,
{ name: 'save-filter', text: t('Save new filter') }, { name: 'save-filter', text: t('Save new filter') },
{ name: 'clear-filter', text: t('Clear all conditions') }, {
name: 'clear-filter',
text: t('Clear all filter conditions'),
},
] ]
} }
/> />

View File

@@ -7,10 +7,27 @@ import { Stack } from '../common/Stack';
import { View } from '../common/View'; import { View } from '../common/View';
import { AppliedFilters } from './AppliedFilters'; import { AppliedFilters } from './AppliedFilters';
import { import { SavedFilterMenuButton } from './SavedFilterMenuButton';
type SavedFilter,
SavedFilterMenuButton, type FiltersStackProps = {
} from './SavedFilterMenuButton'; conditions: readonly RuleConditionEntity[];
conditionsOp: RuleConditionEntity['conditionsOp'];
onUpdateFilter: (
filterCondition: RuleConditionEntity,
newFilterCondition: RuleConditionEntity,
) => void;
onDeleteFilter: (filterCondition: RuleConditionEntity) => void;
onClearFilters: () => void;
onReloadSavedFilter: (
savedFilter: TransactionFilterEntity,
action?: 'reload' | 'update',
) => void;
filter?: TransactionFilterEntity;
dirtyFilter?: TransactionFilterEntity;
onConditionsOpChange: (
conditionsOp: RuleConditionEntity['conditionsOp'],
) => void;
};
export function FiltersStack({ export function FiltersStack({
conditions, conditions,
@@ -19,23 +36,10 @@ export function FiltersStack({
onDeleteFilter, onDeleteFilter,
onClearFilters, onClearFilters,
onReloadSavedFilter, onReloadSavedFilter,
filterId, filter,
savedFilters, dirtyFilter,
onConditionsOpChange, onConditionsOpChange,
}: { }: FiltersStackProps) {
conditions: RuleConditionEntity[];
conditionsOp: 'and' | 'or';
onUpdateFilter: (
filter: RuleConditionEntity,
newFilter: RuleConditionEntity,
) => void;
onDeleteFilter: (filter: RuleConditionEntity) => void;
onClearFilters: () => void;
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
filterId?: SavedFilter;
savedFilters: TransactionFilterEntity[];
onConditionsOpChange: (value: 'and' | 'or') => void;
}) {
return ( return (
<View> <View>
<Stack <Stack
@@ -55,10 +59,10 @@ export function FiltersStack({
<SavedFilterMenuButton <SavedFilterMenuButton
conditions={conditions} conditions={conditions}
conditionsOp={conditionsOp} conditionsOp={conditionsOp}
filterId={filterId} filter={filter}
dirtyFilter={dirtyFilter}
onClearFilters={onClearFilters} onClearFilters={onClearFilters}
onReloadSavedFilter={onReloadSavedFilter} onReloadSavedFilter={onReloadSavedFilter}
savedFilters={savedFilters}
/> />
</Stack> </Stack>
</View> </View>

View File

@@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { useFilters } from 'loot-core/client/data-hooks/filters';
import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; import { send, sendCatch } from 'loot-core/src/platform/client/fetch';
import { type TransactionFilterEntity } from 'loot-core/types/models'; import { type TransactionFilterEntity } from 'loot-core/types/models';
import { type RuleConditionEntity } from 'loot-core/types/models/rule'; import { type RuleConditionEntity } from 'loot-core/types/models/rule';
@@ -14,29 +15,26 @@ import { View } from '../common/View';
import { FilterMenu } from './FilterMenu'; import { FilterMenu } from './FilterMenu';
import { NameFilter } from './NameFilter'; import { NameFilter } from './NameFilter';
export type SavedFilter = { type SavedFilterMenuButtonProps = {
conditions?: RuleConditionEntity[]; conditions: readonly RuleConditionEntity[];
conditionsOp?: 'and' | 'or'; conditionsOp: RuleConditionEntity['conditionsOp'];
id?: string; filter?: TransactionFilterEntity;
name: string; dirtyFilter?: TransactionFilterEntity;
status?: string; onClearFilters: () => void;
onReloadSavedFilter: (
savedFilter: TransactionFilterEntity,
action?: 'reload' | 'update',
) => void;
}; };
export function SavedFilterMenuButton({ export function SavedFilterMenuButton({
conditions, conditions,
conditionsOp, conditionsOp,
filterId, filter,
dirtyFilter,
onClearFilters, onClearFilters,
onReloadSavedFilter, onReloadSavedFilter,
savedFilters, }: SavedFilterMenuButtonProps) {
}: {
conditions: RuleConditionEntity[];
conditionsOp: 'and' | 'or';
filterId?: SavedFilter;
onClearFilters: () => void;
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
savedFilters: TransactionFilterEntity[];
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [nameOpen, setNameOpen] = useState(false); const [nameOpen, setNameOpen] = useState(false);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
@@ -44,9 +42,8 @@ export function SavedFilterMenuButton({
const triggerRef = useRef(null); const triggerRef = useRef(null);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
const [menuItem, setMenuItem] = useState(''); const [menuItem, setMenuItem] = useState('');
const [name, setName] = useState(filterId?.name ?? ''); const [name, setName] = useState(filter?.name ?? '');
const id = filterId?.id; const savedFilters = useFilters();
let savedFilter: SavedFilter;
const onFilterMenuSelect = async (item: string) => { const onFilterMenuSelect = async (item: string) => {
setMenuItem(item); setMenuItem(item);
@@ -59,23 +56,22 @@ export function SavedFilterMenuButton({
break; break;
case 'delete-filter': case 'delete-filter':
setMenuOpen(false); setMenuOpen(false);
await send('filter-delete', id); if (filter?.id) {
await send('filter-delete', filter.id);
}
onClearFilters(); onClearFilters();
break; break;
case 'update-filter': case 'update-filter':
setErr(null); setErr(null);
setAdding(false); setAdding(false);
setMenuOpen(false); setMenuOpen(false);
savedFilter = { if (!filter || !dirtyFilter) {
conditions, // No active filter or filter is not dirty, nothing to update.
conditionsOp, return;
id: filterId?.id, }
name: filterId?.name ?? '',
status: 'saved',
};
const response = await sendCatch('filter-update', { const response = await sendCatch('filter-update', {
state: savedFilter, state: dirtyFilter,
filters: [...savedFilters], filters: savedFilters,
}); });
if (response.error) { if (response.error) {
@@ -84,7 +80,7 @@ export function SavedFilterMenuButton({
return; return;
} }
onReloadSavedFilter(savedFilter, 'update'); onReloadSavedFilter(dirtyFilter, 'update');
break; break;
case 'save-filter': case 'save-filter':
setErr(null); setErr(null);
@@ -94,11 +90,9 @@ export function SavedFilterMenuButton({
break; break;
case 'reload-filter': case 'reload-filter':
setMenuOpen(false); setMenuOpen(false);
savedFilter = { if (filter) {
...savedFilter, onReloadSavedFilter(filter, 'reload');
status: 'saved', }
};
onReloadSavedFilter(savedFilter, 'reload');
break; break;
case 'clear-filter': case 'clear-filter':
setMenuOpen(false); setMenuOpen(false);
@@ -111,10 +105,9 @@ export function SavedFilterMenuButton({
async function onAddUpdate() { async function onAddUpdate() {
if (adding) { if (adding) {
const newSavedFilter = { const newSavedFilter = {
conditions, conditions: [...conditions],
conditionsOp, conditionsOp: conditionsOp || 'and',
name, name,
status: 'saved',
}; };
const response = await sendCatch('filter-create', { const response = await sendCatch('filter-create', {
@@ -132,30 +125,32 @@ export function SavedFilterMenuButton({
onReloadSavedFilter({ onReloadSavedFilter({
...newSavedFilter, ...newSavedFilter,
id: response.data, id: response.data,
tombstone: false,
}); });
return; } else {
if (!filter) {
return;
}
const updatedFilter = {
...filter,
...dirtyFilter,
name,
};
const response = await sendCatch('filter-update', {
state: updatedFilter,
filters: [...savedFilters],
});
if (response.error) {
setErr(response.error.message);
setNameOpen(true);
} else {
setNameOpen(false);
onReloadSavedFilter(updatedFilter);
}
} }
const updatedFilter = {
conditions: filterId?.conditions,
conditionsOp: filterId?.conditionsOp,
id: filterId?.id,
name,
};
const response = await sendCatch('filter-update', {
state: updatedFilter,
filters: [...savedFilters],
});
if (response.error) {
setErr(response.error.message);
setNameOpen(true);
return;
}
setNameOpen(false);
onReloadSavedFilter(updatedFilter);
} }
return ( return (
@@ -178,9 +173,9 @@ export function SavedFilterMenuButton({
flexShrink: 0, flexShrink: 0,
}} }}
> >
{!filterId?.id ? t('Unsaved filter') : filterId?.name}&nbsp; {!filter?.id ? t('Unsaved filter') : filter?.name}&nbsp;
</Text> </Text>
{filterId?.id && filterId?.status !== 'saved' && ( {filter?.id && !!dirtyFilter && (
<Text> <Text>
<Trans>(modified)</Trans>&nbsp; <Trans>(modified)</Trans>&nbsp;
</Text> </Text>
@@ -196,7 +191,8 @@ export function SavedFilterMenuButton({
style={{ width: 200 }} style={{ width: 200 }}
> >
<FilterMenu <FilterMenu
filterId={filterId} filter={filter}
dirtyFilter={dirtyFilter}
onFilterMenuSelect={onFilterMenuSelect} onFilterMenuSelect={onFilterMenuSelect}
/> />
</Popover> </Popover>

View File

@@ -11,6 +11,7 @@ import {
} from 'loot-core/client/actions'; } from 'loot-core/client/actions';
import { amountToInteger } from 'loot-core/src/shared/util'; import { amountToInteger } from 'loot-core/src/shared/util';
import { useCategories } from '../../../hooks/useCategories';
import { useDateFormat } from '../../../hooks/useDateFormat'; import { useDateFormat } from '../../../hooks/useDateFormat';
import { useSyncedPrefs } from '../../../hooks/useSyncedPrefs'; import { useSyncedPrefs } from '../../../hooks/useSyncedPrefs';
import { useDispatch } from '../../../redux'; import { useDispatch } from '../../../redux';
@@ -157,7 +158,8 @@ export function ImportTransactionsModal({ options }) {
const [flipAmount, setFlipAmount] = useState(false); const [flipAmount, setFlipAmount] = useState(false);
const [multiplierEnabled, setMultiplierEnabled] = useState(false); const [multiplierEnabled, setMultiplierEnabled] = useState(false);
const [reconcile, setReconcile] = useState(true); const [reconcile, setReconcile] = useState(true);
const { accountId, categories, onImported } = options; const { accountId, onImported } = options;
const { list: categories } = useCategories();
// This cannot be set after parsing the file, because changing it // This cannot be set after parsing the file, because changing it
// requires re-parsing the file. This is different from the other // requires re-parsing the file. This is different from the other
@@ -241,7 +243,7 @@ export function ImportTransactionsModal({ options }) {
break; break;
} }
const category_id = parseCategoryFields(trans, categories.list); const category_id = parseCategoryFields(trans, categories);
if (category_id != null) { if (category_id != null) {
trans.category = category_id; trans.category = category_id;
} }
@@ -295,7 +297,7 @@ export function ImportTransactionsModal({ options }) {
// add the updated existing transaction in the list, with the // add the updated existing transaction in the list, with the
// isMatchedTransaction flag to identify it in display and not send it again // isMatchedTransaction flag to identify it in display and not send it again
existing_trx.isMatchedTransaction = true; existing_trx.isMatchedTransaction = true;
existing_trx.category = categories.list.find( existing_trx.category = categories.find(
cat => cat.id === existing_trx.category, cat => cat.id === existing_trx.category,
)?.name; )?.name;
// add parent transaction attribute to mimic behaviour // add parent transaction attribute to mimic behaviour
@@ -310,7 +312,7 @@ export function ImportTransactionsModal({ options }) {
return next; return next;
}, []); }, []);
}, },
[accountId, categories.list, clearOnImport, dispatch], [accountId, categories, clearOnImport, dispatch],
); );
const parse = useCallback( const parse = useCallback(
@@ -584,7 +586,7 @@ export function ImportTransactionsModal({ options }) {
break; break;
} }
const category_id = parseCategoryFields(trans, categories.list); const category_id = parseCategoryFields(trans, categories);
trans.category = category_id; trans.category = category_id;
const { const {
@@ -784,7 +786,7 @@ export function ImportTransactionsModal({ options }) {
outValue={outValue} outValue={outValue}
flipAmount={flipAmount} flipAmount={flipAmount}
multiplierAmount={multiplierAmount} multiplierAmount={multiplierAmount}
categories={categories.list} categories={categories}
onCheckTransaction={onCheckTransaction} onCheckTransaction={onCheckTransaction}
reconcile={reconcile} reconcile={reconcile}
/> />

View File

@@ -35,7 +35,7 @@ type HeaderProps = {
mode: TimeFrame['mode'], mode: TimeFrame['mode'],
) => void; ) => void;
filters?: RuleConditionEntity[]; filters?: RuleConditionEntity[];
conditionsOp: 'and' | 'or'; conditionsOp: RuleConditionEntity['conditionsOp'];
onApply?: (conditions: RuleConditionEntity) => void; onApply?: (conditions: RuleConditionEntity) => void;
onUpdateFilter: ComponentProps<typeof AppliedFilters>['onUpdate']; onUpdateFilter: ComponentProps<typeof AppliedFilters>['onUpdate'];
onDeleteFilter: ComponentProps<typeof AppliedFilters>['onDelete']; onDeleteFilter: ComponentProps<typeof AppliedFilters>['onDelete'];

View File

@@ -38,7 +38,6 @@ import { useMergedRefs } from '../../../hooks/useMergedRefs';
import { useNavigate } from '../../../hooks/useNavigate'; import { useNavigate } from '../../../hooks/useNavigate';
import { usePayees } from '../../../hooks/usePayees'; import { usePayees } from '../../../hooks/usePayees';
import { useResizeObserver } from '../../../hooks/useResizeObserver'; import { useResizeObserver } from '../../../hooks/useResizeObserver';
import { SelectedProviderWithItems } from '../../../hooks/useSelected';
import { SplitsExpandedProvider } from '../../../hooks/useSplitsExpanded'; import { SplitsExpandedProvider } from '../../../hooks/useSplitsExpanded';
import { useSyncedPref } from '../../../hooks/useSyncedPref'; import { useSyncedPref } from '../../../hooks/useSyncedPref';
import { import {
@@ -563,141 +562,129 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
/> />
</View> </View>
</View> </View>
<SelectedProviderWithItems <SchedulesProvider query={undefined}>
name="transactions" <View
items={[]} style={{
fetchAllIds={async () => []} width: '100%',
registerDispatch={() => {}} flexGrow: 1,
selectAllFilter={(item: TransactionEntity) => overflow: isNarrowWidth ? 'auto' : 'hidden',
!item._unmatched && !item.is_parent }}
} ref={table}
> >
<SchedulesProvider query={undefined}> {!isNarrowWidth ? (
<View <SplitsExpandedProvider initialMode="collapse">
style={{ <TransactionList
width: '100%', tableRef={table}
flexGrow: 1, account={undefined}
overflow: isNarrowWidth ? 'auto' : 'hidden', transactions={transactionsGrouped}
}} allTransactions={allTransactions}
ref={table} loadMoreTransactions={loadMoreTransactions}
> accounts={accounts}
{!isNarrowWidth ? ( category={undefined}
<SplitsExpandedProvider initialMode="collapse"> categoryGroups={categoryGroups}
<TransactionList payees={payees}
headerContent={undefined} balances={null}
tableRef={table} showBalances={false}
account={undefined} showReconciled={true}
transactions={transactionsGrouped} showCleared={false}
allTransactions={allTransactions} showAccount={true}
loadMoreTransactions={loadMoreTransactions} isAdding={false}
accounts={accounts} isNew={() => false}
category={undefined} isMatched={() => false}
categoryGroups={categoryGroups} isFiltered={() => true}
payees={payees} dateFormat={dateFormat}
balances={null} hideFraction={false}
showBalances={false} renderEmpty={() => (
showReconciled={true} <View
showCleared={false} style={{
showAccount={true} color: theme.tableText,
isAdding={false} marginTop: 20,
isNew={() => false} textAlign: 'center',
isMatched={() => false} fontStyle: 'italic',
isFiltered={() => true} }}
dateFormat={dateFormat} >
hideFraction={false} <Trans>No transactions</Trans>
addNotification={addNotification} </View>
renderEmpty={() => ( )}
<View onSort={onSort}
style={{ sortField={sortField}
color: theme.tableText, ascDesc={ascDesc}
marginTop: 20, onChange={() => {}}
textAlign: 'center', onRefetch={() => setDirty(true)}
fontStyle: 'italic', onCloseAddTransaction={() => {}}
}} onCreatePayee={() => {}}
> onApplyFilter={() => {}}
<Trans>No transactions</Trans> onBatchDelete={() => {}}
</View> onBatchDuplicate={() => {}}
)} onBatchLinkSchedule={() => {}}
onSort={onSort} onBatchUnlinkSchedule={() => {}}
sortField={sortField} onCreateRule={() => {}}
ascDesc={ascDesc} onScheduleAction={() => {}}
onChange={() => {}} onMakeAsNonSplitTransactions={() => {}}
onRefetch={() => setDirty(true)} showSelection={false}
onCloseAddTransaction={() => {}} allowSplitTransaction={false}
onCreatePayee={() => {}} />
onApplyFilter={() => {}} </SplitsExpandedProvider>
onBatchDelete={() => {}} ) : (
onBatchDuplicate={() => {}} <animated.div
onBatchLinkSchedule={() => {}} {...bind()}
onBatchUnlinkSchedule={() => {}} style={{
onCreateRule={() => {}} y,
onScheduleAction={() => {}} touchAction: 'pan-x',
onMakeAsNonSplitTransactions={() => {}} backgroundColor: theme.mobileNavBackground,
showSelection={false} borderTop: `1px solid ${theme.menuBorder}`,
allowSplitTransaction={false} ...styles.shadow,
/> height: totalHeight + CHEVRON_HEIGHT,
</SplitsExpandedProvider> width: '100%',
) : ( position: 'fixed',
<animated.div zIndex: 100,
{...bind()} bottom: 0,
style={{ display: isNarrowWidth ? 'flex' : 'none',
y, flexDirection: 'column',
touchAction: 'pan-x', alignItems: 'center',
backgroundColor: theme.mobileNavBackground, }}
borderTop: `1px solid ${theme.menuBorder}`, >
...styles.shadow, <Button
height: totalHeight + CHEVRON_HEIGHT, variant="bare"
width: '100%', onPress={() =>
position: 'fixed', !mobileTransactionsOpen
zIndex: 100, ? open({ canceled: false })
bottom: 0, : close()
display: isNarrowWidth ? 'flex' : 'none', }
flexDirection: 'column', className={css({
alignItems: 'center', color: theme.pageTextSubdued,
}} height: 42,
'&[data-pressed]': { backgroundColor: 'transparent' },
})}
> >
<Button {!mobileTransactionsOpen && (
variant="bare" <>
onPress={() => <SvgCheveronUp width={16} height={16} />
!mobileTransactionsOpen <Trans>Show transactions</Trans>
? open({ canceled: false }) </>
: close() )}
} {mobileTransactionsOpen && (
className={css({ <>
color: theme.pageTextSubdued, <SvgCheveronDown width={16} height={16} />
height: 42, <Trans>Hide transactions</Trans>
'&[data-pressed]': { backgroundColor: 'transparent' }, </>
})} )}
> </Button>
{!mobileTransactionsOpen && ( <View
<> style={{ height: '100%', width: '100%', overflow: 'auto' }}
<SvgCheveronUp width={16} height={16} /> >
<Trans>Show transactions</Trans> <TransactionListMobile
</> isLoading={false}
)} onLoadMore={loadMoreTransactions}
{mobileTransactionsOpen && ( transactions={allTransactions}
<> onOpenTransaction={onOpenTransaction}
<SvgCheveronDown width={16} height={16} /> isLoadingMore={false}
<Trans>Hide transactions</Trans> />
</> </View>
)} </animated.div>
</Button> )}
<View </View>
style={{ height: '100%', width: '100%', overflow: 'auto' }} </SchedulesProvider>
>
<TransactionListMobile
isLoading={false}
onLoadMore={loadMoreTransactions}
transactions={allTransactions}
onOpenTransaction={onOpenTransaction}
isLoadingMore={false}
/>
</View>
</animated.div>
)}
</View>
</SchedulesProvider>
</SelectedProviderWithItems>
</View> </View>
</Page> </Page>
); );

View File

@@ -16,7 +16,7 @@ export function simpleCashFlow(
startMonth: string, startMonth: string,
endMonth: string, endMonth: string,
conditions: RuleConditionEntity[] = [], conditions: RuleConditionEntity[] = [],
conditionsOp: 'and' | 'or' = 'and', conditionsOp: RuleConditionEntity['conditionsOp'] = 'and',
) { ) {
const start = monthUtils.firstDayOfMonth(startMonth); const start = monthUtils.firstDayOfMonth(startMonth);
const end = monthUtils.lastDayOfMonth(endMonth); const end = monthUtils.lastDayOfMonth(endMonth);
@@ -71,7 +71,7 @@ export function cashFlowByDate(
endMonth: string, endMonth: string,
isConcise: boolean, isConcise: boolean,
conditions: RuleConditionEntity[] = [], conditions: RuleConditionEntity[] = [],
conditionsOp: 'and' | 'or', conditionsOp: RuleConditionEntity['conditionsOp'],
) { ) {
const start = monthUtils.firstDayOfMonth(startMonth); const start = monthUtils.firstDayOfMonth(startMonth);
const end = monthUtils.lastDayOfMonth(endMonth); const end = monthUtils.lastDayOfMonth(endMonth);

View File

@@ -26,7 +26,7 @@ export function createSpreadsheet(
end: string, end: string,
accounts: AccountEntity[], accounts: AccountEntity[],
conditions: RuleConditionEntity[] = [], conditions: RuleConditionEntity[] = [],
conditionsOp: 'and' | 'or' = 'and', conditionsOp: RuleConditionEntity['conditionsOp'] = 'and',
) { ) {
return async ( return async (
spreadsheet: ReturnType<typeof useSpreadsheet>, spreadsheet: ReturnType<typeof useSpreadsheet>,

View File

@@ -14,7 +14,7 @@ export function summarySpreadsheet(
start: string, start: string,
end: string, end: string,
conditions: RuleConditionEntity[] = [], conditions: RuleConditionEntity[] = [],
conditionsOp: 'and' | 'or' = 'and', conditionsOp: RuleConditionEntity['conditionsOp'] = 'and',
summaryContent: SummaryContent, summaryContent: SummaryContent,
) { ) {
return async ( return async (

View File

@@ -70,6 +70,8 @@ export function Value<T>({
return value ? 'true' : 'false'; return value ? 'true' : 'false';
} else { } else {
switch (field) { switch (field) {
case 'id':
return value;
case 'amount': case 'amount':
return integerToCurrency(value); return integerToCurrency(value);
case 'date': case 'date':

View File

@@ -1,11 +1,11 @@
// @ts-strict-ignore // @ts-strict-ignore
import React, { useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { pushModal } from 'loot-core/client/actions'; import { pushModal } from 'loot-core/client/actions';
import { q } from 'loot-core/shared/query';
import { useSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
import { send } from 'loot-core/src/platform/client/fetch'; import { send } from 'loot-core/src/platform/client/fetch';
import { q } from 'loot-core/src/shared/query';
import { import {
type ScheduleEntity, type ScheduleEntity,
type TransactionEntity, type TransactionEntity,
@@ -37,6 +37,7 @@ export function ScheduleLink({
const dispatch = useDispatch(); const dispatch = useDispatch();
const [filter, setFilter] = useState(accountName || ''); const [filter, setFilter] = useState(accountName || '');
const schedulesQuery = useMemo( const schedulesQuery = useMemo(
() => q('schedules').filter({ completed: false }).select('*'), () => q('schedules').filter({ completed: false }).select('*'),
[], [],
@@ -47,25 +48,26 @@ export function ScheduleLink({
statuses, statuses,
} = useSchedules({ query: schedulesQuery }); } = useSchedules({ query: schedulesQuery });
const searchInput = useRef(null); const onSelect = useCallback(
async (scheduleId: string) => {
if (ids?.length > 0) {
await send('transactions-batch-update', {
updated: ids.map(id => ({ id, schedule: scheduleId })),
});
onScheduleLinked?.(schedules.find(s => s.id === scheduleId));
}
},
[ids, onScheduleLinked, schedules],
);
async function onSelect(scheduleId: string) { const onCreate = useCallback(() => {
if (ids?.length > 0) {
await send('transactions-batch-update', {
updated: ids.map(id => ({ id, schedule: scheduleId })),
});
onScheduleLinked?.(schedules.find(s => s.id === scheduleId));
}
}
async function onCreate() {
dispatch( dispatch(
pushModal('schedule-edit', { pushModal('schedule-edit', {
id: null, id: null,
transaction: getTransaction(ids[0]), transaction: getTransaction(ids[0]),
}), }),
); );
} }, [dispatch, getTransaction, ids]);
return ( return (
<Modal <Modal
@@ -98,7 +100,6 @@ export function ScheduleLink({
</Text> </Text>
<InitialFocus> <InitialFocus>
<Search <Search
inputRef={searchInput}
isInModal isInModal
width={300} width={300}
placeholder={t('Filter schedules…')} placeholder={t('Filter schedules…')}

View File

@@ -290,7 +290,7 @@ function ScheduleRow({
} }
export function SchedulesTable({ export function SchedulesTable({
isLoading, isLoading = false,
schedules, schedules,
statuses, statuses,
filter, filter,

View File

@@ -72,13 +72,16 @@ export type Spreadsheets = {
goal: number; goal: number;
'long-goal': number; 'long-goal': number;
}; };
[`balance`]: { balance: {
// Common fields // Common fields
'uncategorized-amount': number; 'uncategorized-amount': number;
'uncategorized-balance': number; 'uncategorized-balance': number;
'filtered-balance': number;
// Balance fields // Balance fields
[key: `balance-query-${string}`]: number; [key: `balance-query-${string}`]: number;
[key: `balance-query-${string}-cleared`]: number;
[key: `balance-query-${string}-uncleared`]: number;
[key: `selected-transactions-${string}`]: Array<{ id: string }>; [key: `selected-transactions-${string}`]: Array<{ id: string }>;
[key: `selected-balance-${string}`]: number; [key: `selected-balance-${string}`]: number;
}; };

View File

@@ -29,7 +29,7 @@ import { SvgDelete, SvgExpandArrow } from '../icons/v0';
import { SvgCheckmark } from '../icons/v1'; import { SvgCheckmark } from '../icons/v1';
import { styles, theme } from '../style'; import { styles, theme } from '../style';
import { Button } from './common/Button2'; import { ButtonWithLoading } from './common/Button2';
import { Input } from './common/Input'; import { Input } from './common/Input';
import { Menu, type MenuItem } from './common/Menu'; import { Menu, type MenuItem } from './common/Menu';
import { Popover } from './common/Popover'; import { Popover } from './common/Popover';
@@ -815,6 +815,7 @@ type SelectedItemsButtonProps<Name extends string> = {
name: ((count: number) => string) | string; name: ((count: number) => string) | string;
items: MenuItem<Name>[]; items: MenuItem<Name>[];
onSelect: (name: Name, items: string[]) => void; onSelect: (name: Name, items: string[]) => void;
isLoading?: boolean;
}; };
export function SelectedItemsButton<Name extends string>({ export function SelectedItemsButton<Name extends string>({
@@ -822,6 +823,7 @@ export function SelectedItemsButton<Name extends string>({
name, name,
items, items,
onSelect, onSelect,
isLoading = false,
}: SelectedItemsButtonProps<Name>) { }: SelectedItemsButtonProps<Name>) {
const selectedItems = useSelectedItems(); const selectedItems = useSelectedItems();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@@ -836,7 +838,8 @@ export function SelectedItemsButton<Name extends string>({
return ( return (
<View style={{ marginLeft: 10, flexShrink: 0 }}> <View style={{ marginLeft: 10, flexShrink: 0 }}>
<Button <ButtonWithLoading
isLoading={isLoading}
ref={triggerRef} ref={triggerRef}
variant="bare" variant="bare"
style={{ color: theme.pageTextPositive }} style={{ color: theme.pageTextPositive }}
@@ -849,7 +852,7 @@ export function SelectedItemsButton<Name extends string>({
style={{ marginRight: 5, color: theme.pageText }} style={{ marginRight: 5, color: theme.pageText }}
/> />
{buttonLabel} {buttonLabel}
</Button> </ButtonWithLoading>
<Popover <Popover
triggerRef={triggerRef} triggerRef={triggerRef}

View File

@@ -39,6 +39,7 @@ type SelectedTransactionsButtonProps = {
showMakeTransfer: boolean; showMakeTransfer: boolean;
onMakeAsSplitTransaction: (selectedIds: string[]) => void; onMakeAsSplitTransaction: (selectedIds: string[]) => void;
onMakeAsNonSplitTransactions: (selectedIds: string[]) => void; onMakeAsNonSplitTransactions: (selectedIds: string[]) => void;
isLoading?: boolean;
}; };
export function SelectedTransactionsButton({ export function SelectedTransactionsButton({
@@ -55,6 +56,7 @@ export function SelectedTransactionsButton({
showMakeTransfer, showMakeTransfer,
onMakeAsSplitTransaction, onMakeAsSplitTransaction,
onMakeAsNonSplitTransactions, onMakeAsNonSplitTransactions,
isLoading = false,
}: SelectedTransactionsButtonProps) { }: SelectedTransactionsButtonProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -206,6 +208,7 @@ export function SelectedTransactionsButton({
return ( return (
<SelectedItemsButton <SelectedItemsButton
id="transactions" id="transactions"
isLoading={isLoading}
name={count => t('{{count}} transactions', { count })} name={count => t('{{count}} transactions', { count })}
// @ts-expect-error fix me // @ts-expect-error fix me
items={[ items={[

View File

@@ -57,6 +57,7 @@ async function saveDiffAndApply(diff, changes, onChange) {
} }
export function TransactionList({ export function TransactionList({
isLoading = false,
tableRef, tableRef,
transactions, transactions,
allTransactions, allTransactions,
@@ -71,14 +72,12 @@ export function TransactionList({
showReconciled, showReconciled,
showCleared, showCleared,
showAccount, showAccount,
headerContent,
isAdding, isAdding,
isNew, isNew,
isMatched, isMatched,
isFiltered, isFiltered,
dateFormat, dateFormat,
hideFraction, hideFraction,
addNotification,
renderEmpty, renderEmpty,
onSort, onSort,
sortField, sortField,
@@ -110,7 +109,6 @@ export function TransactionList({
newTransactions = realizeTempTransactions(newTransactions); newTransactions = realizeTempTransactions(newTransactions);
await saveDiff({ added: newTransactions }); await saveDiff({ added: newTransactions });
onRefetch();
}, []); }, []);
const onSave = useCallback(async transaction => { const onSave = useCallback(async transaction => {
@@ -208,6 +206,7 @@ export function TransactionList({
return ( return (
<TransactionTable <TransactionTable
isLoading={isLoading}
ref={tableRef} ref={tableRef}
transactions={allTransactions} transactions={allTransactions}
loadMoreTransactions={loadMoreTransactions} loadMoreTransactions={loadMoreTransactions}
@@ -228,8 +227,6 @@ export function TransactionList({
isFiltered={isFiltered} isFiltered={isFiltered}
dateFormat={dateFormat} dateFormat={dateFormat}
hideFraction={hideFraction} hideFraction={hideFraction}
addNotification={addNotification}
headerContent={headerContent}
renderEmpty={renderEmpty} renderEmpty={renderEmpty}
onSave={onSave} onSave={onSave}
onApplyRules={onApplyRules} onApplyRules={onApplyRules}

View File

@@ -1,6 +1,5 @@
import React, { import React, {
createElement, createElement,
createRef,
forwardRef, forwardRef,
memo, memo,
useState, useState,
@@ -20,7 +19,7 @@ import {
isValid as isDateValid, isValid as isDateValid,
} from 'date-fns'; } from 'date-fns';
import { pushModal } from 'loot-core/client/actions'; import { addNotification, pushModal } from 'loot-core/client/actions';
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
import { import {
getAccountsById, getAccountsById,
@@ -1831,6 +1830,7 @@ function NewTransaction({
} }
function TransactionTableInner({ function TransactionTableInner({
isLoading,
tableNavigator, tableNavigator,
tableRef, tableRef,
listContainerRef, listContainerRef,
@@ -1840,7 +1840,7 @@ function TransactionTableInner({
onScroll, onScroll,
...props ...props
}) { }) {
const containerRef = createRef(); const containerRef = useRef();
const isAddingPrev = usePrevious(props.isAdding); const isAddingPrev = usePrevious(props.isAdding);
const [scrollWidth, setScrollWidth] = useState(0); const [scrollWidth, setScrollWidth] = useState(0);
@@ -2070,6 +2070,7 @@ function TransactionTableInner({
data-testid="transaction-table" data-testid="transaction-table"
> >
<Table <Table
loading={isLoading}
navigator={tableNavigator} navigator={tableNavigator}
ref={tableRef} ref={tableRef}
listContainerRef={listContainerRef} listContainerRef={listContainerRef}
@@ -2103,6 +2104,7 @@ function TransactionTableInner({
} }
export const TransactionTable = forwardRef((props, ref) => { export const TransactionTable = forwardRef((props, ref) => {
const dispatch = useDispatch();
const [newTransactions, setNewTransactions] = useState(null); const [newTransactions, setNewTransactions] = useState(null);
const [prevIsAdding, setPrevIsAdding] = useState(false); const [prevIsAdding, setPrevIsAdding] = useState(false);
const splitsExpanded = useSplitsExpanded(); const splitsExpanded = useSplitsExpanded();
@@ -2235,10 +2237,12 @@ export const TransactionTable = forwardRef((props, ref) => {
useEffect(() => { useEffect(() => {
if (shouldAdd.current) { if (shouldAdd.current) {
if (newTransactions[0].account == null) { if (newTransactions[0].account == null) {
props.addNotification({ dispatch(
type: 'error', addNotification({
message: 'Account is a required field', type: 'error',
}); message: 'Account is a required field',
}),
);
newNavigator.onEdit('temp', 'account'); newNavigator.onEdit('temp', 'account');
} else { } else {
const transactions = latestState.current.newTransactions; const transactions = latestState.current.newTransactions;

View File

@@ -22,7 +22,7 @@ import {
import { integerToCurrency } from 'loot-core/src/shared/util'; import { integerToCurrency } from 'loot-core/src/shared/util';
import { AuthProvider } from '../../auth/AuthProvider'; import { AuthProvider } from '../../auth/AuthProvider';
import { SelectedProviderWithItems } from '../../hooks/useSelected'; import { SelectedProvider, useSelected } from '../../hooks/useSelected';
import { SplitsExpandedProvider } from '../../hooks/useSplitsExpanded'; import { SplitsExpandedProvider } from '../../hooks/useSplitsExpanded';
import { TestProvider } from '../../redux/mock'; import { TestProvider } from '../../redux/mock';
import { ResponsiveProvider } from '../responsive/ResponsiveProvider'; import { ResponsiveProvider } from '../responsive/ResponsiveProvider';
@@ -110,6 +110,20 @@ function generateTransactions(count, splitAtIndexes = [], showError = false) {
return transactions; return transactions;
} }
// This is needed because useSelected needs redux access.
// This provider needs to be a child of the redux TestProvider.
function TestSelectedProvider({ transactions, children }) {
const selectedInst = useSelected('transactions', transactions);
return (
<SelectedProvider
instance={selectedInst}
fetchAllIds={() => transactions.map(t => t.id)}
>
{children}
</SelectedProvider>
);
}
function LiveTransactionTable(props) { function LiveTransactionTable(props) {
const [transactions, setTransactions] = useState(props.transactions); const [transactions, setTransactions] = useState(props.transactions);
@@ -152,11 +166,7 @@ function LiveTransactionTable(props) {
<AuthProvider> <AuthProvider>
<SpreadsheetProvider> <SpreadsheetProvider>
<SchedulesProvider> <SchedulesProvider>
<SelectedProviderWithItems <TestSelectedProvider transactions={transactions}>
name="transactions"
items={transactions}
fetchAllIds={() => transactions.map(t => t.id)}
>
<SplitsExpandedProvider> <SplitsExpandedProvider>
<TransactionTable <TransactionTable
{...props} {...props}
@@ -164,17 +174,14 @@ function LiveTransactionTable(props) {
loadMoreTransactions={() => {}} loadMoreTransactions={() => {}}
commonPayees={[]} commonPayees={[]}
payees={payees} payees={payees}
addNotification={n => console.log(n)}
onSave={onSave} onSave={onSave}
onSplit={onSplit} onSplit={onSplit}
onAdd={onAdd} onAdd={onAdd}
onAddSplit={onAddSplit} onAddSplit={onAddSplit}
onCreatePayee={onCreatePayee} onCreatePayee={onCreatePayee}
showSelection={true}
allowSplitTransaction={true}
/> />
</SplitsExpandedProvider> </SplitsExpandedProvider>
</SelectedProviderWithItems> </TestSelectedProvider>
</SchedulesProvider> </SchedulesProvider>
</SpreadsheetProvider> </SpreadsheetProvider>
</AuthProvider> </AuthProvider>

View File

@@ -25,6 +25,7 @@ export type BoundActions = {
* @deprecated please use actions directly with `useAppDispatch` * @deprecated please use actions directly with `useAppDispatch`
* @see https://github.com/reduxjs/react-redux/issues/1252#issuecomment-488160930 * @see https://github.com/reduxjs/react-redux/issues/1252#issuecomment-488160930
**/ **/
export function useActions() { export function useActions() {
const dispatch = useDispatch(); const dispatch = useDispatch();
return useMemo(() => { return useMemo(() => {

View File

@@ -4,19 +4,22 @@ import { type RuleConditionEntity } from 'loot-core/types/models/rule';
export function useFilters<T extends RuleConditionEntity>( export function useFilters<T extends RuleConditionEntity>(
initialConditions: T[] = [], initialConditions: T[] = [],
initialConditionsOp: 'and' | 'or' = 'and', initialConditionsOp: RuleConditionEntity['conditionsOp'] = 'and',
) { ) {
const [conditions, setConditions] = useState<T[]>(initialConditions); const [conditions, setConditions] = useState<T[]>(initialConditions);
const [conditionsOp, setConditionsOp] = useState<'and' | 'or'>( const [conditionsOp, setConditionsOp] =
initialConditionsOp, useState<RuleConditionEntity['conditionsOp']>(initialConditionsOp);
);
const [saved, setSaved] = useState<T[] | null>(null); const [saved, setSaved] = useState<T[] | null>(null);
const onApply = useCallback( const onApply = useCallback(
( (
conditionsOrSavedFilter: conditionsOrSavedFilter:
| null | null
| { conditions: T[]; conditionsOp: 'and' | 'or'; id: T[] | null } | {
conditions: T[];
conditionsOp: RuleConditionEntity['conditionsOp'];
id: T[] | null;
}
| T, | T,
) => { ) => {
if (conditionsOrSavedFilter === null) { if (conditionsOrSavedFilter === null) {

View File

@@ -6,7 +6,6 @@ import React, {
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
type Dispatch,
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
@@ -47,12 +46,12 @@ type SelectAllAction = {
isRangeSelect?: boolean; isRangeSelect?: boolean;
}; };
export type Actions = SelectAction | SelectNoneAction | SelectAllAction; type Actions = SelectAction | SelectNoneAction | SelectAllAction;
export function useSelected<T extends Item>( export function useSelected<T extends Item>(
name: string, name: string,
items: T[], items: readonly T[],
initialSelectedIds: string[], initialSelectedIds: readonly string[],
selectAllFilter?: (item: T) => boolean, selectAllFilter?: (item: T) => boolean,
) { ) {
const [state, dispatch] = useReducer( const [state, dispatch] = useReducer(
@@ -304,42 +303,3 @@ export function SelectedProvider<T extends Item>({
</SelectedItems.Provider> </SelectedItems.Provider>
); );
} }
type SelectedProviderWithItemsProps<T extends Item> = {
name: string;
items: T[];
initialSelectedIds?: string[];
fetchAllIds: () => Promise<string[]>;
registerDispatch?: (dispatch: Dispatch<Actions>) => void;
selectAllFilter?: (item: T) => boolean;
children: ReactElement;
};
// This can be helpful in class components if you cannot use the
// custom hook
export function SelectedProviderWithItems<T extends Item>({
name,
items,
initialSelectedIds = [],
fetchAllIds,
registerDispatch,
selectAllFilter,
children,
}: SelectedProviderWithItemsProps<T>) {
const selected = useSelected<T>(
name,
items,
initialSelectedIds,
selectAllFilter,
);
useEffect(() => {
registerDispatch?.(selected.dispatch);
}, [registerDispatch]);
return (
<SelectedProvider<T> instance={selected} fetchAllIds={fetchAllIds}>
{children}
</SelectedProvider>
);
}

View File

@@ -98,7 +98,7 @@ export function useSchedules({
return; return;
} }
setIsLoading(true); setIsLoading(query !== null);
scheduleQueryRef.current = liveQuery<ScheduleEntity>(query, { scheduleQueryRef.current = liveQuery<ScheduleEntity>(query, {
onData: async schedules => { onData: async schedules => {

View File

@@ -7,6 +7,8 @@ import { type Query } from '../../shared/query';
import { getScheduledAmount } from '../../shared/schedules'; import { getScheduledAmount } from '../../shared/schedules';
import { ungroupTransactions } from '../../shared/transactions'; import { ungroupTransactions } from '../../shared/transactions';
import { import {
type TransactionFilterEntity,
type RuleConditionEntity,
type ScheduleEntity, type ScheduleEntity,
type TransactionEntity, type TransactionEntity,
} from '../../types/models'; } from '../../types/models';
@@ -233,20 +235,26 @@ export function useTransactionsSearch({
}: UseTransactionsSearchProps): UseTransactionsSearchResult { }: UseTransactionsSearchProps): UseTransactionsSearchResult {
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const updateQueryRef = useRef(updateQuery);
updateQueryRef.current = updateQuery;
const resetQueryRef = useRef(resetQuery);
resetQueryRef.current = resetQuery;
const updateSearchQuery = useMemo( const updateSearchQuery = useMemo(
() => () =>
debounce((searchText: string) => { debounce((searchText: string) => {
if (searchText === '') { if (searchText === '') {
resetQuery(); resetQueryRef.current?.();
setIsSearching(false); setIsSearching(false);
} else if (searchText) { } else if (searchText) {
updateQuery(previousQuery => updateQueryRef.current?.(previousQuery =>
queries.transactionsSearch(previousQuery, searchText, dateFormat), queries.transactionsSearch(previousQuery, searchText, dateFormat),
); );
setIsSearching(true); setIsSearching(true);
} }
}, delayMs), }, delayMs),
[dateFormat, delayMs, resetQuery, updateQuery], [dateFormat, delayMs],
); );
useEffect(() => { useEffect(() => {
@@ -259,6 +267,206 @@ export function useTransactionsSearch({
}; };
} }
type UseTransactionsFilterProps = {
updateQuery?: (updateFn: (filterQuery: Query) => Query) => void;
resetQuery?: () => void;
initialConditions?: RuleConditionEntity[];
initialConditionsOp?: RuleConditionEntity['conditionsOp'];
};
type UseTransactionsFilterResult = {
isFiltered: boolean;
activeFilter?: TransactionFilterEntity;
dirtyFilter?: TransactionFilterEntity;
conditionsOp: RuleConditionEntity['conditionsOp'];
updateConditionsOp: (op: RuleConditionEntity['conditionsOp']) => void;
conditions: readonly RuleConditionEntity[];
updateConditions: (
conditions:
| RuleConditionEntity[]
| ((conditions: RuleConditionEntity[]) => RuleConditionEntity[]),
) => void;
clear: () => void;
reset: () => void;
applyFilter: (
savedFilter: TransactionFilterEntity,
clearConditions?: boolean,
) => void;
};
export function useTransactionsFilter({
updateQuery,
resetQuery,
initialConditions = [],
initialConditionsOp = 'and',
}: UseTransactionsFilterProps): UseTransactionsFilterResult {
const [isFiltered, setIsFiltered] = useState(false);
const [activeFilter, setActiveFilter] = useState<
TransactionFilterEntity | undefined
>(undefined);
const [dirtyFilter, setDirtyFilter] = useState<
TransactionFilterEntity | undefined
>(undefined);
const [conditions, setConditions] =
useState<RuleConditionEntity[]>(initialConditions);
const [conditionsOp, setConditionsOp] =
useState<RuleConditionEntity['conditionsOp']>(initialConditionsOp);
const updateQueryRef = useRef(updateQuery);
updateQueryRef.current = updateQuery;
const resetQueryRef = useRef(resetQuery);
resetQueryRef.current = resetQuery;
const updateQueryFilter = useCallback(
async (conditions: RuleConditionEntity[]) => {
const { filters } = await send('make-filters-from-conditions', {
conditions,
});
const filter = {
[conditionsOp === 'and' ? '$and' : '$or']: filters,
};
updateQueryRef.current?.(previousQuery =>
previousQuery.unfilter().filter(filter),
);
},
[conditionsOp],
);
useEffect(() => {
let isUnmounted = false;
if (conditions.length === 0) {
resetQueryRef.current?.();
setIsFiltered(false);
} else {
updateQueryFilter(conditions).then(() => {
if (!isUnmounted) {
setIsFiltered(true);
}
});
}
return () => {
isUnmounted = true;
};
}, [conditions, updateQueryFilter]);
const clear = useCallback(() => {
setConditionsOp(initialConditionsOp);
setConditions([]);
setActiveFilter(undefined);
setDirtyFilter(undefined);
}, [initialConditionsOp]);
const reset = useCallback(() => {
setConditionsOp(initialConditionsOp);
setConditions(initialConditions);
setActiveFilter(undefined);
setDirtyFilter(undefined);
}, [initialConditions, initialConditionsOp]);
const applyFilter = useCallback(
(savedFilter: TransactionFilterEntity, clearConditions = false) => {
setActiveFilter(savedFilter);
setDirtyFilter(undefined);
setConditionsOp(savedFilter.conditionsOp);
if (clearConditions) {
setConditions(savedFilter.conditions);
} else {
setConditions(previousConditions => [
...previousConditions,
...savedFilter.conditions,
]);
}
},
[],
);
const updateConditionsOp = useCallback(
(op: RuleConditionEntity['conditionsOp'] = 'and') => {
setConditionsOp(op);
if (activeFilter && activeFilter.conditionsOp !== op) {
setDirtyFilter({ ...activeFilter, conditionsOp: op });
}
},
[activeFilter],
);
const updateActiveFilterIfNeeded = useCallback(
({
activeFilter,
conditions,
}: {
activeFilter?: TransactionFilterEntity;
conditions: RuleConditionEntity[];
}) => {
if (activeFilter) {
if (conditions.length === 0) {
setActiveFilter(undefined);
setDirtyFilter(undefined);
} else if (activeFilter.conditions !== conditions) {
setDirtyFilter({ ...activeFilter, conditions });
}
}
},
[],
);
const updateConditions = useCallback(
(conditions: Parameters<typeof setConditions>[0]) => {
setConditions(previousConditions => {
const maybeNewConditions =
typeof conditions === 'function'
? (conditions(previousConditions) ?? [])
: (conditions ?? []);
updateActiveFilterIfNeeded({
activeFilter,
conditions: maybeNewConditions,
});
return maybeNewConditions;
});
},
[activeFilter, updateActiveFilterIfNeeded],
);
return {
isFiltered,
activeFilter,
dirtyFilter,
applyFilter,
conditionsOp,
updateConditionsOp,
conditions,
updateConditions,
clear,
reset,
};
}
export type TransactionFilter = RuleConditionEntity | TransactionFilterEntity;
export function isTransactionFilterEntity(
filter: TransactionFilter,
): filter is TransactionFilterEntity {
return 'id' in filter;
}
export function toFilterConditions(
filter: TransactionFilter,
): RuleConditionEntity[] {
if (isTransactionFilterEntity(filter)) {
// This is a saved transaction filter.
return filter.conditions;
} else {
// This is a rule condition.
return [filter];
}
}
function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) { function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) {
const status = statuses.get(schedule.id); const status = statuses.get(schedule.id);
return ( return (

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, type DependencyList } from 'react'; import { type DependencyList, useState, useEffect, useMemo } from 'react';
import { type Query } from '../shared/query'; import { type Query } from '../shared/query';

View File

@@ -63,6 +63,7 @@ type FieldInfoConstraint = Record<
>; >;
const FIELD_INFO = { const FIELD_INFO = {
id: { type: 'id' },
imported_payee: { imported_payee: {
type: 'string', type: 'string',
disallowedOps: new Set(['hasTags']), disallowedOps: new Set(['hasTags']),

View File

@@ -90,7 +90,10 @@ export function recalculateSplit(trans: TransactionEntity) {
} as TransactionEntityWithError; } as TransactionEntityWithError;
} }
function findParentIndex(transactions: TransactionEntity[], idx: number) { function findParentIndex(
transactions: readonly TransactionEntity[],
idx: number,
) {
// This relies on transactions being sorted in a way where parents // This relies on transactions being sorted in a way where parents
// are always before children, which is enforced in the db layer. // are always before children, which is enforced in the db layer.
// Walk backwards and find the last parent; // Walk backwards and find the last parent;
@@ -104,7 +107,10 @@ function findParentIndex(transactions: TransactionEntity[], idx: number) {
return null; return null;
} }
function getSplit(transactions: TransactionEntity[], parentIndex: number) { function getSplit(
transactions: readonly TransactionEntity[],
parentIndex: number,
) {
const split = [transactions[parentIndex]]; const split = [transactions[parentIndex]];
let curr = parentIndex + 1; let curr = parentIndex + 1;
while (curr < transactions.length && transactions[curr].is_child) { while (curr < transactions.length && transactions[curr].is_child) {
@@ -114,7 +120,9 @@ function getSplit(transactions: TransactionEntity[], parentIndex: number) {
return split; return split;
} }
export function ungroupTransactions(transactions: TransactionEntity[]) { export function ungroupTransactions(
transactions: readonly TransactionEntity[],
) {
return transactions.reduce<TransactionEntity[]>((list, parent) => { return transactions.reduce<TransactionEntity[]>((list, parent) => {
const { subtransactions, ...trans } = parent; const { subtransactions, ...trans } = parent;
const _subtransactions = subtransactions || []; const _subtransactions = subtransactions || [];
@@ -128,7 +136,7 @@ export function ungroupTransactions(transactions: TransactionEntity[]) {
}, []); }, []);
} }
export function groupTransaction(split: TransactionEntity[]) { export function groupTransaction(split: readonly TransactionEntity[]) {
return { ...split[0], subtransactions: split.slice(1) } as TransactionEntity; return { ...split[0], subtransactions: split.slice(1) } as TransactionEntity;
} }
@@ -152,7 +160,7 @@ export function applyTransactionDiff(
} }
function replaceTransactions( function replaceTransactions(
transactions: TransactionEntity[], transactions: readonly TransactionEntity[],
id: string, id: string,
func: ( func: (
transaction: TransactionEntity, transaction: TransactionEntity,
@@ -218,7 +226,7 @@ function replaceTransactions(
} }
export function addSplitTransaction( export function addSplitTransaction(
transactions: TransactionEntity[], transactions: readonly TransactionEntity[],
id: string, id: string,
) { ) {
return replaceTransactions(transactions, id, trans => { return replaceTransactions(transactions, id, trans => {
@@ -237,7 +245,7 @@ export function addSplitTransaction(
} }
export function updateTransaction( export function updateTransaction(
transactions: TransactionEntity[], transactions: readonly TransactionEntity[],
transaction: TransactionEntity, transaction: TransactionEntity,
) { ) {
return replaceTransactions(transactions, transaction.id, trans => { return replaceTransactions(transactions, transaction.id, trans => {
@@ -270,7 +278,7 @@ export function updateTransaction(
} }
export function deleteTransaction( export function deleteTransaction(
transactions: TransactionEntity[], transactions: readonly TransactionEntity[],
id: string, id: string,
) { ) {
return replaceTransactions(transactions, id, trans => { return replaceTransactions(transactions, id, trans => {
@@ -295,7 +303,7 @@ export function deleteTransaction(
} }
export function splitTransaction( export function splitTransaction(
transactions: TransactionEntity[], transactions: readonly TransactionEntity[],
id: string, id: string,
createSubtransactions?: ( createSubtransactions?: (
parentTransaction: TransactionEntity, parentTransaction: TransactionEntity,
@@ -323,7 +331,7 @@ export function splitTransaction(
} }
export function realizeTempTransactions( export function realizeTempTransactions(
transactions: TransactionEntity[], transactions: readonly TransactionEntity[],
): TransactionEntity[] { ): TransactionEntity[] {
const parent = { const parent = {
...transactions.find(t => !t.is_child), ...transactions.find(t => !t.is_child),
@@ -344,8 +352,8 @@ export function realizeTempTransactions(
} }
export function makeAsNonChildTransactions( export function makeAsNonChildTransactions(
childTransactionsToUpdate: TransactionEntity[], childTransactionsToUpdate: readonly TransactionEntity[],
transactions: TransactionEntity[], transactions: readonly TransactionEntity[],
) { ) {
const [parentTransaction, ...childTransactions] = transactions; const [parentTransaction, ...childTransactions] = transactions;
const newNonChildTransactions = childTransactionsToUpdate.map(t => const newNonChildTransactions = childTransactionsToUpdate.map(t =>

View File

@@ -26,7 +26,7 @@ export type NetWorthWidget = AbstractWidget<
{ {
name?: string; name?: string;
conditions?: RuleConditionEntity[]; conditions?: RuleConditionEntity[];
conditionsOp?: 'and' | 'or'; conditionsOp?: RuleConditionEntity['conditionsOp'];
timeFrame?: TimeFrame; timeFrame?: TimeFrame;
} | null } | null
>; >;
@@ -35,7 +35,7 @@ export type CashFlowWidget = AbstractWidget<
{ {
name?: string; name?: string;
conditions?: RuleConditionEntity[]; conditions?: RuleConditionEntity[];
conditionsOp?: 'and' | 'or'; conditionsOp?: RuleConditionEntity['conditionsOp'];
timeFrame?: TimeFrame; timeFrame?: TimeFrame;
showBalance?: boolean; showBalance?: boolean;
} | null } | null
@@ -45,7 +45,7 @@ export type SpendingWidget = AbstractWidget<
{ {
name?: string; name?: string;
conditions?: RuleConditionEntity[]; conditions?: RuleConditionEntity[];
conditionsOp?: 'and' | 'or'; conditionsOp?: RuleConditionEntity['conditionsOp'];
compare?: string; compare?: string;
compareTo?: string; compareTo?: string;
isLive?: boolean; isLive?: boolean;
@@ -96,7 +96,7 @@ export type SummaryWidget = AbstractWidget<
{ {
name?: string; name?: string;
conditions?: RuleConditionEntity[]; conditions?: RuleConditionEntity[];
conditionsOp?: 'and' | 'or'; conditionsOp?: RuleConditionEntity['conditionsOp'];
timeFrame?: TimeFrame; timeFrame?: TimeFrame;
content?: string; content?: string;
} | null } | null
@@ -110,7 +110,7 @@ export type BaseSummaryContent = {
export type PercentageSummaryContent = { export type PercentageSummaryContent = {
type: 'percentage'; type: 'percentage';
divisorConditions: RuleConditionEntity[]; divisorConditions: RuleConditionEntity[];
divisorConditionsOp: 'and' | 'or'; divisorConditionsOp: RuleConditionEntity['conditionsOp'];
divisorAllTimeDateRange?: boolean; divisorAllTimeDateRange?: boolean;
fontSize?: number; fontSize?: number;
}; };

View File

@@ -18,7 +18,7 @@ export interface CustomReportEntity {
showUncategorized: boolean; showUncategorized: boolean;
graphType: string; graphType: string;
conditions?: RuleConditionEntity[]; conditions?: RuleConditionEntity[];
conditionsOp: 'and' | 'or'; conditionsOp: RuleConditionEntity['conditionsOp'];
data?: GroupedEntity; data?: GroupedEntity;
tombstone?: boolean; tombstone?: boolean;
} }
@@ -130,7 +130,7 @@ export interface CustomReportData {
show_uncategorized: number; show_uncategorized: number;
graph_type: string; graph_type: string;
conditions?: RuleConditionEntity[]; conditions?: RuleConditionEntity[];
conditions_op: 'and' | 'or'; conditions_op: RuleConditionEntity['conditionsOp'];
metadata?: GroupedEntity; metadata?: GroupedEntity;
interval: string; interval: string;
color_scheme?: string; color_scheme?: string;

View File

@@ -32,6 +32,7 @@ export type RuleConditionOp =
| 'offBudget'; | 'offBudget';
export type FieldValueTypes = { export type FieldValueTypes = {
id: string;
account: string; account: string;
amount: number; amount: number;
category: string; category: string;
@@ -64,13 +65,13 @@ type BaseConditionEntity<
month?: boolean; month?: boolean;
year?: boolean; year?: boolean;
}; };
conditionsOp?: string; conditionsOp?: RuleConditionEntity['conditionsOp'];
type?: 'id' | 'boolean' | 'date' | 'number' | 'string'; type?: 'id' | 'boolean' | 'date' | 'number' | 'string';
customName?: string; customName?: string;
queryFilter?: Record<string, { $oneof: string[] }>;
}; };
export type RuleConditionEntity = export type RuleConditionEntity =
| BaseConditionEntity<'id', 'oneOf'>
| BaseConditionEntity< | BaseConditionEntity<
'account', 'account',
| 'is' | 'is'

View File

@@ -3,7 +3,7 @@ import { type RuleConditionEntity } from './rule';
export interface TransactionFilterEntity { export interface TransactionFilterEntity {
id: string; id: string;
name: string; name: string;
conditionsOp: 'and' | 'or'; conditionsOp: RuleConditionEntity['conditionsOp'];
conditions: RuleConditionEntity[]; conditions: RuleConditionEntity[];
tombstone: boolean; tombstone: boolean;
} }

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Convert Account component to a functional component and use useTransactions hook.