import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
initiallyLoadPayees,
setUndoEnabled
} from 'loot-core/src/client/actions/queries';
import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
import q, { runQuery } from 'loot-core/src/client/query-helpers';
import { send } from 'loot-core/src/platform/client/fetch';
import * as monthUtils from 'loot-core/src/shared/months';
import {
mapField,
friendlyOp,
getFieldError,
parse,
unparse,
makeValue,
FIELD_TYPES,
TYPE_INFO
} from 'loot-core/src/shared/rules';
import {
integerToCurrency,
integerToAmount,
amountToInteger
} from 'loot-core/src/shared/util';
import {
View,
Text,
Modal,
Button,
Stack,
CustomSelect,
Tooltip
} from 'loot-design/src/components/common';
import useSelected, {
SelectedProvider
} from 'loot-design/src/components/useSelected';
import { colors } from 'loot-design/src/style';
import AddIcon from 'loot-design/src/svg/Add';
import SubtractIcon from 'loot-design/src/svg/Subtract';
import InformationOutline from 'loot-design/src/svg/v1/InformationOutline';
import SimpleTransactionsTable from '../accounts/SimpleTransactionsTable';
import { StatusBadge } from '../schedules/StatusBadge';
import { BetweenAmountInput } from '../util/AmountInput';
import DisplayId from '../util/DisplayId';
import GenericInput from '../util/GenericInput';
function updateValue(array, value, update) {
return array.map(v => (v === value ? update() : v));
}
function applyErrors(array, errorsArray) {
return array.map((item, i) => {
return { ...item, error: errorsArray[i] };
});
}
function getTransactionFields(conditions, actions) {
let fields = ['date'];
if (conditions.find(c => c.field === 'imported_payee')) {
fields.push('imported_payee');
}
fields.push('payee');
if (actions.find(a => a.field === 'category')) {
fields.push('category');
} else if (
actions.length > 0 &&
!['payee', 'date', 'amount'].includes(actions[0].field)
) {
fields.push(actions[0].field);
}
fields.push('amount');
return fields;
}
export function FieldSelect({ fields, style, value, onChange }) {
return (
onChange('field', value)}
style={{ color: colors.p4 }}
/>
);
}
export function OpSelect({
ops,
type,
style,
value,
formatOp = friendlyOp,
onChange
}) {
// We don't support the `contains` operator for the id type for
// rules yet
if (type === 'id') {
ops = ops.filter(op => op !== 'contains');
}
return (
[op, formatOp(op, type)])}
value={value}
onChange={value => onChange('op', value)}
style={style}
/>
);
}
function EditorButtons({ onAdd, onDelete, style }) {
return (
<>
{onDelete && (
)}
{onAdd && (
)}
>
);
}
function FieldError({ type }) {
return (
{getFieldError(type)}
);
}
function Editor({ error, style, children }) {
return (
{children}
{error && }
);
}
export function ConditionEditor({
conditionFields,
ops,
condition,
editorStyle,
onChange,
onDelete,
onAdd
}) {
let { field, op, value, type, options, error, inputKey } = condition;
if (field === 'amount' && options) {
if (options.inflow) {
field = 'amount-inflow';
} else if (options.outflow) {
field = 'amount-outflow';
}
}
let valueEditor;
if (type === 'number' && op === 'isbetween') {
valueEditor = (
onChange('value', v)}
/>
);
} else {
valueEditor = (
onChange('value', v)}
/>
);
}
return (
{valueEditor}
);
}
function ScheduleDescription({ id }) {
let dateFormat = useSelector(state => {
return state.prefs.local.dateFormat || 'MM/dd/yyyy';
});
let scheduleData = useSchedules({
transform: useCallback(q => q.filter({ id }), [])
});
if (scheduleData == null) {
return null;
}
if (scheduleData.schedules.length === 0) {
return {id};
}
let [schedule] = scheduleData.schedules;
let status = schedule && scheduleData.statuses.get(schedule.id);
return (
Payee:{' '}
—
Amount: {integerToCurrency(schedule._amount || 0)}
—
Next: {monthUtils.format(schedule.next_date, dateFormat)}
);
}
let actionFields = [
'payee',
'notes',
'date',
'amount',
'category',
'account'
].map(field => [field, mapField(field)]);
function ActionEditor({ ops, action, editorStyle, onChange, onDelete, onAdd }) {
let { field, op, value, type, error, inputKey = 'initial' } = action;
return (
{/**/}
{op === 'set' ? (
<>
{friendlyOp(op)}
onChange('value', v)}
/>
>
) : op === 'link-schedule' ? (
<>
{friendlyOp(op)}
>
) : null}
);
}
function StageInfo() {
let [open, setOpen] = useState();
return (
setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
{open && (
The stage of a rule allows you to force a specific order. Pre rules
always run first, and post rules always run last. Within each stage
rules are automatically ordered from least to most specific.
)}
);
}
function StageButton({ selected, children, style, onSelect }) {
return (
);
}
function newInput(item) {
return { ...item, inputKey: '' + Math.random() };
}
export function ConditionsList({
conditions,
conditionFields,
editorStyle,
onChangeConditions
}) {
function addCondition(index) {
let field = 'payee';
let copy = [...conditions];
copy.splice(index + 1, 0, {
type: FIELD_TYPES.get(field),
field,
op: 'is',
value: null
});
onChangeConditions(copy);
}
function addInitialCondition() {
addCondition(-1);
}
function removeCondition(cond) {
onChangeConditions(conditions.filter(c => c !== cond));
}
function updateCondition(cond, field, value) {
onChangeConditions(
updateValue(conditions, cond, () => {
if (field === 'field') {
let newCond = { field: value };
if (value === 'amount-inflow') {
newCond.field = 'amount';
newCond.options = { inflow: true };
} else if (value === 'amount-outflow') {
newCond.field = 'amount';
newCond.options = { outflow: true };
}
newCond.type = FIELD_TYPES.get(newCond.field);
let prevType = FIELD_TYPES.get(cond.field);
if (
(prevType === 'string' || prevType === 'number') &&
prevType === newCond.type &&
cond.op !== 'isbetween'
) {
// Don't clear the value & op if the type is string/number and
// the type hasn't changed
newCond.op = cond.op;
return newInput(makeValue(cond.value, newCond));
} else {
newCond.op = TYPE_INFO[newCond.type].ops[0];
return newInput(makeValue(null, newCond));
}
} else if (field === 'op') {
let op = value;
// Switching between oneOf and other operators is a
// special-case. It changes the input type, so we need to
// clear the value
if (cond.op !== 'oneOf' && op === 'oneOf') {
return newInput(
makeValue(cond.value != null ? [cond.value] : [], {
...cond,
op: value
})
);
} else if (cond.op === 'oneOf' && op !== 'oneOf') {
return newInput(
makeValue(cond.value.length > 0 ? cond.value[0] : null, {
...cond,
op: value
})
);
} else if (cond.op !== 'isbetween' && op === 'isbetween') {
// TODO: I don't think we need `makeValue` anymore. It
// tries to parse the value as a float and we had to
// special-case isbetween. I don't know why we need that
// behavior and we can probably get rid of `makeValue`
return makeValue(
{
num1: amountToInteger(cond.value),
num2: amountToInteger(cond.value)
},
{ ...cond, op: value }
);
} else if (cond.op === 'isbetween' && op !== 'isbetween') {
return makeValue(integerToAmount(cond.value.num1 || 0), {
...cond,
op: value
});
} else {
return { ...cond, op: value };
}
} else if (field === 'value') {
return makeValue(value, cond);
}
return cond;
})
);
}
return conditions.length === 0 ? (
) : (
{conditions.map((cond, i) => {
let ops = TYPE_INFO[cond.type].ops;
// Hack for now, these ops should be the only ones available
// for recurring dates
if (cond.type === 'date' && cond.value && cond.value.frequency) {
ops = ['is', 'isapprox'];
} else if (
cond.options &&
(cond.options.inflow || cond.options.outflow)
) {
ops = ops.filter(op => op !== 'isbetween');
}
return (
{
updateCondition(cond, name, value);
}}
onDelete={() => removeCondition(cond)}
onAdd={() => addCondition(i)}
/>
);
})}
);
}
// TODO:
// * Dont touch child transactions?
let conditionFields = [
'account',
'imported_payee',
'payee',
'category',
'date',
'notes',
'amount'
]
.map(field => [field, mapField(field)])
.concat([
['amount-inflow', mapField('amount', { inflow: true })],
['amount-outflow', mapField('amount', { outflow: true })]
]);
export default function EditRule({
history,
modalProps,
defaultRule,
onSave: originalOnSave
}) {
let [conditions, setConditions] = useState(defaultRule.conditions.map(parse));
let [actions, setActions] = useState(defaultRule.actions.map(parse));
let [stage, setStage] = useState(defaultRule.stage);
let [transactions, setTransactions] = useState([]);
let dispatch = useDispatch();
let scrollableEl = useRef();
useEffect(() => {
dispatch(initiallyLoadPayees());
// Disable undo while this modal is open
setUndoEnabled(false);
return () => setUndoEnabled(true);
}, []);
useEffect(() => {
// Flash the scrollbar
if (scrollableEl.current) {
let el = scrollableEl.current;
let top = el.scrollTop;
el.scrollTop = top + 1;
el.scrollTop = top;
}
// Run it here
async function run() {
let { filters } = await send('make-filters-from-conditions', {
conditions: conditions.map(unparse)
});
if (filters.length > 0) {
let { data: transactions } = await runQuery(
q('transactions')
.filter({ $and: filters })
.select('*')
);
setTransactions(transactions);
} else {
setTransactions([]);
}
}
run();
}, [actions, conditions]);
let selectedInst = useSelected('transactions', transactions, []);
function addInitialAction() {
addAction(-1);
}
function addAction(index) {
let field = 'category';
let copy = [...actions];
copy.splice(index + 1, 0, {
type: FIELD_TYPES.get(field),
field,
op: 'set',
value: null
});
setActions(copy);
}
function onChangeAction(action, field, value) {
setActions(
updateValue(actions, action, () => {
let a = { ...action };
a[field] = value;
if (field === 'field') {
a.type = FIELD_TYPES.get(a.field);
a.value = null;
return newInput(a);
} else if (field === 'op') {
a.value = null;
a.inputKey = '' + Math.random();
return newInput(a);
}
return a;
})
);
}
function onChangeStage(stage) {
setStage(stage);
}
function onRemoveAction(action) {
setActions(actions.filter(a => a !== action));
}
function onApply() {
send('rule-apply-actions', {
transactionIds: [...selectedInst.items],
actions
}).then(() => {
// This makes it refetch the transactions
setActions([...actions]);
});
}
async function onSave() {
let rule = {
...defaultRule,
stage,
conditions: conditions.map(unparse),
actions: actions.map(unparse)
};
let method = rule.id ? 'rule-update' : 'rule-add';
let { error, id: newId } = await send(method, rule);
if (error) {
if (error.conditionErrors) {
setConditions(applyErrors(conditions, error.conditionErrors));
}
if (error.actionErrors) {
setActions(applyErrors(actions, error.actionErrors));
}
} else {
// If adding a rule, we got back an id
if (newId) {
rule.id = newId;
}
originalOnSave && originalOnSave(rule);
modalProps.onClose();
}
}
let editorStyle = {
backgroundColor: colors.n10,
borderRadius: 4
};
return (
{() => (
Stage of rule:
onChangeStage('pre')}
>
Pre
onChangeStage(null)}
>
Default
onChangeStage('post')}
>
Post
If all these conditions match:
setConditions(conds)}
/>
Then apply these actions:
{actions.length === 0 ? (
) : (
{actions.map((action, i) => (
{
onChangeAction(action, name, value);
}}
onDelete={() => onRemoveAction(action)}
onAdd={() => addAction(i)}
/>
))}
)}
This rule applies to these transactions:
)}
);
}