9c0df36e16
* style: enforce sorting of imports * style: alphabetize imports * style: merge duplicated imports
578 lines
14 KiB
JavaScript
578 lines
14 KiB
JavaScript
import React, {
|
|
useState,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useMemo,
|
|
useCallback,
|
|
useImperativeHandle
|
|
} from 'react';
|
|
|
|
import Component from '@reactions/component';
|
|
import memoizeOne from 'memoize-one';
|
|
|
|
import { groupById } from 'loot-core/src/shared/util';
|
|
|
|
import { colors } from '../style';
|
|
import Delete from '../svg/Delete';
|
|
import ExpandArrow from '../svg/ExpandArrow';
|
|
import Merge from '../svg/merge';
|
|
import ArrowThinRight from '../svg/v1/ArrowThinRight';
|
|
import {
|
|
useStableCallback,
|
|
View,
|
|
Text,
|
|
Modal,
|
|
Input,
|
|
Button,
|
|
Tooltip,
|
|
Menu
|
|
} from './common';
|
|
import {
|
|
Table,
|
|
Row,
|
|
Cell,
|
|
InputCell,
|
|
SelectCell,
|
|
CellButton,
|
|
useTableNavigator
|
|
} from './table';
|
|
import useSelected, {
|
|
SelectedProvider,
|
|
useSelectedItems,
|
|
useSelectedDispatch
|
|
} from './useSelected';
|
|
|
|
let getPayeesById = memoizeOne(payees => groupById(payees));
|
|
|
|
function plural(count, singleText, pluralText) {
|
|
return count === 1 ? singleText : pluralText;
|
|
}
|
|
|
|
function RuleButton({ ruleCount, focused, onEdit, onClick }) {
|
|
return (
|
|
<Cell
|
|
name="rule-count"
|
|
width="auto"
|
|
focused={focused}
|
|
style={{ padding: '0 10px' }}
|
|
plain
|
|
>
|
|
<CellButton
|
|
style={{
|
|
borderRadius: 4,
|
|
padding: '3px 6px',
|
|
backgroundColor: colors.g9,
|
|
border: '1px solid ' + colors.g9,
|
|
color: colors.g1,
|
|
fontSize: 12
|
|
}}
|
|
onEdit={onEdit}
|
|
onSelect={onClick}
|
|
onFocus={onEdit}
|
|
>
|
|
<Text style={{ paddingRight: 5 }}>
|
|
{ruleCount > 0 ? (
|
|
<>
|
|
{ruleCount} associated {plural(ruleCount, 'rule', 'rules')}
|
|
</>
|
|
) : (
|
|
<>Create rule</>
|
|
)}
|
|
</Text>
|
|
<ArrowThinRight style={{ width: 8, height: 8, color: colors.g1 }} />
|
|
</CellButton>
|
|
</Cell>
|
|
);
|
|
}
|
|
|
|
let Payee = React.memo(
|
|
({
|
|
style,
|
|
payee,
|
|
ruleCount,
|
|
categoryGroups,
|
|
selected,
|
|
highlighted,
|
|
hovered,
|
|
editing,
|
|
focusedField,
|
|
onViewRules,
|
|
onCreateRule,
|
|
onHover,
|
|
onEdit,
|
|
onUpdate,
|
|
ruleActions
|
|
}) => {
|
|
let { id } = payee;
|
|
let dispatchSelected = useSelectedDispatch();
|
|
let borderColor = selected ? colors.b8 : colors.border;
|
|
let backgroundFocus = hovered || focusedField === 'select';
|
|
|
|
return (
|
|
<Row
|
|
borderColor={borderColor}
|
|
backgroundColor={
|
|
selected ? colors.b9 : backgroundFocus ? colors.hover : 'white'
|
|
}
|
|
highlighted={highlighted}
|
|
style={[
|
|
{ alignItems: 'stretch' },
|
|
style,
|
|
{
|
|
backgroundColor: hovered ? colors.hover : null
|
|
},
|
|
selected && {
|
|
backgroundColor: colors.b9,
|
|
zIndex: 100
|
|
}
|
|
]}
|
|
data-focus-key={payee.id}
|
|
onMouseEnter={() => onHover && onHover(payee.id)}
|
|
>
|
|
<SelectCell
|
|
exposed={
|
|
payee.transfer_acct == null && (hovered || selected || editing)
|
|
}
|
|
focused={focusedField === 'select'}
|
|
selected={selected}
|
|
onSelect={() => {
|
|
dispatchSelected({ type: 'select', id: payee.id });
|
|
}}
|
|
/>
|
|
<InputCell
|
|
value={(payee.transfer_acct ? 'Transfer: ' : '') + payee.name}
|
|
valueStyle={!selected && payee.transfer_acct && { color: colors.n7 }}
|
|
exposed={focusedField === 'name'}
|
|
width="flex"
|
|
onUpdate={value =>
|
|
!payee.transfer_acct && onUpdate(id, 'name', value)
|
|
}
|
|
onExpose={() => onEdit(id, 'name')}
|
|
inputProps={{ readOnly: !!payee.transfer_acct }}
|
|
/>
|
|
<RuleButton
|
|
ruleCount={ruleCount}
|
|
focused={focusedField === 'rule-count'}
|
|
onEdit={() => onEdit(id, 'rule-count')}
|
|
onClick={() =>
|
|
ruleCount > 0 ? onViewRules(payee.id) : onCreateRule(payee.id)
|
|
}
|
|
/>
|
|
</Row>
|
|
);
|
|
}
|
|
);
|
|
|
|
const PayeeTable = React.forwardRef(
|
|
(
|
|
{
|
|
payees,
|
|
ruleCounts,
|
|
navigator,
|
|
categoryGroups,
|
|
highlightedRows,
|
|
ruleActions,
|
|
onUpdate,
|
|
onViewRules,
|
|
onCreateRule
|
|
},
|
|
ref
|
|
) => {
|
|
let [hovered, setHovered] = useState(null);
|
|
let selectedItems = useSelectedItems();
|
|
|
|
useLayoutEffect(() => {
|
|
let firstSelected = [...selectedItems][0];
|
|
ref.current.scrollTo(firstSelected, 'center');
|
|
navigator.onEdit(firstSelected, 'select');
|
|
}, []);
|
|
|
|
let onHover = useCallback(id => {
|
|
setHovered(id);
|
|
}, []);
|
|
|
|
return (
|
|
<View style={[{ flex: 1 }]} onMouseLeave={() => setHovered(null)}>
|
|
<Table
|
|
ref={ref}
|
|
items={payees}
|
|
navigator={navigator}
|
|
renderItem={({ item, editing, focusedField, onEdit }) => {
|
|
return (
|
|
<Payee
|
|
payee={item}
|
|
ruleCount={ruleCounts.get(item.id) || 0}
|
|
categoryGroups={categoryGroups}
|
|
selected={selectedItems.has(item.id)}
|
|
highlighted={highlightedRows && highlightedRows.has(item.id)}
|
|
editing={editing}
|
|
focusedField={focusedField}
|
|
hovered={hovered === item.id}
|
|
onHover={onHover}
|
|
onEdit={onEdit}
|
|
onUpdate={onUpdate}
|
|
onViewRules={onViewRules}
|
|
onCreateRule={onCreateRule}
|
|
/>
|
|
);
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
);
|
|
|
|
function PayeeTableHeader() {
|
|
let borderColor = colors.border;
|
|
let dispatchSelected = useSelectedDispatch();
|
|
let selectedItems = useSelectedItems();
|
|
|
|
return (
|
|
<View>
|
|
<Row
|
|
borderColor={borderColor}
|
|
style={{
|
|
backgroundColor: 'white',
|
|
color: colors.n4,
|
|
zIndex: 200,
|
|
userSelect: 'none'
|
|
}}
|
|
collapsed={true}
|
|
>
|
|
<SelectCell
|
|
exposed={true}
|
|
focused={false}
|
|
selected={selectedItems.size > 0}
|
|
onSelect={() => dispatchSelected({ type: 'select-all' })}
|
|
/>
|
|
<Cell value="Name" width="flex" />
|
|
</Row>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function EmptyMessage({ text, style }) {
|
|
return (
|
|
<View
|
|
style={[
|
|
{
|
|
textAlign: 'center',
|
|
color: colors.n7,
|
|
fontStyle: 'italic',
|
|
fontSize: 13,
|
|
marginTop: 5
|
|
},
|
|
style
|
|
]}
|
|
>
|
|
{text}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function PayeeMenu({ payeesById, selectedPayees, onDelete, onMerge, onClose }) {
|
|
// Transfer accounts are never editable
|
|
let isDisabled = [...selectedPayees].some(
|
|
id => payeesById[id] == null || payeesById[id].transfer_acct
|
|
);
|
|
|
|
return (
|
|
<Tooltip
|
|
position="bottom"
|
|
width={250}
|
|
style={{ padding: 0 }}
|
|
onClose={onClose}
|
|
>
|
|
<Menu
|
|
onMenuSelect={type => {
|
|
onClose();
|
|
switch (type) {
|
|
case 'delete':
|
|
onDelete();
|
|
break;
|
|
case 'merge':
|
|
onMerge();
|
|
break;
|
|
default:
|
|
}
|
|
}}
|
|
footer={
|
|
<View
|
|
style={{
|
|
padding: 3,
|
|
fontSize: 11,
|
|
fontStyle: 'italic',
|
|
color: colors.n7
|
|
}}
|
|
>
|
|
{[...selectedPayees]
|
|
.slice(0, 4)
|
|
.map(id => payeesById[id].name)
|
|
.join(', ') + (selectedPayees.size > 4 ? ', and more' : '')}
|
|
</View>
|
|
}
|
|
items={[
|
|
{
|
|
icon: Delete,
|
|
name: 'delete',
|
|
text: 'Delete',
|
|
disabled: isDisabled
|
|
},
|
|
{
|
|
icon: Merge,
|
|
iconSize: 9,
|
|
name: 'merge',
|
|
text: 'Merge',
|
|
disabled: isDisabled || selectedPayees.size < 2
|
|
},
|
|
Menu.line
|
|
]}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export const ManagePayees = React.forwardRef(
|
|
(
|
|
{
|
|
modalProps,
|
|
payees,
|
|
ruleCounts,
|
|
categoryGroups,
|
|
tableNavigatorOpts,
|
|
initialSelectedIds,
|
|
ruleActions,
|
|
onBatchChange,
|
|
onViewRules,
|
|
onCreateRule,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
let [highlightedRows, setHighlightedRows] = useState(null);
|
|
let [filter, setFilter] = useState('');
|
|
let table = useRef(null);
|
|
let scrollTo = useRef(null);
|
|
let resetAnimation = useRef(false);
|
|
|
|
let filteredPayees = useMemo(
|
|
() =>
|
|
filter === ''
|
|
? payees
|
|
: payees.filter(p =>
|
|
p.name.toLowerCase().includes(filter.toLowerCase())
|
|
),
|
|
[payees, filter]
|
|
);
|
|
|
|
let selected = useSelected('payees', filteredPayees, initialSelectedIds);
|
|
|
|
function applyFilter(f) {
|
|
if (filter !== f) {
|
|
table.current && table.current.setRowAnimation(false);
|
|
setFilter(f);
|
|
resetAnimation.current = true;
|
|
}
|
|
}
|
|
|
|
function _scrollTo(id) {
|
|
applyFilter('');
|
|
scrollTo.current = id;
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (resetAnimation.current) {
|
|
// Very annoying, for some reason it's as if the table doesn't
|
|
// actually update its contents until the next tick or
|
|
// something? The table keeps being animated without this
|
|
setTimeout(() => {
|
|
table.current && table.current.setRowAnimation(true);
|
|
}, 0);
|
|
resetAnimation.current = false;
|
|
}
|
|
});
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
selectRows: (ids, scroll) => {
|
|
tableNavigator.onEdit(null);
|
|
selected.dispatch({ type: 'select-all', ids });
|
|
setHighlightedRows(null);
|
|
|
|
if (scroll && ids.length > 0) {
|
|
_scrollTo(ids[0]);
|
|
}
|
|
},
|
|
|
|
highlightRow: id => {
|
|
tableNavigator.onEdit(null);
|
|
setHighlightedRows(new Set([id]));
|
|
_scrollTo(id);
|
|
}
|
|
}));
|
|
|
|
// `highlightedRows` should only ever be true once, and we
|
|
// immediately discard it. This triggers an animation.
|
|
useEffect(() => {
|
|
if (highlightedRows) {
|
|
setHighlightedRows(null);
|
|
}
|
|
}, [highlightedRows]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (scrollTo.current) {
|
|
table.current.scrollTo(scrollTo.current);
|
|
scrollTo.current = null;
|
|
}
|
|
});
|
|
|
|
let onUpdate = useStableCallback((id, name, value) => {
|
|
let payee = payees.find(p => p.id === id);
|
|
if (payee[name] !== value) {
|
|
onBatchChange({ updated: [{ id, [name]: value }] });
|
|
}
|
|
});
|
|
|
|
let getSelectableIds = useCallback(() => {
|
|
return filteredPayees.filter(p => p.transfer_acct == null).map(p => p.id);
|
|
}, [filteredPayees]);
|
|
|
|
function onDelete() {
|
|
onBatchChange({ deleted: [...selected.items].map(id => ({ id })) });
|
|
selected.dispatch({ type: 'select-none' });
|
|
}
|
|
|
|
async function onMerge() {
|
|
let ids = [...selected.items];
|
|
await props.onMerge(ids);
|
|
|
|
tableNavigator.onEdit(ids[0], 'name');
|
|
selected.dispatch({ type: 'select-none' });
|
|
_scrollTo(ids[0]);
|
|
}
|
|
|
|
let buttonsDisabled = selected.items.size === 0;
|
|
|
|
let tableNavigator = useTableNavigator(
|
|
filteredPayees,
|
|
item =>
|
|
['select', 'name', 'rule-count'].filter(name => {
|
|
switch (name) {
|
|
case 'select':
|
|
return item.transfer_acct == null;
|
|
default:
|
|
return true;
|
|
}
|
|
}),
|
|
tableNavigatorOpts
|
|
);
|
|
|
|
let payeesById = getPayeesById(payees);
|
|
|
|
return (
|
|
<Modal
|
|
title="Payees"
|
|
padding={0}
|
|
{...modalProps}
|
|
style={[modalProps.style, { flex: 'inherit', maxWidth: '90%' }]}
|
|
>
|
|
<View
|
|
style={{
|
|
maxWidth: '100%',
|
|
width: 900,
|
|
height: 550
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: '0 10px'
|
|
}}
|
|
>
|
|
<Component initialState={{ menuOpen: false }}>
|
|
{({ state, setState }) => (
|
|
<View>
|
|
<Button
|
|
bare
|
|
style={{ marginRight: 10 }}
|
|
disabled={buttonsDisabled}
|
|
onClick={() => setState({ menuOpen: true })}
|
|
>
|
|
{buttonsDisabled
|
|
? 'No payees selected'
|
|
: selected.items.size +
|
|
' ' +
|
|
plural(selected.items.size, 'payee', 'payees')}
|
|
<ExpandArrow
|
|
width={8}
|
|
height={8}
|
|
style={{ marginLeft: 5 }}
|
|
/>
|
|
</Button>
|
|
{state.menuOpen && (
|
|
<PayeeMenu
|
|
payeesById={payeesById}
|
|
selectedPayees={selected.items}
|
|
onClose={() => setState({ menuOpen: false })}
|
|
onDelete={onDelete}
|
|
onMerge={onMerge}
|
|
/>
|
|
)}
|
|
</View>
|
|
)}
|
|
</Component>
|
|
<View style={{ flex: 1 }} />
|
|
<Input
|
|
placeholder="Filter payees..."
|
|
value={filter}
|
|
onChange={e => {
|
|
applyFilter(e.target.value);
|
|
tableNavigator.onEdit(null);
|
|
}}
|
|
style={{
|
|
width: 350,
|
|
borderColor: 'transparent',
|
|
backgroundColor: colors.n11,
|
|
':focus': {
|
|
backgroundColor: 'white',
|
|
'::placeholder': { color: colors.n8 }
|
|
}
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
<SelectedProvider instance={selected} fetchAllIds={getSelectableIds}>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
border: '1px solid ' + colors.border,
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
margin: 5
|
|
}}
|
|
>
|
|
<PayeeTableHeader />
|
|
{filteredPayees.length === 0 ? (
|
|
<EmptyMessage text="No payees" style={{ marginTop: 15 }} />
|
|
) : (
|
|
<PayeeTable
|
|
ref={table}
|
|
payees={filteredPayees}
|
|
ruleCounts={ruleCounts}
|
|
categoryGroups={categoryGroups}
|
|
highlightedRows={highlightedRows}
|
|
navigator={tableNavigator}
|
|
onUpdate={onUpdate}
|
|
onViewRules={onViewRules}
|
|
onCreateRule={onCreateRule}
|
|
/>
|
|
)}
|
|
</View>
|
|
</SelectedProvider>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
);
|