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 && (
)}
)}
);
}