Skip to content
Snippets Groups Projects
ManagePayees.jsx 8.98 KiB
import {
  forwardRef,
  useState,
  useEffect,
  useLayoutEffect,
  useRef,
  useMemo,
  useCallback,
  useImperativeHandle,
} from 'react';

import memoizeOne from 'memoize-one';

import { groupById } from 'loot-core/src/shared/util';

import {
  useSelected,
  SelectedProvider,
  useSelectedDispatch,
  useSelectedItems,
} from '../../hooks/useSelected';
import { useStableCallback } from '../../hooks/useStableCallback';
import { SvgExpandArrow } from '../../icons/v0';
import { theme } from '../../style';
import { Button } from '../common/Button2';
import { Popover } from '../common/Popover';
import { Search } from '../common/Search';
import { View } from '../common/View';
import { TableHeader, Cell, SelectCell, useTableNavigator } from '../table';

import { PayeeMenu } from './PayeeMenu';
import { PayeeTable } from './PayeeTable';

const getPayeesById = memoizeOne(payees => groupById(payees));

function plural(count, singleText, pluralText) {
  return count === 1 ? singleText : pluralText;
}

function PayeeTableHeader() {
  const borderColor = theme.tableborder;
  const dispatchSelected = useSelectedDispatch();
  const selectedItems = useSelectedItems();

  return (
    <View>
      <TableHeader
        style={{
          borderColor,
          backgroundColor: theme.tableBackground,
          color: theme.pageTextLight,
          zIndex: 200,
          userSelect: 'none',
        }}
        collapsed={true}
      >
        <SelectCell
          exposed={true}
          focused={false}
          selected={selectedItems.size > 0}
          onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
        />
        <Cell value="Name" width="flex" />
      </TableHeader>
    </View>
  );
}

function EmptyMessage({ text, style }) {
  return (
    <View
      style={{
        textAlign: 'center',
        color: theme.pageTextSubdued,
        fontStyle: 'italic',
        fontSize: 13,
        marginTop: 5,
        style,
      }}
    >
      {text}
    </View>
  );
}

export const ManagePayees = forwardRef(
  (
    {
      payees,
      ruleCounts,
      orphanedPayees,
      categoryGroups,
      initialSelectedIds,
      ruleActions,
      onBatchChange,
      onViewRules,
      onCreateRule,
      ...props
    },
    ref,
  ) => {
    const [highlightedRows, setHighlightedRows] = useState(null);
    const [filter, setFilter] = useState('');
    const table = useRef(null);
    const scrollTo = useRef(null);
    const triggerRef = useRef(null);
    const resetAnimation = useRef(false);
    const [orphanedOnly, setOrphanedOnly] = useState(false);

    const filteredPayees = useMemo(() => {
      let filtered = payees;
      if (filter) {
        filtered = filtered.filter(p =>
          p.name.toLowerCase().includes(filter.toLowerCase()),
        );
      }
      if (orphanedOnly) {
        filtered = filtered.filter(p =>
          orphanedPayees.map(o => o.id).includes(p.id),
        );
      }
      return filtered;
    }, [payees, filter, orphanedOnly]);

    const selected = useSelected('payees', filteredPayees, initialSelectedIds);

    function applyFilter(f) {
      if (filter !== f) {
        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?.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;
      }
    });

    const onUpdate = useStableCallback((id, name, value) => {
      const payee = payees.find(p => p.id === id);
      if (payee[name] !== value) {
        onBatchChange({ updated: [{ id, [name]: value }] });
      }
    });

    const 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() {
      const ids = [...selected.items];
      await props.onMerge(ids);

      tableNavigator.onEdit(ids[0], 'name');
      selected.dispatch({ type: 'select-none' });
      _scrollTo(ids[0]);
    }

    const buttonsDisabled = selected.items.size === 0;

    const tableNavigator = useTableNavigator(filteredPayees, item =>
      ['select', 'name', 'rule-count'].filter(name => {
        switch (name) {
          case 'select':
            return item.transfer_acct == null;
          default:
            return true;
        }
      }),
    );

    const payeesById = getPayeesById(payees);

    const [menuOpen, setMenuOpen] = useState(false);

    return (
      <View style={{ height: '100%' }}>
        <View
          style={{
            flexDirection: 'row',
            alignItems: 'center',
            padding: '0 0 15px',
          }}
        >
          <View style={{ flexShrink: 0 }}>
            <Button
              ref={triggerRef}
              variant="bare"
              style={{ marginRight: 10 }}
              isDisabled={buttonsDisabled}
              onPress={() => setMenuOpen(true)}
            >
              {buttonsDisabled
                ? 'No payees selected'
                : selected.items.size +
                  ' ' +
                  plural(selected.items.size, 'payee', 'payees')}
              <SvgExpandArrow width={8} height={8} style={{ marginLeft: 5 }} />
            </Button>

            <Popover
              triggerRef={triggerRef}
              isOpen={menuOpen}
              placement="bottom start"
              style={{ width: 250 }}
              onOpenChange={() => setMenuOpen(false)}
            >
              <PayeeMenu
                payeesById={payeesById}
                selectedPayees={selected.items}
                onClose={() => setMenuOpen(false)}
                onDelete={onDelete}
                onMerge={onMerge}
              />
            </Popover>
          </View>
          <View
            style={{
              flexShrink: 0,
            }}
          >
            {(orphanedOnly ||
              (orphanedPayees && orphanedPayees.length > 0)) && (
              <Button
                variant="bare"
                style={{ marginRight: 10 }}
                onPress={() => {
                  setOrphanedOnly(!orphanedOnly);
                  applyFilter(filter);
                  tableNavigator.onEdit(null);
                }}
              >
                {orphanedOnly
                  ? 'Show all payees'
                  : `Show ${
                      orphanedPayees.length === 1
                        ? '1 unused payee'
                        : `${orphanedPayees.length} unused payees`
                    }`}
              </Button>
            )}
          </View>
          <View style={{ flex: 1 }} />
          <Search
            id="filter-input"
            placeholder="Filter payees..."
            value={filter}
            onChange={applyFilter}
          />
        </View>

        <SelectedProvider instance={selected} fetchAllIds={getSelectableIds}>
          <View
            style={{
              flex: 1,
              border: '1px solid ' + theme.tableBorder,
              borderTopLeftRadius: 4,
              borderTopRightRadius: 4,
              overflow: 'hidden',
            }}
          >
            <PayeeTableHeader />
            {filteredPayees.length === 0 ? (
              <EmptyMessage text="No payees" style={{ marginTop: 15 }} />
            ) : (
              <PayeeTable
                ref={table}
                payees={filteredPayees}
                ruleCounts={ruleCounts}
                categoryGroups={categoryGroups}
                navigator={tableNavigator}
                onUpdate={onUpdate}
                onViewRules={onViewRules}
                onCreateRule={onCreateRule}
              />
            )}
          </View>
        </SelectedProvider>
      </View>
    );
  },
);

ManagePayees.displayName = 'ManagePayees';