-
Matiss Janis Aboltins authoredMatiss Janis Aboltins authored
ManageRules.js 8.62 KiB
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { pushModal } from 'loot-core/src/client/actions/modals';
import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries';
import { send } from 'loot-core/src/platform/client/fetch';
import * as undo from 'loot-core/src/platform/client/undo';
import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
import { describeSchedule } from 'loot-core/src/shared/schedules';
import useCategories from '../hooks/useCategories';
import useSelected, { SelectedProvider } from '../hooks/useSelected';
import { theme } from '../style';
import Button from './common/Button';
import ExternalLink from './common/ExternalLink';
import Search from './common/Search';
import Stack from './common/Stack';
import Text from './common/Text';
import View from './common/View';
import RulesHeader from './rules/RulesHeader';
import RulesList from './rules/RulesList';
import { SchedulesQuery } from './rules/SchedulesQuery';
import SimpleTable from './rules/SimpleTable';
function mapValue(field, value, { payees, categories, accounts }) {
if (!value) return '';
let object = null;
if (field === 'payee') {
object = payees.find(p => p.id === value);
} else if (field === 'category') {
object = categories.find(c => c.id === value);
} else if (field === 'account') {
object = accounts.find(a => a.id === value);
} else {
return value;
}
if (object) {
return object.name;
}
return '(deleted)';
}
function ruleToString(rule, data) {
let conditions = rule.conditions.flatMap(cond => [
mapField(cond.field),
friendlyOp(cond.op),
cond.op === 'oneOf' || cond.op === 'notOneOf'
? 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 { data: schedules } = SchedulesQuery.useQuery();
let { list: categories } = useCategories();
let state = useSelector(state => ({
payees: state.queries.payees,
accounts: state.queries.accounts,
schedules,
}));
let filterData = useMemo(
() => ({
...state,
categories,
}),
[state, categories],
);
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);
async function loadRules() {
setLoading(true);
let loadedRules = null;
if (payeeId) {
loadedRules = await send('payees-get-rules', {
id: payeeId,
});
} else {
loadedRules = await send('rules-get');
}
setAllRules(loadedRules);
return loadedRules;
}
useEffect(() => {
async function loadData() {
let loadedRules = await loadRules();
setRules(loadedRules.slice(0, 100));
setLoading(false);
await dispatch(initiallyLoadPayees());
}
undo.setUndoState('openModal', 'manage-rules');
loadData();
return () => {
undo.setUndoState('openModal', null);
};
}, []);
function loadMore() {
setRules(rules.concat(allRules.slice(rules.length, rules.length + 50)));
}
async function onDeleteSelected() {
setLoading(true);
let { someDeletionsFailed } = await send('rule-delete-all', [
...selectedInst.items,
]);
if (someDeletionsFailed) {
alert('Some rules were not deleted because they are linked to schedules');
}
let newRules = await loadRules();
setRules(rules => {
return newRules.slice(0, rules.length);
});
selectedInst.dispatch({ type: 'select-none' });
setLoading(false);
}
let onEditRule = useCallback(rule => {
dispatch(
pushModal('edit-rule', {
rule,
onSave: async newRule => {
let newRules = await loadRules();
setRules(rules => {
let newIdx = newRules.findIndex(rule => rule.id === newRule.id);
if (newIdx > rules.length) {
return newRules.slice(0, newIdx + 75);
} else {
return newRules.slice(0, rules.length);
}
});
setLoading(false);
},
}),
);
}, []);
function onCreateRule() {
let rule = {
stage: null,
conditionsOp: 'and',
conditions: [
{
field: 'payee',
op: 'is',
value: payeeId || null,
type: 'id',
},
],
actions: [
{
op: 'set',
field: 'category',
value: null,
type: 'id',
},
],
};
dispatch(
pushModal('edit-rule', {
rule,
onSave: async newRule => {
let newRules = await loadRules();
setRules(rules => {
let newIdx = newRules.findIndex(rule => rule.id === newRule.id);
return newRules.slice(0, newIdx + 75);
});
setLoading(false);
},
}),
);
}
let onHover = useCallback(id => {
setHoveredRule(id);
}, []);
if (rules === null) {
return null;
}
return (
<SelectedProvider instance={selectedInst}>
<View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
padding: isModal ? '0 13px 15px' : '0 0 15px',
flexShrink: 0,
}}
>
<View
style={{
color: theme.pageTextLight,
flexDirection: 'row',
alignItems: 'center',
width: '50%',
}}
>
<Text>
Rules are always run in the order that you see them.{' '}
<ExternalLink
to="https://actualbudget.org/docs/budgeting/rules/"
linkColor="muted"
>
Learn more
</ExternalLink>
</Text>
</View>
<View style={{ flex: 1 }} />
<Search
placeholder="Filter rules..."
value={filter}
onChange={setFilter}
/>
</View>
<View style={{ flex: 1 }}>
<RulesHeader />
<SimpleTable
ref={tableRef}
data={filteredRules}
loadMore={loadMore}
// Hide the last border of the item in the table
style={{ marginBottom: -1 }}
>
<RulesList
rules={filteredRules}
selectedItems={selectedInst.items}
hoveredRule={hoveredRule}
onHover={onHover}
onEditRule={onEditRule}
/>
</SimpleTable>
</View>
<View
style={{
paddingBlock: 15,
paddingInline: isModal ? 13 : 0,
borderTop: isModal && '1px solid ' + theme.pillBorder,
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 type="primary" onClick={onCreateRule}>
Create new rule
</Button>
</Stack>
</View>
</View>
</SelectedProvider>
);
}
export default function ManageRules({
isModal,
payeeId,
setLoading = () => {},
}) {
return (
<SchedulesQuery.Provider>
<ManageRulesContent
isModal={isModal}
payeeId={payeeId}
setLoading={setLoading}
/>
</SchedulesQuery.Provider>
);
}