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: )} ); }