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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,17 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { type TransactionFilterEntity } from 'loot-core/types/models';
import { Menu } from '../common/Menu';
import { type SavedFilter } from './SavedFilterMenuButton';
export function FilterMenu({
filterId,
filter,
dirtyFilter,
onFilterMenuSelect,
}: {
filterId?: SavedFilter;
filter?: TransactionFilterEntity;
dirtyFilter?: TransactionFilterEntity;
onFilterMenuSelect: (item: string) => void;
}) {
const { t } = useTranslation();
@@ -20,12 +22,12 @@ export function FilterMenu({
onFilterMenuSelect(item);
}}
items={
!filterId?.id
!filter?.id
? [
{ 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: 'delete-filter', text: t('Delete') },
@@ -35,16 +37,22 @@ export function FilterMenu({
text: t('Save new filter'),
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: 'update-filter', text: t('Update condtions') },
{ name: 'update-filter', text: t('Update filter conditions') },
{ name: 'reload-filter', text: t('Revert changes') },
{ name: 'delete-filter', text: t('Delete') },
Menu.line,
{ 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 { AppliedFilters } from './AppliedFilters';
import {
type SavedFilter,
SavedFilterMenuButton,
} from './SavedFilterMenuButton';
import { SavedFilterMenuButton } from './SavedFilterMenuButton';
type FiltersStackProps = {
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({
conditions,
@@ -19,23 +36,10 @@ export function FiltersStack({
onDeleteFilter,
onClearFilters,
onReloadSavedFilter,
filterId,
savedFilters,
filter,
dirtyFilter,
onConditionsOpChange,
}: {
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;
}) {
}: FiltersStackProps) {
return (
<View>
<Stack
@@ -55,10 +59,10 @@ export function FiltersStack({
<SavedFilterMenuButton
conditions={conditions}
conditionsOp={conditionsOp}
filterId={filterId}
filter={filter}
dirtyFilter={dirtyFilter}
onClearFilters={onClearFilters}
onReloadSavedFilter={onReloadSavedFilter}
savedFilters={savedFilters}
/>
</Stack>
</View>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,13 +72,16 @@ export type Spreadsheets = {
goal: number;
'long-goal': number;
};
[`balance`]: {
balance: {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
'filtered-balance': number;
// Balance fields
[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-balance-${string}`]: number;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import React, {
useCallback,
useEffect,
useRef,
type Dispatch,
type ReactElement,
type ReactNode,
} from 'react';
@@ -47,12 +46,12 @@ type SelectAllAction = {
isRangeSelect?: boolean;
};
export type Actions = SelectAction | SelectNoneAction | SelectAllAction;
type Actions = SelectAction | SelectNoneAction | SelectAllAction;
export function useSelected<T extends Item>(
name: string,
items: T[],
initialSelectedIds: string[],
items: readonly T[],
initialSelectedIds: readonly string[],
selectAllFilter?: (item: T) => boolean,
) {
const [state, dispatch] = useReducer(
@@ -304,42 +303,3 @@ export function SelectedProvider<T extends Item>({
</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;
}
setIsLoading(true);
setIsLoading(query !== null);
scheduleQueryRef.current = liveQuery<ScheduleEntity>(query, {
onData: async schedules => {

View File

@@ -7,6 +7,8 @@ import { type Query } from '../../shared/query';
import { getScheduledAmount } from '../../shared/schedules';
import { ungroupTransactions } from '../../shared/transactions';
import {
type TransactionFilterEntity,
type RuleConditionEntity,
type ScheduleEntity,
type TransactionEntity,
} from '../../types/models';
@@ -233,20 +235,26 @@ export function useTransactionsSearch({
}: UseTransactionsSearchProps): UseTransactionsSearchResult {
const [isSearching, setIsSearching] = useState(false);
const updateQueryRef = useRef(updateQuery);
updateQueryRef.current = updateQuery;
const resetQueryRef = useRef(resetQuery);
resetQueryRef.current = resetQuery;
const updateSearchQuery = useMemo(
() =>
debounce((searchText: string) => {
if (searchText === '') {
resetQuery();
resetQueryRef.current?.();
setIsSearching(false);
} else if (searchText) {
updateQuery(previousQuery =>
updateQueryRef.current?.(previousQuery =>
queries.transactionsSearch(previousQuery, searchText, dateFormat),
);
setIsSearching(true);
}
}, delayMs),
[dateFormat, delayMs, resetQuery, updateQuery],
[dateFormat, delayMs],
);
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) {
const status = statuses.get(schedule.id);
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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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