parameter-input.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import {
  2. Combobox,
  3. ComboboxInput,
  4. ComboboxOption,
  5. ComboboxOptions,
  6. } from "@headlessui/react";
  7. import { ArrowPathIcon } from "@heroicons/react/24/outline";
  8. import base58 from "bs58";
  9. import clsx from "clsx";
  10. import Image from "next/image";
  11. import {
  12. type ChangeEvent,
  13. type Dispatch,
  14. type SetStateAction,
  15. useState,
  16. useCallback,
  17. useMemo,
  18. useEffect,
  19. } from "react";
  20. import {
  21. type Parameter,
  22. PLACEHOLDERS,
  23. isValid,
  24. getValidationError,
  25. ParameterType,
  26. } from "./parameter";
  27. import {
  28. type PriceFeed,
  29. PriceFeedListContextType,
  30. usePriceFeedList,
  31. } from "../../use-price-feed-list";
  32. import { InlineLink } from "../InlineLink";
  33. import { Input } from "../Input";
  34. import { Markdown } from "../Markdown";
  35. type ParameterProps<ParameterName extends string> = {
  36. spec: Parameter<ParameterName>;
  37. value: string | undefined;
  38. setParamValues: Dispatch<
  39. SetStateAction<Partial<Record<ParameterName, string>>>
  40. >;
  41. };
  42. export const ParameterInput = <ParameterName extends string>(
  43. props: ParameterProps<ParameterName>,
  44. ) => {
  45. switch (props.spec.type) {
  46. case ParameterType.PriceFeedId:
  47. case ParameterType.PriceFeedIdArray: {
  48. return <PriceFeedIdInput {...props} />;
  49. }
  50. default: {
  51. return <DefaultParameterInput {...props} />;
  52. }
  53. }
  54. };
  55. const PriceFeedIdInput = <ParameterName extends string>({
  56. spec,
  57. value,
  58. setParamValues,
  59. }: ParameterProps<ParameterName>) => {
  60. const { validationError, internalValue, onChange } = useParameterInput(
  61. spec,
  62. value,
  63. setParamValues,
  64. );
  65. const { selectedPriceFeed, onSelectPriceFeed, priceFeedList } =
  66. usePriceFeedSelector(internalValue, onChange);
  67. const onChangeInput = useCallback(
  68. (event: ChangeEvent<HTMLInputElement>) => {
  69. onChange(event.target.value);
  70. },
  71. [onChange],
  72. );
  73. return (
  74. <Combobox
  75. value={selectedPriceFeed}
  76. onChange={onSelectPriceFeed}
  77. as="div"
  78. className="group relative"
  79. immediate
  80. virtual={{
  81. options:
  82. priceFeedList.type === PriceFeedListContextType.Loaded
  83. ? priceFeedList.list
  84. : [],
  85. }}
  86. >
  87. <ComboboxInput
  88. as={Input}
  89. displayValue={() =>
  90. selectedPriceFeed
  91. ? `${selectedPriceFeed.name} (${selectedPriceFeed.feedId})`
  92. : internalValue
  93. }
  94. onChange={onChangeInput}
  95. validationError={validationError}
  96. label={spec.name}
  97. description={<Markdown inline>{spec.description}</Markdown>}
  98. placeholder={PLACEHOLDERS[spec.type]}
  99. required={true}
  100. />
  101. <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">
  102. <PriceFeedListOptions priceFeedList={priceFeedList} />
  103. {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
  104. <div
  105. className="bg-neutral-200 p-1 px-2 text-right text-xs dark:bg-neutral-700"
  106. onMouseDown={(e) => {
  107. e.preventDefault();
  108. }}
  109. >
  110. See all price feed IDs on{" "}
  111. <InlineLink
  112. target="_blank"
  113. href="https://pyth.network/developers/price-feed-ids"
  114. >
  115. the reference page
  116. </InlineLink>
  117. </div>
  118. </div>
  119. </Combobox>
  120. );
  121. };
  122. type PriceFeedListOptionsProps = {
  123. priceFeedList: ReturnType<typeof usePriceFeedSelector>["priceFeedList"];
  124. };
  125. const PriceFeedListOptions = ({ priceFeedList }: PriceFeedListOptionsProps) => {
  126. if (priceFeedList.type === PriceFeedListContextType.Loaded) {
  127. return priceFeedList.list.length === 0 ? (
  128. <div className="flex w-full items-center justify-center py-10">
  129. No matching price feeds
  130. </div>
  131. ) : (
  132. <ComboboxOptions className="h-80 overflow-y-auto py-1" modal={false}>
  133. {({ option }) => {
  134. // The `option` parameter is typed as `unknown` and we have to
  135. // cast to get it to be correctly typed, see
  136. // https://github.com/tailwindlabs/headlessui/issues/3326
  137. const { feedId, name, description } = option as PriceFeed;
  138. return (
  139. <ComboboxOption
  140. key={feedId}
  141. value={option}
  142. 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"
  143. >
  144. <PriceFeedIcon name={name} />
  145. <div>
  146. <div className="flex flex-row items-center gap-3">
  147. <div className="font-medium">{name}</div>
  148. <div className="text-xs">{description}</div>
  149. </div>
  150. <div className="text-xs text-neutral-600 dark:text-neutral-400">
  151. {feedId}
  152. </div>
  153. </div>
  154. </ComboboxOption>
  155. );
  156. }}
  157. </ComboboxOptions>
  158. );
  159. } else {
  160. return (
  161. <div className="flex w-full items-center justify-center py-10">
  162. <ArrowPathIcon className="size-6 animate-spin" />
  163. </div>
  164. );
  165. }
  166. };
  167. type PriceFeedIconProps = {
  168. name: string;
  169. };
  170. const PriceFeedIcon = ({ name }: PriceFeedIconProps) => {
  171. const [isLoaded, setIsLoaded] = useState(false);
  172. const setLoaded = useCallback(() => {
  173. setIsLoaded(true);
  174. }, [setIsLoaded]);
  175. const icon = useMemo(() => {
  176. const nameParts = name.split(".");
  177. return nameParts.at(-1)?.split("/")[0]?.toLowerCase() ?? "generic";
  178. }, [name]);
  179. return (
  180. <div className="relative size-6">
  181. <Image
  182. src={`/currency-icons/${icon}.svg`}
  183. alt=""
  184. className={clsx("absolute inset-0 transition", {
  185. "opacity-0": !isLoaded,
  186. })}
  187. width={24}
  188. height={24}
  189. onLoad={setLoaded}
  190. />
  191. <Image
  192. src={`/currency-icons/generic.svg`}
  193. alt=""
  194. className="size-full"
  195. width={24}
  196. height={24}
  197. />
  198. </div>
  199. );
  200. };
  201. const DefaultParameterInput = <ParameterName extends string>({
  202. spec,
  203. value,
  204. setParamValues,
  205. }: ParameterProps<ParameterName>) => {
  206. const { validationError, internalValue, onChange } = useParameterInput(
  207. spec,
  208. value,
  209. setParamValues,
  210. );
  211. const onChangeInput = useCallback(
  212. (event: ChangeEvent<HTMLInputElement>) => {
  213. onChange(event.target.value);
  214. },
  215. [onChange],
  216. );
  217. return (
  218. <Input
  219. validationError={validationError}
  220. label={spec.name}
  221. description={<Markdown inline>{spec.description}</Markdown>}
  222. placeholder={PLACEHOLDERS[spec.type]}
  223. required={true}
  224. value={internalValue}
  225. onChange={onChangeInput}
  226. />
  227. );
  228. };
  229. const useParameterInput = <ParameterName extends string>(
  230. spec: Parameter<ParameterName>,
  231. value: string | undefined,
  232. setParamValues: Dispatch<
  233. SetStateAction<Partial<Record<ParameterName, string>>>
  234. >,
  235. ) => {
  236. const [internalValue, setInternalValue] = useState(value ?? "");
  237. const validationError = useMemo(
  238. () => (internalValue ? getValidationError(spec, internalValue) : undefined),
  239. [internalValue, spec],
  240. );
  241. const onChange = useCallback(
  242. (value: string) => {
  243. setInternalValue(value);
  244. setParamValues((paramValues) => ({
  245. ...paramValues,
  246. [spec.name]: value === "" || !isValid(spec, value) ? undefined : value,
  247. }));
  248. },
  249. [setParamValues, spec],
  250. );
  251. useEffect(() => {
  252. if (value) {
  253. setInternalValue(value);
  254. }
  255. }, [value]);
  256. return { internalValue, validationError, onChange };
  257. };
  258. const usePriceFeedSelector = (
  259. internalValue: string,
  260. onChange: (value: string) => void,
  261. ) => {
  262. const priceFeedList = usePriceFeedList();
  263. const sortedPriceFeedListWithHexIds = useMemo(() => {
  264. return priceFeedList.type === PriceFeedListContextType.Loaded
  265. ? priceFeedList.priceFeedList
  266. .map((feed) => ({ ...feed, feedId: pubKeyToHex(feed.feedId) }))
  267. .sort((a, b) => a.name.localeCompare(b.name))
  268. : [];
  269. }, [priceFeedList]);
  270. const selectedPriceFeed = useMemo(
  271. () =>
  272. sortedPriceFeedListWithHexIds.find(
  273. ({ feedId }) => feedId === internalValue,
  274. // eslint-disable-next-line unicorn/no-null
  275. ) ?? null,
  276. [sortedPriceFeedListWithHexIds, internalValue],
  277. );
  278. const onSelectPriceFeed = useCallback(
  279. (priceFeed: PriceFeed | null) => {
  280. if (priceFeed) {
  281. onChange(priceFeed.feedId);
  282. }
  283. },
  284. [onChange],
  285. );
  286. const filteredPriceFeedList = useMemo(() => {
  287. if (selectedPriceFeed === null) {
  288. const query = internalValue.toLowerCase();
  289. return sortedPriceFeedListWithHexIds.filter(
  290. ({ name, description }) =>
  291. name.toLowerCase().includes(query) ||
  292. description.toLowerCase().includes(query),
  293. );
  294. } else {
  295. return sortedPriceFeedListWithHexIds;
  296. }
  297. }, [selectedPriceFeed, internalValue, sortedPriceFeedListWithHexIds]);
  298. const transformedPriceFeedList = useMemo(
  299. () =>
  300. priceFeedList.type === PriceFeedListContextType.Loaded
  301. ? {
  302. type: PriceFeedListContextType.Loaded as const,
  303. list: filteredPriceFeedList,
  304. }
  305. : priceFeedList,
  306. [priceFeedList, filteredPriceFeedList],
  307. );
  308. return {
  309. selectedPriceFeed,
  310. onSelectPriceFeed,
  311. priceFeedList: transformedPriceFeedList,
  312. };
  313. };
  314. const pubKeyToHex = (pubKey: string) =>
  315. [
  316. "0x",
  317. ...Array.from(base58.decode(pubKey), (byte) =>
  318. byte.toString(16).padStart(2, "0"),
  319. ),
  320. ].join("");