synapsecns/sanguine

View on GitHub
packages/widget/src/components/ui/TokenPopoverSelect.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import { useState, useEffect } from 'react'
import _ from 'lodash'
import { BridgeableToken } from 'types'

import usePopover from '@/hooks/usePopoverRef'
import { TokenBalance } from '@/utils/actions/fetchTokenBalances'
import { DownArrow } from '@/components/icons/DownArrow'
import { SearchInput } from '@/components/ui/SearchInput'
import { TokenOption } from '@/components/ui/TokenOption'

type PopoverSelectProps = {
  options: BridgeableToken[]
  remaining: BridgeableToken[]
  balances: TokenBalance[]
  onSelect: (selected: BridgeableToken) => void
  selected: BridgeableToken
  isOrigin: boolean
}

export const TokenPopoverSelect = ({
  options,
  remaining,
  balances,
  onSelect,
  selected,
  isOrigin,
}: PopoverSelectProps) => {
  const { popoverRef, isOpen, togglePopover, closePopover } = usePopover()

  const handleSelect = (option: BridgeableToken) => {
    onSelect(option)
    closePopover()
  }

  /** Merge options with balances to sort */
  const optionsWithBalances: TokenBalance[] = mergeTokenOptionsWithBalances(
    options,
    balances
  )

  /** Merge remaining with balances to sort */
  const remainingWithBalances: TokenBalance[] = mergeTokenOptionsWithBalances(
    remaining,
    balances
  )

  const sortedOptionsWithBalances: TokenBalance[] = _.orderBy(
    optionsWithBalances,
    ['parsedBalance', 'token.priorityRank'],
    ['desc', 'asc']
  )

  const sortedRemainingWithBalances: TokenBalance[] = _.orderBy(
    remainingWithBalances,
    ['parsedBalance', 'token.priorityRank'],
    ['desc', 'asc']
  )

  const {
    filterValue,
    setFilterValue,
    filteredOptions: filteredSortedOptionsWithBalances,
    filteredRemaining: filteredSortedRemainingWithBalances,
    hasFilteredRemaining,
    hasFilteredResults,
  } = useTokenInputFilter(
    sortedOptionsWithBalances,
    sortedRemainingWithBalances,
    isOpen
  )

  return (
    <div
      data-test-id="token-popover-select"
      className="relative w-min justify-self-end align-self-center"
      ref={popoverRef}
    >
      <div
        id={`${isOrigin ? 'origin' : 'destination'}-token-select`}
        onClick={() => togglePopover()}
        style={{ background: 'var(--synapse-select-bg)' }}
        className={`
          flex px-2.5 py-1.5 gap-2 items-center rounded
          text-[--synapse-select-text] whitespace-nowrap
          border border-solid border-[--synapse-select-border]
          cursor-pointer hover:border-[--synapse-focus]
        `}
      >
        {selected?.imgUrl && (
          <img
            src={selected?.imgUrl}
            alt={`${selected.symbol} token icon`}
            className="inline w-4 h-4"
          />
        )}
        {selected?.symbol || 'Token'}

        <DownArrow />
      </div>
      {isOpen && (
        <div
          style={{ background: 'var(--synapse-select-bg)' }}
          className={`
            absolute right-0 z-50 mt-1 max-h-80 min-w-48 rounded
            shadow popover text-left list-none overflow-y-auto
            border border-solid border-[--synapse-select-border]
            animate-slide-down origin-top
          `}
        >
          <div className="p-1">
            <SearchInput
              inputValue={filterValue}
              setInputValue={setFilterValue}
              placeholder="Search Tokens"
              isActive={isOpen}
            />
          </div>
          {hasFilteredResults ? (
            <ul className="p-0 m-0">
              {filteredSortedOptionsWithBalances?.map(
                (option: TokenBalance, index) => (
                  <TokenOption
                    option={option?.token}
                    key={index}
                    onSelect={handleSelect}
                    selected={selected}
                    parsedBalance={option?.parsedBalance}
                    isOrigin={isOrigin}
                  />
                )
              )}
              {hasFilteredRemaining && (
                <div
                  className={`
                    sticky top-0 px-2.5 py-2 mt-2 text-sm
                    text-[--synapse-secondary] bg-[--synapse-surface]
                  `}
                >
                  Other tokens
                </div>
              )}
              {filteredSortedRemainingWithBalances?.map(
                (option: TokenBalance, index) => (
                  <TokenOption
                    option={option?.token}
                    key={index}
                    onSelect={handleSelect}
                    selected={selected}
                    parsedBalance={option?.parsedBalance}
                    isOrigin={isOrigin}
                  />
                )
              )}
            </ul>
          ) : (
            <div className="p-2 text-sm break-all">
              No tokens found
              <br />
              matching '{filterValue}'.
            </div>
          )}
        </div>
      )}
    </div>
  )
}

const useTokenInputFilter = (
  options: TokenBalance[],
  remaining: TokenBalance[],
  isActive: boolean
) => {
  const [filterValue, setFilterValue] = useState('')

  useEffect(() => {
    if (!isActive) {
      setFilterValue('')
    }
  }, [isActive])

  const filterTokens = (tokens: TokenBalance[], filter: string) => {
    const lowerFilter = filter.toLowerCase()
    return _.filter(tokens, (option) => {
      const symbol = option.token.symbol.toLowerCase()
      return symbol.includes(lowerFilter) || symbol === lowerFilter
    })
  }

  const filteredOptions = filterTokens(options, filterValue)
  const filteredRemaining = filterTokens(remaining, filterValue)

  const hasFilteredOptions = !_.isEmpty(filteredOptions)
  const hasFilteredRemaining = !_.isEmpty(filteredRemaining)
  const hasFilteredResults = hasFilteredOptions || hasFilteredRemaining

  return {
    filterValue,
    setFilterValue,
    filteredOptions,
    filteredRemaining,
    hasFilteredOptions,
    hasFilteredRemaining,
    hasFilteredResults,
  }
}

const mergeTokenOptionsWithBalances = (
  tokens: BridgeableToken[],
  balances: TokenBalance[]
) => {
  return tokens?.map((token) => {
    /** If token balance does not exist, set balance to null */
    if (_.isArray(balances) && _.isEmpty(balances)) {
      return {
        token,
        balance: null,
        parsedBalance: null,
      }
    } else {
      const matchedTokenBalance: TokenBalance = balances?.find(
        (currentToken: TokenBalance) => currentToken.token === token
      )
      return {
        token,
        balance: matchedTokenBalance?.balance,
        parsedBalance: matchedTokenBalance?.parsedBalance,
      }
    }
  })
}