(typescript) Refactoring the mobile TransactionListWithBalance component into typescript. (#4061)

* refactor: convert txListwBal to tsx

* docs: add release notes

* docs: rename notes

* refactor: fix missing cleared/uncleared balance

* refactor: use Binding type
This commit is contained in:
Leo Lee
2025-01-06 13:23:03 -08:00
committed by GitHub
parent 832fd1e5d8
commit eadd11b7f0
5 changed files with 301 additions and 205 deletions

View File

@@ -1,197 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectedProvider, useSelected } from '../../../hooks/useSelected';
import { SvgSearchAlternate } from '../../../icons/v2';
import { styles, theme } from '../../../style';
import { InputWithContent } from '../../common/InputWithContent';
import { Label } from '../../common/Label';
import { View } from '../../common/View';
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { PullToRefresh } from '../PullToRefresh';
import { TransactionList } from './TransactionList';
function TransactionSearchInput({ placeholder, onSearch }) {
const [text, setText] = useState('');
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
}}
>
<InputWithContent
leftContent={
<SvgSearchAlternate
style={{
width: 13,
height: 13,
flexShrink: 0,
color: text ? theme.formInputTextHighlight : 'inherit',
margin: 5,
marginRight: 0,
}}
/>
}
value={text}
onChangeValue={text => {
setText(text);
onSearch(text);
}}
placeholder={placeholder}
style={{
backgroundColor: theme.tableBackground,
border: `1px solid ${theme.formInputBorder}`,
flex: 1,
height: styles.mobileMinHeight,
}}
/>
</View>
);
}
export function TransactionListWithBalances({
isLoading,
transactions,
balance,
balanceCleared,
balanceUncleared,
searchPlaceholder = 'Search...',
onSearch,
isLoadingMore,
onLoadMore,
onOpenTransaction,
onRefresh,
}) {
const selectedInst = useSelected('transactions', transactions);
return (
<SelectedProvider instance={selectedInst}>
<View
style={{
flexShrink: 0,
marginTop: 10,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-evenly',
}}
>
{balanceCleared && balanceUncleared ? (
<BalanceWithCleared
balance={balance}
balanceCleared={balanceCleared}
balanceUncleared={balanceUncleared}
/>
) : (
<Balance balance={balance} />
)}
</View>
<TransactionSearchInput
placeholder={searchPlaceholder}
onSearch={onSearch}
/>
</View>
<PullToRefresh isPullable={!!onRefresh} onRefresh={onRefresh}>
<TransactionList
isLoading={isLoading}
transactions={transactions}
isLoadingMore={isLoadingMore}
onLoadMore={onLoadMore}
onOpenTransaction={onOpenTransaction}
/>
</PullToRefresh>
</SelectedProvider>
);
}
function BalanceWithCleared({ balanceUncleared, balanceCleared, balance }) {
const { t } = useTranslation();
const unclearedAmount = useSheetValue(balanceUncleared);
return (
<>
<View
style={{
display: !unclearedAmount ? 'none' : undefined,
flexBasis: '33%',
}}
>
<Label
title={t('Cleared')}
style={{ textAlign: 'center', fontSize: 12 }}
/>
<CellValue binding={balanceCleared} type="financial">
{props => (
<CellValueText
{...props}
style={{
fontSize: 12,
textAlign: 'center',
fontWeight: '500',
}}
data-testid="transactions-balance-cleared"
/>
)}
</CellValue>
</View>
<Balance balance={balance} />
<View
style={{
display: !unclearedAmount ? 'none' : undefined,
flexBasis: '33%',
}}
>
<Label
title={t('Uncleared')}
style={{ textAlign: 'center', fontSize: 12 }}
/>
<CellValue binding={balanceUncleared} type="financial">
{props => (
<CellValueText
{...props}
style={{
fontSize: 12,
textAlign: 'center',
fontWeight: '500',
}}
data-testid="transactions-balance-uncleared"
/>
)}
</CellValue>
</View>
</>
);
}
function Balance({ balance }) {
const { t } = useTranslation();
return (
<View style={{ flexBasis: '33%' }}>
<Label title={t('Balance')} style={{ textAlign: 'center' }} />
<CellValue binding={balance} type="financial">
{props => (
<CellValueText
{...props}
style={{
fontSize: 18,
textAlign: 'center',
fontWeight: '500',
color:
props.value < 0 ? theme.errorText : theme.pillTextHighlighted,
}}
data-testid="transactions-balance"
/>
)}
</CellValue>
</View>
);
}

View File

@@ -0,0 +1,277 @@
import React, { type ComponentProps, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type TransactionEntity } from 'loot-core/types/models/transaction';
import { SelectedProvider, useSelected } from '../../../hooks/useSelected';
import { SvgSearchAlternate } from '../../../icons/v2';
import { styles, theme } from '../../../style';
import { InputWithContent } from '../../common/InputWithContent';
import { Label } from '../../common/Label';
import { View } from '../../common/View';
import type { Binding, SheetNames, SheetFields } from '../../spreadsheet';
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { PullToRefresh } from '../PullToRefresh';
import { TransactionList } from './TransactionList';
type TransactionSearchInputProps = {
placeholder: string;
onSearch: TransactionListWithBalancesProps['onSearch'];
};
function TransactionSearchInput({
placeholder,
onSearch,
}: TransactionSearchInputProps) {
const [text, setText] = useState('');
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
}}
>
<InputWithContent
leftContent={
<SvgSearchAlternate
style={{
width: 13,
height: 13,
flexShrink: 0,
color: text ? theme.formInputTextHighlight : 'inherit',
margin: 5,
marginRight: 0,
}}
/>
}
value={text}
onChangeValue={text => {
setText(text);
onSearch(text);
}}
placeholder={placeholder}
style={{
backgroundColor: theme.tableBackground,
border: `1px solid ${theme.formInputBorder}`,
flex: 1,
height: styles.mobileMinHeight,
}}
/>
</View>
);
}
type TransactionListWithBalancesProps = {
isLoading: boolean;
transactions: readonly TransactionEntity[];
balance:
| Binding<'account', 'onbudget-accounts-balance'>
| Binding<'account', 'offbudget-accounts-balance'>
| Binding<SheetNames, 'uncategorized-balance'>
| Binding<'category', 'balance'>
| Binding<'account', 'balance'>
| Binding<'account', 'accounts-balance'>;
balanceCleared?:
| Binding<'category', 'balanceCleared'>
| Binding<'account', 'balanceCleared'>;
balanceUncleared?:
| Binding<'category', 'balanceUncleared'>
| Binding<'account', 'balanceUncleared'>;
searchPlaceholder: string;
onSearch: (searchText: string) => void;
isLoadingMore: boolean;
onLoadMore: () => void;
onOpenTransaction: (transaction: TransactionEntity) => void;
onRefresh?: () => void;
};
export function TransactionListWithBalances({
isLoading,
transactions,
balance,
balanceCleared,
balanceUncleared,
searchPlaceholder = 'Search...',
onSearch,
isLoadingMore,
onLoadMore,
onOpenTransaction,
onRefresh,
}: TransactionListWithBalancesProps) {
const selectedInst = useSelected('transactions', [...transactions], []);
return (
<SelectedProvider instance={selectedInst}>
<>
<View
style={{
flexShrink: 0,
marginTop: 10,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-evenly',
}}
>
{balanceCleared && balanceUncleared ? (
<BalanceWithCleared
balance={balance}
balanceCleared={balanceCleared}
balanceUncleared={balanceUncleared}
/>
) : (
<Balance balance={balance} />
)}
</View>
<TransactionSearchInput
placeholder={searchPlaceholder}
onSearch={onSearch}
/>
</View>
<PullToRefresh
isPullable={!!onRefresh}
onRefresh={async () => onRefresh?.()}
>
<TransactionList
isLoading={isLoading}
transactions={transactions}
isLoadingMore={isLoadingMore}
onLoadMore={onLoadMore}
onOpenTransaction={onOpenTransaction}
/>
</PullToRefresh>
</>
</SelectedProvider>
);
}
const TransactionListBalanceCellValue = <
FieldName extends SheetFields<'account'> | SheetFields<'category'>,
>(
props: ComponentProps<
typeof CellValue<
FieldName extends SheetFields<'account'> ? 'account' : 'category',
FieldName
>
>,
) => {
return <CellValue {...props} />;
};
type BalanceWithClearedProps = {
balanceUncleared: NonNullable<
TransactionListWithBalancesProps['balanceUncleared']
>;
balanceCleared: NonNullable<
TransactionListWithBalancesProps['balanceCleared']
>;
balance: TransactionListWithBalancesProps['balance'];
};
function BalanceWithCleared({
balanceUncleared,
balanceCleared,
balance,
}: BalanceWithClearedProps) {
const { t } = useTranslation();
const unclearedAmount = useSheetValue<
'account' | 'category',
'balanceUncleared'
>(balanceUncleared);
return (
<>
<View
style={{
display: !unclearedAmount ? 'none' : undefined,
flexBasis: '33%',
}}
>
<Label
title={t('Cleared')}
style={{ textAlign: 'center', fontSize: 12 }}
/>
<TransactionListBalanceCellValue
binding={balanceCleared}
type="financial"
>
{props => (
<CellValueText
{...props}
style={{
fontSize: 12,
textAlign: 'center',
fontWeight: '500',
}}
data-testid="transactions-balance-cleared"
/>
)}
</TransactionListBalanceCellValue>
</View>
<Balance balance={balance} />
<View
style={{
display: !unclearedAmount ? 'none' : undefined,
flexBasis: '33%',
}}
>
<Label
title={t('Uncleared')}
style={{ textAlign: 'center', fontSize: 12 }}
/>
<TransactionListBalanceCellValue
binding={balanceUncleared}
type="financial"
>
{props => (
<CellValueText
{...props}
style={{
fontSize: 12,
textAlign: 'center',
fontWeight: '500',
}}
data-testid="transactions-balance-uncleared"
/>
)}
</TransactionListBalanceCellValue>
</View>
</>
);
}
type BalanceProps = {
balance: TransactionListWithBalancesProps['balance'];
};
function Balance({ balance }: BalanceProps) {
const { t } = useTranslation();
return (
<View style={{ flexBasis: '33%' }}>
<Label title={t('Balance')} style={{ textAlign: 'center' }} />
<TransactionListBalanceCellValue binding={balance} type="financial">
{props => (
<CellValueText
{...props}
style={{
fontSize: 18,
textAlign: 'center',
fontWeight: '500',
color:
props.value < 0 ? theme.errorText : theme.pillTextHighlighted,
}}
data-testid="transactions-balance"
/>
)}
</TransactionListBalanceCellValue>
</View>
);
}

View File

@@ -15,6 +15,15 @@ export type Spreadsheets = {
balanceCleared: number;
balanceUncleared: number;
};
category: {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
balance: number;
balanceCleared: number;
balanceUncleared: number;
};
'envelope-budget': {
// Common fields
'uncategorized-amount': number;

View File

@@ -25,6 +25,7 @@ type BudgetType<SheetName extends SheetNames> = Record<
>;
const accountParametrizedField = parametrizedField<'account'>();
const categoryParametrizedField = parametrizedField<'category'>();
const envelopeParametrizedField = parametrizedField<'envelope-budget'>();
const trackingParametrizedField = parametrizedField<'tracking-budget'>();
@@ -179,7 +180,7 @@ export function offBudgetAccountBalance() {
export function categoryBalance(category: CategoryEntity, month: string) {
return {
name: `balance-${category.id}`,
name: categoryParametrizedField('balance')(category.id),
query: q('transactions')
.filter({
category: category.id,
@@ -187,7 +188,7 @@ export function categoryBalance(category: CategoryEntity, month: string) {
})
.options({ splits: 'inline' })
.calculate({ $sum: '$amount' }),
};
} satisfies Binding<'category', 'balance'>;
}
export function categoryBalanceCleared(
@@ -195,7 +196,7 @@ export function categoryBalanceCleared(
month: string,
) {
return {
name: `balanceCleared-${category.id}`,
name: categoryParametrizedField('balanceCleared')(category.id),
query: q('transactions')
.filter({
category: category.id,
@@ -204,7 +205,7 @@ export function categoryBalanceCleared(
})
.options({ splits: 'inline' })
.calculate({ $sum: '$amount' }),
};
} satisfies Binding<'category', 'balanceCleared'>;
}
export function categoryBalanceUncleared(
@@ -212,7 +213,7 @@ export function categoryBalanceUncleared(
month: string,
) {
return {
name: `balanceUncleared-${category.id}`,
name: categoryParametrizedField('balanceUncleared')(category.id),
query: q('transactions')
.filter({
category: category.id,
@@ -221,7 +222,7 @@ export function categoryBalanceUncleared(
})
.options({ splits: 'inline' })
.calculate({ $sum: '$amount' }),
};
} satisfies Binding<'category', 'balanceUncleared'>;
}
const uncategorizedQuery = q('transactions').filter({
@@ -235,11 +236,11 @@ const uncategorizedQuery = q('transactions').filter({
],
});
export function uncategorizedBalance() {
export function uncategorizedBalance<SheetName extends SheetNames>() {
return {
name: 'uncategorized-balance',
query: uncategorizedQuery.calculate({ $sum: '$amount' }),
};
} satisfies Binding<SheetName, 'uncategorized-balance'>;
}
export function uncategorizedCount<SheetName extends SheetNames>() {

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [leoltl]
---
Refactoring the mobile TransactionListWithBalance component into typescript