Skip to content
Snippets Groups Projects
PayeeAutocomplete.tsx 15.5 KiB
Newer Older
  • Learn to ignore specific revisions
  • import React, {
      Fragment,
      useState,
      useMemo,
      type ComponentProps,
    
      type ReactNode,
      type ComponentType,
      type SVGProps,
    
      type ComponentPropsWithoutRef,
      type ReactElement,
    
    import { useDispatch } from 'react-redux';
    
    import { css } from 'glamor';
    
    
    James Long's avatar
    James Long committed
    import { createPayee } from 'loot-core/src/client/actions/queries';
    
    import { getActivePayees } from 'loot-core/src/client/reducers/queries';
    
    import {
      type AccountEntity,
      type PayeeEntity,
    } from 'loot-core/src/types/models';
    
    import { useAccounts } from '../../hooks/useAccounts';
    import { usePayees } from '../../hooks/usePayees';
    
    import { useResponsive } from '../../ResponsiveProvider';
    
    import { type CSSProperties, theme, styles } from '../../style';
    
    import { Button } from '../common/Button';
    
    import { TextOneLine } from '../common/TextOneLine';
    
    import { View } from '../common/View';
    
    import {
      Autocomplete,
    
    James Long's avatar
    James Long committed
      defaultFilterSuggestion,
      AutocompleteFooter,
    } from './Autocomplete';
    
    import { ItemHeader } from './ItemHeader';
    
    James Long's avatar
    James Long committed
    
    
    type PayeeAutocompleteItem = PayeeEntity;
    
    function getPayeeSuggestions(
      payees: PayeeAutocompleteItem[],
      focusTransferPayees: boolean,
      accounts: AccountEntity[],
    ): PayeeAutocompleteItem[] {
    
    James Long's avatar
    James Long committed
      let activePayees = accounts ? getActivePayees(payees, accounts) : payees;
    
      if (focusTransferPayees && activePayees) {
        activePayees = activePayees.filter(p => !!p.transfer_acct);
      }
    
      return activePayees || [];
    }
    
    
    function makeNew(id, rawPayee) {
      if (id === 'new' && !rawPayee.startsWith('new:')) {
    
    James Long's avatar
    James Long committed
      }
    
    James Long's avatar
    James Long committed
    }
    
    // Convert the fully resolved new value into the 'new' id that can be
    // looked up in the suggestions
    function stripNew(value) {
      if (typeof value === 'string' && value.startsWith('new:')) {
        return 'new';
      }
      return value;
    }
    
    
    type PayeeListProps = {
      items: PayeeAutocompleteItem[];
      getItemProps: (arg: {
        item: PayeeAutocompleteItem;
      }) => ComponentProps<typeof View>;
      highlightedIndex: number;
      embedded: boolean;
      inputValue: string;
      renderCreatePayeeButton?: (
        props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
      ) => ReactNode;
      renderPayeeItemGroupHeader?: (
        props: ComponentPropsWithoutRef<typeof ItemHeader>,
      ) => ReactNode;
      renderPayeeItem?: (
        props: ComponentPropsWithoutRef<typeof PayeeItem>,
      ) => ReactNode;
      footer: ReactNode;
    };
    
    
    James Long's avatar
    James Long committed
      items,
      getItemProps,
      highlightedIndex,
      embedded,
      inputValue,
    
      renderCreatePayeeButton = defaultRenderCreatePayeeButton,
      renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
      renderPayeeItem = defaultRenderPayeeItem,
    
    James Long's avatar
    James Long committed
      let createNew = null;
      items = [...items];
    
      // If the "new payee" item exists, create it as a special-cased item
      // with the value of the input so it always shows whatever the user
      // entered
      if (items[0].id === 'new') {
    
    James Long's avatar
    James Long committed
        createNew = first;
        items = rest;
      }
    
    
    James Long's avatar
    James Long committed
      let lastType = null;
    
      return (
        <View>
          <View
    
            style={{
              overflow: 'auto',
              padding: '5px 0',
              ...(!embedded && { maxHeight: 175 }),
            }}
    
    James Long's avatar
    James Long committed
          >
    
            {createNew &&
              renderCreatePayeeButton({
                ...(getItemProps ? getItemProps({ item: createNew }) : null),
                payeeName: inputValue,
                highlighted: highlightedIndex === 0,
                embedded,
              })}
    
    James Long's avatar
    James Long committed
    
            {items.map((item, idx) => {
    
              const type = item.transfer_acct ? 'account' : 'payee';
    
    James Long's avatar
    James Long committed
              let title;
              if (type === 'payee' && lastType !== type) {
                title = 'Payees';
              } else if (type === 'account' && lastType !== type) {
                title = 'Transfer To/From';
              }
    
              const showMoreMessage =
                idx === items.length - 1 && items.length > 100;
    
    James Long's avatar
    James Long committed
              lastType = type;
    
              return (
    
                <Fragment key={item.id}>
    
    James Long's avatar
    James Long committed
                  {title && (
    
                    <Fragment key={`title-${idx}`}>
                      {renderPayeeItemGroupHeader({ title })}
                    </Fragment>
    
    James Long's avatar
    James Long committed
                  )}
    
                  <Fragment key={item.id}>
                    {renderPayeeItem({
                      ...(getItemProps ? getItemProps({ item }) : null),
                      item,
                      highlighted: highlightedIndex === idx + offset,
                      embedded,
                    })}
                  </Fragment>
    
    James Long's avatar
    James Long committed
    
                  {showMoreMessage && (
                    <div
                      style={{
    
    James Long's avatar
    James Long committed
                        padding: 5,
    
    Neil's avatar
    Neil committed
                        color: theme.pageTextLight,
    
    James Long's avatar
    James Long committed
                      }}
                    >
                      More payees are available, search to find them
                    </div>
                  )}
    
    James Long's avatar
    James Long committed
              );
            })}
          </View>
          {footer}
        </View>
      );
    }
    
    
    type PayeeAutocompleteProps = ComponentProps<
      typeof Autocomplete<PayeeAutocompleteItem>
    > & {
    
      showMakeTransfer?: boolean;
      showManagePayees?: boolean;
      embedded?: boolean;
    
      onManagePayees?: () => void;
      renderCreatePayeeButton?: (
        props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
      ) => ReactElement<typeof CreatePayeeButton>;
      renderPayeeItemGroupHeader?: (
        props: ComponentPropsWithoutRef<typeof ItemHeader>,
      ) => ReactElement<typeof ItemHeader>;
      renderPayeeItem?: (
        props: ComponentPropsWithoutRef<typeof PayeeItem>,
      ) => ReactElement<typeof PayeeItem>;
    
      accounts?: AccountEntity[];
    
      payees?: PayeeAutocompleteItem[];
    
    export function PayeeAutocomplete({
    
    James Long's avatar
    James Long committed
      value,
      inputProps,
      showMakeTransfer = true,
      showManagePayees = false,
    
    James Long's avatar
    James Long committed
      onUpdate,
      onSelect,
      onManagePayees,
    
      renderCreatePayeeButton = defaultRenderCreatePayeeButton,
      renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
      renderPayeeItem = defaultRenderPayeeItem,
    
    James Long's avatar
    James Long committed
      ...props
    
    }: PayeeAutocompleteProps) {
    
      const retrievedPayees = usePayees();
    
        payees = retrievedPayees;
    
      const cachedAccounts = useAccounts();
    
      if (!accounts) {
        accounts = cachedAccounts;
      }
    
    James Long's avatar
    James Long committed
    
    
      const [focusTransferPayees, setFocusTransferPayees] = useState(false);
      const [rawPayee, setRawPayee] = useState('');
      const hasPayeeInput = !!rawPayee;
    
      const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
    
        const suggestions = getPayeeSuggestions(
          payees,
          focusTransferPayees,
          accounts,
        );
    
        if (!hasPayeeInput) {
          return suggestions;
        }
        return [{ id: 'new', name: '' }, ...suggestions];
      }, [payees, focusTransferPayees, accounts, hasPayeeInput]);
    
    James Long's avatar
    James Long committed
    
    
    James Long's avatar
    James Long committed
    
    
      async function handleSelect(idOrIds, rawInputValue) {
        if (!clearOnBlur) {
          onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
    
    James Long's avatar
    James Long committed
        } else {
    
          const create = payeeName => dispatch(createPayee(payeeName));
    
    James Long's avatar
    James Long committed
    
    
          if (Array.isArray(idOrIds)) {
            idOrIds = await Promise.all(
              idOrIds.map(v => (v === 'new' ? create(rawInputValue) : v)),
            );
    
    James Long's avatar
    James Long committed
          } else {
    
            if (idOrIds === 'new') {
              idOrIds = await create(rawInputValue);
    
          onSelect?.(idOrIds, rawInputValue);
    
      const [payeeFieldFocused, setPayeeFieldFocused] = useState(false);
    
    
    James Long's avatar
    James Long committed
      return (
        <Autocomplete
          key={focusTransferPayees ? 'transfers' : 'all'}
          strict={true}
          embedded={embedded}
          value={stripNew(value)}
          suggestions={payeeSuggestions}
    
    James Long's avatar
    James Long committed
          itemToString={item => {
            if (!item) {
              return '';
            } else if (item.id === 'new') {
    
    James Long's avatar
    James Long committed
            }
            return item.name;
          }}
    
    James Long's avatar
    James Long committed
          inputProps={{
            ...inputProps,
    
            onBlur: () => {
              setRawPayee('');
              setPayeeFieldFocused(false);
            },
    
            onFocus: () => setPayeeFieldFocused(true),
    
    James Long's avatar
    James Long committed
          }}
    
          onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
    
    James Long's avatar
    James Long committed
          onSelect={handleSelect}
          getHighlightedIndex={suggestions => {
            if (suggestions.length > 1 && suggestions[0].id === 'new') {
              return 1;
            }
            return 0;
          }}
          filterSuggestions={(suggestions, value) => {
    
            let filtered = suggestions.filter(suggestion => {
    
    James Long's avatar
    James Long committed
              if (suggestion.id === 'new') {
                return !value || value === '' || focusTransferPayees ? false : true;
              }
    
              return defaultFilterSuggestion(suggestion, value);
            });
    
            filtered.sort((p1, p2) => {
    
              const r1 = p1.name.toLowerCase().startsWith(value.toLowerCase());
              const r2 = p2.name.toLowerCase().startsWith(value.toLowerCase());
              const r1exact = p1.name.toLowerCase() === value.toLowerCase();
              const r2exact = p2.name.toLowerCase() === value.toLowerCase();
    
    James Long's avatar
    James Long committed
    
              // (maniacal laughter) mwahaHAHAHAHAH
              if (p1.id === 'new') {
                return -1;
              } else if (p2.id === 'new') {
                return 1;
              } else {
                if (r1exact && !r2exact) {
                  return -1;
                } else if (!r1exact && r2exact) {
                  return 1;
                } else {
                  if (r1 === r2) {
                    return 0;
                  } else if (r1 && !r2) {
                    return -1;
                  } else {
                    return 1;
                  }
                }
              }
            });
    
            filtered = filtered.slice(0, 100);
    
            if (filtered.length >= 2 && filtered[0].id === 'new') {
    
              if (
                filtered[1].name.toLowerCase() === value.toLowerCase() &&
                !filtered[1].transfer_acct
              ) {
    
    James Long's avatar
    James Long committed
                return filtered.slice(1);
              }
            }
            return filtered;
          }}
          renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
            <PayeeList
              items={items}
              getItemProps={getItemProps}
              highlightedIndex={highlightedIndex}
              inputValue={inputValue}
              embedded={embedded}
    
              renderCreatePayeeButton={renderCreatePayeeButton}
              renderPayeeItemGroupHeader={renderPayeeItemGroupHeader}
              renderPayeeItem={renderPayeeItem}
    
    James Long's avatar
    James Long committed
              footer={
                <AutocompleteFooter embedded={embedded}>
                  {showMakeTransfer && (
    
                    <Button
                      type={focusTransferPayees ? 'menuSelected' : 'menu'}
                      style={showManagePayees && { marginBottom: 5 }}
    
    James Long's avatar
    James Long committed
                      onClick={() => {
    
    James Long's avatar
    James Long committed
                        setFocusTransferPayees(!focusTransferPayees);
                      }}
    
                    >
                      Make Transfer
                    </Button>
    
    James Long's avatar
    James Long committed
                  )}
                  {showManagePayees && (
    
                    <Button type="menu" onClick={() => onManagePayees()}>
                      Manage Payees
                    </Button>
    
    James Long's avatar
    James Long committed
                  )}
                </AutocompleteFooter>
              }
            />
          )}
          {...props}
        />
      );
    }
    
    
    type CreatePayeeButtonProps = {
      Icon?: ComponentType<SVGProps<SVGElement>>;
      payeeName: string;
      highlighted?: boolean;
      embedded?: boolean;
      style?: CSSProperties;
    };
    
    
    // eslint-disable-next-line import/no-unused-modules
    
    export function CreatePayeeButton({
      Icon,
      payeeName,
      highlighted,
      embedded,
      style,
      ...props
    }: CreatePayeeButtonProps) {
    
      const { isNarrowWidth } = useResponsive();
    
      const narrowStyle = isNarrowWidth
        ? {
            ...styles.mobileMenuItem,
          }
        : {};
      const iconSize = isNarrowWidth ? 14 : 8;
    
    
      return (
        <View
          data-testid="create-payee-button"
          style={{
            display: 'block',
    
            flex: '1 0',
    
            color: highlighted
              ? theme.menuAutoCompleteTextHover
              : theme.noticeTextMenu,
    
            borderRadius: embedded ? 4 : 0,
            fontSize: 11,
            fontWeight: 500,
            padding: '6px 9px',
            backgroundColor: highlighted
    
              ? theme.menuAutoCompleteBackgroundHover
    
              : 'transparent',
            ':active': {
              backgroundColor: 'rgba(100, 100, 100, .25)',
            },
    
            ...style,
          }}
          {...props}
        >
          {Icon ? (
            <Icon style={{ marginRight: 5, display: 'inline-block' }} />
          ) : (
    
              width={iconSize}
              height={iconSize}
    
              style={{ marginRight: 5, display: 'inline-block' }}
            />
          )}
          Create Payee “{payeeName}
        </View>
      );
    }
    
    function defaultRenderCreatePayeeButton(
    
      props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
    ): ReactElement<typeof CreatePayeeButton> {
    
      return <CreatePayeeButton {...props} />;
    }
    
    
    function defaultRenderPayeeItemGroupHeader(
      props: ComponentPropsWithoutRef<typeof ItemHeader>,
    ): ReactElement<typeof ItemHeader> {
    
    Neil's avatar
    Neil committed
      return <ItemHeader {...props} type="payee" />;
    
      item: PayeeAutocompleteItem;
    
      className?: string;
      style?: CSSProperties;
      highlighted?: boolean;
      embedded?: boolean;
    };
    
    
    function PayeeItem({
    
      item,
      className,
      highlighted,
      embedded,
      ...props
    }: PayeeItemProps) {
    
      const { isNarrowWidth } = useResponsive();
    
      const narrowStyle = isNarrowWidth
        ? {
            ...styles.mobileMenuItem,
            borderRadius: 0,
            borderTop: `1px solid ${theme.pillBorder}`,
          }
        : {};
    
    
      return (
        <div
          // Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
          // event handler they set on this element. When this code runs
          // in WebKit on touch-enabled devices, taps on this element end
          // up not triggering the `onClick` event (and therefore delaying
          // response to user input) until after the `setTimeout` callback
          // finishes executing. This is caused by content observation code
          // that implements various strategies to prevent the user from
          // accidentally clicking content that changed as a result of code
          // run in the `onMouseMove` event.
          //
          // Long story short, we don't want any delay here between the user
          // tapping and the resulting action being performed. It turns out
          // there's some "fast path" logic that can be triggered in various
          // ways to force WebKit to bail on the content observation process.
          // One of those ways is setting `role="button"` (or a number of
          // other aria roles) on the element, which is what we're doing here.
          //
          // ref:
          // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html
          // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp
          // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783
          role="button"
          className={`${className} ${css([
            {
              backgroundColor: highlighted
    
                ? theme.menuAutoCompleteBackgroundHover
    
              color: highlighted
                ? theme.menuAutoCompleteItemTextHover
                : theme.menuAutoCompleteItemText,
    
              borderRadius: embedded ? 4 : 0,
              padding: 4,
              paddingLeft: 20,
    
            },
          ])}`}
          data-testid={`${item.name}-payee-item`}
          data-highlighted={highlighted || undefined}
          {...props}
        >
    
          <TextOneLine>{item.name}</TextOneLine>
    
    function defaultRenderPayeeItem(
      props: ComponentPropsWithoutRef<typeof PayeeItem>,
    ): ReactElement<typeof PayeeItem> {
    
      return <PayeeItem {...props} />;
    }