ManageRules.js 21.49 KiB
import React, {
forwardRef,
memo,
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { format as formatDate, parseISO } from 'date-fns';
import { css } from 'glamor';
import { pushModal } from 'loot-core/src/client/actions/modals';
import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries';
import q from 'loot-core/src/client/query-helpers';
import { liveQueryContext } from 'loot-core/src/client/query-hooks';
import { getPayeesById } from 'loot-core/src/client/reducers/queries';
import { send } from 'loot-core/src/platform/client/fetch';
import * as undo from 'loot-core/src/platform/client/undo';
import { getMonthYearFormat } from 'loot-core/src/shared/months';
import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
import { getRecurringDescription } from 'loot-core/src/shared/schedules';
import { integerToCurrency } from 'loot-core/src/shared/util';
import useSelected, {
useSelectedDispatch,
useSelectedItems,
SelectedProvider,
} from '../hooks/useSelected';
import ArrowRight from '../icons/v0/RightArrow2';
import { colors } from '../style';
import { View, Text, Button, Stack, ExternalLink, Input } from './common';
import {
SelectCell,
Row,
Field,
Cell,
CellButton,
TableHeader,
useTableNavigator,
} from './table';
let SchedulesQuery = liveQueryContext(q('schedules').select('*'));
export function Value({
value,
field,
valueIsRaw,
inline = false,
data: dataProp,
describe = x => x.name,
}) {
let { data, dateFormat } = useSelector(state => {
let data;
if (dataProp) {
data = dataProp;
} else {
switch (field) {
case 'payee':
data = state.queries.payees;
break;
case 'category':
data = state.queries.categories.list;
break;
case 'account':
data = state.queries.accounts;
break;
default:
data = [];
}
}
return {
data,
dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
};
});
let [expanded, setExpanded] = useState(false);
function onExpand(e) {
e.preventDefault();
setExpanded(true);
}
function formatValue(value) {
if (value == null || value === '') {
return '(nothing)';
} else if (typeof value === 'boolean') {
return value ? 'true' : 'false';
} else {
switch (field) {
case 'amount':
return integerToCurrency(value);
case 'date':
if (value) {
if (value.frequency) {
return getRecurringDescription(value);
}
return formatDate(parseISO(value), dateFormat);
}
return null;
case 'month':
return value
? formatDate(parseISO(value), getMonthYearFormat(dateFormat))
: null;
case 'year':
return value ? formatDate(parseISO(value), 'yyyy') : null;
case 'notes':
case 'imported_payee':
return value;
case 'payee':
case 'category':
case 'account':
case 'rule':
if (valueIsRaw) {
return value;
}
if (data && data.length) {
let item = data.find(item => item.id === value);
if (item) {
return describe(item);
} else {
return '(deleted)';
}
}
return '…';
default:
throw new Error(`Unknown field ${field}`);
}
}
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <Text style={{ color: colors.p4 }}>(empty)</Text>;
} else if (value.length === 1) {
return (
<Text>
[<Text style={{ color: colors.p4 }}>{formatValue(value[0])}</Text>]
</Text>
);
}
let displayed = value;
if (!expanded && value.length > 4) {
displayed = value.slice(0, 3);
}
let numHidden = value.length - displayed.length;
return (
<Text style={{ color: colors.n3 }}>
[
{displayed.map((v, i) => {
let text = <Text style={{ color: colors.p4 }}>{formatValue(v)}</Text>;
let spacing;
if (inline) {
spacing = i !== 0 ? ' ' : '';
} else {
spacing = (
<>
{i === 0 && <br />}
</>
);
}
return (
<Text key={i}>
{spacing}
{text}
{i === value.length - 1 ? '' : ','}
{!inline && <br />}
</Text>
);
})}
{numHidden > 0 && (
<Text style={{ color: colors.p4 }}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
href="#"
onClick={onExpand}
{...css({
color: colors.p4,
textDecoration: 'none',
':hover': { textDecoration: 'underline' },
})}
>
{numHidden} more items...
</a>
{!inline && <br />}
</Text>
)}
]
</Text>
);
} else if (value && value.num1 != null && value.num2 != null) {
// An "in between" type
return (
<Text>
<Text style={{ color: colors.p4 }}>{formatValue(value.num1)}</Text> and{' '}
<Text style={{ color: colors.p4 }}>{formatValue(value.num2)}</Text>
</Text>
);
} else {
return <Text style={{ color: colors.p4 }}>{formatValue(value)}</Text>;
}
}
export function ConditionExpression({
field,
op,
value,
options,
prefix,
style,
}) {
return (
<View
style={[
{
display: 'block',
maxWidth: '100%',
backgroundColor: colors.n10,
borderRadius: 4,
padding: '3px 5px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
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} />
</View>
);
}
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);
let { data: schedules } = SchedulesQuery.useQuery();
return (
<Value
value={value}
field="rule"
data={schedules}
describe={schedule => describeSchedule(schedule, byId[schedule._payee])}
/>
);
}
export function ActionExpression({ field, op, value, options, style }) {
return (
<View
style={[
{
display: 'block',
maxWidth: '100%',
backgroundColor: colors.n10,
borderRadius: 4,
padding: '3px 5px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
style,
]}
>
{op === 'set' ? (
<>
<Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '}
<Text style={{ color: colors.p4 }}>{mapField(field, options)}</Text>{' '}
<Text style={{ color: colors.n3 }}>to </Text>
<Value value={value} field={field} />
</>
) : op === 'link-schedule' ? (
<>
<Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '}
<ScheduleValue value={value} />
</>
) : null}
</View>
);
}
let Rule = memo(
({
rule,
hovered,
selected,
editing,
focusedField,
onHover,
onEdit,
onEditRule,
}) => {
let dispatchSelected = useSelectedDispatch();
let borderColor = selected ? colors.b8 : colors.border;
let backgroundFocus = hovered || focusedField === 'select';
return (
<Row
height="auto"
borderColor={borderColor}
backgroundColor={
selected ? colors.selected : backgroundFocus ? colors.hover : 'white'
}
style={{ fontSize: 13, zIndex: editing || selected ? 101 : 'auto' }}
collapsed="true"
onMouseEnter={() => onHover && onHover(rule.id)}
onMouseLeave={() => onHover && onHover(null)}
>
<SelectCell
exposed={hovered || selected || editing}
focused={focusedField === 'select'}
onSelect={e => {
dispatchSelected({ type: 'select', id: rule.id, event: e });
}}
onEdit={() => onEdit(rule.id, 'select')}
selected={selected}
/>
<Cell name="stage" width={50} plain style={{ color: colors.n5 }}>
{rule.stage && (
<View
style={{
alignSelf: 'flex-start',
margin: 5,
backgroundColor: colors.b10,
color: colors.b1,
borderRadius: 4,
padding: '3px 5px',
}}
>
{rule.stage}
</View>
)}
</Cell>
<Field width="flex" style={{ padding: '15px 0' }} truncate={false}>
<Stack direction="row" align="center">
<View
style={{ flex: 1, alignItems: 'flex-start' }}
data-testid="conditions"
>
{rule.conditions.map((cond, i) => (
<ConditionExpression
key={i}
field={cond.field}
op={cond.op}
value={cond.value}
options={cond.options}
prefix={i > 0 ? friendlyOp(rule.conditionsOp) : null}
style={i !== 0 && { marginTop: 3 }}
/>
))}
</View>
<Text>
<ArrowRight color={colors.n4} style={{ width: 12, height: 12 }} />
</Text>
<View
style={{ flex: 1, alignItems: 'flex-start' }}
data-testid="actions"
>
{rule.actions.map((action, i) => (
<ActionExpression
key={i}
field={action.field}
op={action.op}
value={action.value}
options={action.options}
style={i !== 0 && { marginTop: 3 }}
/>
))}
</View>
</Stack>
</Field>
<Cell
name="edit"
focused={focusedField === 'edit'}
plain
style={{ padding: '0 15px', paddingLeft: 5 }}
>
<Button
as={CellButton}
onSelect={() => onEditRule(rule)}
onEdit={() => onEdit(rule.id, 'edit')}
>
Edit
</Button>
</Cell>
</Row>
);
},
);
let SimpleTable = forwardRef(
(
{ data, navigator, loadMore, style, onHoverLeave, children, ...props },
ref,
) => {
let contentRef = useRef();
let contentHeight = useRef();
let scrollRef = useRef();
let { getNavigatorProps } = navigator;
function onScroll(e) {
if (contentHeight.current != null) {
if (loadMore && e.target.scrollTop > contentHeight.current - 750) {
loadMore();
}
}
}
useEffect(() => {
if (contentRef.current) {
contentHeight.current =
contentRef.current.getBoundingClientRect().height;
} else {
contentHeight.current = null;
}
}, [contentRef.current, data]);
return (
<View
style={[
{
flex: 1,
outline: 'none',
'& .animated .animated-row': { transition: '.25s transform' },
},
style,
]}
tabIndex="1"
data-testid="table"
{...getNavigatorProps(props)}
>
<View
innerRef={scrollRef}
style={{ maxWidth: '100%', overflow: 'auto' }}
onScroll={onScroll}
>
<div ref={contentRef} onMouseLeave={onHoverLeave}>
{children}
</div>
</View>
</View>
);
},
);
function RulesHeader() {
let selectedItems = useSelectedItems();
let dispatchSelected = useSelectedDispatch();
return (
<TableHeader version="v2" style={{}}>
<SelectCell
exposed={true}
focused={false}
selected={selectedItems.size > 0}
onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
/>
<Cell value="Stage" width={50} />
<Cell value="Rule" width="flex" />
</TableHeader>
);
}
function RulesList({
rules,
selectedItems,
navigator,
hoveredRule,
collapsed: borderCollapsed,
onHover,
onCollapse,
onEditRule,
}) {
if (rules.length === 0) {
return null;
}
return (
<View>
{rules.map(rule => {
let hovered = hoveredRule === rule.id;
let selected = selectedItems.has(rule.id);
let editing = navigator.editingId === rule.id;
return (
<Rule
key={rule.id}
rule={rule}
hovered={hovered}
selected={selected}
editing={editing}
focusedField={editing && navigator.focusedField}
onHover={onHover}
onEdit={navigator.onEdit}
onEditRule={onEditRule}
/>
);
})}
</View>
);
}
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.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);
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();
navigator.onEdit(newRule.id, 'edit');
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 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%',
}}
>
<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>
</SelectedProvider>
);
}
export default function ManageRules({
isModal,
payeeId,
setLoading = () => {},
}) {
return (
<SchedulesQuery.Provider>
<ManageRulesContent
isModal={isModal}
payeeId={payeeId}
setLoading={setLoading}
/>
</SchedulesQuery.Provider>
);
}