src/components/commons/searchbar/SearchBar.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import { IoSearchSharp } from "react-icons/io5";
import { useEffect, useMemo, useState } from "react";
import { useWhaleApiClient } from "@contexts/WhaleContext";
import { debounce } from "lodash";
import {
  getOverflowAncestors,
  shift,
  size,
  useFloating,
} from "@floating-ui/react-dom";
import { Combobox, Transition } from "@headlessui/react";
import { Transaction } from "@defichain/whale-api-client/dist/api/transactions";
import { Block } from "@defichain/whale-api-client/dist/api/blocks";
import { fromAddress } from "@defichain/jellyfish-address";
import { useNetwork } from "@contexts/NetworkContext";
import { NetworkName } from "@defichain/jellyfish-network";
import { WhaleApiClient } from "@defichain/whale-api-client";
import {
  LoanVaultActive,
  LoanVaultLiquidated,
} from "@defichain/whale-api-client/dist/api/loan";
import classNames from "classnames";
import { useRouter } from "next/router";
import { getEnvironment } from "@contexts/Environment";
import { GovernanceProposal } from "@defichain/whale-api-client/dist/api/governance";
import { SearchResult, SearchResultTable } from "./SearchResult";

interface SearchBarInterface {
  atHeader: boolean;
}

export function SearchBar(props: SearchBarInterface): JSX.Element {
  const api = useWhaleApiClient();
  const { name: network, connection } = useNetwork();
  const router = useRouter();

  const [isSearching, setIsSearching] = useState<boolean>(false);
  const [searchResults, setSearchResults] = useState<
    SearchResult[] | undefined
  >(undefined);

  const [selected, setSelected] = useState<SearchResult>();

  const { x, y, reference, floating, strategy, refs } = useFloating({
    placement: "bottom-end",
    middleware: [
      shift(),
      size({
        apply({ rects }) {
          if (
            refs.floating.current !== null &&
            refs.floating.current !== undefined
          ) {
            Object.assign(refs.floating.current.style, {
              minWidth: "325px",
              width: `${rects.reference.width}px`,
            });
          }
        },
      }),
    ],
  });

  function updateFloater(): void {
    if (refs.reference.current == null || refs.floating.current == null) {
      return;
    }
    const currentReference = refs.reference.current as Element;
    Object.assign(refs.floating.current?.style, {
      width: `${currentReference.scrollWidth}px`,
      left:
        `${
          currentReference.scrollLeft +
          (currentReference.scrollWidth - refs.floating.current?.scrollWidth)
        }px` ?? "",
    });
  }

  useEffect(() => {
    if (refs.reference.current == null || refs.floating.current == null) {
      return;
    }

    const parents = [
      ...(refs.reference.current instanceof Element
        ? getOverflowAncestors(refs.reference.current)
        : []),
    ];

    parents.forEach((parent) => {
      parent.addEventListener("resize", updateFloater);
    });

    return () => {
      parents.forEach((parent) => {
        parent.removeEventListener("resize", updateFloater);
      });
    };
  }, [refs.reference, refs.floating, updateFloater]);

  async function changeHandler(event): Promise<void> {
    const query = event.target.value.trim();
    setSelected({ title: query, url: "", type: "Query" });
    if (query.length > 0) {
      setIsSearching(true);
      const results = await getSearchResults(api, network, query);
      setSearchResults(results);
      setIsSearching(false);
    } else {
      setSearchResults([]);
    }
  }

  const onChangeDebounceHandler = useMemo(
    () => debounce(changeHandler, 200),
    []
  );

  function onSelect(result: SearchResult): void {
    setSelected(result);
    if (result?.url !== undefined && result?.url !== "") {
      void router.push({
        pathname: result.url,
        query: getEnvironment().isDefaultConnection(connection)
          ? {}
          : { network: connection },
      });
    }
  }

  return (
    <Combobox value={selected} onChange={onSelect} nullable>
      <div
        className={classNames("flex w-full", {
          "md:w-3/4 xl:w-1/2": !props.atHeader,
        })}
      >
        <div
          className={classNames(
            "flex w-full p-2 rounded-3xl h-10 bg-white dark:bg-gray-800 dark:border-gray-700 border focus-within:border-primary-200"
          )}
          data-testid="SearchBar"
          ref={reference}
        >
          <Combobox.Button as="div" className="flex w-full">
            <IoSearchSharp
              size={22}
              className="dark:text-gray-100 text-gray-600 ml-0.5 self-center"
            />
            <Combobox.Input
              as="input"
              placeholder="Search Block / Txn / Vault ID and more"
              className="ml-1.5 h-full w-full focus:outline-none dark:bg-gray-800 dark:placeholder-gray-400 dark:text-dark-gray-900"
              data-testid="SearchBar.Input"
              displayValue={(item: SearchResult) => item?.title}
              onChange={onChangeDebounceHandler}
            />
          </Combobox.Button>
        </div>

        <Transition className="absolute">
          <div
            className="z-40"
            ref={floating}
            style={{
              position: strategy,
              top: y ?? "",
              left: x ?? "",
            }}
          >
            <div className="w-full mt-1.5 rounded-md shadow-lg drop-shadow bg-white overflow-hidden dark:bg-gray-800">
              <SearchResultTable
                searchResults={searchResults}
                isSearching={isSearching}
              />
            </div>
          </div>
        </Transition>
      </div>
    </Combobox>
  );
}

async function getSearchResults(
  api: WhaleApiClient,
  network: NetworkName,
  query: string
): Promise<SearchResult[]> {
  const searchResults: SearchResult[] = [];

  const txnData = await api.transactions
    .get(query)
    .then((data: Transaction) => {
      if (data === undefined) {
        return undefined;
      }

      return {
        url: `/transactions/${data.txid}`,
        title: data.txid,
        type: "Transaction",
      };
    })
    .catch(() => undefined);

  if (txnData !== undefined) {
    searchResults.push(txnData);
  }

  const blocksData = await api.blocks
    .get(query)
    .then((data: Block) => {
      if (data === undefined) {
        return undefined;
      }

      return {
        url: `/blocks/${data.id}`,
        title: `${data.height}`,
        type: "Block",
      };
    })
    .catch(() => undefined);

  if (blocksData !== undefined) {
    searchResults.push(blocksData);
  }

  const addressData = fromAddress(query, network);
  if (addressData !== undefined) {
    searchResults.push({
      url: `/address/${query}`,
      title: query,
      type: "Address",
    });
  }

  const vaultsData = await api.loan
    .getVault(query)
    .then((data: LoanVaultActive | LoanVaultLiquidated) => {
      if (data === undefined) {
        return undefined;
      }

      return {
        url: `/vaults/${data.vaultId}`,
        title: `${data.vaultId}`,
        type: "Vault",
      };
    })
    .catch(() => undefined);

  if (vaultsData !== undefined) {
    searchResults.push(vaultsData);
  }

  const proposalsData = await api.governance
    .getGovProposal(query)
    .then((data: GovernanceProposal) => {
      if (data === undefined) {
        return undefined;
      }

      return {
        url: `/governance/${data.proposalId}`,
        title: `${data.proposalId}`,
        type: "Proposal",
      };
    })
    .catch(() => undefined);

  if (proposalsData !== undefined) {
    searchResults.push(proposalsData);
  }

  return searchResults;
}