-
Matiss Janis Aboltins authoredMatiss Janis Aboltins authored
ManageRules.tsx 8.90 KiB
// @ts-strict-ignore
import React, {
useState,
useEffect,
useCallback,
useMemo,
type SetStateAction,
type Dispatch,
} 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 { type RuleEntity } from 'loot-core/src/types/models';
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) {
const 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),
]);
const 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') {
const 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(' ')
);
}
type ManageRulesContentProps = {
isModal: boolean;
payeeId: string | null;
setLoading?: Dispatch<SetStateAction<boolean>>;
};
function ManageRulesContent({
isModal,
payeeId,
setLoading,
}: ManageRulesContentProps) {
const [allRules, setAllRules] = useState([]);
const [page, setPage] = useState(0);
const [filter, setFilter] = useState('');
const dispatch = useDispatch();
const { data: schedules } = SchedulesQuery.useQuery();
const { list: categories } = useCategories();
const state = useSelector(state => ({
payees: state.queries.payees,
accounts: state.queries.accounts,
schedules,
}));
const filterData = useMemo(
() => ({
...state,
categories,
}),
[state, categories],
);
const filteredRules = useMemo(
() =>
(filter === ''
? allRules
: allRules.filter(rule =>
ruleToString(rule, filterData)
.toLowerCase()
.includes(filter.toLowerCase()),
)
).slice(0, 100 + page * 50),
[allRules, filter, filterData, page],
);
const selectedInst = useSelected('manage-rules', allRules, []);
const [hoveredRule, setHoveredRule] = useState(null);
const onSearchChange = useCallback(
(value: string) => {
setFilter(value);
setPage(0);
},
[setFilter],
);
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() {
await loadRules();
setLoading(false);
await dispatch(initiallyLoadPayees());
}
if (payeeId) {
undo.setUndoState('openModal', 'manage-rules');
}
loadData();
return () => {
undo.setUndoState('openModal', null);
};
}, []);
function loadMore() {
setPage(page => page + 1);
}
async function onDeleteSelected() {
setLoading(true);
const { someDeletionsFailed } = await send('rule-delete-all', [
...selectedInst.items,
]);
if (someDeletionsFailed) {
alert('Some rules were not deleted because they are linked to schedules');
}
await loadRules();
selectedInst.dispatch({ type: 'select-none' });
setLoading(false);
}
const onEditRule = useCallback(rule => {
dispatch(
pushModal('edit-rule', {
rule,
onSave: async () => {
await loadRules();
setLoading(false);
},
}),
);
}, []);
function onCreateRule() {
const rule: RuleEntity = {
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 () => {
await loadRules();
setLoading(false);
},
}),
);
}
const onHover = useCallback(id => {
setHoveredRule(id);
}, []);
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={onSearchChange}
/>
</View>
<View style={{ flex: 1 }}>
<RulesHeader />
<SimpleTable
loadMore={loadMore}
// Hide the last border of the item in the table
style={{ marginBottom: -1 }}
>
{filteredRules.length === 0 ? (
<EmptyMessage text="No rules" style={{ marginTop: 15 }} />
) : (
<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>
);
}
function EmptyMessage({ text, style }) {
return (
<View
style={{
textAlign: 'center',
color: theme.pageTextSubdued,
fontStyle: 'italic',
fontSize: 13,
marginTop: 5,
style,
}}
>
{text}
</View>
);
}
type ManageRulesProps = {
isModal: boolean;
payeeId: string | null;
setLoading?: Dispatch<SetStateAction<boolean>>;
};
export function ManageRules({
isModal,
payeeId,
setLoading = () => {},
}: ManageRulesProps) {
return (
<SchedulesQuery.Provider>
<ManageRulesContent
isModal={isModal}
payeeId={payeeId}
setLoading={setLoading}
/>
</SchedulesQuery.Provider>
);
}