price-feeds-card.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. "use client";
  2. import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
  3. import { Badge } from "@pythnetwork/component-library/Badge";
  4. import { Card } from "@pythnetwork/component-library/Card";
  5. import { Paginator } from "@pythnetwork/component-library/Paginator";
  6. import { SearchInput } from "@pythnetwork/component-library/SearchInput";
  7. import { Select } from "@pythnetwork/component-library/Select";
  8. import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
  9. import { usePathname } from "next/navigation";
  10. import { type ReactNode, Suspense, useCallback, useMemo } from "react";
  11. import { useFilter, useCollator } from "react-aria";
  12. import { serialize, useQueryParams } from "./query-params";
  13. import { SKELETON_WIDTH, LivePrice, LiveConfidence } from "../LivePrices";
  14. type Props = {
  15. id: string;
  16. nameLoadingSkeleton: ReactNode;
  17. priceFeeds: PriceFeed[];
  18. };
  19. type PriceFeed = {
  20. symbol: string;
  21. id: string;
  22. displaySymbol: string;
  23. assetClassAsString: string;
  24. exponent: number;
  25. numPublishers: number;
  26. priceFeedId: ReactNode;
  27. priceFeedName: ReactNode;
  28. assetClass: ReactNode;
  29. };
  30. export const PriceFeedsCard = ({ priceFeeds, ...props }: Props) => (
  31. <Suspense fallback={<PriceFeedsCardContents isLoading {...props} />}>
  32. <ResolvedPriceFeedsCard priceFeeds={priceFeeds} {...props} />
  33. </Suspense>
  34. );
  35. const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
  36. const {
  37. search,
  38. page,
  39. pageSize,
  40. assetClass,
  41. updateSearch,
  42. updatePage,
  43. updatePageSize,
  44. updateAssetClass,
  45. } = useQueryParams();
  46. const filter = useFilter({ sensitivity: "base", usage: "search" });
  47. const collator = useCollator();
  48. const sortedFeeds = useMemo(
  49. () =>
  50. priceFeeds.sort((a, b) =>
  51. collator.compare(a.displaySymbol, b.displaySymbol),
  52. ),
  53. [priceFeeds, collator],
  54. );
  55. const feedsFilteredByAssetClass = useMemo(
  56. () =>
  57. assetClass
  58. ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass)
  59. : sortedFeeds,
  60. [assetClass, sortedFeeds],
  61. );
  62. const filteredFeeds = useMemo(() => {
  63. if (search === "") {
  64. return feedsFilteredByAssetClass;
  65. } else {
  66. const searchTokens = search
  67. .split(" ")
  68. .flatMap((item) => item.split(","))
  69. .filter(Boolean);
  70. return feedsFilteredByAssetClass.filter((feed) =>
  71. searchTokens.some((token) => filter.contains(feed.symbol, token)),
  72. );
  73. }
  74. }, [search, feedsFilteredByAssetClass, filter]);
  75. const paginatedFeeds = useMemo(
  76. () => filteredFeeds.slice((page - 1) * pageSize, page * pageSize),
  77. [page, pageSize, filteredFeeds],
  78. );
  79. const rows = useMemo(
  80. () =>
  81. paginatedFeeds.map(({ id, ...data }) => ({
  82. id,
  83. href: "#",
  84. data: {
  85. ...data,
  86. price: <LivePrice account={id} />,
  87. confidenceInterval: <LiveConfidence account={id} />,
  88. },
  89. })),
  90. [paginatedFeeds],
  91. );
  92. const numPages = useMemo(
  93. () => Math.ceil(filteredFeeds.length / pageSize),
  94. [filteredFeeds.length, pageSize],
  95. );
  96. const pathname = usePathname();
  97. const mkPageLink = useCallback(
  98. (page: number) => `${pathname}${serialize({ page, pageSize })}`,
  99. [pathname, pageSize],
  100. );
  101. const assetClasses = useMemo(
  102. () =>
  103. [...new Set(priceFeeds.map((feed) => feed.assetClassAsString))].sort(
  104. (a, b) => collator.compare(a, b),
  105. ),
  106. [priceFeeds, collator],
  107. );
  108. return (
  109. <PriceFeedsCardContents
  110. numResults={filteredFeeds.length}
  111. search={search}
  112. assetClass={assetClass}
  113. assetClasses={assetClasses}
  114. numPages={numPages}
  115. page={page}
  116. pageSize={pageSize}
  117. onSearchChange={updateSearch}
  118. onAssetClassChange={updateAssetClass}
  119. onPageSizeChange={updatePageSize}
  120. onPageChange={updatePage}
  121. mkPageLink={mkPageLink}
  122. rows={rows}
  123. {...props}
  124. />
  125. );
  126. };
  127. type PriceFeedsCardContents = Pick<Props, "id" | "nameLoadingSkeleton"> &
  128. (
  129. | { isLoading: true }
  130. | {
  131. isLoading?: false;
  132. numResults: number;
  133. search: string;
  134. assetClass: string;
  135. assetClasses: string[];
  136. numPages: number;
  137. page: number;
  138. pageSize: number;
  139. onSearchChange: (newSearch: string) => void;
  140. onAssetClassChange: (newAssetClass: string) => void;
  141. onPageSizeChange: (newPageSize: number) => void;
  142. onPageChange: (newPage: number) => void;
  143. mkPageLink: (page: number) => string;
  144. rows: RowConfig<
  145. | "priceFeedName"
  146. | "assetClass"
  147. | "priceFeedId"
  148. | "price"
  149. | "confidenceInterval"
  150. | "exponent"
  151. | "numPublishers"
  152. >[];
  153. }
  154. );
  155. const PriceFeedsCardContents = ({
  156. id,
  157. nameLoadingSkeleton,
  158. ...props
  159. }: PriceFeedsCardContents) => (
  160. <Card
  161. id={id}
  162. icon={<ChartLine />}
  163. title={
  164. <>
  165. <span>Price Feeds</span>
  166. {!props.isLoading && (
  167. <Badge style="filled" variant="neutral" size="md">
  168. {props.numResults}
  169. </Badge>
  170. )}
  171. </>
  172. }
  173. toolbar={
  174. <>
  175. <Select<string>
  176. label="Asset Class"
  177. size="sm"
  178. variant="outline"
  179. hideLabel
  180. {...(props.isLoading
  181. ? { isPending: true, options: [], buttonLabel: "Asset Class" }
  182. : {
  183. optionGroups: [
  184. { name: "All", options: [""] },
  185. { name: "Asset classes", options: props.assetClasses },
  186. ],
  187. hideGroupLabel: true,
  188. show: (value) => (value === "" ? "All" : value),
  189. placement: "bottom end",
  190. buttonLabel:
  191. props.assetClass === "" ? "Asset Class" : props.assetClass,
  192. selectedKey: props.assetClass,
  193. onSelectionChange: props.onAssetClassChange,
  194. })}
  195. />
  196. <SearchInput
  197. size="sm"
  198. width={40}
  199. {...(props.isLoading
  200. ? { isPending: true, isDisabled: true }
  201. : {
  202. defaultValue: props.search,
  203. onChange: props.onSearchChange,
  204. })}
  205. />
  206. </>
  207. }
  208. {...(!props.isLoading && {
  209. footer: (
  210. <Paginator
  211. numPages={props.numPages}
  212. currentPage={props.page}
  213. onPageChange={props.onPageChange}
  214. pageSize={props.pageSize}
  215. onPageSizeChange={props.onPageSizeChange}
  216. pageSizeOptions={[10, 20, 30, 40, 50]}
  217. mkPageLink={props.mkPageLink}
  218. />
  219. ),
  220. })}
  221. >
  222. <Table
  223. rounded
  224. fill
  225. label="Price Feeds"
  226. columns={[
  227. {
  228. id: "priceFeedName",
  229. name: "PRICE FEED",
  230. isRowHeader: true,
  231. alignment: "left",
  232. width: 50,
  233. loadingSkeleton: nameLoadingSkeleton,
  234. },
  235. {
  236. id: "assetClass",
  237. name: "ASSET CLASS",
  238. alignment: "left",
  239. width: 60,
  240. loadingSkeletonWidth: 20,
  241. },
  242. {
  243. id: "priceFeedId",
  244. name: "PRICE FEED ID",
  245. alignment: "left",
  246. width: 40,
  247. loadingSkeletonWidth: 30,
  248. },
  249. {
  250. id: "price",
  251. name: "PRICE",
  252. alignment: "right",
  253. width: 40,
  254. loadingSkeletonWidth: SKELETON_WIDTH,
  255. },
  256. {
  257. id: "confidenceInterval",
  258. name: "CONFIDENCE INTERVAL",
  259. alignment: "left",
  260. width: 40,
  261. loadingSkeletonWidth: SKELETON_WIDTH,
  262. },
  263. {
  264. id: "exponent",
  265. name: "EXPONENT",
  266. alignment: "left",
  267. width: 8,
  268. },
  269. {
  270. id: "numPublishers",
  271. name: "# PUBLISHERS",
  272. alignment: "left",
  273. width: 8,
  274. },
  275. ]}
  276. {...(props.isLoading
  277. ? {
  278. isLoading: true,
  279. }
  280. : {
  281. rows: props.rows,
  282. renderEmptyState: () => <p>No results!</p>,
  283. })}
  284. />
  285. </Card>
  286. );