diff --git a/packages/desktop-client/src/components/ManageRules.js b/packages/desktop-client/src/components/ManageRules.js index 8f3715754108edc4fb0513fa67acfd9deaf71b06..5a26a7bd5df88dae6b4d7924b8e5c38cbd1c4378 100644 --- a/packages/desktop-client/src/components/ManageRules.js +++ b/packages/desktop-client/src/components/ManageRules.js @@ -208,7 +208,15 @@ export function Value({ } } -function ConditionExpression({ field, op, value, options, prefix, style }) { +function ConditionExpression({ + field, + op, + value, + options, + prefix, + style, + inline, +}) { return ( <View style={[ @@ -228,7 +236,7 @@ function ConditionExpression({ field, op, value, options, prefix, style }) { {prefix && <Text style={{ color: colors.n3 }}>{prefix} </Text>} <Text style={{ color: colors.p4 }}>{mapField(field, options)}</Text>{' '} <Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '} - <Value value={value} field={field} /> + <Value value={value} field={field} inline={inline} /> </View> ); } @@ -355,6 +363,7 @@ let Rule = memo( key={i} field={cond.field} op={cond.op} + inline={true} value={cond.value} options={cond.options} prefix={i > 0 ? friendlyOp(rule.conditionsOp) : null} diff --git a/packages/desktop-client/src/components/accounts/Account.js b/packages/desktop-client/src/components/accounts/Account.js index 9f1c91b53395f00a0199f309bf26e860f43e140c..b61099ee2063a57f28a6c32f34208f1b21158340 100644 --- a/packages/desktop-client/src/components/accounts/Account.js +++ b/packages/desktop-client/src/components/accounts/Account.js @@ -19,6 +19,7 @@ import { debounce } from 'debounce'; import { bindActionCreators } from 'redux'; import * as actions from 'loot-core/src/client/actions'; +import { useFilters } from 'loot-core/src/client/data-hooks/filters'; import { SchedulesProvider, useCachedSchedules, @@ -71,6 +72,8 @@ import { Menu, Stack, } from '../common'; +import { FilterButton } from '../filters/FiltersMenu'; +import { FiltersStack } from '../filters/SavedFilters'; import { KeyHandlers } from '../KeyHandlers'; import NotesButton from '../NotesButton'; import CellValue from '../spreadsheet/CellValue'; @@ -78,7 +81,6 @@ import format from '../spreadsheet/format'; import useSheetValue from '../spreadsheet/useSheetValue'; import { SelectedItemsButton } from '../table'; -import { FilterButton, AppliedFilters } from './Filters'; import TransactionList from './TransactionList'; import { SplitsExpandedProvider, @@ -253,11 +255,11 @@ function MenuButton({ onClick }) { ); } -function MenuTooltip({ onClose, children }) { +export function MenuTooltip({ width, onClose, children }) { return ( <Tooltip position="bottom-right" - width={200} + width={width} style={{ padding: 0 }} onClose={onClose} > @@ -286,7 +288,7 @@ function AccountMenu({ onReconcile={onReconcile} /> ) : ( - <MenuTooltip onClose={onClose}> + <MenuTooltip width={200} onClose={onClose}> <Menu onMenuSelect={item => { if (item === 'reconcile') { @@ -328,7 +330,7 @@ function AccountMenu({ function CategoryMenu({ onClose, onMenuSelect }) { return ( - <MenuTooltip onClose={onClose}> + <MenuTooltip width={200} onClose={onClose}> <Menu onMenuSelect={item => { onMenuSelect(item); @@ -674,6 +676,8 @@ const AccountHeader = memo( workingHard, accountName, account, + filterId, + filtersList, accountsSyncing, accounts, transactions, @@ -686,6 +690,7 @@ const AccountHeader = memo( canCalculateBalance, search, filters, + conditionsOp, savePrefs, onSearch, onAddTransaction, @@ -706,6 +711,9 @@ const AccountHeader = memo( onCreateRule, onApplyFilter, onUpdateFilter, + onClearFilters, + onReloadSavedFilter, + onCondOpChange, onDeleteFilter, onScheduleAction, }) => { @@ -1010,10 +1018,16 @@ const AccountHeader = memo( </Stack> {filters && filters.length > 0 && ( - <AppliedFilters + <FiltersStack filters={filters} - onUpdate={onUpdateFilter} - onDelete={onDeleteFilter} + conditionsOp={conditionsOp} + onUpdateFilter={onUpdateFilter} + onDeleteFilter={onDeleteFilter} + onClearFilters={onClearFilters} + onReloadSavedFilter={onReloadSavedFilter} + filterId={filterId} + filtersList={filtersList} + onCondOpChange={onCondOpChange} /> )} </View> @@ -1096,6 +1110,8 @@ class AccountInternal extends PureComponent { editingName: false, isAdding: false, latestDate: null, + filterId: [], + conditionsOp: 'and', }; } @@ -1760,14 +1776,56 @@ class AccountInternal extends PureComponent { this.props.pushModal('edit-rule', { rule }); }; + onCondOpChange = (value, filters) => { + this.setState({ conditionsOp: value }); + this.setState({ filterId: { ...this.state.filterId, status: 'changed' } }); + this.applyFilters([...filters]); + }; + + onReloadSavedFilter = (savedFilter, item) => { + if (item === 'reload') { + let [getFilter] = this.props.filtersList.filter( + f => f.id === this.state.filterId.id, + ); + this.setState({ conditionsOp: getFilter.conditionsOp }); + this.applyFilters([...getFilter.conditions]); + } else { + savedFilter.status && + this.setState({ conditionsOp: savedFilter.conditionsOp }) && + this.applyFilters([...savedFilter.conditions]); + } + this.setState({ filterId: { ...this.state.filterId, ...savedFilter } }); + }; + + onClearFilters = () => { + this.setState({ conditionsOp: 'and' }); + this.setState({ filterId: [] }); + this.applyFilters([]); + }; + onUpdateFilter = (oldFilter, updatedFilter) => { this.applyFilters( this.state.filters.map(f => (f === oldFilter ? updatedFilter : f)), ); + this.setState({ + filterId: { + ...this.state.filterId, + status: this.state.filterId && 'changed', + }, + }); }; onDeleteFilter = filter => { this.applyFilters(this.state.filters.filter(f => f !== filter)); + this.state.filters.length === 1 + ? this.setState({ filterId: [] }) && + this.setState({ conditionsOp: 'and' }) + : this.setState({ + filterId: { + ...this.state.filterId, + status: this.state.filterId && 'changed', + }, + }); }; onApplyFilter = async cond => { @@ -1775,7 +1833,19 @@ class AccountInternal extends PureComponent { if (cond.customName) { filters = filters.filter(f => f.customName !== cond.customName); } - this.applyFilters([...filters, cond]); + if (cond.conditions) { + this.setState({ filterId: { ...cond, status: 'saved' } }); + this.setState({ conditionsOp: cond.conditionsOp }); + this.applyFilters([...cond.conditions]); + } else { + this.setState({ + filterId: { + ...this.state.filterId, + status: this.state.filterId && 'changed', + }, + }); + this.applyFilters([...filters, cond]); + } }; onScheduleAction = async (name, ids) => { @@ -1805,9 +1875,9 @@ class AccountInternal extends PureComponent { let { filters } = 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({ - $and: [...filters, ...customFilters], + [conditionsOpKey]: [...filters, ...customFilters], }); this.updateQuery(this.currentQuery, true); this.setState({ filters: conditions, search: '' }); @@ -1836,6 +1906,7 @@ class AccountInternal extends PureComponent { transactions, loading, workingHard, + filterId, reconcileAmount, transactionsFiltered, editingName, @@ -1881,13 +1952,16 @@ class AccountInternal extends PureComponent { fetchAllIds={this.fetchAllIds} registerDispatch={dispatch => (this.dispatchSelected = dispatch)} > - <View style={[styles.page, { backgroundColor: colors.n11 }]}> + <View style={[styles.page]}> <AccountHeader tableRef={this.table} editingName={editingName} isNameEditable={isNameEditable} workingHard={workingHard} account={account} + filterId={filterId} + filtersList={this.props.filtersList} + location={this.props.location} accountName={accountName} accountsSyncing={accountsSyncing} accounts={accounts} @@ -1901,6 +1975,7 @@ class AccountInternal extends PureComponent { reconcileAmount={reconcileAmount} search={this.state.search} filters={this.state.filters} + conditionsOp={this.state.conditionsOp} savePrefs={this.props.savePrefs} onSearch={this.onSearch} onShowTransactions={this.onShowTransactions} @@ -1922,6 +1997,9 @@ class AccountInternal extends PureComponent { onBatchUnlink={this.onBatchUnlink} onCreateRule={this.onCreateRule} onUpdateFilter={this.onUpdateFilter} + onClearFilters={this.onClearFilters} + onReloadSavedFilter={this.onReloadSavedFilter} + onCondOpChange={this.onCondOpChange} onDeleteFilter={this.onDeleteFilter} onApplyFilter={this.onApplyFilter} onScheduleAction={this.onScheduleAction} @@ -2040,6 +2118,7 @@ export default function Account() { })); let dispatch = useDispatch(); + let filtersList = useFilters(); let actionCreators = useMemo( () => bindActionCreators(actions, dispatch), [dispatch], @@ -2085,6 +2164,7 @@ export default function Account() { accountId={params.id} categoryId={activeLocation?.state?.filter?.category} location={location} + filtersList={filtersList} /> </SplitsExpandedProvider> </SchedulesProvider> diff --git a/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.js b/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.js new file mode 100644 index 0000000000000000000000000000000000000000..4b944a9efc073201cbf95fb87a697642ab9a41c1 --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.js @@ -0,0 +1,64 @@ +import React from 'react'; + +import { useFilters } from 'loot-core/src/client/data-hooks/filters'; + +import { colors } from '../../style'; +import { View } from '../common'; + +import Autocomplete from './Autocomplete'; + +function FilterList({ items, getItemProps, highlightedIndex, embedded }) { + return ( + <View> + <View + style={[ + { overflow: 'auto', padding: '5px 0' }, + !embedded && { maxHeight: 175 }, + ]} + > + {items.map((item, idx) => { + return [ + <div + {...(getItemProps ? getItemProps({ item }) : null)} + key={item.id} + style={{ + backgroundColor: + highlightedIndex === idx ? colors.n4 : 'transparent', + padding: 4, + paddingLeft: 20, + borderRadius: embedded ? 4 : 0, + }} + data-testid={ + 'filter-item' + (highlightedIndex === idx ? '-highlighted' : '') + } + > + {item.name} + </div>, + ]; + })} + </View> + </View> + ); +} + +export default function SavedFilterAutocomplete({ embedded, ...props }) { + let filters = useFilters() || []; + + return ( + <Autocomplete + strict={true} + highlightFirst={true} + embedded={embedded} + suggestions={filters} + renderItems={(items, getItemProps, highlightedIndex) => ( + <FilterList + items={items} + getItemProps={getItemProps} + highlightedIndex={highlightedIndex} + embedded={embedded} + /> + )} + {...props} + /> + ); +} diff --git a/packages/desktop-client/src/components/common.tsx b/packages/desktop-client/src/components/common.tsx index 63af5c81be83a36d4feca963ae05a71dbb4b99e8..9e53e0b3634e207b4604f4e44518c0d96e89b485 100644 --- a/packages/desktop-client/src/components/common.tsx +++ b/packages/desktop-client/src/components/common.tsx @@ -519,6 +519,7 @@ export function Menu({ lineHeight: '1em', textTransform: 'uppercase', margin: '3px 9px', + marginTop: 5, }} > {item.name} diff --git a/packages/desktop-client/src/components/accounts/Filters.js b/packages/desktop-client/src/components/filters/FiltersMenu.js similarity index 92% rename from packages/desktop-client/src/components/accounts/Filters.js rename to packages/desktop-client/src/components/filters/FiltersMenu.js index b0a552258c5cb2f82ed591f23fd2d104b826e57a..b63b2d6cc95d62a469bb981c7c5ec56ca0844b9e 100644 --- a/packages/desktop-client/src/components/accounts/Filters.js +++ b/packages/desktop-client/src/components/filters/FiltersMenu.js @@ -8,6 +8,7 @@ import { isValid as isDateValid, } from 'date-fns'; +import { useFilters } from 'loot-core/src/client/data-hooks/filters'; import { send } from 'loot-core/src/platform/client/fetch'; import { getMonthYearFormat } from 'loot-core/src/shared/months'; import { @@ -37,6 +38,8 @@ import { import { Value } from '../ManageRules'; import GenericInput from '../util/GenericInput'; +import { CondOpMenu } from './SavedFilters'; + let filterFields = [ 'date', 'account', @@ -45,6 +48,7 @@ let filterFields = [ 'category', 'amount', 'cleared', + 'saved', ].map(field => [field, mapField(field)]); function subfieldFromFilter({ field, options, value }) { @@ -163,7 +167,7 @@ function ConfigureField({ <Tooltip position="bottom-left" style={{ padding: 15 }} - width={300} + width={250} onClose={() => dispatch({ type: 'close' })} > <FocusScope> @@ -200,6 +204,15 @@ function ConfigureField({ )} </View> + <View + style={{ + color: colors.n4, + marginBottom: 10, + }} + > + {field === 'saved' && 'Existing filters will be cleared'} + </View> + <Stack direction="row" align="flex-start" @@ -251,7 +264,8 @@ function ConfigureField({ /> )} - <View> + <Stack direction="row" justify="flex-end" align="center"> + <View style={{ flex: 1 }} /> <Button primary style={{ marginTop: 15 }} @@ -267,7 +281,7 @@ function ConfigureField({ > Apply </Button> - </View> + </Stack> </form> </FocusScope> </Tooltip> @@ -275,6 +289,8 @@ function ConfigureField({ } export function FilterButton({ onApply }) { + let filters = useFilters(); + let { dateFormat } = useSelector(state => { return { dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy', @@ -335,16 +351,20 @@ export function FilterButton({ onApply }) { } } - let { error } = await send('rule-validate', { - conditions: [cond], - actions: [], - }); + let { error } = + cond.field !== 'saved' && + (await send('rule-validate', { + conditions: [cond], + actions: [], + })); + + let saved = filters.find(f => cond.value === f.id); if (error && error.conditionErrors.length > 0) { let field = titleFirst(mapField(cond.field)); alert(field + ': ' + getFieldError(error.conditionErrors[0])); } else { - onApply(cond); + onApply(saved ? saved : cond); dispatch({ type: 'close' }); } } @@ -441,12 +461,12 @@ function FilterExpression({ <View style={[ { - backgroundColor: colors.n10, + backgroundColor: colors.n9, borderRadius: 4, flexDirection: 'row', alignItems: 'center', - marginBottom: 10, marginRight: 10, + marginTop: 10, }, style, ]} @@ -503,17 +523,27 @@ function FilterExpression({ ); } -export function AppliedFilters({ filters, editingFilter, onUpdate, onDelete }) { +export function AppliedFilters({ + filters, + editingFilter, + onUpdate, + onDelete, + conditionsOp, + onCondOpChange, +}) { return ( <View style={{ flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-start', flexWrap: 'wrap', - marginTop: 10, - marginBottom: -5, }} > + <CondOpMenu + conditionsOp={conditionsOp} + onCondOpChange={onCondOpChange} + filters={filters} + /> {filters.map((filter, i) => ( <FilterExpression key={i} diff --git a/packages/desktop-client/src/components/filters/SavedFilters.js b/packages/desktop-client/src/components/filters/SavedFilters.js new file mode 100644 index 0000000000000000000000000000000000000000..a243cf8f4b3f6bf2c305b8de0701f3a920a5208c --- /dev/null +++ b/packages/desktop-client/src/components/filters/SavedFilters.js @@ -0,0 +1,318 @@ +import React, { useState, useRef, useEffect } from 'react'; + +import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; + +import ExpandArrow from '../../icons/v0/ExpandArrow'; +import { colors } from '../../style'; +import { MenuTooltip } from '../accounts/Account'; +import { View, Text, Button, Menu, Stack } from '../common'; +import { FormField, FormLabel } from '../forms'; +import { FieldSelect } from '../modals/EditRule'; +import GenericInput from '../util/GenericInput'; + +import { AppliedFilters } from './FiltersMenu'; + +function SavedFilterMenuButton({ + filters, + conditionsOp, + filterId, + onClearFilters, + onReloadSavedFilter, + filtersList, +}) { + let [nameOpen, setNameOpen] = useState(false); + let [adding, setAdding] = useState(false); + let [menuOpen, setMenuOpen] = useState(false); + let [err, setErr] = useState(null); + let [menuItem, setMenuItem] = useState(null); + let inputRef = useRef(); + let name = filterId.name; + let id = filterId.id; + let res; + let savedFilter; + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [NameFilter]); + + const onFilterMenuSelect = async item => { + setMenuItem(item); + switch (item) { + case 'rename-filter': + setErr(null); + setAdding(false); + setMenuOpen(false); + setNameOpen(true); + break; + case 'delete-filter': + setMenuOpen(false); + await send('filter-delete', id); + onClearFilters(); + break; + case 'update-filter': + setErr(null); + setAdding(false); + setMenuOpen(false); + savedFilter = { + conditions: filters, + conditionsOp: conditionsOp, + id: filterId.id, + name: filterId.name, + status: 'saved', + }; + res = await sendCatch('filter-update', { + state: savedFilter, + filters: [...filtersList], + }); + if (res.error) { + setErr(res.error.message); + setNameOpen(true); + } else { + onReloadSavedFilter(savedFilter, 'update'); + } + break; + case 'save-filter': + setErr(null); + setAdding(true); + setMenuOpen(false); + setNameOpen(true); + break; + case 'reload-filter': + setMenuOpen(false); + savedFilter = { + status: 'saved', + }; + onReloadSavedFilter(savedFilter, 'reload'); + break; + case 'clear-filter': + setMenuOpen(false); + onClearFilters(); + break; + default: + } + }; + + function FilterMenu({ onClose, filterId }) { + return ( + <MenuTooltip width={200} onClose={onClose}> + <Menu + onMenuSelect={item => { + onFilterMenuSelect(item); + }} + items={[ + ...(!filterId.id + ? [ + { name: 'save-filter', text: 'Save new filter' }, + { name: 'clear-filter', text: 'Clear all conditions' }, + ] + : [ + ...(filterId.id !== null && filterId.status === 'saved' + ? [ + { name: 'rename-filter', text: 'Rename' }, + { name: 'delete-filter', text: 'Delete' }, + Menu.line, + { + name: 'save-filter', + text: 'Save new filter', + disabled: true, + }, + { name: 'clear-filter', text: 'Clear all conditions' }, + ] + : [ + { name: 'rename-filter', text: 'Rename' }, + { name: 'update-filter', text: 'Update condtions' }, + { name: 'reload-filter', text: 'Revert changes' }, + { name: 'delete-filter', text: 'Delete' }, + Menu.line, + { name: 'save-filter', text: 'Save new filter' }, + { name: 'clear-filter', text: 'Clear all conditions' }, + ]), + ]), + ]} + /> + </MenuTooltip> + ); + } + + async function onAddUpdate() { + if (adding) { + //create new flow + savedFilter = { + conditions: filters, + conditionsOp: conditionsOp, + name: name, + status: 'saved', + }; + res = await sendCatch('filter-create', { + state: savedFilter, + filters: [...filtersList], + }); + savedFilter = { + ...savedFilter, + id: res.data, + }; + } else { + //rename flow + savedFilter = { + conditions: filterId.conditions, + conditionsOp: filterId.conditionsOp, + id: filterId.id, + name: name, + }; + res = await sendCatch('filter-update', { + state: savedFilter, + filters: [...filtersList], + }); + } + if (res.error) { + setErr(res.error.message); + } else { + setNameOpen(false); + onReloadSavedFilter(savedFilter); + } + } + + function NameFilter({ onClose }) { + return ( + <MenuTooltip width={325} onClose={onClose}> + {menuItem !== 'update-filter' && ( + <form> + <Stack + direction="row" + justify="flex-end" + align="center" + style={{ padding: 10 }} + > + <FormField style={{ flex: 1 }}> + <FormLabel + title="Filter Name" + htmlFor="name-field" + style={{ userSelect: 'none' }} + /> + <GenericInput + inputRef={inputRef} + id="name-field" + field="string" + type="string" + value={name} + onChange={e => (name = e)} + /> + </FormField> + <Button + primary + type="submit" + style={{ marginTop: 18 }} + onClick={e => { + e.preventDefault(); + onAddUpdate(); + }} + > + {adding ? 'Add' : 'Update'} + </Button> + </Stack> + </form> + )} + {err && ( + <Stack direction="row" align="center" style={{ padding: 10 }}> + <Text style={{ color: colors.r4 }}>{err}</Text> + </Stack> + )} + </MenuTooltip> + ); + } + + return ( + <View> + {filters.length > 0 && ( + <Button + bare + style={{ marginTop: 10 }} + onClick={() => { + setMenuOpen(true); + }} + > + <Text + style={{ + maxWidth: 150, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + flexShrink: 0, + }} + > + {!filterId.id ? 'Unsaved filter' : filterId.name} + </Text> + {filterId.id && filterId.status !== 'saved' && ( + <Text>(modified) </Text> + )} + <ExpandArrow width={8} height={8} style={{ marginRight: 5 }} /> + </Button> + )} + {menuOpen && ( + <FilterMenu onClose={() => setMenuOpen(false)} filterId={filterId} /> + )} + {nameOpen && <NameFilter onClose={() => setNameOpen(false)} />} + </View> + ); +} + +export function CondOpMenu({ conditionsOp, onCondOpChange, filters }) { + return ( + filters.length > 1 && ( + <Text style={{ color: colors.n4, marginTop: 11, marginRight: 5 }}> + <FieldSelect + style={{ display: 'inline-flex' }} + fields={[ + ['and', 'all'], + ['or', 'any'], + ]} + value={conditionsOp} + onChange={(name, value) => onCondOpChange(value, filters)} + /> + of: + </Text> + ) + ); +} + +export function FiltersStack({ + filters, + conditionsOp, + onUpdateFilter, + onDeleteFilter, + onClearFilters, + onReloadSavedFilter, + filterId, + filtersList, + onCondOpChange, +}) { + return ( + <View> + <Stack + spacing={2} + direction="row" + justify="flex-start" + align="flex-start" + > + <AppliedFilters + filters={filters} + conditionsOp={conditionsOp} + onCondOpChange={onCondOpChange} + onUpdate={onUpdateFilter} + onDelete={onDeleteFilter} + /> + <View style={{ flex: 1 }} /> + <SavedFilterMenuButton + filters={filters} + conditionsOp={conditionsOp} + filterId={filterId} + onClearFilters={onClearFilters} + onReloadSavedFilter={onReloadSavedFilter} + filtersList={filtersList} + /> + </Stack> + </View> + ); +} diff --git a/packages/desktop-client/src/components/modals/EditRule.js b/packages/desktop-client/src/components/modals/EditRule.js index f0ce39494b8819193dd667eaf2bc458bbf7f8d77..bfb2594218b76a0ef07ee34a55cb4a9b1910dce7 100644 --- a/packages/desktop-client/src/components/modals/EditRule.js +++ b/packages/desktop-client/src/components/modals/EditRule.js @@ -78,7 +78,7 @@ function getTransactionFields(conditions, actions) { return fields; } -function FieldSelect({ fields, style, value, onChange }) { +export function FieldSelect({ fields, style, value, onChange }) { return ( <View style={style}> <CustomSelect diff --git a/packages/desktop-client/src/components/reports/CashFlow.js b/packages/desktop-client/src/components/reports/CashFlow.js index c33a373172a1cee1ef14b6a95b4755429a62c692..5c5f652c545a3e3585123e0dd3b1bbbe9f8f4a1a 100644 --- a/packages/desktop-client/src/components/reports/CashFlow.js +++ b/packages/desktop-client/src/components/reports/CashFlow.js @@ -8,7 +8,6 @@ import { integerToCurrency } from 'loot-core/src/shared/util'; import useFilters from '../../hooks/useFilters'; import { colors, styles } from '../../style'; -import { FilterButton, AppliedFilters } from '../accounts/Filters'; import { View, Text, Block, P, AlignedText } from '../common'; import Change from './Change'; @@ -20,9 +19,11 @@ import useReport from './useReport'; function CashFlow() { const { filters, + conditionsOp, onApply: onApplyFilter, onDelete: onDeleteFilter, onUpdate: onUpdateFilter, + onCondOpChange, } = useFilters(); const [allMonths, setAllMonths] = useState(null); @@ -40,8 +41,8 @@ function CashFlow() { }); const params = useMemo( - () => cashFlowByDate(start, end, isConcise, filters), - [start, end, isConcise, filters], + () => cashFlowByDate(start, end, isConcise, filters, conditionsOp), + [start, end, isConcise, filters, conditionsOp], ); const data = useReport('cash_flow', params); @@ -97,31 +98,19 @@ function CashFlow() { end={monthUtils.getMonth(end)} show1Month onChangeDates={onChangeDates} - extraButtons={<FilterButton onApply={onApplyFilter} />} + onApply={onApplyFilter} + filters={filters} + onUpdateFilter={onUpdateFilter} + onDeleteFilter={onDeleteFilter} + conditionsOp={conditionsOp} + onCondOpChange={onCondOpChange} /> <View style={{ - marginTop: -10, - paddingLeft: 20, - paddingRight: 20, backgroundColor: 'white', - }} - > - {filters.length > 0 && ( - <AppliedFilters - filters={filters} - onUpdate={onUpdateFilter} - onDelete={onDeleteFilter} - /> - )} - </View> - - <View - style={{ - backgroundColor: 'white', - paddingLeft: 30, - paddingRight: 30, + padding: 30, + paddingTop: 0, overflow: 'auto', }} > diff --git a/packages/desktop-client/src/components/reports/Header.js b/packages/desktop-client/src/components/reports/Header.js index f233f6a25404655dd1243ed0b821bf6e252553bd..3c77d5ce8c211afada82091882bdb4721f5fcde2 100644 --- a/packages/desktop-client/src/components/reports/Header.js +++ b/packages/desktop-client/src/components/reports/Header.js @@ -1,10 +1,9 @@ -import React from 'react'; - import * as monthUtils from 'loot-core/src/shared/months'; import ArrowLeft from '../../icons/v1/ArrowLeft'; import { styles } from '../../style'; import { View, Select, Button, ButtonLink } from '../common'; +import { FilterButton, AppliedFilters } from '../filters/FiltersMenu'; function validateStart(allMonths, start, end) { const earliest = allMonths[allMonths.length - 1].name; @@ -52,12 +51,17 @@ function Header({ show1Month, allMonths, onChangeDates, - extraButtons, + filters, + conditionsOp, + onApply, + onUpdateFilter, + onDeleteFilter, + onCondOpChange, }) { return ( <View style={{ - padding: 20, + padding: 10, paddingTop: 0, flexShrink: 0, }} @@ -109,7 +113,7 @@ function Header({ </Select> </div> - {extraButtons} + <FilterButton onApply={onApply} /> {show1Month && ( <Button bare onClick={() => onChangeDates(...getLatestRange(1))}> @@ -129,6 +133,23 @@ function Header({ All Time </Button> </View> + {filters.length > 0 && ( + <View + style={{ marginTop: 5 }} + spacing={2} + direction="row" + justify="flex-start" + align="flex-start" + > + <AppliedFilters + filters={filters} + onUpdate={onUpdateFilter} + onDelete={onDeleteFilter} + conditionsOp={conditionsOp} + onCondOpChange={onCondOpChange} + /> + </View> + )} </View> ); } diff --git a/packages/desktop-client/src/components/reports/NetWorth.js b/packages/desktop-client/src/components/reports/NetWorth.js index fc3f314e0af3152e4de813cafb91dda28fe39b9c..c6a7507b0559b9fbc5ec88c59899fc33e0ac26ad 100644 --- a/packages/desktop-client/src/components/reports/NetWorth.js +++ b/packages/desktop-client/src/components/reports/NetWorth.js @@ -11,7 +11,6 @@ import { integerToCurrency } from 'loot-core/src/shared/util'; import useFilters from '../../hooks/useFilters'; import { styles } from '../../style'; -import { FilterButton, AppliedFilters } from '../accounts/Filters'; import { View, P } from '../common'; import Change from './Change'; @@ -24,9 +23,12 @@ import { fromDateRepr } from './util'; function NetWorth({ accounts }) { const { filters, + saved, + conditionsOp, onApply: onApplyFilter, onDelete: onDeleteFilter, onUpdate: onUpdateFilter, + onCondOpChange, } = useFilters(); const [allMonths, setAllMonths] = useState(null); @@ -36,8 +38,8 @@ function NetWorth({ accounts }) { const [end, setEnd] = useState(monthUtils.currentMonth()); const params = useMemo( - () => netWorthSpreadsheet(start, end, accounts, filters), - [start, end, accounts, filters], + () => netWorthSpreadsheet(start, end, accounts, filters, conditionsOp), + [start, end, accounts, filters, conditionsOp], ); const data = useReport('net_worth', params); @@ -87,34 +89,31 @@ function NetWorth({ accounts }) { start={start} end={end} onChangeDates={onChangeDates} - extraButtons={<FilterButton onApply={onApplyFilter} />} + filters={filters} + saved={saved} + onApply={onApplyFilter} + onUpdateFilter={onUpdateFilter} + onDeleteFilter={onDeleteFilter} + conditionsOp={conditionsOp} + onCondOpChange={onCondOpChange} /> <View style={{ - marginTop: -10, - paddingLeft: 20, - paddingRight: 20, backgroundColor: 'white', - }} - > - {filters.length > 0 && ( - <AppliedFilters - filters={filters} - onUpdate={onUpdateFilter} - onDelete={onDeleteFilter} - /> - )} - </View> - - <View - style={{ - backgroundColor: 'white', - padding: '30px', + padding: 30, + paddingTop: 0, overflow: 'auto', }} > - <View style={{ textAlign: 'right', paddingRight: 20, flexShrink: 0 }}> + <View + style={{ + textAlign: 'right', + paddingTop: 20, + paddingRight: 20, + flexShrink: 0, + }} + > <View style={[styles.largeText, { fontWeight: 400, marginBottom: 5 }]} > diff --git a/packages/desktop-client/src/components/reports/graphs/cash-flow-spreadsheet.tsx b/packages/desktop-client/src/components/reports/graphs/cash-flow-spreadsheet.tsx index 4ac4af5745a2b31077ff9979173db482eaa2409c..b3623f6f28f0004a7f8e4d425779b83be63cdaa2 100644 --- a/packages/desktop-client/src/components/reports/graphs/cash-flow-spreadsheet.tsx +++ b/packages/desktop-client/src/components/reports/graphs/cash-flow-spreadsheet.tsx @@ -44,27 +44,37 @@ export function simpleCashFlow(start, end) { }; } -export function cashFlowByDate(start, end, isConcise, conditions = []) { +export function cashFlowByDate( + start, + end, + isConcise, + conditions = [], + conditionsOp, +) { return async (spreadsheet, setData) => { let { filters } = await send('make-filters-from-conditions', { conditions: conditions.filter(cond => !cond.customName), }); + const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; function makeQuery(where) { - let query = q('transactions').filter({ - $and: [ - ...filters, - { date: { $transform: '$month', $gte: start } }, - { date: { $transform: '$month', $lte: end } }, - ], - 'account.offbudget': false, - $or: [ - { - 'payee.transfer_acct.offbudget': true, - 'payee.transfer_acct': null, - }, - ], - }); + let query = q('transactions') + .filter({ + [conditionsOpKey]: [...filters], + }) + .filter({ + $and: [ + { date: { $transform: '$month', $gte: start } }, + { date: { $transform: '$month', $lte: end } }, + ], + 'account.offbudget': false, + $or: [ + { + 'payee.transfer_acct.offbudget': true, + 'payee.transfer_acct': null, + }, + ], + }); if (isConcise) { return query @@ -84,7 +94,7 @@ export function cashFlowByDate(start, end, isConcise, conditions = []) { [ q('transactions') .filter({ - $and: filters, + [conditionsOpKey]: filters, date: { $transform: '$month', $lt: start }, 'account.offbudget': false, }) diff --git a/packages/desktop-client/src/components/reports/graphs/net-worth-spreadsheet.tsx b/packages/desktop-client/src/components/reports/graphs/net-worth-spreadsheet.tsx index 99bf6ce1ebd07f915b39ded8313123bda5a3a957..dc61bdc3178b44ba7377ff350c628f947b725c28 100644 --- a/packages/desktop-client/src/components/reports/graphs/net-worth-spreadsheet.tsx +++ b/packages/desktop-client/src/components/reports/graphs/net-worth-spreadsheet.tsx @@ -19,6 +19,7 @@ export default function createSpreadsheet( end, accounts, conditions = [], + conditionsOp, ) { return async (spreadsheet, setData) => { if (accounts.length === 0) { @@ -28,6 +29,7 @@ export default function createSpreadsheet( let { filters } = await send('make-filters-from-conditions', { conditions: conditions.filter(cond => !cond.customName), }); + const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; const data = await Promise.all( accounts.map(async acct => { @@ -35,7 +37,7 @@ export default function createSpreadsheet( runQuery( q('transactions') .filter({ - $and: filters, + [conditionsOpKey]: filters, account: acct.id, date: { $lt: start + '-01' }, }) @@ -44,10 +46,12 @@ export default function createSpreadsheet( runQuery( q('transactions') + .filter({ + [conditionsOpKey]: [...filters], + }) .filter({ account: acct.id, $and: [ - ...filters, { date: { $gte: start + '-01' } }, { date: { $lte: end + '-31' } }, ], diff --git a/packages/desktop-client/src/components/util/GenericInput.js b/packages/desktop-client/src/components/util/GenericInput.js index 64f76de4e7e2aa32d90b0fe3762a87652eafdbe2..f5fd15985606cbdf79f2fb3d573620a370745d6a 100644 --- a/packages/desktop-client/src/components/util/GenericInput.js +++ b/packages/desktop-client/src/components/util/GenericInput.js @@ -7,6 +7,7 @@ import AccountAutocomplete from '../autocomplete/AccountAutocomplete'; import Autocomplete from '../autocomplete/Autocomplete'; import CategoryAutocomplete from '../autocomplete/CategorySelect'; import PayeeAutocomplete from '../autocomplete/PayeeAutocomplete'; +import SavedFilterAutocomplete from '../autocomplete/SavedFilterAutocomplete'; import { View, Input } from '../common'; import { Checkbox } from '../forms'; import DateSelect from '../select/DateSelect'; @@ -22,10 +23,9 @@ export default function GenericInput({ style, onChange, }) { - let { payees, accounts, categoryGroups, dateFormat } = useSelector(state => { + let { saved, categoryGroups, dateFormat } = useSelector(state => { return { - payees: state.queries.payees, - accounts: state.queries.accounts, + saved: state.queries.saved, categoryGroups: state.queries.categories.grouped, dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy', }; @@ -47,8 +47,6 @@ export default function GenericInput({ case 'payee': content = ( <PayeeAutocomplete - payees={payees} - accounts={accounts} multi={multi} showMakeTransfer={false} openOnFocus={true} @@ -65,7 +63,6 @@ export default function GenericInput({ case 'account': content = ( <AccountAutocomplete - accounts={accounts} value={value} multi={multi} openOnFocus={true} @@ -98,6 +95,28 @@ export default function GenericInput({ } break; + case 'saved': + switch (field) { + case 'saved': + content = ( + <SavedFilterAutocomplete + saved={saved} + value={value} + multi={multi} + openOnFocus={true} + onSelect={onChange} + inputProps={{ + inputRef, + ...(showPlaceholder ? { placeholder: 'nothing' } : null), + }} + /> + ); + break; + + default: + } + break; + case 'date': switch (subfield) { case 'month': diff --git a/packages/desktop-client/src/hooks/useFilters.ts b/packages/desktop-client/src/hooks/useFilters.ts index bb13ab0b35ae082727dd48b17bee4113f774aba7..617146a615b80327cbe5c9c7b8699ae4b420875c 100644 --- a/packages/desktop-client/src/hooks/useFilters.ts +++ b/packages/desktop-client/src/hooks/useFilters.ts @@ -2,10 +2,19 @@ import { useCallback, useMemo, useState } from 'react'; export default function useFilters<T>(initialFilters: T[] = []) { const [filters, setFilters] = useState<T[]>(initialFilters); + const [conditionsOp, setConditionsOp] = useState('and'); + const [saved, setSaved] = useState<T[]>(null); const onApply = useCallback( - (newFilter: T) => { - setFilters(state => [...state, newFilter]); + newFilter => { + if (newFilter.conditions) { + setFilters([...newFilter.conditions]); + setConditionsOp(newFilter.conditionsOp); + setSaved(newFilter.id); + } else { + setFilters(state => [...state, newFilter]); + setSaved(null); + } }, [setFilters], ); @@ -15,6 +24,7 @@ export default function useFilters<T>(initialFilters: T[] = []) { setFilters(state => state.map(f => (f === oldFilter ? updatedFilter : f)), ); + setSaved(null); }, [setFilters], ); @@ -22,17 +32,28 @@ export default function useFilters<T>(initialFilters: T[] = []) { const onDelete = useCallback( (deletedFilter: T) => { setFilters(state => state.filter(f => f !== deletedFilter)); + setSaved(null); }, [setFilters], ); + const onCondOpChange = useCallback( + condOp => { + setConditionsOp(condOp); + }, + [setConditionsOp], + ); + return useMemo( () => ({ filters, + saved, + conditionsOp, onApply, onUpdate, onDelete, + onCondOpChange, }), - [filters, onApply, onUpdate, onDelete], + [filters, saved, onApply, onUpdate, onDelete, onCondOpChange, conditionsOp], ); } diff --git a/packages/loot-core/migrations/1685375406832_transaction_filters.sql b/packages/loot-core/migrations/1685375406832_transaction_filters.sql new file mode 100644 index 0000000000000000000000000000000000000000..eb87f1c9f4deec8958d83fc24a879f88b14d16f9 --- /dev/null +++ b/packages/loot-core/migrations/1685375406832_transaction_filters.sql @@ -0,0 +1,10 @@ +BEGIN TRANSACTION; + +CREATE TABLE transaction_filters + (id TEXT PRIMARY KEY, + name TEXT, + conditions TEXT, + conditions_op TEXT DEFAULT 'and', + tombstone INTEGER DEFAULT 0); + +COMMIT; diff --git a/packages/loot-core/src/client/data-hooks/filters.tsx b/packages/loot-core/src/client/data-hooks/filters.tsx new file mode 100644 index 0000000000000000000000000000000000000000..875673395f1faf782e6e6cef7eb14d36c517a846 --- /dev/null +++ b/packages/loot-core/src/client/data-hooks/filters.tsx @@ -0,0 +1,22 @@ +import q from '../query-helpers'; +import { useLiveQuery } from '../query-hooks'; + +function toJS(rows) { + let filters = rows.map(row => { + return { + ...row.fields, + id: row.id, + name: row.name, + tombstone: row.tombstone, + conditionsOp: row.conditions_op, + conditions: row.conditions, + }; + }); + return filters; +} + +export function useFilters() { + return toJS( + useLiveQuery(() => q('transaction_filters').select('*'), []) || [], + ); +} diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts index fe81388007c03046c023b9668315c9eb4f96bd0a..9190bc5896e267467adbc2a919825cb4f0f3c616 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.ts +++ b/packages/loot-core/src/server/accounts/transaction-rules.ts @@ -62,20 +62,42 @@ function invert(obj) { let internalFields = schemaConfig.views.transactions.fields; let publicFields = invert(schemaConfig.views.transactions.fields); -function fromInternalField(obj) { +function fromInternalField<T extends { field: string }>(obj: T): T { return { ...obj, field: publicFields[obj.field] || obj.field, }; } -function toInternalField(obj) { +function toInternalField<T extends { field: string }>(obj: T): T { return { ...obj, field: internalFields[obj.field] || obj.field, }; } +function parseArray(str) { + let value; + try { + value = typeof str === 'string' ? JSON.parse(str) : str; + } catch (e) { + throw new RuleError('internal', 'Cannot parse rule json'); + } + + if (!Array.isArray(value)) { + throw new RuleError('internal', 'Rule json must be an array'); + } + return value; +} + +export function parseConditionsOrActions(str) { + return str ? parseArray(str).map(item => fromInternalField(item)) : []; +} + +export function serializeConditionsOrActions(arr) { + return JSON.stringify(arr.map(item => toInternalField(item))); +} + export const ruleModel = { validate(rule, { update }: { update?: boolean } = {}) { requiredFields('rules', rule, ['conditions', 'actions'], update); @@ -99,30 +121,12 @@ export const ruleModel = { }, toJS(row) { - function parseArray(str) { - let value; - try { - value = typeof str === 'string' ? JSON.parse(str) : str; - } catch (e) { - throw new RuleError('internal', 'Cannot parse rule json'); - } - - if (!Array.isArray(value)) { - throw new RuleError('internal', 'Rule json must be an array'); - } - return value; - } - let { conditions, conditions_op, actions, ...fields } = row; return { ...fields, conditionsOp: conditions_op, - conditions: conditions - ? parseArray(conditions).map(cond => fromInternalField(cond)) - : [], - actions: actions - ? parseArray(actions).map(action => fromInternalField(action)) - : [], + conditions: parseConditionsOrActions(conditions), + actions: parseConditionsOrActions(actions), }; }, @@ -132,12 +136,10 @@ export const ruleModel = { row.conditions_op = conditionsOp; } if (Array.isArray(conditions)) { - let value = conditions.map(cond => toInternalField(cond)); - row.conditions = JSON.stringify(value); + row.conditions = serializeConditionsOrActions(conditions); } if (Array.isArray(actions)) { - let value = actions.map(action => toInternalField(action)); - row.actions = JSON.stringify(value); + row.actions = serializeConditionsOrActions(actions); } return row; }, diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts index 5f4a84acf3eaef046ac1129d436741c78e1d194e..6c56ec68946cd27f9c93a82fde7c0b540b9c5f3e 100644 --- a/packages/loot-core/src/server/aql/schema/index.ts +++ b/packages/loot-core/src/server/aql/schema/index.ts @@ -114,6 +114,13 @@ export const schema = { id: f('id'), note: f('string'), }, + transaction_filters: { + id: f('id'), + name: f('string'), + conditions_op: f('string'), + conditions: f('json'), + tombstone: f('boolean'), + }, }; export const schemaConfig = { diff --git a/packages/loot-core/src/server/filters/app.ts b/packages/loot-core/src/server/filters/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..748946475c9e3dfbd957efba2fd5b8e75e380554 --- /dev/null +++ b/packages/loot-core/src/server/filters/app.ts @@ -0,0 +1,179 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { parseConditionsOrActions } from '../accounts/transaction-rules'; +import { createApp } from '../app'; +import * as db from '../db'; +import { requiredFields } from '../models'; +import { mutator } from '../mutators'; +import { undoable } from '../undo'; + +import { FiltersHandlers } from './types/handlers'; + +const filterModel = { + validate(filter, { update }: { update?: boolean } = {}) { + requiredFields('transaction_filters', filter, ['conditions'], update); + + if (!update || 'conditionsOp' in filter) { + if (!['and', 'or'].includes(filter.conditionsOp)) { + throw new Error('Invalid filter conditionsOp: ' + filter.conditionsOp); + } + } + + return filter; + }, + + toJS(row) { + let { conditions, conditions_op, ...fields } = row; + return { + ...fields, + conditionsOp: conditions_op, + conditions: parseConditionsOrActions(conditions), + }; + }, + + fromJS(filter) { + let { conditionsOp, ...row } = filter; + if (conditionsOp) { + row.conditions_op = conditionsOp; + } + return row; + }, +}; + +async function filterNameExists(name, filterId, newItem) { + let idForName = await db.first( + 'SELECT id from transaction_filters WHERE tombstone = 0 AND name = ?', + [name], + ); + + if (idForName === null) { + return false; + } + if (!newItem) { + return idForName.id !== filterId; + } + return true; +} + +//TODO: Possible to simplify this? +//use filters and maps +function conditionExists(item, filters, newItem) { + let { conditions, conditionsOp } = item; + let condCheck = []; + let fCondCheck = false; + let fCondFound; + + filters.map(filter => { + if ( + !fCondCheck && + //If conditions.length equals 1 then ignore conditionsOp + (conditions.length === 1 ? true : filter.conditionsOp === conditionsOp) && + !filter.tombstone && + filter.conditions.length === conditions.length + ) { + fCondCheck = false; + conditions.map((cond, i) => { + condCheck[i] = + filter.conditions.filter(fcond => { + return ( + cond.value === fcond.value && + cond.op === fcond.op && + cond.field === fcond.field + ); + }).length > 0; + fCondCheck = (i === 0 ? true : fCondCheck) && condCheck[i]; + return true; + }); + fCondFound = fCondCheck && condCheck[conditions.length - 1] && filter; + } + return true; + }); + + condCheck = []; + + if (!newItem) { + return fCondFound + ? fCondFound.id !== item.id + ? fCondFound.name + : false + : false; + } + return fCondFound ? fCondFound.name : false; +} + +async function createFilter(filter) { + let filterId = uuidv4(); + let item = { + id: filterId, + conditions: filter.state.conditions, + conditionsOp: filter.state.conditionsOp, + name: filter.state.name, + }; + + if (item.name) { + if (await filterNameExists(item.name, item.id, true)) { + throw new Error('There is already a filter named ' + item.name); + } + } else { + throw new Error('Filter name is required'); + } + + if (item.conditions.length > 0) { + let condExists = conditionExists(item, filter.filters, true); + if (condExists) { + throw new Error( + 'Duplicate filter warning: conditions already exist. Filter name: ' + + condExists, + ); + } + } else { + throw new Error('Conditions are required'); + } + + // Create the filter here based on the info + await db.insertWithSchema('transaction_filters', filterModel.fromJS(item)); + + return filterId; +} + +async function updateFilter(filter) { + let item = { + id: filter.state.id, + conditions: filter.state.conditions, + conditionsOp: filter.state.conditionsOp, + name: filter.state.name, + }; + if (item.name) { + if (await filterNameExists(item.name, item.id, false)) { + throw new Error('There is already a filter named ' + item.name); + } + } else { + throw new Error('Filter name is required'); + } + + if (item.conditions.length > 0) { + let condExists = conditionExists(item, filter.filters, false); + if (condExists) { + throw new Error( + 'Duplicate filter warning: conditions already exist. Filter name: ' + + condExists, + ); + } + } else { + throw new Error('Conditions are required'); + } + + await db.updateWithSchema('transaction_filters', filterModel.fromJS(item)); +} + +async function deleteFilter(id) { + await db.delete_('transaction_filters', id); +} + +let app = createApp<FiltersHandlers>(); + +app.method('filter-create', mutator(createFilter)); +app.method('filter-update', mutator(updateFilter)); +app.method('filter-delete', mutator(undoable(deleteFilter))); + +export default app; diff --git a/packages/loot-core/src/server/filters/types/handlers.d.ts b/packages/loot-core/src/server/filters/types/handlers.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..2606be1a79d00130188fb78adf540100628cc1e9 --- /dev/null +++ b/packages/loot-core/src/server/filters/types/handlers.d.ts @@ -0,0 +1,7 @@ +export interface FiltersHandlers { + 'filter-create': (filter: object) => Promise<string>; + + 'filter-update': (filter: object) => Promise<void>; + + 'filter-delete': (id: string) => Promise<void>; +} diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 7b060f7af734cbe5a9642d3e85ef6404ec3e61f3..a9eacee60d5bac0232fdbd013d26d33a2c89a709 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -43,6 +43,7 @@ import * as db from './db'; import * as mappings from './db/mappings'; import * as encryption from './encryption'; import { APIError, TransactionError, PostError, RuleError } from './errors'; +import filtersApp from './filters/app'; import app from './main-app'; import { mutator, runHandler } from './mutators'; import notesApp from './notes/app'; @@ -2309,7 +2310,7 @@ injectAPI.override((name, args) => runHandler(app.handlers[name], args)); // A hack for now until we clean up everything app.handlers = handlers; -app.combine(schedulesApp, budgetApp, notesApp, toolsApp); +app.combine(schedulesApp, budgetApp, notesApp, toolsApp, filtersApp); function getDefaultDocumentDir() { if (Platform.isMobile) { diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts index 93b2727ff9d54da234c2d0f6595faf7aa0ff9385..efcf73da0233bb1719520dc0ddee3c76209abf3b 100644 --- a/packages/loot-core/src/shared/rules.ts +++ b/packages/loot-core/src/shared/rules.ts @@ -11,6 +11,10 @@ export const TYPE_INFO = { ops: ['is', 'contains', 'oneOf'], nullable: true, }, + saved: { + ops: [], + nullable: false, + }, string: { ops: ['is', 'contains', 'oneOf'], nullable: false, @@ -37,6 +41,7 @@ export const FIELD_TYPES = new Map( category: 'id', account: 'id', cleared: 'boolean', + saved: 'saved', }), ); diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts index 81dd0f2c118379dbf529d03f8803738c59bad5dc..802e0d3bece01757fdf3f7663c64e3bd83c6386f 100644 --- a/packages/loot-core/src/types/handlers.d.ts +++ b/packages/loot-core/src/types/handlers.d.ts @@ -1,4 +1,5 @@ import type { BudgetHandlers } from '../server/budget/types/handlers'; +import type { FiltersHandlers } from '../server/filters/types/handlers'; import type { NotesHandlers } from '../server/notes/types/handlers'; import type { SchedulesHandlers } from '../server/schedules/types/handlers'; import type { ToolsHandlers } from '../server/tools/types/handlers'; @@ -10,6 +11,7 @@ export interface Handlers extends ServerHandlers, ApiHandlers, BudgetHandlers, + FiltersHandlers, NotesHandlers, SchedulesHandlers, ToolsHandlers {} diff --git a/upcoming-release-notes/1122.md b/upcoming-release-notes/1122.md new file mode 100644 index 0000000000000000000000000000000000000000..64eeb36fac548e9ab4b4fcc94194e5cd13040d79 --- /dev/null +++ b/upcoming-release-notes/1122.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [carkom] +--- + +Added ability to save/update/delete filters within accounts page.