| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- import {
- Combobox,
- ComboboxInput,
- ComboboxOption,
- ComboboxOptions,
- } from "@headlessui/react";
- import { ArrowPathIcon } from "@heroicons/react/24/outline";
- import base58 from "bs58";
- import clsx from "clsx";
- import Image from "next/image";
- import {
- type ChangeEvent,
- type Dispatch,
- type SetStateAction,
- useState,
- useCallback,
- useMemo,
- useEffect,
- } from "react";
- import {
- type Parameter,
- PLACEHOLDERS,
- isValid,
- getValidationError,
- ParameterType,
- } from "./parameter";
- import {
- type PriceFeed,
- PriceFeedListContextType,
- usePriceFeedList,
- } from "../../use-price-feed-list";
- import { InlineLink } from "../InlineLink";
- import { Input } from "../Input";
- import { Markdown } from "../Markdown";
- type ParameterProps<ParameterName extends string> = {
- spec: Parameter<ParameterName>;
- value: string | undefined;
- setParamValues: Dispatch<
- SetStateAction<Partial<Record<ParameterName, string>>>
- >;
- };
- export const ParameterInput = <ParameterName extends string>(
- props: ParameterProps<ParameterName>,
- ) => {
- switch (props.spec.type) {
- case ParameterType.PriceFeedId:
- case ParameterType.PriceFeedIdArray: {
- return <PriceFeedIdInput {...props} />;
- }
- default: {
- return <DefaultParameterInput {...props} />;
- }
- }
- };
- const PriceFeedIdInput = <ParameterName extends string>({
- spec,
- value,
- setParamValues,
- }: ParameterProps<ParameterName>) => {
- const { validationError, internalValue, onChange } = useParameterInput(
- spec,
- value,
- setParamValues,
- );
- const { selectedPriceFeed, onSelectPriceFeed, priceFeedList } =
- usePriceFeedSelector(internalValue, onChange);
- const onChangeInput = useCallback(
- (event: ChangeEvent<HTMLInputElement>) => {
- onChange(event.target.value);
- },
- [onChange],
- );
- return (
- <Combobox
- value={selectedPriceFeed}
- onChange={onSelectPriceFeed}
- as="div"
- className="group relative"
- immediate
- virtual={{
- options:
- priceFeedList.type === PriceFeedListContextType.Loaded
- ? priceFeedList.list
- : [],
- }}
- >
- <ComboboxInput
- as={Input}
- displayValue={() =>
- selectedPriceFeed
- ? `${selectedPriceFeed.name} (${selectedPriceFeed.feedId})`
- : internalValue
- }
- onChange={onChangeInput}
- validationError={validationError}
- label={spec.name}
- description={<Markdown inline>{spec.description}</Markdown>}
- placeholder={PLACEHOLDERS[spec.type]}
- required={true}
- />
- <div className="absolute right-0 top-0 z-50 mt-20 hidden w-full min-w-[34rem] overflow-hidden rounded-lg border border-neutral-400 bg-neutral-100 text-sm shadow focus-visible:border-pythpurple-600 focus-visible:outline-none group-data-[open]:block dark:border-neutral-600 dark:bg-neutral-800 dark:shadow-white/20 dark:focus-visible:border-pythpurple-400">
- <PriceFeedListOptions priceFeedList={priceFeedList} />
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
- <div
- className="bg-neutral-200 p-1 px-2 text-right text-xs dark:bg-neutral-700"
- onMouseDown={(e) => {
- e.preventDefault();
- }}
- >
- See all price feed IDs on{" "}
- <InlineLink
- target="_blank"
- href="https://pyth.network/developers/price-feed-ids"
- >
- the reference page
- </InlineLink>
- </div>
- </div>
- </Combobox>
- );
- };
- type PriceFeedListOptionsProps = {
- priceFeedList: ReturnType<typeof usePriceFeedSelector>["priceFeedList"];
- };
- const PriceFeedListOptions = ({ priceFeedList }: PriceFeedListOptionsProps) => {
- if (priceFeedList.type === PriceFeedListContextType.Loaded) {
- return priceFeedList.list.length === 0 ? (
- <div className="flex w-full items-center justify-center py-10">
- No matching price feeds
- </div>
- ) : (
- <ComboboxOptions className="h-80 overflow-y-auto py-1" modal={false}>
- {({ option }) => {
- // The `option` parameter is typed as `unknown` and we have to
- // cast to get it to be correctly typed, see
- // https://github.com/tailwindlabs/headlessui/issues/3326
- const { feedId, name, description } = option as PriceFeed;
- return (
- <ComboboxOption
- key={feedId}
- value={option}
- className="group flex w-32 min-w-full cursor-pointer flex-row items-center gap-3 p-2 py-1 data-[focus]:bg-neutral-300 data-[selected]:text-pythpurple-600 dark:data-[focus]:bg-neutral-700 dark:data-[selected]:text-pythpurple-400"
- >
- <PriceFeedIcon name={name} />
- <div>
- <div className="flex flex-row items-center gap-3">
- <div className="font-medium">{name}</div>
- <div className="text-xs">{description}</div>
- </div>
- <div className="text-xs text-neutral-600 dark:text-neutral-400">
- {feedId}
- </div>
- </div>
- </ComboboxOption>
- );
- }}
- </ComboboxOptions>
- );
- } else {
- return (
- <div className="flex w-full items-center justify-center py-10">
- <ArrowPathIcon className="size-6 animate-spin" />
- </div>
- );
- }
- };
- type PriceFeedIconProps = {
- name: string;
- };
- const PriceFeedIcon = ({ name }: PriceFeedIconProps) => {
- const [isLoaded, setIsLoaded] = useState(false);
- const setLoaded = useCallback(() => {
- setIsLoaded(true);
- }, [setIsLoaded]);
- const icon = useMemo(() => {
- const nameParts = name.split(".");
- return nameParts.at(-1)?.split("/")[0]?.toLowerCase() ?? "generic";
- }, [name]);
- return (
- <div className="relative size-6">
- <Image
- src={`/currency-icons/${icon}.svg`}
- alt=""
- className={clsx("absolute inset-0 transition", {
- "opacity-0": !isLoaded,
- })}
- width={24}
- height={24}
- onLoad={setLoaded}
- />
- <Image
- src={`/currency-icons/generic.svg`}
- alt=""
- className="size-full"
- width={24}
- height={24}
- />
- </div>
- );
- };
- const DefaultParameterInput = <ParameterName extends string>({
- spec,
- value,
- setParamValues,
- }: ParameterProps<ParameterName>) => {
- const { validationError, internalValue, onChange } = useParameterInput(
- spec,
- value,
- setParamValues,
- );
- const onChangeInput = useCallback(
- (event: ChangeEvent<HTMLInputElement>) => {
- onChange(event.target.value);
- },
- [onChange],
- );
- return (
- <Input
- validationError={validationError}
- label={spec.name}
- description={<Markdown inline>{spec.description}</Markdown>}
- placeholder={PLACEHOLDERS[spec.type]}
- required={true}
- value={internalValue}
- onChange={onChangeInput}
- />
- );
- };
- const useParameterInput = <ParameterName extends string>(
- spec: Parameter<ParameterName>,
- value: string | undefined,
- setParamValues: Dispatch<
- SetStateAction<Partial<Record<ParameterName, string>>>
- >,
- ) => {
- const [internalValue, setInternalValue] = useState(value ?? "");
- const validationError = useMemo(
- () => (internalValue ? getValidationError(spec, internalValue) : undefined),
- [internalValue, spec],
- );
- const onChange = useCallback(
- (value: string) => {
- setInternalValue(value);
- setParamValues((paramValues) => ({
- ...paramValues,
- [spec.name]: value === "" || !isValid(spec, value) ? undefined : value,
- }));
- },
- [setParamValues, spec],
- );
- useEffect(() => {
- if (value) {
- setInternalValue(value);
- }
- }, [value]);
- return { internalValue, validationError, onChange };
- };
- const usePriceFeedSelector = (
- internalValue: string,
- onChange: (value: string) => void,
- ) => {
- const priceFeedList = usePriceFeedList();
- const sortedPriceFeedListWithHexIds = useMemo(() => {
- return priceFeedList.type === PriceFeedListContextType.Loaded
- ? priceFeedList.priceFeedList
- .map((feed) => ({ ...feed, feedId: pubKeyToHex(feed.feedId) }))
- .sort((a, b) => a.name.localeCompare(b.name))
- : [];
- }, [priceFeedList]);
- const selectedPriceFeed = useMemo(
- () =>
- sortedPriceFeedListWithHexIds.find(
- ({ feedId }) => feedId === internalValue,
- // eslint-disable-next-line unicorn/no-null
- ) ?? null,
- [sortedPriceFeedListWithHexIds, internalValue],
- );
- const onSelectPriceFeed = useCallback(
- (priceFeed: PriceFeed | null) => {
- if (priceFeed) {
- onChange(priceFeed.feedId);
- }
- },
- [onChange],
- );
- const filteredPriceFeedList = useMemo(() => {
- if (selectedPriceFeed === null) {
- const query = internalValue.toLowerCase();
- return sortedPriceFeedListWithHexIds.filter(
- ({ name, description }) =>
- name.toLowerCase().includes(query) ||
- description.toLowerCase().includes(query),
- );
- } else {
- return sortedPriceFeedListWithHexIds;
- }
- }, [selectedPriceFeed, internalValue, sortedPriceFeedListWithHexIds]);
- const transformedPriceFeedList = useMemo(
- () =>
- priceFeedList.type === PriceFeedListContextType.Loaded
- ? {
- type: PriceFeedListContextType.Loaded as const,
- list: filteredPriceFeedList,
- }
- : priceFeedList,
- [priceFeedList, filteredPriceFeedList],
- );
- return {
- selectedPriceFeed,
- onSelectPriceFeed,
- priceFeedList: transformedPriceFeedList,
- };
- };
- const pubKeyToHex = (pubKey: string) =>
- [
- "0x",
- ...Array.from(base58.decode(pubKey), (byte) =>
- byte.toString(16).padStart(2, "0"),
- ),
- ].join("");
|