import React, { useState, useEffect, useRef, useCallback } 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 { extractScheduleConds, getRecurringDescription } from 'loot-core/src/shared/schedules'; import { integerToCurrency } from 'loot-core/src/shared/util'; import { View, Text, Modal, Button, Stack, ExternalLink } from 'loot-design/src/components/common'; import { SelectCell, Row, Field, Cell, CellButton, TableHeader, useTableNavigator } from 'loot-design/src/components/table'; import useSelected, { useSelectedDispatch, useSelectedItems, SelectedProvider } from 'loot-design/src/components/useSelected'; import { colors } from 'loot-design/src/style'; import ArrowRight from 'loot-design/src/svg/RightArrow2'; let SchedulesQuery = liveQueryContext(q('schedules').select('*')); export function Value({ value, field, 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 { if (field === 'amount') { return integerToCurrency(value); } else if (field === 'date') { if (value) { if (value.frequency) { return getRecurringDescription(value); } return formatDate(parseISO(value), dateFormat); } return null; } else if (field === 'month') { return value ? formatDate(parseISO(value), getMonthYearFormat(dateFormat)) : null; } else if (field === 'year') { return value ? formatDate(parseISO(value), 'yyyy') : null; } else { let name = value; if (data) { let item = data.find(item => item.id === value); if (item) { name = describe(item); } } return name; } } } if (Array.isArray(value)) { if (value.length === 0) { return (empty); } else if (value.length === 1) { return ( [{formatValue(value[0])}] ); } let displayed = value; if (!expanded && value.length > 4) { displayed = value.slice(0, 3); } let numHidden = value.length - displayed.length; return ( [ {displayed.map((v, i) => { let text = {formatValue(v)}; let spacing; if (inline) { spacing = i !== 0 ? ' ' : ''; } else { spacing = ( <> {i === 0 &&
}    ); } return ( {spacing} {text} {i === value.length - 1 ? '' : ','} {!inline &&
}
); })} {// prettier-ignore numHidden > 0 && (    { // eslint-disable-next-line } {numHidden} more items... {!inline &&
}
)} ]
); } else if (value && value.num1 != null && value.num2 != null) { // An "in between" type return ( {formatValue(value.num1)} and{' '} {formatValue(value.num2)} ); } else { return {formatValue(value)}; } } export function ConditionExpression({ field, op, value, options, stage, style }) { return ( {mapField(field, options)}{' '} {friendlyOp(op)}{' '} ); } function ScheduleValue({ value }) { let payees = useSelector(state => state.queries.payees); let byId = getPayeesById(payees); let { data: schedules } = SchedulesQuery.useQuery(); return ( { let { payee } = extractScheduleConds(s._conditions); return payee ? `${byId[payee.value].name} (${s.next_date})` : `Next: ${s.next_date}`; }} /> ); } export function ActionExpression({ field, op, value, options, style }) { return ( {op === 'set' ? ( <> {friendlyOp(op)}{' '} {mapField(field, options)}{' '} to ) : op === 'link-schedule' ? ( <> {friendlyOp(op)}{' '} ) : null} ); } let Rule = React.memo( ({ rule, hovered, selected, editing, focusedField, onHover, onEdit, onEditRule }) => { let dispatch = useDispatch(); let dispatchSelected = useSelectedDispatch(); let borderColor = selected ? colors.b8 : colors.border; let backgroundFocus = hovered || focusedField === 'select'; return ( onHover && onHover(rule.id)} onMouseLeave={() => onHover && onHover(null)} > { dispatchSelected({ type: 'select', id: rule.id }); }} onEdit={() => onEdit(rule.id, 'select')} selected={selected} /> {rule.stage && ( {rule.stage} )} {rule.conditions.map((cond, i) => ( ))} {rule.actions.map((action, i) => ( ))} ); } ); let SimpleTable = React.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 (
{children}
); } ); function RulesHeader() { let selectedItems = useSelectedItems(); let dispatchSelected = useSelectedDispatch(); return ( 0} onSelect={() => dispatchSelected({ type: 'select-all' })} /> ); } function RulesList({ rules, selectedItems, navigator, hoveredRule, collapsed: borderCollapsed, onHover, onCollapse, onEditRule }) { if (rules.length === 0) { return null; } return ( {rules.map(rule => { let hovered = hoveredRule === rule.id; let selected = selectedItems.has(rule.id); let editing = navigator.editingId === rule.id; return ( ); })} ); } export default function ManageRules({ history, modalProps, payeeId }) { let [allRules, setAllRules] = useState(null); let [rules, setRules] = useState(null); let dispatch = useDispatch(); let navigator = useTableNavigator(rules, ['select', 'edit']); let selectedInst = useSelected('manage-rules', allRules, []); let [hoveredRule, setHoveredRule] = useState(null); let [loading, setLoading] = useState(true); 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); let oldIdx = rules.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() { dispatch( pushModal('edit-rule', { rule: { stage: null, conditions: [{ op: 'is', field: 'payee', value: null, type: 'id' }], actions: [{ op: 'set', field: 'category', value: null, type: 'id' }] }, 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 ( {() => ( Rules are always run in the order that you see them.{' '} Learn more {selectedInst.items.size > 0 && ( )} )} ); }