diff --git a/packages/desktop-client/src/components/ManageRules.js b/packages/desktop-client/src/components/ManageRules.js index 9df30b76da6fc3fa8e6009a4765e0118b30df418..76ecda8353c3438e0d4928c1d3842692af582da7 100644 --- a/packages/desktop-client/src/components/ManageRules.js +++ b/packages/desktop-client/src/components/ManageRules.js @@ -1,4 +1,10 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { + useState, + useEffect, + useRef, + useCallback, + useMemo +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { format as formatDate, parseISO } from 'date-fns'; @@ -20,7 +26,8 @@ import { Text, Button, Stack, - ExternalLink + ExternalLink, + Input } from 'loot-design/src/components/common'; import { SelectCell, @@ -222,6 +229,14 @@ export function ConditionExpression({ ); } +function describeSchedule(schedule, payee) { + if (payee) { + return `${payee.name} (${schedule.next_date})`; + } else { + return `Next: ${schedule.next_date}`; + } +} + function ScheduleValue({ value }) { let payees = useSelector(state => state.queries.payees); let byId = getPayeesById(payees); @@ -232,12 +247,7 @@ function ScheduleValue({ value }) { value={value} field="rule" data={schedules} - describe={s => { - let payeeId = s._payee; - return byId[payeeId] - ? `${byId[payeeId].name} (${s.next_date})` - : `Next: ${s.next_date}`; - }} + describe={schedule => describeSchedule(schedule, byId[schedule._payee])} /> ); } @@ -495,15 +505,80 @@ function RulesList({ ); } -export default function ManageRules({ - isModal, - payeeId, - setLoading = () => {} -}) { +function mapValue(field, value, { payees, categories, accounts }) { + if (!value) return ''; + + if (field === 'payee') { + return payees.find(p => p.id === value).name; + } else if (field === 'category') { + return categories.find(c => c.id === value).name; + } else if (field === 'account') { + return accounts.find(a => a.id === value).name; + } else { + return value; + } +} + +function ruleToString(rule, data) { + let conditions = rule.conditions.flatMap(cond => [ + mapField(cond.field), + friendlyOp(cond.op), + cond.op === 'oneOf' + ? cond.value.map(v => mapValue(cond.field, v, data)).join(', ') + : mapValue(cond.field, cond.value, data) + ]); + let actions = rule.actions.flatMap(action => { + if (action.op === 'set') { + return [ + friendlyOp(action.op), + mapField(action.field), + 'to', + mapValue(action.field, action.value, data) + ]; + } else if (action.op === 'link-schedule') { + let schedule = data.schedules.find(s => s.id === action.value); + return [ + friendlyOp(action.op), + describeSchedule( + schedule, + data.payees.find(p => p.id === schedule._payee) + ) + ]; + } else { + return []; + } + }); + return ( + (rule.stage || '') + ' ' + conditions.join(' ') + ' ' + actions.join(' ') + ); +} + +function ManageRulesContent({ isModal, payeeId, setLoading }) { let [allRules, setAllRules] = useState(null); let [rules, setRules] = useState(null); + let [filter, setFilter] = useState(''); let dispatch = useDispatch(); let navigator = useTableNavigator(rules, ['select', 'edit']); + + let { data: schedules } = SchedulesQuery.useQuery(); + let filterData = useSelector(state => ({ + payees: state.queries.payees, + categories: state.queries.categories.list, + accounts: state.queries.accounts, + schedules + })); + + let filteredRules = useMemo( + () => + filter === '' || !rules + ? rules + : rules.filter(rule => + ruleToString(rule, filterData) + .toLowerCase() + .includes(filter.toLowerCase()) + ), + [rules, filter, filterData] + ); let selectedInst = useSelected('manage-rules', allRules, []); let [hoveredRule, setHoveredRule] = useState(null); let tableRef = useRef(null); @@ -635,78 +710,113 @@ export default function ManageRules({ return null; } - let actions = ( - <View - style={{ - flexDirection: 'row', - alignItems: 'center', - padding: isModal ? '13px 15px' : '0 0 15px', - borderTop: '1px solid ' + colors.border - }} - > - <View - style={{ - color: colors.n4, - flexDirection: 'row', - alignItems: 'center', - width: '50%' - }} - > - <Text> - Rules are always run in the order that you see them.{' '} - <ExternalLink - asAnchor={true} - href="https://actualbudget.github.io/docs/Budgeting/rules/" - style={{ color: colors.n4 }} + return ( + <SelectedProvider instance={selectedInst}> + <View style={{ overflow: 'hidden' }}> + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + padding: isModal ? '0 13px 15px' : '0 0 15px', + flexShrink: 0 + }} + > + <View + style={{ + color: colors.n4, + flexDirection: 'row', + alignItems: 'center', + width: '50%' + }} > - Learn more - </ExternalLink> - </Text> + <Text> + Rules are always run in the order that you see them.{' '} + <ExternalLink + asAnchor={true} + href="https://actualbudget.github.io/docs/Budgeting/rules/" + style={{ color: colors.n4 }} + > + Learn more + </ExternalLink> + </Text> + </View> + <View style={{ flex: 1 }} /> + <Input + placeholder="Filter rules..." + value={filter} + onChange={e => { + setFilter(e.target.value); + navigator.onEdit(null); + }} + style={{ + width: 350, + borderColor: isModal ? null : 'transparent', + backgroundColor: isModal ? null : colors.n11, + ':focus': isModal + ? null + : { + backgroundColor: 'white', + '::placeholder': { color: colors.n8 } + } + }} + /> + </View> + <View style={{ flex: 1 }}> + <RulesHeader /> + <SimpleTable + ref={tableRef} + data={filteredRules} + navigator={navigator} + loadMore={loadMore} + // Hide the last border of the item in the table + style={{ marginBottom: -1 }} + > + <RulesList + rules={filteredRules} + selectedItems={selectedInst.items} + navigator={navigator} + hoveredRule={hoveredRule} + onHover={onHover} + onEditRule={onEditRule} + /> + </SimpleTable> + </View> + <View + style={{ + paddingBlock: 15, + paddingInline: isModal ? 13 : 0, + borderTop: isModal && '1px solid ' + colors.border, + flexShrink: 0 + }} + > + <Stack direction="row" align="center" justify="flex-end" spacing={2}> + {selectedInst.items.size > 0 && ( + <Button onClick={onDeleteSelected}> + Delete {selectedInst.items.size} rules + </Button> + )} + <Button primary onClick={onCreateRule}> + Create new rule + </Button> + </Stack> + </View> </View> - - <View style={{ flex: 1 }} /> - - <Stack direction="row" align="center" justify="flex-end" spacing={2}> - {selectedInst.items.size > 0 && ( - <Button onClick={onDeleteSelected}> - Delete {selectedInst.items.size} rules - </Button> - )} - <Button primary onClick={onCreateRule}> - Create new rule - </Button> - </Stack> - </View> + </SelectedProvider> ); +} +export default function ManageRules({ + isModal, + payeeId, + setLoading = () => {} +}) { return ( <SchedulesQuery.Provider> - <SelectedProvider instance={selectedInst}> - <View style={{ overflow: 'hidden' }}> - {!isModal && actions} - <View style={{ flex: 1 }}> - <RulesHeader /> - <SimpleTable - ref={tableRef} - data={rules} - navigator={navigator} - loadMore={loadMore} - // Hide the last border of the item in the table - style={{ marginBottom: -1 }} - > - <RulesList - rules={rules} - selectedItems={selectedInst.items} - navigator={navigator} - hoveredRule={hoveredRule} - onHover={onHover} - onEditRule={onEditRule} - /> - </SimpleTable> - </View> - {isModal && actions} - </View> - </SelectedProvider> + <ManageRulesContent + isModal={isModal} + payeeId={payeeId} + setLoading={setLoading} + /> </SchedulesQuery.Provider> ); }