Compare commits
13 Commits
v26.1.0
...
notes-tag-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2044580642 | ||
|
|
01a2f0665e | ||
|
|
12b5e0ac7c | ||
|
|
060716b53c | ||
|
|
6f1b66f9dc | ||
|
|
eea961a20d | ||
|
|
5da0f86384 | ||
|
|
176a4782a1 | ||
|
|
2ece4d35a7 | ||
|
|
fe2166207b | ||
|
|
849a43d9ce | ||
|
|
30d4ca9ac6 | ||
|
|
f49c7f516e |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,12 +1,10 @@
|
||||
import React, { PureComponent, createRef, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom';
|
||||
|
||||
import { debounce } from 'debounce';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { validForTransfer } from 'loot-core/client/transfer';
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
|
||||
import { SchedulesProvider } from 'loot-core/src/client/data-hooks/schedules';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
@@ -26,6 +24,7 @@ import {
|
||||
import { applyChanges, groupById } from 'loot-core/src/shared/util';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useFailedAccounts } from '../../hooks/useFailedAccounts';
|
||||
@@ -179,7 +178,7 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
this.state = {
|
||||
search: '',
|
||||
filters: props.conditions || [],
|
||||
filterConditions: props.filterConditions || [],
|
||||
loading: true,
|
||||
workingHard: false,
|
||||
reconcileAmount: null,
|
||||
@@ -256,7 +255,7 @@ class AccountInternal extends PureComponent {
|
||||
// Important that any async work happens last so that the
|
||||
// listeners are set up synchronously
|
||||
await this.props.initiallyLoadPayees();
|
||||
await this.fetchTransactions(this.state.filters);
|
||||
await this.fetchTransactions(this.state.filterConditions);
|
||||
|
||||
// If there is a pending undo, apply it immediately (this happens
|
||||
// when an undo changes the location to this page)
|
||||
@@ -285,7 +284,7 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
//Resest sort/filter/search on account change
|
||||
if (this.props.accountId !== prevProps.accountId) {
|
||||
this.setState({ sort: [], search: '', filters: [] });
|
||||
this.setState({ sort: [], search: '', filterConditions: [] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,10 +312,10 @@ class AccountInternal extends PureComponent {
|
||||
this.paged?.run();
|
||||
};
|
||||
|
||||
fetchTransactions = filters => {
|
||||
fetchTransactions = filterConditions => {
|
||||
const query = this.makeRootQuery();
|
||||
this.rootQuery = this.currentQuery = query;
|
||||
if (filters) this.applyFilters(filters);
|
||||
if (filterConditions) this.applyFilters(filterConditions);
|
||||
else this.updateQuery(query);
|
||||
|
||||
if (this.props.accountId) {
|
||||
@@ -418,7 +417,10 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
onSearchDone = debounce(() => {
|
||||
if (this.state.search === '') {
|
||||
this.updateQuery(this.currentQuery, this.state.filters.length > 0);
|
||||
this.updateQuery(
|
||||
this.currentQuery,
|
||||
this.state.filterConditions.length > 0,
|
||||
);
|
||||
} else {
|
||||
this.updateQuery(
|
||||
queries.makeTransactionSearchQuery(
|
||||
@@ -511,7 +513,7 @@ class AccountInternal extends PureComponent {
|
||||
return (
|
||||
account &&
|
||||
this.state.search === '' &&
|
||||
this.state.filters.length === 0 &&
|
||||
this.state.filterConditions.length === 0 &&
|
||||
(this.state.sort.length === 0 ||
|
||||
(this.state.sort.field === 'date' &&
|
||||
this.state.sort.ascDesc === 'desc'))
|
||||
@@ -599,7 +601,7 @@ class AccountInternal extends PureComponent {
|
||||
{
|
||||
transactions: [],
|
||||
transactionCount: 0,
|
||||
filters: [],
|
||||
filterConditions: [],
|
||||
search: '',
|
||||
sort: [],
|
||||
showBalances: true,
|
||||
@@ -612,9 +614,9 @@ class AccountInternal extends PureComponent {
|
||||
break;
|
||||
case 'remove-sorting': {
|
||||
this.setState({ sort: [] }, () => {
|
||||
const filters = this.state.filters;
|
||||
if (filters.length > 0) {
|
||||
this.applyFilters([...filters]);
|
||||
const filterConditions = this.state.filterConditions;
|
||||
if (filterConditions.length > 0) {
|
||||
this.applyFilters([...filterConditions]);
|
||||
} else {
|
||||
this.fetchTransactions();
|
||||
}
|
||||
@@ -637,12 +639,12 @@ class AccountInternal extends PureComponent {
|
||||
if (this.state.showReconciled) {
|
||||
this.props.savePrefs({ ['hide-reconciled-' + accountId]: true });
|
||||
this.setState({ showReconciled: false }, () =>
|
||||
this.fetchTransactions(this.state.filters),
|
||||
this.fetchTransactions(this.state.filterConditions),
|
||||
);
|
||||
} else {
|
||||
this.props.savePrefs({ ['hide-reconciled-' + accountId]: false });
|
||||
this.setState({ showReconciled: true }, () =>
|
||||
this.fetchTransactions(this.state.filters),
|
||||
this.fetchTransactions(this.state.filterConditions),
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -797,7 +799,7 @@ class AccountInternal extends PureComponent {
|
||||
onShowTransactions = async ids => {
|
||||
this.onApplyFilter({
|
||||
customName: 'Selected transactions',
|
||||
filter: { id: { $oneof: ids } },
|
||||
queryFilter: { id: { $oneof: ids } },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1183,10 +1185,10 @@ class AccountInternal extends PureComponent {
|
||||
);
|
||||
};
|
||||
|
||||
onCondOpChange = (value, filters) => {
|
||||
onConditionsOpChange = (value, conditions) => {
|
||||
this.setState({ conditionsOp: value });
|
||||
this.setState({ filterId: { ...this.state.filterId, status: 'changed' } });
|
||||
this.applyFilters([...filters]);
|
||||
this.applyFilters([...conditions]);
|
||||
if (this.state.search !== '') {
|
||||
this.onSearch(this.state.search);
|
||||
}
|
||||
@@ -1194,11 +1196,11 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
onReloadSavedFilter = (savedFilter, item) => {
|
||||
if (item === 'reload') {
|
||||
const [getFilter] = this.props.filtersList.filter(
|
||||
const [savedFilter] = this.props.savedFilters.filter(
|
||||
f => f.id === this.state.filterId.id,
|
||||
);
|
||||
this.setState({ conditionsOp: getFilter.conditionsOp });
|
||||
this.applyFilters([...getFilter.conditions]);
|
||||
this.setState({ conditionsOp: savedFilter.conditionsOp });
|
||||
this.applyFilters([...savedFilter.conditions]);
|
||||
} else {
|
||||
if (savedFilter.status) {
|
||||
this.setState({ conditionsOp: savedFilter.conditionsOp });
|
||||
@@ -1217,9 +1219,11 @@ class AccountInternal extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
onUpdateFilter = (oldFilter, updatedFilter) => {
|
||||
onUpdateFilter = (oldCondition, updatedCondition) => {
|
||||
this.applyFilters(
|
||||
this.state.filters.map(f => (f === oldFilter ? updatedFilter : f)),
|
||||
this.state.filterConditions.map(c =>
|
||||
c === oldCondition ? updatedCondition : c,
|
||||
),
|
||||
);
|
||||
this.setState({
|
||||
filterId: {
|
||||
@@ -1232,9 +1236,9 @@ class AccountInternal extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
onDeleteFilter = filter => {
|
||||
this.applyFilters(this.state.filters.filter(f => f !== filter));
|
||||
if (this.state.filters.length === 1) {
|
||||
onDeleteFilter = condition => {
|
||||
this.applyFilters(this.state.filterConditions.filter(c => c !== condition));
|
||||
if (this.state.filterConditions.length === 1) {
|
||||
this.setState({ filterId: [] });
|
||||
this.setState({ conditionsOp: 'and' });
|
||||
} else {
|
||||
@@ -1250,23 +1254,31 @@ class AccountInternal extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
onApplyFilter = async cond => {
|
||||
let filters = this.state.filters;
|
||||
if (cond.customName) {
|
||||
filters = filters.filter(f => f.customName !== cond.customName);
|
||||
onApplyFilter = async conditionOrSavedFilter => {
|
||||
let filterConditions = this.state.filterConditions;
|
||||
if (conditionOrSavedFilter.customName) {
|
||||
filterConditions = filterConditions.filter(
|
||||
c => c.customName !== conditionOrSavedFilter.customName,
|
||||
);
|
||||
}
|
||||
if (cond.conditions) {
|
||||
this.setState({ filterId: { ...cond, status: 'saved' } });
|
||||
this.setState({ conditionsOp: cond.conditionsOp });
|
||||
this.applyFilters([...cond.conditions]);
|
||||
if (conditionOrSavedFilter.conditions) {
|
||||
// A saved filter was passed in.
|
||||
const savedFilter = conditionOrSavedFilter;
|
||||
this.setState({
|
||||
filterId: { ...savedFilter, status: 'saved' },
|
||||
});
|
||||
this.setState({ conditionsOp: savedFilter.conditionsOp });
|
||||
this.applyFilters([...savedFilter.conditions]);
|
||||
} else {
|
||||
// A condition was passed in.
|
||||
const condition = conditionOrSavedFilter;
|
||||
this.setState({
|
||||
filterId: {
|
||||
...this.state.filterId,
|
||||
status: this.state.filterId && 'changed',
|
||||
},
|
||||
});
|
||||
this.applyFilters([...filters, cond]);
|
||||
this.applyFilters([...filterConditions, condition]);
|
||||
}
|
||||
if (this.state.search !== '') {
|
||||
this.onSearch(this.state.search);
|
||||
@@ -1292,28 +1304,38 @@ class AccountInternal extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
applyFilters = async conditions => {
|
||||
applyFilters = async (conditions, append = false) => {
|
||||
if (conditions.length > 0) {
|
||||
const customFilters = conditions
|
||||
const customQueryFilters = conditions
|
||||
.filter(cond => !!cond.customName)
|
||||
.map(f => f.filter);
|
||||
const { filters } = await send('make-filters-from-conditions', {
|
||||
conditions: conditions.filter(cond => !cond.customName),
|
||||
});
|
||||
.map(f => f.queryFilter);
|
||||
const { filters: queryFilters } = await send(
|
||||
'make-filters-from-conditions',
|
||||
{
|
||||
conditions: conditions.filter(cond => !cond.customName),
|
||||
},
|
||||
);
|
||||
const conditionsOpKey = this.state.conditionsOp === 'or' ? '$or' : '$and';
|
||||
this.currentQuery = this.rootQuery.filter({
|
||||
[conditionsOpKey]: [...filters, ...customFilters],
|
||||
[conditionsOpKey]: [...queryFilters, ...customQueryFilters],
|
||||
});
|
||||
|
||||
this.setState({ filters: conditions }, () => {
|
||||
this.updateQuery(this.currentQuery, true);
|
||||
});
|
||||
this.setState(
|
||||
prevState => ({
|
||||
filterConditions: append
|
||||
? [...prevState.filterConditions, ...conditions]
|
||||
: conditions,
|
||||
}),
|
||||
() => {
|
||||
this.updateQuery(this.currentQuery, true);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.setState(
|
||||
{
|
||||
transactions: [],
|
||||
transactionCount: 0,
|
||||
filters: conditions,
|
||||
filterConditions: conditions,
|
||||
},
|
||||
() => {
|
||||
this.fetchTransactions();
|
||||
@@ -1327,8 +1349,8 @@ class AccountInternal extends PureComponent {
|
||||
};
|
||||
|
||||
applySort = (field, ascDesc, prevField, prevAscDesc) => {
|
||||
const filters = this.state.filters;
|
||||
const isFiltered = filters.length > 0;
|
||||
const filterConditions = this.state.filterConditions;
|
||||
const isFiltered = filterConditions.length > 0;
|
||||
const sortField = getField(!field ? this.state.sort.field : field);
|
||||
const sortAscDesc = !ascDesc ? this.state.sort.ascDesc : ascDesc;
|
||||
const sortPrevField = getField(
|
||||
@@ -1395,7 +1417,7 @@ class AccountInternal extends PureComponent {
|
||||
// called directly from UI by sorting a column.
|
||||
// active filters need to be applied before sorting
|
||||
case isFiltered:
|
||||
this.applyFilters([...filters]);
|
||||
this.applyFilters([...filterConditions]);
|
||||
sortCurrentQuery(this, sortField, sortAscDesc);
|
||||
break;
|
||||
|
||||
@@ -1457,7 +1479,6 @@ class AccountInternal extends PureComponent {
|
||||
addNotification,
|
||||
accountsSyncing,
|
||||
failedAccounts,
|
||||
pushModal,
|
||||
replaceModal,
|
||||
showExtraBalances,
|
||||
accountId,
|
||||
@@ -1524,7 +1545,7 @@ class AccountInternal extends PureComponent {
|
||||
workingHard={workingHard}
|
||||
account={account}
|
||||
filterId={filterId}
|
||||
filtersList={this.props.filtersList}
|
||||
savedFilters={this.props.savedFilters}
|
||||
location={this.props.location}
|
||||
accountName={accountName}
|
||||
accountsSyncing={accountsSyncing}
|
||||
@@ -1541,7 +1562,7 @@ class AccountInternal extends PureComponent {
|
||||
isSorted={this.state.sort.length !== 0}
|
||||
reconcileAmount={reconcileAmount}
|
||||
search={this.state.search}
|
||||
filters={this.state.filters}
|
||||
filterConditions={this.state.filterConditions}
|
||||
conditionsOp={this.state.conditionsOp}
|
||||
savePrefs={this.props.savePrefs}
|
||||
pushModal={this.props.pushModal}
|
||||
@@ -1567,7 +1588,7 @@ class AccountInternal extends PureComponent {
|
||||
onUpdateFilter={this.onUpdateFilter}
|
||||
onClearFilters={this.onClearFilters}
|
||||
onReloadSavedFilter={this.onReloadSavedFilter}
|
||||
onCondOpChange={this.onCondOpChange}
|
||||
onConditionsOpChange={this.onConditionsOpChange}
|
||||
onDeleteFilter={this.onDeleteFilter}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
onScheduleAction={this.onScheduleAction}
|
||||
@@ -1601,7 +1622,8 @@ class AccountInternal extends PureComponent {
|
||||
isNew={this.isNew}
|
||||
isMatched={this.isMatched}
|
||||
isFiltered={
|
||||
this.state.search !== '' || this.state.filters.length > 0
|
||||
this.state.search !== '' ||
|
||||
this.state.filterConditions.length > 0
|
||||
}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
@@ -1622,7 +1644,6 @@ class AccountInternal extends PureComponent {
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
pushModal={pushModal}
|
||||
onSort={this.onSort}
|
||||
sortField={this.state.sort.field}
|
||||
ascDesc={this.state.sort.ascDesc}
|
||||
@@ -1638,6 +1659,7 @@ class AccountInternal extends PureComponent {
|
||||
this.setState({ isAdding: false })
|
||||
}
|
||||
onCreatePayee={this.onCreatePayee}
|
||||
onApplyFilters={this.applyFilters}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1685,36 +1707,10 @@ export function Account() {
|
||||
const modalShowing = useSelector(state => state.modals.modalStack.length > 0);
|
||||
const accountsSyncing = useSelector(state => state.account.accountsSyncing);
|
||||
const lastUndoState = useSelector(state => state.app.lastUndoState);
|
||||
const conditions =
|
||||
location.state && location.state.conditions
|
||||
? location.state.conditions
|
||||
: [];
|
||||
const filterConditions = location?.state?.filterConditions || [];
|
||||
|
||||
const state = {
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
accounts,
|
||||
failedAccounts,
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
expandSplits,
|
||||
showBalances,
|
||||
showCleared: !hideCleared,
|
||||
showReconciled: !hideReconciled,
|
||||
showExtraBalances,
|
||||
payees,
|
||||
modalShowing,
|
||||
accountsSyncing,
|
||||
lastUndoState,
|
||||
conditions,
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const filtersList = useFilters();
|
||||
const actionCreators = useMemo(
|
||||
() => bindActionCreators(actions, dispatch),
|
||||
[dispatch],
|
||||
);
|
||||
const savedFiters = useFilters();
|
||||
const actionCreators = useActions();
|
||||
|
||||
const transform = useMemo(() => {
|
||||
const filterByAccount = queries.getAccountFilter(params.id, '_account');
|
||||
@@ -1743,17 +1739,31 @@ export function Account() {
|
||||
return (
|
||||
<SchedulesProvider transform={transform}>
|
||||
<SplitsExpandedProvider
|
||||
initialMode={state.expandSplits ? 'collapse' : 'expand'}
|
||||
initialMode={expandSplits ? 'collapse' : 'expand'}
|
||||
>
|
||||
<AccountHack
|
||||
{...state}
|
||||
newTransactions={newTransactions}
|
||||
matchedTransactions={matchedTransactions}
|
||||
accounts={accounts}
|
||||
failedAccounts={failedAccounts}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
expandSplits={expandSplits}
|
||||
showBalances={showBalances}
|
||||
showCleared={!hideCleared}
|
||||
showReconciled={!hideReconciled}
|
||||
showExtraBalances={showExtraBalances}
|
||||
payees={payees}
|
||||
modalShowing={modalShowing}
|
||||
accountsSyncing={accountsSyncing}
|
||||
lastUndoState={lastUndoState}
|
||||
filterConditions={filterConditions}
|
||||
categoryGroups={categoryGroups}
|
||||
{...actionCreators}
|
||||
modalShowing={state.modalShowing}
|
||||
accountId={params.id}
|
||||
categoryId={location?.state?.categoryId}
|
||||
location={location}
|
||||
filtersList={filtersList}
|
||||
savedFilters={savedFiters}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SchedulesProvider>
|
||||
|
||||
@@ -141,7 +141,7 @@ export function Balances({
|
||||
showExtraBalances,
|
||||
onToggleExtraBalances,
|
||||
account,
|
||||
filteredItems,
|
||||
showFilteredBalances,
|
||||
transactions,
|
||||
}) {
|
||||
const selectedItems = useSelectedItems();
|
||||
@@ -200,9 +200,7 @@ export function Balances({
|
||||
{selectedItems.size > 0 && (
|
||||
<SelectedBalance selectedItems={selectedItems} account={account} />
|
||||
)}
|
||||
{filteredItems.length > 0 && (
|
||||
<FilteredBalance selectedItems={transactions} />
|
||||
)}
|
||||
{showFilteredBalances && <FilteredBalance selectedItems={transactions} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AccountHeader({
|
||||
accountName,
|
||||
account,
|
||||
filterId,
|
||||
filtersList,
|
||||
savedFilters,
|
||||
accountsSyncing,
|
||||
failedAccounts,
|
||||
accounts,
|
||||
@@ -54,7 +54,7 @@ export function AccountHeader({
|
||||
canCalculateBalance,
|
||||
isSorted,
|
||||
search,
|
||||
filters,
|
||||
filterConditions,
|
||||
conditionsOp,
|
||||
pushModal,
|
||||
onSearch,
|
||||
@@ -78,7 +78,7 @@ export function AccountHeader({
|
||||
onUpdateFilter,
|
||||
onClearFilters,
|
||||
onReloadSavedFilter,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
onDeleteFilter,
|
||||
onScheduleAction,
|
||||
onSetTransfer,
|
||||
@@ -241,7 +241,7 @@ export function AccountHeader({
|
||||
showExtraBalances={showExtraBalances}
|
||||
onToggleExtraBalances={onToggleExtraBalances}
|
||||
account={account}
|
||||
filteredItems={filters}
|
||||
showFilteredBalances={filterConditions.length > 0}
|
||||
transactions={transactions}
|
||||
/>
|
||||
|
||||
@@ -320,7 +320,7 @@ export function AccountHeader({
|
||||
)}
|
||||
<Button
|
||||
type="bare"
|
||||
disabled={search !== '' || filters.length > 0}
|
||||
disabled={search !== '' || filterConditions.length > 0}
|
||||
style={{ padding: 6, marginLeft: 10 }}
|
||||
onClick={onToggleSplits}
|
||||
title={
|
||||
@@ -375,17 +375,17 @@ export function AccountHeader({
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{filters && filters.length > 0 && (
|
||||
{filterConditions?.length > 0 && (
|
||||
<FiltersStack
|
||||
filters={filters}
|
||||
conditions={filterConditions}
|
||||
conditionsOp={conditionsOp}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onClearFilters={onClearFilters}
|
||||
onReloadSavedFilter={onReloadSavedFilter}
|
||||
filterId={filterId}
|
||||
filtersList={filtersList}
|
||||
onCondOpChange={onCondOpChange}
|
||||
savedFilters={savedFilters}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, {
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
type KeyboardEvent,
|
||||
type ChangeEvent,
|
||||
} from 'react';
|
||||
|
||||
import Downshift, { type StateChangeTypes } from 'downshift';
|
||||
@@ -27,9 +26,7 @@ type CommonAutocompleteProps<T extends Item> = {
|
||||
embedded?: boolean;
|
||||
containerProps?: HTMLProps<HTMLDivElement>;
|
||||
labelProps?: { id?: string };
|
||||
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
inputProps?: ComponentProps<typeof Input>;
|
||||
suggestions?: T[];
|
||||
tooltipStyle?: CSSProperties;
|
||||
tooltipProps?: ComponentProps<typeof Tooltip>;
|
||||
@@ -502,7 +499,7 @@ function SingleAutocomplete<T extends Item>({
|
||||
// Handle it ourselves
|
||||
e.stopPropagation();
|
||||
onSelect(value, (e.target as HTMLInputElement).value);
|
||||
return onSelectAfter();
|
||||
onSelectAfter();
|
||||
} else {
|
||||
// No highlighted item, still allow the table to save the item
|
||||
// as `null`, even though we're allowing the table to move
|
||||
@@ -541,10 +538,6 @@ function SingleAutocomplete<T extends Item>({
|
||||
}
|
||||
}
|
||||
},
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { onChange } = inputProps || {};
|
||||
onChange?.(e.target.value);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
{isOpen &&
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useMemo, type ComponentProps, useRef, useState } from 'react';
|
||||
|
||||
import { useLiveQuery } from 'loot-core/client/query-hooks';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import {
|
||||
type TransactionEntity,
|
||||
type NoteEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
||||
import { theme } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
export function NotesTagAutocomplete({
|
||||
embedded,
|
||||
...props
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} & ComponentProps<typeof Autocomplete<{ id: string; name: string }>>) {
|
||||
const notesData = useLiveQuery<NoteEntity[]>(
|
||||
() =>
|
||||
q('notes')
|
||||
.filter({ note: { $like: '#%' } })
|
||||
.select('note'),
|
||||
[],
|
||||
);
|
||||
|
||||
const transactionData = useLiveQuery<TransactionEntity[]>(
|
||||
() =>
|
||||
q('transactions')
|
||||
.filter({ notes: { $like: '#%' } })
|
||||
.select('notes'),
|
||||
[],
|
||||
);
|
||||
|
||||
const allNotes = useMemo(
|
||||
() =>
|
||||
[
|
||||
...(notesData || []).map(n => n.note),
|
||||
...(transactionData || []).map(t => t.notes),
|
||||
].filter(n => !!n),
|
||||
[notesData, transactionData],
|
||||
);
|
||||
|
||||
const uniqueTags = useMemo(
|
||||
() =>
|
||||
[
|
||||
...new Set(
|
||||
allNotes
|
||||
.flatMap(note => note.split(' '))
|
||||
.filter(note => note.startsWith('#') && note.length > 1),
|
||||
),
|
||||
].map(tag => ({ id: tag, name: tag })),
|
||||
[allNotes],
|
||||
);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mergedInputRef = useMergedRefs<HTMLInputElement>(
|
||||
inputRef,
|
||||
props.inputProps?.inputRef,
|
||||
);
|
||||
|
||||
const [value, setValue] = useState(props.value || props.inputProps?.value);
|
||||
|
||||
const onUpdate = (tag: string, inputValue: string) => {
|
||||
console.log(`onUpdate inputValue = ${inputValue}`)
|
||||
setValue(inputValue);
|
||||
props.onUpdate?.(tag, inputValue);
|
||||
};
|
||||
|
||||
const onSelect = (tag: string) => {
|
||||
let localValue = value;
|
||||
if (localValue && tag?.startsWith('#') && !/\s/.test(tag)) {
|
||||
// A tag was selected. Append it to the existing notes.
|
||||
localValue = insertTextAtCaret(
|
||||
localValue,
|
||||
tag.substring(1), // Remove hashtag
|
||||
inputRef.current?.selectionStart,
|
||||
);
|
||||
setValue(localValue);
|
||||
}
|
||||
props.onSelect?.(tag, localValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
openOnFocus={false}
|
||||
clearOnBlur={false}
|
||||
clearOnSelect={false}
|
||||
highlightFirst={true}
|
||||
embedded={embedded}
|
||||
suggestions={uniqueTags}
|
||||
filterSuggestions={(suggestions, notes) => {
|
||||
const tag = getTagAtCaret(notes, inputRef.current?.selectionStart);
|
||||
if (!tag) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tag === '#'
|
||||
? suggestions
|
||||
: suggestions.filter(suggestion =>
|
||||
suggestion.name.toLowerCase().includes(tag.toLowerCase()),
|
||||
);
|
||||
}}
|
||||
renderItems={(items, getItemProps, highlightedIndex) => (
|
||||
<TagList
|
||||
items={items}
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
embedded={embedded}
|
||||
/>
|
||||
)}
|
||||
{...props}
|
||||
inputProps={{
|
||||
...props.inputProps,
|
||||
inputRef: mergedInputRef,
|
||||
value: value || '',
|
||||
}}
|
||||
onUpdate={onUpdate}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TagList<T extends { id: string; name: string }>({
|
||||
items,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
embedded,
|
||||
}: {
|
||||
items: T[];
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>;
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
padding: '5px 0',
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
>
|
||||
<ItemHeader title="Tags" type="tag" />
|
||||
{items.map((item, idx) => {
|
||||
return [
|
||||
<div
|
||||
{...(getItemProps ? getItemProps({ item }) : null)}
|
||||
key={item.id}
|
||||
style={{
|
||||
backgroundColor:
|
||||
highlightedIndex === idx
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
padding: 4,
|
||||
paddingLeft: 20,
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
}}
|
||||
data-testid={`${item.name}-tag-item`}
|
||||
data-highlighted={highlightedIndex === idx || undefined}
|
||||
>
|
||||
{item.name}
|
||||
</div>,
|
||||
];
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getTagAtCaret(notes: string, caretPosition: number) {
|
||||
let startIndex = caretPosition;
|
||||
while (startIndex > 0 && notes[startIndex - 1] !== ' ') startIndex--;
|
||||
|
||||
const whiteSpaceIndex = notes.indexOf(' ', caretPosition);
|
||||
const endIndex = whiteSpaceIndex !== -1 ? whiteSpaceIndex : notes.length;
|
||||
|
||||
return notes[startIndex] === '#' ? notes.slice(startIndex, endIndex) : '';
|
||||
}
|
||||
|
||||
function insertTextAtCaret(
|
||||
text: string,
|
||||
textToInsert: string,
|
||||
caretPosition: number,
|
||||
) {
|
||||
// Insert the text at the caret position
|
||||
return (
|
||||
text.substring(0, caretPosition) +
|
||||
textToInsert +
|
||||
text.substring(caretPosition)
|
||||
);
|
||||
}
|
||||
@@ -293,7 +293,7 @@ export function PayeeAutocomplete({
|
||||
setPayeeFieldFocused(false);
|
||||
},
|
||||
onFocus: () => setPayeeFieldFocused(true),
|
||||
onChange: setRawPayee,
|
||||
onChangeValue: setRawPayee,
|
||||
}}
|
||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||
onSelect={handleSelect}
|
||||
|
||||
@@ -277,7 +277,7 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
};
|
||||
|
||||
const onShowActivity = (categoryId, month) => {
|
||||
const conditions = [
|
||||
const filterConditions = [
|
||||
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
|
||||
{
|
||||
field: 'date',
|
||||
@@ -290,7 +290,7 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
conditions,
|
||||
filterConditions,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,26 +4,29 @@ import { type RuleConditionEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { CondOpMenu } from './CondOpMenu';
|
||||
import { ConditionsOpMenu } from './ConditionsOpMenu';
|
||||
import { FilterExpression } from './FilterExpression';
|
||||
|
||||
type AppliedFiltersProps = {
|
||||
filters: RuleConditionEntity[];
|
||||
conditions: RuleConditionEntity[];
|
||||
onUpdate: (
|
||||
filter: RuleConditionEntity,
|
||||
newFilter: RuleConditionEntity,
|
||||
) => void;
|
||||
onDelete: (filter: RuleConditionEntity) => void;
|
||||
conditionsOp: string;
|
||||
onCondOpChange: (value: string, filters: RuleConditionEntity[]) => void;
|
||||
onConditionsOpChange: (
|
||||
value: string,
|
||||
conditions: RuleConditionEntity[],
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function AppliedFilters({
|
||||
filters,
|
||||
conditions,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
conditionsOp,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
}: AppliedFiltersProps) {
|
||||
return (
|
||||
<View
|
||||
@@ -33,12 +36,12 @@ export function AppliedFilters({
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<CondOpMenu
|
||||
<ConditionsOpMenu
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={onCondOpChange}
|
||||
filters={filters}
|
||||
onChange={onConditionsOpChange}
|
||||
conditions={conditions}
|
||||
/>
|
||||
{filters.map((filter: RuleConditionEntity, i: number) => (
|
||||
{conditions.map((filter: RuleConditionEntity, i: number) => (
|
||||
<FilterExpression
|
||||
key={i}
|
||||
customName={filter.customName}
|
||||
|
||||
@@ -7,16 +7,16 @@ import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { FieldSelect } from '../modals/EditRule';
|
||||
|
||||
export function CondOpMenu({
|
||||
export function ConditionsOpMenu({
|
||||
conditionsOp,
|
||||
onCondOpChange,
|
||||
filters,
|
||||
onChange,
|
||||
conditions,
|
||||
}: {
|
||||
conditionsOp: string;
|
||||
onCondOpChange: (value: string, filters: RuleConditionEntity[]) => void;
|
||||
filters: RuleConditionEntity[];
|
||||
onChange: (value: string, conditions: RuleConditionEntity[]) => void;
|
||||
conditions: RuleConditionEntity[];
|
||||
}) {
|
||||
return filters.length > 1 ? (
|
||||
return conditions.length > 1 ? (
|
||||
<Text style={{ color: theme.pageText, marginTop: 11, marginRight: 5 }}>
|
||||
<FieldSelect
|
||||
style={{ display: 'inline-flex' }}
|
||||
@@ -25,9 +25,7 @@ export function CondOpMenu({
|
||||
['or', 'any'],
|
||||
]}
|
||||
value={conditionsOp}
|
||||
onChange={(name: string, value: string) =>
|
||||
onCondOpChange(value, filters)
|
||||
}
|
||||
onChange={(name: string, value: string) => onChange(value, conditions)}
|
||||
/>
|
||||
of:
|
||||
</Text>
|
||||
@@ -58,7 +58,6 @@ export function FilterExpression({
|
||||
type="bare"
|
||||
disabled={customName != null}
|
||||
onClick={() => setEditing(true)}
|
||||
style={{ marginRight: -7 }}
|
||||
>
|
||||
<div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}>
|
||||
{customName ? (
|
||||
@@ -84,8 +83,7 @@ export function FilterExpression({
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: 5,
|
||||
marginLeft: 3,
|
||||
margin: '4px 0',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { type TransactionFilterEntity } from 'loot-core/types/models';
|
||||
import { type RuleConditionEntity } from 'loot-core/types/models/rule';
|
||||
|
||||
import { Stack } from '../common/Stack';
|
||||
@@ -12,17 +13,17 @@ import {
|
||||
} from './SavedFilterMenuButton';
|
||||
|
||||
export function FiltersStack({
|
||||
filters,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
onUpdateFilter,
|
||||
onDeleteFilter,
|
||||
onClearFilters,
|
||||
onReloadSavedFilter,
|
||||
filterId,
|
||||
filtersList,
|
||||
onCondOpChange,
|
||||
savedFilters,
|
||||
onConditionsOpChange,
|
||||
}: {
|
||||
filters: RuleConditionEntity[];
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: string;
|
||||
onUpdateFilter: (
|
||||
filter: RuleConditionEntity,
|
||||
@@ -32,8 +33,8 @@ export function FiltersStack({
|
||||
onClearFilters: () => void;
|
||||
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
|
||||
filterId: SavedFilter;
|
||||
filtersList: RuleConditionEntity[];
|
||||
onCondOpChange: () => void;
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
onConditionsOpChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
@@ -44,20 +45,20 @@ export function FiltersStack({
|
||||
align="flex-start"
|
||||
>
|
||||
<AppliedFilters
|
||||
filters={filters}
|
||||
conditions={conditions}
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={onCondOpChange}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
onUpdate={onUpdateFilter}
|
||||
onDelete={onDeleteFilter}
|
||||
/>
|
||||
<View style={{ flex: 1 }} />
|
||||
<SavedFilterMenuButton
|
||||
filters={filters}
|
||||
conditions={conditions}
|
||||
conditionsOp={conditionsOp}
|
||||
filterId={filterId}
|
||||
onClearFilters={onClearFilters}
|
||||
onReloadSavedFilter={onReloadSavedFilter}
|
||||
filtersList={filtersList}
|
||||
savedFilters={savedFilters}
|
||||
/>
|
||||
</Stack>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
@@ -20,19 +21,19 @@ export type SavedFilter = {
|
||||
};
|
||||
|
||||
export function SavedFilterMenuButton({
|
||||
filters,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
filterId,
|
||||
onClearFilters,
|
||||
onReloadSavedFilter,
|
||||
filtersList,
|
||||
savedFilters,
|
||||
}: {
|
||||
filters: RuleConditionEntity[];
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: string;
|
||||
filterId: SavedFilter;
|
||||
onClearFilters: () => void;
|
||||
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
|
||||
filtersList: RuleConditionEntity[];
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
}) {
|
||||
const [nameOpen, setNameOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -62,7 +63,7 @@ export function SavedFilterMenuButton({
|
||||
setAdding(false);
|
||||
setMenuOpen(false);
|
||||
savedFilter = {
|
||||
conditions: filters,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
id: filterId.id,
|
||||
name: filterId.name,
|
||||
@@ -70,7 +71,7 @@ export function SavedFilterMenuButton({
|
||||
};
|
||||
const response = await sendCatch('filter-update', {
|
||||
state: savedFilter,
|
||||
filters: [...filtersList],
|
||||
filters: [...savedFilters],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -106,7 +107,7 @@ export function SavedFilterMenuButton({
|
||||
async function onAddUpdate() {
|
||||
if (adding) {
|
||||
const newSavedFilter = {
|
||||
conditions: filters,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
name,
|
||||
status: 'saved',
|
||||
@@ -114,7 +115,7 @@ export function SavedFilterMenuButton({
|
||||
|
||||
const response = await sendCatch('filter-create', {
|
||||
state: newSavedFilter,
|
||||
filters: [...filtersList],
|
||||
filters: [...savedFilters],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -140,7 +141,7 @@ export function SavedFilterMenuButton({
|
||||
|
||||
const response = await sendCatch('filter-update', {
|
||||
state: updatedFilter,
|
||||
filters: [...filtersList],
|
||||
filters: [...savedFilters],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -155,7 +156,7 @@ export function SavedFilterMenuButton({
|
||||
|
||||
return (
|
||||
<View>
|
||||
{filters.length > 0 && (
|
||||
{conditions.length > 0 && (
|
||||
<Button
|
||||
type="bare"
|
||||
style={{ marginTop: 10 }}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Header({
|
||||
onApply,
|
||||
onUpdateFilter,
|
||||
onDeleteFilter,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
headerPrefixItems,
|
||||
children,
|
||||
}) {
|
||||
@@ -164,11 +164,11 @@ export function Header({
|
||||
align="flex-start"
|
||||
>
|
||||
<AppliedFilters
|
||||
filters={filters}
|
||||
conditions={filters}
|
||||
onUpdate={onUpdateFilter}
|
||||
onDelete={onDeleteFilter}
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={onCondOpChange}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -191,7 +191,7 @@ export function BarGraph({
|
||||
.map(e => e.id);
|
||||
const offBudgetAccounts = accounts.filter(f => f.offbudget).map(e => e.id);
|
||||
|
||||
const conditions = [
|
||||
const filterConditions = [
|
||||
...filters,
|
||||
{ field, op: 'is', value: item.id, type: 'id' },
|
||||
{
|
||||
@@ -232,7 +232,7 @@ export function BarGraph({
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
conditions,
|
||||
filterConditions,
|
||||
categoryId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -215,7 +215,7 @@ export function DonutGraph({
|
||||
.map(e => e.id);
|
||||
const offBudgetAccounts = accounts.filter(f => f.offbudget).map(e => e.id);
|
||||
|
||||
const conditions = [
|
||||
const filterConditions = [
|
||||
...filters,
|
||||
{ field, op: 'is', value: item.id, type: 'id' },
|
||||
{
|
||||
@@ -256,7 +256,7 @@ export function DonutGraph({
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
conditions,
|
||||
filterConditions,
|
||||
categoryId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ export function LineGraph({
|
||||
.map(e => e.id);
|
||||
const offBudgetAccounts = accounts.filter(f => f.offbudget).map(e => e.id);
|
||||
|
||||
const conditions = [
|
||||
const filterConditions = [
|
||||
...filters,
|
||||
{ field, op: 'is', value: id, type: 'id' },
|
||||
{
|
||||
@@ -183,7 +183,7 @@ export function LineGraph({
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
conditions,
|
||||
filterConditions,
|
||||
categoryId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -181,7 +181,7 @@ export function StackedBarGraph({
|
||||
.map(e => e.id);
|
||||
const offBudgetAccounts = accounts.filter(f => f.offbudget).map(e => e.id);
|
||||
|
||||
const conditions = [
|
||||
const filterConditions = [
|
||||
...filters,
|
||||
{ field, op: 'is', value: id, type: 'id' },
|
||||
{
|
||||
@@ -214,7 +214,7 @@ export function StackedBarGraph({
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
conditions,
|
||||
filterConditions,
|
||||
categoryId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CashFlow() {
|
||||
onApply: onApplyFilter,
|
||||
onDelete: onDeleteFilter,
|
||||
onUpdate: onUpdateFilter,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
} = useFilters<RuleConditionEntity>();
|
||||
|
||||
const [allMonths, setAllMonths] = useState<null | Array<{
|
||||
@@ -121,7 +121,7 @@ export function CashFlow() {
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={onCondOpChange}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
headerPrefixItems={undefined}
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -55,7 +55,7 @@ export function CustomReport() {
|
||||
onApply: onApplyFilter,
|
||||
onDelete: onDeleteFilter,
|
||||
onUpdate: onUpdateFilter,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
} = useFilters();
|
||||
|
||||
const location = useLocation();
|
||||
@@ -400,7 +400,7 @@ export function CustomReport() {
|
||||
setGraphType(input.graphType);
|
||||
onApplyFilter(null);
|
||||
input.conditions.forEach(condition => onApplyFilter(condition));
|
||||
onCondOpChange(input.conditionsOp);
|
||||
onConditionsOpChange(input.conditionsOp);
|
||||
};
|
||||
|
||||
const onChangeAppliedFilter = (filter, changedElement) => {
|
||||
@@ -539,7 +539,7 @@ export function CustomReport() {
|
||||
align="flex-start"
|
||||
>
|
||||
<AppliedFilters
|
||||
filters={filters}
|
||||
conditions={filters}
|
||||
onUpdate={(oldFilter, newFilter) => {
|
||||
setSessionReport(
|
||||
'conditions',
|
||||
@@ -556,8 +556,8 @@ export function CustomReport() {
|
||||
onChangeAppliedFilter(deletedFilter, onDeleteFilter);
|
||||
}}
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={filter =>
|
||||
onChangeAppliedFilter(filter, onCondOpChange)
|
||||
onConditionsOpChange={filter =>
|
||||
onChangeAppliedFilter(filter, onConditionsOpChange)
|
||||
}
|
||||
onUpdateChange={onReportChange}
|
||||
/>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function NetWorth() {
|
||||
onApply: onApplyFilter,
|
||||
onDelete: onDeleteFilter,
|
||||
onUpdate: onUpdateFilter,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
} = useFilters();
|
||||
|
||||
const [allMonths, setAllMonths] = useState(null);
|
||||
@@ -102,7 +102,7 @@ export function NetWorth() {
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={onCondOpChange}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
/>
|
||||
|
||||
<View
|
||||
|
||||
@@ -136,7 +136,9 @@ type CellProps = Omit<ComponentProps<typeof View>, 'children' | 'value'> & {
|
||||
plain?: boolean;
|
||||
exposed?: boolean;
|
||||
children?: ReactNode | (() => ReactNode);
|
||||
unexposedContent?: ReactNode;
|
||||
unexposedContent?: (
|
||||
props: ComponentProps<typeof UnexposedCellContent>,
|
||||
) => ReactNode;
|
||||
value?: string;
|
||||
valueStyle?: CSSProperties;
|
||||
onExpose?: (name: string) => void;
|
||||
@@ -228,7 +230,9 @@ export function Cell({
|
||||
}
|
||||
}
|
||||
>
|
||||
{unexposedContent || (
|
||||
{unexposedContent ? (
|
||||
unexposedContent({ value, formatter })
|
||||
) : (
|
||||
<UnexposedCellContent value={value} formatter={formatter} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useRef, useCallback, useLayoutEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { pushModal } from 'loot-core/client/actions';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import {
|
||||
splitTransaction,
|
||||
@@ -76,7 +78,6 @@ export function TransactionList({
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
addNotification,
|
||||
pushModal,
|
||||
renderEmpty,
|
||||
onSort,
|
||||
sortField,
|
||||
@@ -85,9 +86,11 @@ export function TransactionList({
|
||||
onRefetch,
|
||||
onCloseAddTransaction,
|
||||
onCreatePayee,
|
||||
onApplyFilters,
|
||||
}) {
|
||||
const transactionsLatest = useRef();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
transactionsLatest.current = transactions;
|
||||
@@ -161,13 +164,19 @@ export function TransactionList({
|
||||
});
|
||||
|
||||
const onNavigateToSchedule = useCallback(scheduleId => {
|
||||
pushModal('schedule-edit', { id: scheduleId });
|
||||
dispatch(pushModal('schedule-edit', { id: scheduleId }));
|
||||
});
|
||||
|
||||
const onNavigateToFilteredTagView = useCallback(noteTag => {
|
||||
const filterConditions = [
|
||||
{ field: 'notes', op: 'contains', value: noteTag, type: 'string' },
|
||||
];
|
||||
onApplyFilters(filterConditions, true);
|
||||
});
|
||||
|
||||
return (
|
||||
<TransactionTable
|
||||
ref={tableRef}
|
||||
pushModal={pushModal}
|
||||
transactions={allTransactions}
|
||||
loadMoreTransactions={loadMoreTransactions}
|
||||
accounts={accounts}
|
||||
@@ -200,6 +209,7 @@ export function TransactionList({
|
||||
style={{ backgroundColor: theme.tableBackground }}
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
onNavigateToFilteredTagView={onNavigateToFilteredTagView}
|
||||
onSort={onSort}
|
||||
sortField={sortField}
|
||||
ascDesc={ascDesc}
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import {
|
||||
format as formatDate,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
isValid as isDateValid,
|
||||
} from 'date-fns';
|
||||
|
||||
import { pushModal } from 'loot-core/client/actions';
|
||||
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
|
||||
import {
|
||||
getAccountsById,
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
import { styles, theme } from '../../style';
|
||||
import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete';
|
||||
import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete';
|
||||
import { NotesTagAutocomplete } from '../autocomplete/NotesTagAutocomplete';
|
||||
import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete';
|
||||
import { Button } from '../common/Button';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -413,7 +416,8 @@ function HeaderCell({
|
||||
width={width}
|
||||
name={id}
|
||||
alignItems={alignItems}
|
||||
unexposedContent={
|
||||
value={value}
|
||||
unexposedContent={({ value: cellValue }) => (
|
||||
<Button
|
||||
type="bare"
|
||||
onClick={onClick}
|
||||
@@ -427,7 +431,7 @@ function HeaderCell({
|
||||
marginRight,
|
||||
}}
|
||||
>
|
||||
<UnexposedCellContent value={value} />
|
||||
<UnexposedCellContent value={cellValue} />
|
||||
{icon === 'asc' && (
|
||||
<SvgArrowDown width={10} height={10} style={{ marginLeft: 5 }} />
|
||||
)}
|
||||
@@ -435,22 +439,20 @@ function HeaderCell({
|
||||
<SvgArrowUp width={10} height={10} style={{ marginLeft: 5 }} />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PayeeCell({
|
||||
id,
|
||||
payeeId,
|
||||
accountId,
|
||||
payee,
|
||||
focused,
|
||||
inherited,
|
||||
payees,
|
||||
accounts,
|
||||
valueStyle,
|
||||
transaction,
|
||||
payee,
|
||||
transferAcct,
|
||||
isPreview,
|
||||
onEdit,
|
||||
@@ -462,16 +464,12 @@ function PayeeCell({
|
||||
}) {
|
||||
const isCreatingPayee = useRef(false);
|
||||
|
||||
// Filter out the account we're currently in as it is not a valid transfer
|
||||
accounts = accounts.filter(account => account.id !== accountId);
|
||||
payees = payees.filter(payee => payee.transfer_acct !== accountId);
|
||||
|
||||
return (
|
||||
<CustomCell
|
||||
width="flex"
|
||||
name="payee"
|
||||
textAlign="flex"
|
||||
value={payeeId}
|
||||
value={payee?.id}
|
||||
valueStyle={{
|
||||
...valueStyle,
|
||||
...(inherited && { color: theme.tableTextInactive }),
|
||||
@@ -488,7 +486,8 @@ function PayeeCell({
|
||||
isCreatingPayee.current = false;
|
||||
}
|
||||
}}
|
||||
unexposedContent={
|
||||
formatter={() => getPayeePretty(transaction, payee, transferAcct)}
|
||||
unexposedContent={props => (
|
||||
<>
|
||||
<PayeeIcons
|
||||
transaction={transaction}
|
||||
@@ -496,12 +495,9 @@ function PayeeCell({
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
/>
|
||||
<UnexposedCellContent
|
||||
value={payeeId}
|
||||
formatter={() => getPayeePretty(transaction, payee, transferAcct)}
|
||||
/>
|
||||
<UnexposedCellContent {...props} />
|
||||
</>
|
||||
}
|
||||
)}
|
||||
>
|
||||
{({
|
||||
onBlur,
|
||||
@@ -515,7 +511,7 @@ function PayeeCell({
|
||||
<PayeeAutocomplete
|
||||
payees={payees}
|
||||
accounts={accounts}
|
||||
value={payeeId}
|
||||
value={payee?.id}
|
||||
shouldSaveFromKey={shouldSaveFromKey}
|
||||
inputProps={{
|
||||
onBlur,
|
||||
@@ -527,7 +523,7 @@ function PayeeCell({
|
||||
focused={true}
|
||||
onUpdate={(id, value) => onUpdate?.(value)}
|
||||
onSelect={onSave}
|
||||
onManagePayees={() => onManagePayees(payeeId)}
|
||||
onManagePayees={() => onManagePayees(payee?.id)}
|
||||
menuPortalTarget={undefined}
|
||||
/>
|
||||
);
|
||||
@@ -641,8 +637,10 @@ const Transaction = memo(function Transaction(props) {
|
||||
onToggleSplit,
|
||||
onNavigateToTransferAccount,
|
||||
onNavigateToSchedule,
|
||||
onNavigateToFilteredTagView,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const dispatchSelected = useSelectedDispatch();
|
||||
|
||||
const [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit);
|
||||
@@ -685,13 +683,15 @@ const Transaction = memo(function Transaction(props) {
|
||||
) {
|
||||
if (showReconciliationWarning === false) {
|
||||
setShowReconciliationWarning(true);
|
||||
props.pushModal('confirm-transaction-edit', {
|
||||
onConfirm: () => {
|
||||
setShowReconciliationWarning(false);
|
||||
onUpdateAfterConfirm(name, value);
|
||||
},
|
||||
confirmReason: 'editReconciled',
|
||||
});
|
||||
dispatch(
|
||||
pushModal('confirm-transaction-edit', {
|
||||
onConfirm: () => {
|
||||
setShowReconciliationWarning(false);
|
||||
onUpdateAfterConfirm(name, value);
|
||||
},
|
||||
confirmReason: 'editReconciled',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
onUpdateAfterConfirm(name, value);
|
||||
@@ -700,12 +700,14 @@ const Transaction = memo(function Transaction(props) {
|
||||
|
||||
// Allow un-reconciling (unlocking) transactions
|
||||
if (name === 'cleared' && transaction.reconciled) {
|
||||
props.pushModal('confirm-transaction-edit', {
|
||||
onConfirm: () => {
|
||||
onUpdateAfterConfirm('reconciled', false);
|
||||
},
|
||||
confirmReason: 'unlockReconciled',
|
||||
});
|
||||
dispatch(
|
||||
pushModal('confirm-transaction-edit', {
|
||||
onConfirm: () => {
|
||||
onUpdateAfterConfirm('reconciled', false);
|
||||
},
|
||||
confirmReason: 'unlockReconciled',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,7 +715,7 @@ const Transaction = memo(function Transaction(props) {
|
||||
const newTransaction = { ...transaction, [name]: value };
|
||||
|
||||
// Don't change the note to an empty string if it's null (since they are both rendered the same)
|
||||
if (name === 'note' && value === '' && transaction.note == null) {
|
||||
if (name === 'notes' && value === '' && transaction.notes == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -977,15 +979,14 @@ const Transaction = memo(function Transaction(props) {
|
||||
<PayeeCell
|
||||
/* Payee field for all transactions */
|
||||
id={id}
|
||||
payeeId={payeeId}
|
||||
accountId={accountId}
|
||||
payee={payee}
|
||||
focused={focusedField === 'payee'}
|
||||
inherited={inheritedFields && inheritedFields.has('payee')}
|
||||
payees={payees}
|
||||
accounts={accounts}
|
||||
/* Filter out the account we're currently in as it is not a valid transfer */
|
||||
accounts={accounts.filter(account => account.id !== accountId)}
|
||||
payees={payees.filter(payee => payee.transfer_acct !== accountId)}
|
||||
valueStyle={valueStyle}
|
||||
transaction={transaction}
|
||||
payee={payee}
|
||||
transferAcct={transferAcct}
|
||||
importedPayee={importedPayee}
|
||||
isPreview={isPreview}
|
||||
@@ -1002,20 +1003,42 @@ const Transaction = memo(function Transaction(props) {
|
||||
/* Notes field for all transactions */
|
||||
<Cell name="notes" width="flex" />
|
||||
) : (
|
||||
<InputCell
|
||||
width="flex"
|
||||
<CustomCell
|
||||
name="notes"
|
||||
width="flex"
|
||||
textAlign="flex"
|
||||
exposed={focusedField === 'notes'}
|
||||
focused={focusedField === 'notes'}
|
||||
value={notes || ''}
|
||||
valueStyle={valueStyle}
|
||||
formatter={value =>
|
||||
notesTagFormatter(value, onNavigateToFilteredTagView)
|
||||
}
|
||||
exposed={focusedField === 'notes'}
|
||||
onExpose={name => !isPreview && onEdit(id, name)}
|
||||
inputProps={{
|
||||
value: notes || '',
|
||||
onUpdate: onUpdate.bind(null, 'notes'),
|
||||
valueStyle={valueStyle}
|
||||
onUpdate={value => {
|
||||
onUpdate('notes', value);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{({
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
onUpdate,
|
||||
onSave,
|
||||
shouldSaveFromKey,
|
||||
inputStyle,
|
||||
}) => (
|
||||
<NotesTagAutocomplete
|
||||
value={notes || ''}
|
||||
shouldSaveFromKey={shouldSaveFromKey}
|
||||
inputProps={{
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
style: inputStyle,
|
||||
}}
|
||||
onUpdate={(_, inputValue) => onUpdate(inputValue)}
|
||||
onSelect={(_, inputValue) => onSave(inputValue)}
|
||||
/>
|
||||
)}
|
||||
</CustomCell>
|
||||
)}
|
||||
|
||||
{isPreview ? (
|
||||
@@ -1392,6 +1415,7 @@ function NewTransaction({
|
||||
onCreatePayee,
|
||||
onNavigateToTransferAccount,
|
||||
onNavigateToSchedule,
|
||||
onNavigateToFilteredTagView,
|
||||
balance,
|
||||
}) {
|
||||
const error = transactions[0].error;
|
||||
@@ -1445,6 +1469,7 @@ function NewTransaction({
|
||||
style={{ marginTop: -1 }}
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
onNavigateToFilteredTagView={onNavigateToFilteredTagView}
|
||||
balance={balance}
|
||||
/>
|
||||
))}
|
||||
@@ -1524,6 +1549,14 @@ function TransactionTableInner({
|
||||
[props.onCloseAddTransaction, props.onNavigateToSchedule],
|
||||
);
|
||||
|
||||
const onNavigateToFilteredTagView = useCallback(
|
||||
noteTag => {
|
||||
props.onCloseAddTransaction();
|
||||
props.onNavigateToFilteredTagView(noteTag);
|
||||
},
|
||||
[props.onCloseAddTransaction, props.onNavigateToFilteredTagView],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddingPrev && props.isAdding) {
|
||||
newNavigator.onEdit('temp', 'date');
|
||||
@@ -1626,7 +1659,7 @@ function TransactionTableInner({
|
||||
onToggleSplit={props.onToggleSplit}
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
pushModal={props.pushModal}
|
||||
onNavigateToFilteredTagView={onNavigateToFilteredTagView}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -1684,6 +1717,7 @@ function TransactionTableInner({
|
||||
onCreatePayee={props.onCreatePayee}
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
onNavigateToFilteredTagView={onNavigateToFilteredTagView}
|
||||
onDistributeRemainder={props.onDistributeRemainder}
|
||||
balance={
|
||||
props.transactions?.length > 0
|
||||
@@ -2172,3 +2206,45 @@ export const TransactionTable = forwardRef((props, ref) => {
|
||||
});
|
||||
|
||||
TransactionTable.displayName = 'TransactionTable';
|
||||
|
||||
function notesTagFormatter(value, onNoteTagClick) {
|
||||
const words = value?.split(' ') || [];
|
||||
return (
|
||||
<>
|
||||
{words.map((word, i, arr) => {
|
||||
const separator = arr.length - 1 === i ? '' : ' ';
|
||||
if (word.startsWith('#') && word.length > 1) {
|
||||
return (
|
||||
<span key={`${word}-${i}`}>
|
||||
<Button
|
||||
type="bare"
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
padding: '3px 7px',
|
||||
borderRadius: 16,
|
||||
userSelect: 'none',
|
||||
backgroundColor: theme.noteTagBackground,
|
||||
color: theme.noteTagText,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
hoveredStyle={{
|
||||
backgroundColor: theme.noteTagBackgroundHover,
|
||||
color: theme.noteTagText,
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onNoteTagClick?.(word);
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</Button>
|
||||
{separator}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return `${word}${separator}`;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useFilters<T>(initialFilters: T[] = []) {
|
||||
[setFilters],
|
||||
);
|
||||
|
||||
const onCondOpChange = useCallback(
|
||||
const onConditionsOpChange = useCallback(
|
||||
condOp => {
|
||||
setConditionsOp(condOp);
|
||||
},
|
||||
@@ -56,8 +56,16 @@ export function useFilters<T>(initialFilters: T[] = []) {
|
||||
onApply,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
}),
|
||||
[filters, saved, onApply, onUpdate, onDelete, onCondOpChange, conditionsOp],
|
||||
[
|
||||
filters,
|
||||
saved,
|
||||
onApply,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onConditionsOpChange,
|
||||
conditionsOp,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,3 +191,7 @@ export const reportsBlue = colorPalette.blue400;
|
||||
export const reportsGreen = colorPalette.green400;
|
||||
export const reportsLabel = pageText;
|
||||
export const reportsInnerLabel = colorPalette.navy800;
|
||||
|
||||
export const noteTagBackground = colorPalette.purple700;
|
||||
export const noteTagBackgroundHover = colorPalette.purple500;
|
||||
export const noteTagText = colorPalette.purple100;
|
||||
|
||||
@@ -190,3 +190,7 @@ export const reportsBlue = colorPalette.blue400;
|
||||
export const reportsGreen = colorPalette.green400;
|
||||
export const reportsLabel = colorPalette.navy900;
|
||||
export const reportsInnerLabel = colorPalette.navy800;
|
||||
|
||||
export const noteTagBackground = colorPalette.purple100;
|
||||
export const noteTagBackgroundHover = colorPalette.purple150;
|
||||
export const noteTagText = colorPalette.purple700;
|
||||
|
||||
@@ -193,3 +193,7 @@ export const reportsBlue = colorPalette.blue400;
|
||||
export const reportsGreen = colorPalette.green400;
|
||||
export const reportsLabel = colorPalette.navy900;
|
||||
export const reportsInnerLabel = colorPalette.navy800;
|
||||
|
||||
export const noteTagBackground = colorPalette.purple100;
|
||||
export const noteTagBackgroundHover = colorPalette.purple150;
|
||||
export const noteTagText = colorPalette.purple700;
|
||||
|
||||
@@ -193,3 +193,7 @@ export const reportsBlue = colorPalette.blue400;
|
||||
export const reportsGreen = colorPalette.green400;
|
||||
export const reportsLabel = pageText;
|
||||
export const reportsInnerLabel = colorPalette.navy800;
|
||||
|
||||
export const noteTagBackground = colorPalette.purple800;
|
||||
export const noteTagBackgroundHover = colorPalette.purple600;
|
||||
export const noteTagText = colorPalette.purple100;
|
||||
|
||||
6
upcoming-release-notes/2670.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Format notes that starts with # as clickable tags.
|
||||