diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png index 17fce66c073e6528942d7f8de07a95f092b17bd5..4a682c1f0ed07e7f0cd771fe8e89e1439e202cdb 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png index 9d4020aca1f00f9f5794446dbe29f3f2f8cb4548..f98e9d0f63e8864efa00d4a9be038a01762b653e 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png index 855fa9c2a100e03771e114c4ddca9dcd241f249c..6e51c3f9faf7d2549527b57d34884efbe9408d28 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png index a249633846becee32a59dd56ad0853e84b227233..ec8312ca7f3d1219445943bd9ad91dc2f37f3325 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png index a123a58eb3b9f770e7b6acaec6bd3abfd0a7e2e3..43c1aeca816009a7296098bf3b323233be368dbe 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png index ba4eda349c303bd08b6eff45cc6aaebc1a2dfe18..7c4ef11d701a599fbad18865309f561a49d370ba 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-7-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-7-chromium-linux.png index ee858dd85fb3902c29433cbbba84d01de5374f38..e4b119cf81a25ea436dd31a3aa9ec18eb98bb55e 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-7-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-7-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-8-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-8-chromium-linux.png index 4b2ae366cf5bf7b47b364224bc6adc20a499c561..5d9c61335af4d04b656f6de9e40c245a472fb050 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-8-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-8-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-9-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-9-chromium-linux.png index 5f9e8557af7041453d9082693956f7f8dcc294ae..8fbc99c91f96268502024b6c3b6bad97d947e4bb 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-9-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-date-9-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index ecf4f27f7ff487dd3e14713a6009d3ce08cc46ca..beac95b915d34f9b0d833841833580de23bbd7d4 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -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,9 @@ class AccountInternal extends PureComponent { this.state = { search: '', - filters: props.conditions || [], + filterConditions: props.filterConditions || [], + filterId: [], + filterConditionsOp: 'and', loading: true, workingHard: false, reconcileAmount: null, @@ -192,9 +193,8 @@ class AccountInternal extends PureComponent { editingName: false, isAdding: false, latestDate: null, - filterId: [], - conditionsOp: 'and', sort: [], + filteredAmount: null, }; } @@ -256,7 +256,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 +285,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 +313,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) { @@ -371,6 +371,7 @@ class AccountInternal extends PureComponent { balances: this.state.showBalances ? await this.calculateBalances() : null, + filteredAmount: await this.getFilteredAmount(), }, () => { if (firstLoad) { @@ -418,7 +419,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 +515,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 +603,7 @@ class AccountInternal extends PureComponent { { transactions: [], transactionCount: 0, - filters: [], + filterConditions: [], search: '', sort: [], showBalances: true, @@ -612,9 +616,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 +641,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; @@ -680,24 +684,11 @@ class AccountInternal extends PureComponent { }; } - getFilteredAmount = async (filters, conditionsOpKey) => { - const filter = queries.getAccountFilter(this.props.accountId); - - let query = q('transactions').filter({ - [conditionsOpKey]: [...filters], - }); - if (filter) { - query = query.filter(filter); - } - - const filteredQuery = await runQuery( - query.select([{ amount: { $sum: '$amount' } }]), - ); - const filteredAmount = filteredQuery.data.reduce( - (a, v) => (a = a + v.amount), - 0, + getFilteredAmount = async () => { + const { data: amount } = await runQuery( + this.paged.getQuery().calculate({ $sum: '$amount' }), ); - return filteredAmount; + return amount; }; isNew = id => { @@ -817,7 +808,7 @@ class AccountInternal extends PureComponent { onShowTransactions = async ids => { this.onApplyFilter({ customName: 'Selected transactions', - filter: { id: { $oneof: ids } }, + queryFilter: { id: { $oneof: ids } }, }); }; @@ -1209,10 +1200,10 @@ class AccountInternal extends PureComponent { ); }; - onCondOpChange = (value, filters) => { - this.setState({ conditionsOp: value }); + onConditionsOpChange = (value, conditions) => { + this.setState({ filterConditionsOp: value }); this.setState({ filterId: { ...this.state.filterId, status: 'changed' } }); - this.applyFilters([...filters]); + this.applyFilters([...conditions]); if (this.state.search !== '') { this.onSearch(this.state.search); } @@ -1220,14 +1211,14 @@ 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({ filterConditionsOp: savedFilter.conditionsOp }); + this.applyFilters([...savedFilter.conditions]); } else { if (savedFilter.status) { - this.setState({ conditionsOp: savedFilter.conditionsOp }); + this.setState({ filterConditionsOp: savedFilter.conditionsOp }); this.applyFilters([...savedFilter.conditions]); } } @@ -1235,7 +1226,7 @@ class AccountInternal extends PureComponent { }; onClearFilters = () => { - this.setState({ conditionsOp: 'and' }); + this.setState({ filterConditionsOp: 'and' }); this.setState({ filterId: [] }); this.applyFilters([]); if (this.state.search !== '') { @@ -1243,9 +1234,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: { @@ -1258,11 +1251,11 @@ 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' }); + this.setState({ filterConditionsOp: 'and' }); } else { this.setState({ filterId: { @@ -1276,23 +1269,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({ filterConditionsOp: 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); @@ -1320,30 +1321,35 @@ class AccountInternal extends PureComponent { applyFilters = async conditions => { 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), - }); - const conditionsOpKey = this.state.conditionsOp === 'or' ? '$or' : '$and'; - this.filteredAmount = await this.getFilteredAmount( - filters, - conditionsOpKey, + .map(f => f.queryFilter); + const { filters: queryFilters } = await send( + 'make-filters-from-conditions', + { + conditions: conditions.filter(cond => !cond.customName), + }, ); + const conditionsOpKey = + this.state.filterConditionsOp === '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( + { + filterConditions: conditions, + }, + () => { + this.updateQuery(this.currentQuery, true); + }, + ); } else { this.setState( { transactions: [], transactionCount: 0, - filters: conditions, + filterConditions: conditions, }, () => { this.fetchTransactions(); @@ -1357,8 +1363,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( @@ -1425,7 +1431,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; @@ -1487,7 +1493,6 @@ class AccountInternal extends PureComponent { addNotification, accountsSyncing, failedAccounts, - pushModal, replaceModal, showExtraBalances, accountId, @@ -1505,6 +1510,7 @@ class AccountInternal extends PureComponent { balances, showCleared, showReconciled, + filteredAmount, } = this.state; const account = accounts.find(account => account.id === accountId); @@ -1548,14 +1554,13 @@ class AccountInternal extends PureComponent { > <View style={styles.page}> <AccountHeader - filteredAmount={this.filteredAmount} tableRef={this.table} editingName={editingName} isNameEditable={isNameEditable} workingHard={workingHard} account={account} filterId={filterId} - filtersList={this.props.filtersList} + savedFilters={this.props.savedFilters} location={this.props.location} accountName={accountName} accountsSyncing={accountsSyncing} @@ -1569,11 +1574,13 @@ class AccountInternal extends PureComponent { showEmptyMessage={showEmptyMessage} balanceQuery={balanceQuery} canCalculateBalance={this.canCalculateBalance} + filteredAmount={filteredAmount} + isFiltered={transactionsFiltered} isSorted={this.state.sort.length !== 0} reconcileAmount={reconcileAmount} search={this.state.search} - filters={this.state.filters} - conditionsOp={this.state.conditionsOp} + filterConditions={this.state.filterConditions} + filterConditionsOp={this.state.filterConditionsOp} savePrefs={this.props.savePrefs} pushModal={this.props.pushModal} onSearch={this.onSearch} @@ -1598,7 +1605,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} @@ -1631,9 +1638,7 @@ class AccountInternal extends PureComponent { isAdding={this.state.isAdding} isNew={this.isNew} isMatched={this.isMatched} - isFiltered={ - this.state.search !== '' || this.state.filters.length > 0 - } + isFiltered={transactionsFiltered} dateFormat={dateFormat} hideFraction={hideFraction} addNotification={addNotification} @@ -1653,7 +1658,6 @@ class AccountInternal extends PureComponent { </View> ) : null } - pushModal={pushModal} onSort={this.onSort} sortField={this.state.sort.field} ascDesc={this.state.sort.ascDesc} @@ -1669,6 +1673,7 @@ class AccountInternal extends PureComponent { this.setState({ isAdding: false }) } onCreatePayee={this.onCreatePayee} + onApplyFilter={this.onApplyFilter} /> </View> </View> @@ -1716,36 +1721,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 state = { - newTransactions, - matchedTransactions, - accounts, - failedAccounts, - dateFormat, - hideFraction, - expandSplits, - showBalances, - showCleared: !hideCleared, - showReconciled: !hideReconciled, - showExtraBalances, - payees, - modalShowing, - accountsSyncing, - lastUndoState, - conditions, - }; + const filterConditions = location?.state?.filterConditions || []; - 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'); @@ -1774,17 +1753,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> diff --git a/packages/desktop-client/src/components/accounts/Balance.jsx b/packages/desktop-client/src/components/accounts/Balance.jsx index 30706faa17b25b73e7c4f33f62661c3c35f13d0a..f2db1f2e63ab4838838946979cd96d935314085a 100644 --- a/packages/desktop-client/src/components/accounts/Balance.jsx +++ b/packages/desktop-client/src/components/accounts/Balance.jsx @@ -137,7 +137,7 @@ export function Balances({ showExtraBalances, onToggleExtraBalances, account, - filteredItems, + isFiltered, filteredAmount, }) { const selectedItems = useSelectedItems(); @@ -196,9 +196,7 @@ export function Balances({ {selectedItems.size > 0 && ( <SelectedBalance selectedItems={selectedItems} account={account} /> )} - {filteredItems.length > 0 && ( - <FilteredBalance filteredAmount={filteredAmount} /> - )} + {isFiltered && <FilteredBalance filteredAmount={filteredAmount} />} </View> ); } diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx index e152595fa4c4d9436e0b09caac23f67544574dca..fd29b4cc42781c7391e34d7d4e40a2156f039d48 100644 --- a/packages/desktop-client/src/components/accounts/Header.jsx +++ b/packages/desktop-client/src/components/accounts/Header.jsx @@ -32,7 +32,6 @@ import { Balances } from './Balance'; import { ReconcilingMessage, ReconcileMenu } from './Reconcile'; export function AccountHeader({ - filteredAmount, tableRef, editingName, isNameEditable, @@ -40,7 +39,7 @@ export function AccountHeader({ accountName, account, filterId, - filtersList, + savedFilters, accountsSyncing, failedAccounts, accounts, @@ -53,10 +52,12 @@ export function AccountHeader({ balanceQuery, reconcileAmount, canCalculateBalance, + isFiltered, + filteredAmount, isSorted, search, - filters, - conditionsOp, + filterConditions, + filterConditionsOp, pushModal, onSearch, onAddTransaction, @@ -79,7 +80,7 @@ export function AccountHeader({ onUpdateFilter, onClearFilters, onReloadSavedFilter, - onCondOpChange, + onConditionsOpChange, onDeleteFilter, onScheduleAction, onSetTransfer, @@ -243,7 +244,7 @@ export function AccountHeader({ showExtraBalances={showExtraBalances} onToggleExtraBalances={onToggleExtraBalances} account={account} - filteredItems={filters} + isFiltered={isFiltered} filteredAmount={filteredAmount} /> @@ -322,7 +323,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={ @@ -391,17 +392,17 @@ export function AccountHeader({ )} </Stack> - {filters && filters.length > 0 && ( + {filterConditions?.length > 0 && ( <FiltersStack - filters={filters} - conditionsOp={conditionsOp} + conditions={filterConditions} + conditionsOp={filterConditionsOp} onUpdateFilter={onUpdateFilter} onDeleteFilter={onDeleteFilter} onClearFilters={onClearFilters} onReloadSavedFilter={onReloadSavedFilter} filterId={filterId} - filtersList={filtersList} - onCondOpChange={onCondOpChange} + savedFilters={savedFilters} + onConditionsOpChange={onConditionsOpChange} /> )} </View> diff --git a/packages/desktop-client/src/components/budget/index.tsx b/packages/desktop-client/src/components/budget/index.tsx index 67f3ec43c1e2de7083a0629360f5353d11190183..056d240b29802db73f4e5854acaf4f9357c1b948 100644 --- a/packages/desktop-client/src/components/budget/index.tsx +++ b/packages/desktop-client/src/components/budget/index.tsx @@ -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, }, }); diff --git a/packages/desktop-client/src/components/filters/AppliedFilters.tsx b/packages/desktop-client/src/components/filters/AppliedFilters.tsx index 538bdd72b7ce54b7d27756ad2fd9511dfaee0e33..bb297777e305f3a7ab32a5ec3fb64d32ceefe92a 100644 --- a/packages/desktop-client/src/components/filters/AppliedFilters.tsx +++ b/packages/desktop-client/src/components/filters/AppliedFilters.tsx @@ -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} diff --git a/packages/desktop-client/src/components/filters/CondOpMenu.tsx b/packages/desktop-client/src/components/filters/ConditionsOpMenu.tsx similarity index 67% rename from packages/desktop-client/src/components/filters/CondOpMenu.tsx rename to packages/desktop-client/src/components/filters/ConditionsOpMenu.tsx index af5eec779cb77b54259b100bf526d3a3658ac189..baff43467410c3f946a8265ac7770c80903d4c0d 100644 --- a/packages/desktop-client/src/components/filters/CondOpMenu.tsx +++ b/packages/desktop-client/src/components/filters/ConditionsOpMenu.tsx @@ -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> diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx index e72a657cb22967591d2b7c597df9e36983d5296c..674be7e8bfc5ca3edd2597d0f5239b2bd7112a14 100644 --- a/packages/desktop-client/src/components/filters/FilterExpression.tsx +++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx @@ -61,7 +61,6 @@ export function FilterExpression({ type="bare" disabled={customName != null} onClick={() => setEditing(true)} - style={{ marginRight: -7 }} > <div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}> {customName ? ( @@ -76,7 +75,11 @@ export function FilterExpression({ value={value} field={field} inline={true} - valueIsRaw={op === 'contains' || op === 'doesNotContain'} + valueIsRaw={ + op === 'contains' || + op === 'matches' || + op === 'doesNotContain' + } /> </> )} @@ -87,8 +90,7 @@ export function FilterExpression({ style={{ width: 8, height: 8, - margin: 5, - marginLeft: 3, + margin: 4, }} /> </Button> diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx index ac1f48d6aed7e1eb195ed7f88ac2d248e9b29ba3..1a94aff7944ccf6360062f15f68048c5f4f91bd7 100644 --- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx +++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx @@ -199,7 +199,8 @@ function ConfigureField({ field={field} subfield={subfield} type={ - type === 'id' && (op === 'contains' || op === 'doesNotContain') + type === 'id' && + (op === 'contains' || op === 'matches' || op === 'doesNotContain') ? 'string' : type } diff --git a/packages/desktop-client/src/components/filters/FiltersStack.tsx b/packages/desktop-client/src/components/filters/FiltersStack.tsx index 3d050ec44a57941506fc7efe336754758d8ff158..3af0c68b736915ef8af9fa34dc48a0f7d746ad5c 100644 --- a/packages/desktop-client/src/components/filters/FiltersStack.tsx +++ b/packages/desktop-client/src/components/filters/FiltersStack.tsx @@ -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> diff --git a/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx b/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx index 10e1176fa96fb72abb1f997a73b26ec86b3d2750..ba5658f6b76fc3197624e8860e0e23f71b236709 100644 --- a/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx +++ b/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx @@ -1,6 +1,7 @@ import React, { useRef, 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'; @@ -21,19 +22,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); @@ -64,7 +65,7 @@ export function SavedFilterMenuButton({ setAdding(false); setMenuOpen(false); savedFilter = { - conditions: filters, + conditions, conditionsOp, id: filterId.id, name: filterId.name, @@ -72,7 +73,7 @@ export function SavedFilterMenuButton({ }; const response = await sendCatch('filter-update', { state: savedFilter, - filters: [...filtersList], + filters: [...savedFilters], }); if (response.error) { @@ -108,7 +109,7 @@ export function SavedFilterMenuButton({ async function onAddUpdate() { if (adding) { const newSavedFilter = { - conditions: filters, + conditions, conditionsOp, name, status: 'saved', @@ -116,7 +117,7 @@ export function SavedFilterMenuButton({ const response = await sendCatch('filter-create', { state: newSavedFilter, - filters: [...filtersList], + filters: [...savedFilters], }); if (response.error) { @@ -142,7 +143,7 @@ export function SavedFilterMenuButton({ const response = await sendCatch('filter-update', { state: updatedFilter, - filters: [...filtersList], + filters: [...savedFilters], }); if (response.error) { @@ -157,7 +158,7 @@ export function SavedFilterMenuButton({ return ( <View> - {filters.length > 0 && ( + {conditions.length > 0 && ( <Button ref={triggerRef} type="bare" diff --git a/packages/desktop-client/src/components/filters/updateFilterReducer.ts b/packages/desktop-client/src/components/filters/updateFilterReducer.ts index 8bbca3ce85055e832bedb59aa25b61a866a43ba9..694c00059228f1ae57ac43c77dd50258db9a819c 100644 --- a/packages/desktop-client/src/components/filters/updateFilterReducer.ts +++ b/packages/desktop-client/src/components/filters/updateFilterReducer.ts @@ -12,6 +12,7 @@ export function updateFilterReducer( if ( (type === 'id' || type === 'string') && (action.op === 'contains' || + action.op === 'matches' || action.op === 'is' || action.op === 'doesNotContain' || action.op === 'isNot') diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx index 6387eaa11c2b8d9ebdcc7b8cf94853b635d3225a..44617a988645bbce7ab08494d2a5a277718bc5b0 100644 --- a/packages/desktop-client/src/components/modals/EditRule.jsx +++ b/packages/desktop-client/src/components/modals/EditRule.jsx @@ -103,10 +103,13 @@ export function OpSelect({ onChange, }) { let line; - // We don't support the `contains` operator for the id type for + // We don't support the `contains, `doesNotContain`, `matches` operators for the id type for // rules yet + // TODO: Add matches op support for payees, accounts, categories. if (type === 'id') { - ops = ops.filter(op => op !== 'contains' && op !== 'doesNotContain'); + ops = ops.filter( + op => op !== 'contains' && op !== 'matches' && op !== 'doesNotContain', + ); line = ops.length / 2; } if (type === 'string') { diff --git a/packages/desktop-client/src/components/reports/Header.jsx b/packages/desktop-client/src/components/reports/Header.jsx index 360a754de7a065cb5201f14435ef192807652a93..03c855d29a9b76554fb19908598fa20840ef609c 100644 --- a/packages/desktop-client/src/components/reports/Header.jsx +++ b/packages/desktop-client/src/components/reports/Header.jsx @@ -27,7 +27,7 @@ export function Header({ onApply, onUpdateFilter, onDeleteFilter, - onCondOpChange, + onConditionsOpChange, headerPrefixItems, children, }) { @@ -151,11 +151,11 @@ export function Header({ align="flex-start" > <AppliedFilters - filters={filters} + conditions={filters} onUpdate={onUpdateFilter} onDelete={onDeleteFilter} conditionsOp={conditionsOp} - onCondOpChange={onCondOpChange} + onConditionsOpChange={onConditionsOpChange} /> </View> )} diff --git a/packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableRow.tsx b/packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableRow.tsx index 57aec0d235ba12ba47922f4c2c363359e98be7f5..c18cf54c9cac902ba1ad8626457776d8876010b7 100644 --- a/packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableRow.tsx +++ b/packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableRow.tsx @@ -13,6 +13,7 @@ import { useCategories } from '../../../../hooks/useCategories'; import { useNavigate } from '../../../../hooks/useNavigate'; import { useResponsive } from '../../../../ResponsiveProvider'; import { type CSSProperties, theme } from '../../../../style'; +import { Text } from '../../../common/Text'; import { View } from '../../../common/View'; import { Row, Cell } from '../../../table'; import { showActivity } from '../showActivity'; @@ -125,7 +126,9 @@ export const ReportTableRow = memo( style={{ minWidth: compact ? 50 : 85, }} - linkStyle={hoverUnderline} + unexposedContent={({ value }) => ( + <Text style={hoverUnderline}>{value}</Text> + )} valueStyle={compactStyle} value={amountToCurrency(intervalItem[balanceTypeOp])} title={ @@ -173,7 +176,9 @@ export const ReportTableRow = memo( style={{ minWidth: compact ? 50 : 85, }} - linkStyle={hoverUnderline} + unexposedContent={({ value }) => ( + <Text style={hoverUnderline}>{value}</Text> + )} valueStyle={compactStyle} onClick={() => !isNarrowWidth && @@ -208,7 +213,9 @@ export const ReportTableRow = memo( style={{ minWidth: compact ? 50 : 85, }} - linkStyle={hoverUnderline} + unexposedContent={({ value }) => ( + <Text style={hoverUnderline}>{value}</Text> + )} valueStyle={compactStyle} onClick={() => !isNarrowWidth && @@ -244,7 +251,9 @@ export const ReportTableRow = memo( fontWeight: 600, minWidth: compact ? 50 : 85, }} - linkStyle={hoverUnderline} + unexposedContent={({ value }) => ( + <Text style={hoverUnderline}>{value}</Text> + )} valueStyle={compactStyle} onClick={() => !isNarrowWidth && diff --git a/packages/desktop-client/src/components/reports/reports/CashFlow.tsx b/packages/desktop-client/src/components/reports/reports/CashFlow.tsx index 62187004ee24c62f2b56cc0351420ad8c933accd..949f7dd93fd58ab0577bb6956e1ed79ea4a7814f 100644 --- a/packages/desktop-client/src/components/reports/reports/CashFlow.tsx +++ b/packages/desktop-client/src/components/reports/reports/CashFlow.tsx @@ -28,12 +28,12 @@ import { useReport } from '../useReport'; export function CashFlow() { const { - filters, + conditions, conditionsOp, onApply: onApplyFilter, onDelete: onDeleteFilter, onUpdate: onUpdateFilter, - onCondOpChange, + onConditionsOpChange, } = useFilters<RuleConditionEntity>(); const [allMonths, setAllMonths] = useState<null | Array<{ @@ -55,8 +55,8 @@ export function CashFlow() { }); const params = useMemo( - () => cashFlowByDate(start, end, isConcise, filters, conditionsOp), - [start, end, isConcise, filters, conditionsOp], + () => cashFlowByDate(start, end, isConcise, conditions, conditionsOp), + [start, end, isConcise, conditions, conditionsOp], ); const data = useReport('cash_flow', params); @@ -129,11 +129,11 @@ export function CashFlow() { show1Month onChangeDates={onChangeDates} onApply={onApplyFilter} - filters={filters} + filters={conditions} onUpdateFilter={onUpdateFilter} onDeleteFilter={onDeleteFilter} conditionsOp={conditionsOp} - onCondOpChange={onCondOpChange} + onConditionsOpChange={onConditionsOpChange} headerPrefixItems={undefined} > <View diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx index 7171fb521964f7daf2df62dc40c7ac9f15413327..2fb2a375c1b0039f2f114f292eed77ab16f200a5 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx @@ -68,12 +68,12 @@ export function CustomReport() { useLocalPref('reportsViewLabel'); const { - filters, + conditions, conditionsOp, onApply: onApplyFilter, onDelete: onDeleteFilter, onUpdate: onUpdateFilter, - onCondOpChange, + onConditionsOpChange, } = useFilters(); const location = useLocation(); @@ -260,7 +260,7 @@ export function CustomReport() { interval, categories, selectedCategories, - conditions: filters, + conditions, conditionsOp, showEmpty, showOffBudget, @@ -276,7 +276,7 @@ export function CustomReport() { balanceTypeOp, categories, selectedCategories, - filters, + conditions, conditionsOp, showEmpty, showOffBudget, @@ -293,7 +293,7 @@ export function CustomReport() { interval, categories, selectedCategories, - conditions: filters, + conditions, conditionsOp, showEmpty, showOffBudget, @@ -317,7 +317,7 @@ export function CustomReport() { selectedCategories, payees, accounts, - filters, + conditions, conditionsOp, showEmpty, showOffBudget, @@ -349,7 +349,7 @@ export function CustomReport() { showUncategorized, selectedCategories, graphType, - conditions: filters, + conditions, conditionsOp, }; @@ -494,7 +494,7 @@ export function CustomReport() { setGraphType(input.graphType); onApplyFilter(null); (input.conditions || []).forEach(condition => onApplyFilter(condition)); - onCondOpChange(input.conditionsOp); + onConditionsOpChange(input.conditionsOp); }; const onReportChange = ({ @@ -623,7 +623,7 @@ export function CustomReport() { defaultItems={defaultItems} /> )} - {filters && filters.length > 0 && ( + {conditions && conditions.length > 0 && ( <View style={{ marginBottom: 10, @@ -635,11 +635,11 @@ export function CustomReport() { }} > <AppliedFilters - filters={filters} + conditions={conditions} onUpdate={(oldFilter, newFilter) => { setSessionReport( 'conditions', - filters.map(f => (f === oldFilter ? newFilter : f)), + conditions.map(f => (f === oldFilter ? newFilter : f)), ); onReportChange({ type: 'modify' }); onUpdateFilter(oldFilter, newFilter); @@ -647,14 +647,14 @@ export function CustomReport() { onDelete={deletedFilter => { setSessionReport( 'conditions', - filters.filter(f => f !== deletedFilter), + conditions.filter(f => f !== deletedFilter), ); onDeleteFilter(deletedFilter); onReportChange({ type: 'modify' }); }} conditionsOp={conditionsOp} - onCondOpChange={co => { - onCondOpChange(co); + onConditionsOpChange={co => { + onConditionsOpChange(co); onReportChange({ type: 'modify' }); }} /> @@ -704,7 +704,7 @@ export function CustomReport() { {dataCheck ? ( <ChooseGraph data={data} - filters={filters} + filters={conditions} mode={mode} graphType={graphType} balanceType={balanceType} diff --git a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx index 2e17cae066f362d438e6dac2654a93f51149fff8..1340ba4811211da114037389e460d49b1b0a5682 100644 --- a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx +++ b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx @@ -26,13 +26,13 @@ import { fromDateRepr } from '../util'; export function NetWorth() { const accounts = useAccounts(); const { - filters, + conditions, saved, conditionsOp, onApply: onApplyFilter, onDelete: onDeleteFilter, onUpdate: onUpdateFilter, - onCondOpChange, + onConditionsOpChange, } = useFilters(); const [allMonths, setAllMonths] = useState(null); @@ -42,8 +42,8 @@ export function NetWorth() { const [end, setEnd] = useState(monthUtils.currentMonth()); const params = useMemo( - () => netWorthSpreadsheet(start, end, accounts, filters, conditionsOp), - [start, end, accounts, filters, conditionsOp], + () => netWorthSpreadsheet(start, end, accounts, conditions, conditionsOp), + [start, end, accounts, conditions, conditionsOp], ); const data = useReport('net_worth', params); useEffect(() => { @@ -108,13 +108,13 @@ export function NetWorth() { start={start} end={end} onChangeDates={onChangeDates} - filters={filters} + filters={conditions} saved={saved} onApply={onApplyFilter} onUpdateFilter={onUpdateFilter} onDeleteFilter={onDeleteFilter} conditionsOp={conditionsOp} - onCondOpChange={onCondOpChange} + onConditionsOpChange={onConditionsOpChange} /> <View diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx index 5f54faaaf7823f8adbf38c1849203a4b27a913f5..bfecccbd13ea30264e14a29b06ff9fdde98779a6 100644 --- a/packages/desktop-client/src/components/reports/reports/Spending.tsx +++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx @@ -29,12 +29,12 @@ export function Spending() { const categories = useCategories(); const { - filters, + conditions, conditionsOp, onApply: onApplyFilter, onDelete: onDeleteFilter, onUpdate: onUpdateFilter, - onCondOpChange, + onConditionsOpChange, } = useFilters<RuleConditionEntity>(); const [dataCheck, setDataCheck] = useState(false); @@ -44,11 +44,11 @@ export function Spending() { setDataCheck(false); return createSpendingSpreadsheet({ categories, - conditions: filters, + conditions, conditionsOp, setDataCheck, }); - }, [categories, filters, conditionsOp]); + }, [categories, conditions, conditionsOp]); const data = useReport('default', getGraphData); const navigate = useNavigate(); @@ -100,7 +100,7 @@ export function Spending() { flexShrink: 0, }} > - {filters && ( + {conditions && ( <View style={{ flexDirection: 'row' }}> <FilterButton onApply={onApplyFilter} @@ -126,7 +126,7 @@ export function Spending() { flexGrow: 1, }} > - {filters && filters.length > 0 && ( + {conditions && conditions.length > 0 && ( <View style={{ marginBottom: 10, @@ -139,11 +139,11 @@ export function Spending() { }} > <AppliedFilters - filters={filters} + conditions={conditions} onUpdate={onUpdateFilter} onDelete={onDeleteFilter} conditionsOp={conditionsOp} - onCondOpChange={onCondOpChange} + onConditionsOpChange={onConditionsOpChange} /> </View> )} diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 42560177b3654363ba4498d00aa9fe52cbbf20f4..81516a418db02f684c4460aab7521b89440939c0 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -114,8 +114,9 @@ export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field( export function UnexposedCellContent({ value, formatter, - linkStyle, -}: Pick<CellProps, 'value' | 'formatter' | 'linkStyle'>) { + style, + ...props +}: Pick<CellProps, 'value' | 'formatter' | 'style'>) { return ( <Text style={{ @@ -123,7 +124,8 @@ export function UnexposedCellContent({ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - ...linkStyle, + ...style, + ...props, }} > {formatter ? formatter(value) : value} @@ -139,10 +141,11 @@ 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; - linkStyle?: CSSProperties; onExpose?: (name: string) => void; privacyFilter?: ComponentProps< typeof ConditionalPrivacyFilter @@ -162,7 +165,6 @@ export function Cell({ plain, style, valueStyle, - linkStyle, unexposedContent, privacyFilter, ...viewProps @@ -233,12 +235,10 @@ export function Cell({ } } > - {unexposedContent || ( - <UnexposedCellContent - linkStyle={linkStyle} - value={value} - formatter={formatter} - /> + {unexposedContent ? ( + unexposedContent({ value, formatter }) + ) : ( + <UnexposedCellContent value={value} formatter={formatter} /> )} </View> )} diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx index c25778a34e3a3be45e66542ce2639bb998bcedcf..20cf3a97717421818cfb40eea3c4d2621cb7814f 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -1,5 +1,9 @@ import React, { useRef, useCallback, useLayoutEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import escapeRegExp from 'lodash/escapeRegExp'; + +import { pushModal } from 'loot-core/client/actions'; import { send } from 'loot-core/src/platform/client/fetch'; import { splitTransaction, @@ -76,7 +80,6 @@ export function TransactionList({ dateFormat, hideFraction, addNotification, - pushModal, renderEmpty, onSort, sortField, @@ -85,9 +88,11 @@ export function TransactionList({ onRefetch, onCloseAddTransaction, onCreatePayee, + onApplyFilter, }) { const transactionsLatest = useRef(); const navigate = useNavigate(); + const dispatch = useDispatch(); useLayoutEffect(() => { transactionsLatest.current = transactions; @@ -161,13 +166,21 @@ export function TransactionList({ }); const onNavigateToSchedule = useCallback(scheduleId => { - pushModal('schedule-edit', { id: scheduleId }); + dispatch(pushModal('schedule-edit', { id: scheduleId })); + }); + + const onNotesTagClick = useCallback(tag => { + onApplyFilter({ + field: 'notes', + op: 'matches', + value: `(^|\\s|\\w|#)${escapeRegExp(tag)}($|\\s|#)`, + type: 'string', + }); }); return ( <TransactionTable ref={tableRef} - pushModal={pushModal} transactions={allTransactions} loadMoreTransactions={loadMoreTransactions} accounts={accounts} @@ -200,6 +213,7 @@ export function TransactionList({ style={{ backgroundColor: theme.tableBackground }} onNavigateToTransferAccount={onNavigateToTransferAccount} onNavigateToSchedule={onNavigateToSchedule} + onNotesTagClick={onNotesTagClick} onSort={onSort} sortField={sortField} ascDesc={ascDesc} diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index 46849be920ff686953187a6de744757429cbe65d..60286ed4eca32efc28656edbff4d8d800b253936 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -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, @@ -413,7 +415,8 @@ function HeaderCell({ width={width} name={id} alignItems={alignItems} - unexposedContent={ + value={value} + unexposedContent={({ value: cellValue }) => ( <Button type="bare" onClick={onClick} @@ -427,7 +430,7 @@ function HeaderCell({ marginRight, }} > - <UnexposedCellContent value={value} /> + <UnexposedCellContent value={cellValue} /> {icon === 'asc' && ( <SvgArrowDown width={10} height={10} style={{ marginLeft: 5 }} /> )} @@ -435,22 +438,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 +463,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 +485,8 @@ function PayeeCell({ isCreatingPayee.current = false; } }} - unexposedContent={ + formatter={() => getPayeePretty(transaction, payee, transferAcct)} + unexposedContent={props => ( <> <PayeeIcons transaction={transaction} @@ -496,12 +494,9 @@ function PayeeCell({ onNavigateToTransferAccount={onNavigateToTransferAccount} onNavigateToSchedule={onNavigateToSchedule} /> - <UnexposedCellContent - value={payeeId} - formatter={() => getPayeePretty(transaction, payee, transferAcct)} - /> + <UnexposedCellContent {...props} /> </> - } + )} > {({ onBlur, @@ -515,7 +510,7 @@ function PayeeCell({ <PayeeAutocomplete payees={payees} accounts={accounts} - value={payeeId} + value={payee?.id} shouldSaveFromKey={shouldSaveFromKey} inputProps={{ onBlur, @@ -527,7 +522,7 @@ function PayeeCell({ focused={true} onUpdate={(id, value) => onUpdate?.(value)} onSelect={onSave} - onManagePayees={() => onManagePayees(payeeId)} + onManagePayees={() => onManagePayees(payee?.id)} menuPortalTarget={undefined} /> ); @@ -641,8 +636,10 @@ const Transaction = memo(function Transaction(props) { onToggleSplit, onNavigateToTransferAccount, onNavigateToSchedule, + onNotesTagClick, } = props; + const dispatch = useDispatch(); const dispatchSelected = useSelectedDispatch(); const [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit); @@ -685,13 +682,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 +699,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', + }), + ); } } @@ -976,15 +977,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} @@ -1009,6 +1009,7 @@ const Transaction = memo(function Transaction(props) { focused={focusedField === 'notes'} value={notes || ''} valueStyle={valueStyle} + formatter={value => notesTagFormatter(value, onNotesTagClick)} onExpose={name => !isPreview && onEdit(id, name)} inputProps={{ value: notes || '', @@ -1391,6 +1392,7 @@ function NewTransaction({ onCreatePayee, onNavigateToTransferAccount, onNavigateToSchedule, + onNotesTagClick, balance, }) { const error = transactions[0].error; @@ -1444,6 +1446,7 @@ function NewTransaction({ style={{ marginTop: -1 }} onNavigateToTransferAccount={onNavigateToTransferAccount} onNavigateToSchedule={onNavigateToSchedule} + onNotesTagClick={onNotesTagClick} balance={balance} /> ))} @@ -1523,6 +1526,14 @@ function TransactionTableInner({ [props.onCloseAddTransaction, props.onNavigateToSchedule], ); + const onNotesTagClick = useCallback( + noteTag => { + props.onCloseAddTransaction(); + props.onNotesTagClick(noteTag); + }, + [props.onCloseAddTransaction, props.onNotesTagClick], + ); + useEffect(() => { if (!isAddingPrev && props.isAdding) { newNavigator.onEdit('temp', 'date'); @@ -1625,7 +1636,7 @@ function TransactionTableInner({ onToggleSplit={props.onToggleSplit} onNavigateToTransferAccount={onNavigateToTransferAccount} onNavigateToSchedule={onNavigateToSchedule} - pushModal={props.pushModal} + onNotesTagClick={onNotesTagClick} /> </> ); @@ -1683,6 +1694,7 @@ function TransactionTableInner({ onCreatePayee={props.onCreatePayee} onNavigateToTransferAccount={onNavigateToTransferAccount} onNavigateToSchedule={onNavigateToSchedule} + onNotesTagClick={onNotesTagClick} onDistributeRemainder={props.onDistributeRemainder} balance={ props.transactions?.length > 0 @@ -2171,3 +2183,59 @@ export const TransactionTable = forwardRef((props, ref) => { }); TransactionTable.displayName = 'TransactionTable'; + +function notesTagFormatter(notes, onNotesTagClick) { + const words = notes.split(' '); + return ( + <> + {words.map((word, i, arr) => { + const separator = arr.length - 1 === i ? '' : ' '; + if (word.includes('#') && word.length > 1) { + // Treat tags in a single word as separate tags. + // #tag1#tag2 => (#tag1)(#tag2) + // not-a-tag#tag2#tag3 => not-a-tag(#tag2)(#tag3) + return word.split('#').map((tag, ti) => { + if (ti === 0) { + return tag; + } + + if (!tag) { + return '#'; + } + + const validTag = `#${tag}`; + return ( + <span key={`${validTag}${ti}`}> + <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(); + onNotesTagClick?.(validTag); + }} + > + {validTag} + </Button> + {separator} + </span> + ); + }); + } + return `${word}${separator}`; + })} + </> + ); +} diff --git a/packages/desktop-client/src/hooks/useFilters.ts b/packages/desktop-client/src/hooks/useFilters.ts index cd8fcaed8688a75b0d90c1b36f8f2f767f808124..825b0e2cb3b2ecadf7c5f42ee9938e4b14e93cc9 100644 --- a/packages/desktop-client/src/hooks/useFilters.ts +++ b/packages/desktop-client/src/hooks/useFilters.ts @@ -4,48 +4,48 @@ import { useCallback, useMemo, useState } from 'react'; import { type RuleConditionEntity } from 'loot-core/types/models/rule'; export function useFilters<T extends RuleConditionEntity>( - initialFilters: T[] = [], + initialConditions: T[] = [], ) { - const [filters, setFilters] = useState<T[]>(initialFilters); + const [conditions, setConditions] = useState<T[]>(initialConditions); const [conditionsOp, setConditionsOp] = useState<'and' | 'or'>('and'); const [saved, setSaved] = useState<T[]>(null); const onApply = useCallback( - newFilter => { - if (newFilter === null) { - setFilters([]); + conditionsOrSavedFilter => { + if (conditionsOrSavedFilter === null) { + setConditions([]); setSaved(null); - } else if (newFilter.conditions) { - setFilters([...newFilter.conditions]); - setConditionsOp(newFilter.conditionsOp); - setSaved(newFilter.id); + } else if (conditionsOrSavedFilter.conditions) { + setConditions([...conditionsOrSavedFilter.conditions]); + setConditionsOp(conditionsOrSavedFilter.conditionsOp); + setSaved(conditionsOrSavedFilter.id); } else { - setFilters(state => [...state, newFilter]); + setConditions(state => [...state, conditionsOrSavedFilter]); setSaved(null); } }, - [setFilters], + [setConditions], ); const onUpdate = useCallback( (oldFilter: T, updatedFilter: T) => { - setFilters(state => + setConditions(state => state.map(f => (f === oldFilter ? updatedFilter : f)), ); setSaved(null); }, - [setFilters], + [setConditions], ); const onDelete = useCallback( (deletedFilter: T) => { - setFilters(state => state.filter(f => f !== deletedFilter)); + setConditions(state => state.filter(f => f !== deletedFilter)); setSaved(null); }, - [setFilters], + [setConditions], ); - const onCondOpChange = useCallback( + const onConditionsOpChange = useCallback( condOp => { setConditionsOp(condOp); }, @@ -54,14 +54,22 @@ export function useFilters<T extends RuleConditionEntity>( return useMemo( () => ({ - filters, + conditions, saved, conditionsOp, onApply, onUpdate, onDelete, - onCondOpChange, + onConditionsOpChange, }), - [filters, saved, onApply, onUpdate, onDelete, onCondOpChange, conditionsOp], + [ + conditions, + saved, + onApply, + onUpdate, + onDelete, + onConditionsOpChange, + conditionsOp, + ], ); } diff --git a/packages/desktop-client/src/style/themes/dark.ts b/packages/desktop-client/src/style/themes/dark.ts index 8932bfbc7c9f9d68bbcc1fce12bf6e9e5f0929d0..3385e100a4223b9c8e3e50604cc56df376b4224c 100644 --- a/packages/desktop-client/src/style/themes/dark.ts +++ b/packages/desktop-client/src/style/themes/dark.ts @@ -192,3 +192,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; diff --git a/packages/desktop-client/src/style/themes/development.ts b/packages/desktop-client/src/style/themes/development.ts index d369f5037a90f10a902bbb26f1ee8cc3b653cd60..3d73f670b6407567bce82ae2f95e47a81ab4f0d7 100644 --- a/packages/desktop-client/src/style/themes/development.ts +++ b/packages/desktop-client/src/style/themes/development.ts @@ -191,3 +191,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; diff --git a/packages/desktop-client/src/style/themes/light.ts b/packages/desktop-client/src/style/themes/light.ts index dd3812f881b7dc858b5a888c8f7c3630c752c7fb..ad8318f869e0445a2ef2e64499c342206fee7a14 100644 --- a/packages/desktop-client/src/style/themes/light.ts +++ b/packages/desktop-client/src/style/themes/light.ts @@ -194,3 +194,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; diff --git a/packages/desktop-client/src/style/themes/midnight.ts b/packages/desktop-client/src/style/themes/midnight.ts index 9116d3a4aa2f077c0cf17c0ce1b1e93da1f404f3..4ee705558857f598500956d8305fe3a106b69131 100644 --- a/packages/desktop-client/src/style/themes/midnight.ts +++ b/packages/desktop-client/src/style/themes/midnight.ts @@ -194,3 +194,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; diff --git a/packages/loot-core/src/platform/server/sqlite/index.d.ts b/packages/loot-core/src/platform/server/sqlite/index.d.ts index 63a9575212a23eac36f909e8f9bcbfe302f7f84b..1c2b9fc04c9fca64999aecca1a982a48a829f1aa 100644 --- a/packages/loot-core/src/platform/server/sqlite/index.d.ts +++ b/packages/loot-core/src/platform/server/sqlite/index.d.ts @@ -1,10 +1,10 @@ -import { type Database } from 'better-sqlite3'; +import { type Database } from '@jlongster/sql.js'; -export async function init(): unknown; +export async function init(): Promise<void>; export function _getModule(): SqlJsStatic; -export function prepare(db, sql): unknown; +export function prepare(db: Database, sql: string): string; export function runQuery( db: Database, diff --git a/packages/loot-core/src/platform/server/sqlite/index.web.ts b/packages/loot-core/src/platform/server/sqlite/index.web.ts index bfd273d5283c1dbb0ec8da06d6f43da80d98b93f..8930e7a3e4d4b43673a8269e737765e7806987a3 100644 --- a/packages/loot-core/src/platform/server/sqlite/index.web.ts +++ b/packages/loot-core/src/platform/server/sqlite/index.web.ts @@ -28,7 +28,7 @@ export function _getModule() { return SQL; } -function verifyParamTypes(sql, arr) { +function verifyParamTypes(sql: string, arr: (string | number)[] = []) { arr.forEach(val => { if (typeof val !== 'string' && typeof val !== 'number' && val !== null) { throw new Error('Invalid field type ' + val + ' for sql ' + sql); @@ -36,23 +36,28 @@ function verifyParamTypes(sql, arr) { }); } -export function prepare(db, sql) { +export function prepare(db: Database, sql: string) { return db.prepare(sql); } export function runQuery( - db: unknown, + db: Database, sql: string, params?: (string | number)[], fetchAll?: false, ): { changes: unknown }; export function runQuery( - db: unknown, + db: Database, sql: string, params: (string | number)[], fetchAll: true, ): unknown[]; -export function runQuery(db, sql, params = [], fetchAll = false) { +export function runQuery( + db: Database, + sql: string, + params: (string | number)[] = [], + fetchAll = false, +): unknown[] | { changes: unknown } { if (params) { verifyParamTypes(sql, params); } @@ -89,13 +94,13 @@ export function runQuery(db, sql, params = [], fetchAll = false) { } } -export function execQuery(db, sql) { +export function execQuery(db: Database, sql: string) { db.exec(sql); } let transactionDepth = 0; -export function transaction(db, fn) { +export function transaction(db: Database, fn: () => void) { let before, after, undo; if (transactionDepth > 0) { before = 'SAVEPOINT __actual_sp'; @@ -111,9 +116,8 @@ export function transaction(db, fn) { transactionDepth++; try { - const result = fn(); + fn(); execQuery(db, after); - return result; } catch (ex) { execQuery(db, undo); @@ -151,6 +155,10 @@ export async function asyncTransaction(db: Database, fn: () => Promise<void>) { } } +function regexp(regex: string, text: string) { + return new RegExp(regex).test(text) ? 1 : 0; +} + export async function openDatabase(pathOrBuffer?: string | Buffer) { let db = null; if (pathOrBuffer) { @@ -193,6 +201,7 @@ export async function openDatabase(pathOrBuffer?: string | Buffer) { // but SQL.js does not support this: https://github.com/sql-js/sql.js/issues/551 db.create_function('UNICODE_LOWER', arg => arg?.toLowerCase()); db.create_function('UNICODE_UPPER', arg => arg?.toUpperCase()); + db.create_function('REGEXP', regexp); return db; } diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index d164c53091fb04a8290d4ccf57c89897c75b3865..4116cd01048a3fe2c3d5000c5b702672d1bd067e 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -123,7 +123,15 @@ const CONDITION_TYPES = { }, }, id: { - ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'], + ops: [ + 'is', + 'contains', + 'matches', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + ], nullable: true, parse(op, value, fieldName) { if (op === 'oneOf' || op === 'notOneOf') { @@ -138,7 +146,15 @@ const CONDITION_TYPES = { }, }, string: { - ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'], + ops: [ + 'is', + 'contains', + 'matches', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + ], nullable: true, parse(op, value, fieldName) { if (op === 'oneOf' || op === 'notOneOf') { @@ -152,7 +168,7 @@ const CONDITION_TYPES = { return value.filter(Boolean).map(val => val.toLowerCase()); } - if (op === 'contains' || op === 'doesNotContain') { + if (op === 'contains' || op === 'matches' || op === 'doesNotContain') { assert( typeof value === 'string' && value.length > 0, 'no-empty-string', @@ -812,6 +828,7 @@ const OP_SCORES: Record<RuleConditionEntity['op'], number> = { lte: 1, contains: 0, doesNotContain: 0, + matches: 0, }; function computeScore(rule) { diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts index 3119cdd69c0fb662b1a160f528a3dc08da9ee29c..be667ce6533853729091026874d40ff60fa85514 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.ts +++ b/packages/loot-core/src/server/accounts/transaction-rules.ts @@ -452,6 +452,10 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) { '$like', '%' + value + '%', ); + case 'matches': + // Running contains with id will automatically reach into + // the `name` of the referenced table and do a regex match + return apply(type === 'id' ? field + '.name' : field, '$regexp', value); case 'doesNotContain': // Running contains with id will automatically reach into // the `name` of the referenced table and do a string match diff --git a/packages/loot-core/src/server/aql/compiler.ts b/packages/loot-core/src/server/aql/compiler.ts index 66c4e372373fed1b3d2647f327756b44273cd92e..5488efa3ed1764412cebb122f73a400d9a344e0d 100644 --- a/packages/loot-core/src/server/aql/compiler.ts +++ b/packages/loot-core/src/server/aql/compiler.ts @@ -722,6 +722,10 @@ const compileOp = saveStack('op', (state, fieldRef, opData) => { const [left, right] = valArray(state, [lhs, rhs], ['string', 'string']); return `${left} LIKE ${right}`; } + case '$regexp': { + const [left, right] = valArray(state, [lhs, rhs], ['string', 'string']); + return `REGEXP(${right}, ${left})`; + } case '$notlike': { const [left, right] = valArray(state, [lhs, rhs], ['string', 'string']); return `(${left} NOT LIKE ${right}\n OR ${left} IS NULL)`; diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 507401875c96bc8b2295834cbf097b40294b357e..a578b408c0dda9a0ff4854559da3d402846125c0 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -7,6 +7,7 @@ import { makeClientId, Timestamp, } from '@actual-app/crdt'; +import { Database } from '@jlongster/sql.js'; import LRU from 'lru-cache'; import { v4 as uuidv4 } from 'uuid'; @@ -37,8 +38,8 @@ import { shoveSortOrders, SORT_INCREMENT } from './sort'; export { toDateRepr, fromDateRepr } from '../models'; -let dbPath; -let db; +let dbPath: string | null = null; +let db: Database | null = null; // Util @@ -46,7 +47,7 @@ export function getDatabasePath() { return dbPath; } -export async function openDatabase(id?) { +export async function openDatabase(id?: string) { if (db) { await sqlite.closeDatabase(db); } @@ -57,11 +58,6 @@ export async function openDatabase(id?) { // await execQuery('PRAGMA journal_mode = WAL'); } -export async function reopenDatabase() { - await sqlite.closeDatabase(db); - setDatabase(await sqlite.openDatabase(dbPath)); -} - export async function closeDatabase() { if (db) { await sqlite.closeDatabase(db); @@ -69,7 +65,7 @@ export async function closeDatabase() { } } -export function setDatabase(db_) { +export function setDatabase(db_: Database) { db = db_; resetQueryCache(); } @@ -115,14 +111,14 @@ export function runQuery(sql, params, fetchAll) { return result; } -export function execQuery(sql) { +export function execQuery(sql: string) { sqlite.execQuery(db, sql); } // This manages an LRU cache of prepared query statements. This is // only needed in hot spots when you are running lots of queries. let _queryCache = new LRU({ max: 100 }); -export function cache(sql) { +export function cache(sql: string) { const cached = _queryCache.get(sql); if (cached) { return cached; diff --git a/packages/loot-core/src/server/migrate/migrations.ts b/packages/loot-core/src/server/migrate/migrations.ts index a34960d68e27f0d49e14cafe6defd6484b289e4d..0726ec893a0619dcd559e2402dd051b1c8e0be9f 100644 --- a/packages/loot-core/src/server/migrate/migrations.ts +++ b/packages/loot-core/src/server/migrate/migrations.ts @@ -2,7 +2,7 @@ // We have to bundle in JS migrations manually to avoid having to `eval` // them which doesn't play well with CSP. There isn't great, and eventually // we can remove this migration. -import { Database } from 'better-sqlite3'; +import { Database } from '@jlongster/sql.js'; import { v4 as uuidv4 } from 'uuid'; import m1632571489012 from '../../../migrations/1632571489012_remove_cache'; diff --git a/packages/loot-core/src/server/sheet.ts b/packages/loot-core/src/server/sheet.ts index 65ce0e34ca55ac792b6acb1c9ad1650c921ee64b..3ac4b6e7b197cccba40493ae17679379499f9b4e 100644 --- a/packages/loot-core/src/server/sheet.ts +++ b/packages/loot-core/src/server/sheet.ts @@ -1,5 +1,5 @@ // @ts-strict-ignore -import { type Database } from 'better-sqlite3'; +import { type Database } from '@jlongster/sql.js'; import { captureBreadcrumb } from '../platform/exceptions'; import * as sqlite from '../platform/server/sqlite'; diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts index c54b8723a3a6ea09963a27aa14e4615d24d93ba8..a2c53e8a4b258038805aeca8f31ceb337de2c0db 100644 --- a/packages/loot-core/src/shared/rules.ts +++ b/packages/loot-core/src/shared/rules.ts @@ -9,7 +9,15 @@ export const TYPE_INFO = { nullable: false, }, id: { - ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'], + ops: [ + 'is', + 'contains', + 'matches', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + ], nullable: true, }, saved: { @@ -17,7 +25,15 @@ export const TYPE_INFO = { nullable: false, }, string: { - ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'], + ops: [ + 'is', + 'contains', + 'matches', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + ], nullable: true, }, number: { @@ -91,6 +107,8 @@ export function friendlyOp(op, type?) { return 'is between'; case 'contains': return 'contains'; + case 'matches': + return 'matches'; case 'doesNotContain': return 'does not contain'; case 'gt': diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index 026aef47a259e06a33321af21fefbcbfdd42ff01..793d36dc2561151e06c502c0232ee857a6aecb4b 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -24,7 +24,8 @@ export type RuleConditionOp = | 'lt' | 'lte' | 'contains' - | 'doesNotContain'; + | 'doesNotContain' + | 'matches'; export interface RuleConditionEntity { field?: string; diff --git a/upcoming-release-notes/2670.md b/upcoming-release-notes/2670.md new file mode 100644 index 0000000000000000000000000000000000000000..0fd902c06af795933be782ec3043489f4c85848f --- /dev/null +++ b/upcoming-release-notes/2670.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [joel-jeremy] +--- + +Format notes that starts with # as clickable tags.