From d12445dabf90dd679bd30ca6d089208e78e89a8c Mon Sep 17 00:00:00 2001 From: Connor Prussin Date: Thu, 19 Dec 2024 17:24:43 -0800 Subject: [PATCH] feat(insights): many insights hub improvements - Add column sorting to tables - Add empty states to searchable tables - Improve error & not found pages - Fix jumpy page transition animations --- .../src/app/price-feeds/[slug]/error.ts | 3 + .../src/components/Error/index.module.scss | 47 +++++ apps/insights/src/components/Error/index.tsx | 22 ++- .../components/NoResults/index.module.scss | 38 ++++ .../src/components/NoResults/index.tsx | 28 +++ .../src/components/NotFound/index.module.scss | 41 +++++ .../src/components/NotFound/index.tsx | 20 ++- .../src/components/Overview/index.module.scss | 1 - .../components/PriceFeed/chart.module.scss | 8 + .../src/components/PriceFeed/chart.tsx | 12 +- .../components/PriceFeed/layout.module.scss | 2 +- .../PriceFeed/price-components-card.tsx | 169 +++++++++++++++--- .../components/PriceFeed/price-components.tsx | 46 +---- .../components/PriceFeed/reference-data.tsx | 2 +- .../components/PriceFeeds/index.module.scss | 2 +- .../PriceFeeds/price-feeds-card.tsx | 53 ++++-- .../components/Publishers/index.module.scss | 25 +-- .../src/components/Publishers/index.tsx | 22 +-- .../components/Publishers/publishers-card.tsx | 103 ++++++++--- .../src/components/Ranking/index.module.scss | 32 ++++ .../insights/src/components/Ranking/index.tsx | 26 +++ .../src/components/Root/index.module.scss | 1 + .../src/use-query-param-filter-pagination.ts | 49 ++++- .../src/Table/index.module.scss | 40 +++++ .../component-library/src/Table/index.tsx | 55 +++++- packages/component-library/src/theme.scss | 2 + 26 files changed, 667 insertions(+), 182 deletions(-) create mode 100644 apps/insights/src/app/price-feeds/[slug]/error.ts create mode 100644 apps/insights/src/components/Error/index.module.scss create mode 100644 apps/insights/src/components/NoResults/index.module.scss create mode 100644 apps/insights/src/components/NoResults/index.tsx create mode 100644 apps/insights/src/components/NotFound/index.module.scss create mode 100644 apps/insights/src/components/PriceFeed/chart.module.scss create mode 100644 apps/insights/src/components/Ranking/index.module.scss create mode 100644 apps/insights/src/components/Ranking/index.tsx diff --git a/apps/insights/src/app/price-feeds/[slug]/error.ts b/apps/insights/src/app/price-feeds/[slug]/error.ts new file mode 100644 index 0000000000..4f357cc1ba --- /dev/null +++ b/apps/insights/src/app/price-feeds/[slug]/error.ts @@ -0,0 +1,3 @@ +"use client"; + +export { Error as default } from "../../../components/Error"; diff --git a/apps/insights/src/components/Error/index.module.scss b/apps/insights/src/components/Error/index.module.scss new file mode 100644 index 0000000000..82eb6edb3f --- /dev/null +++ b/apps/insights/src/components/Error/index.module.scss @@ -0,0 +1,47 @@ +@use "@pythnetwork/component-library/theme"; + +.error { + @include theme.max-width; + + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(12); + align-items: center; + text-align: center; + padding: theme.spacing(36) theme.spacing(0); + + .errorIcon { + font-size: theme.spacing(20); + height: theme.spacing(20); + color: theme.color("states", "error", "color"); + } + + .text { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + align-items: center; + + .header { + @include theme.text("5xl", "semibold"); + + color: theme.color("heading"); + } + + .subheader { + @include theme.text("xl", "light"); + + color: theme.color("heading"); + } + + .details { + @include theme.text("sm", "normal"); + + background: theme.color("background", "secondary"); + border-radius: theme.border-radius("md"); + padding: theme.spacing(4) theme.spacing(6); + color: theme.color("paragraph"); + margin-top: theme.spacing(2); + } + } +} diff --git a/apps/insights/src/components/Error/index.tsx b/apps/insights/src/components/Error/index.tsx index 3d74bfd332..1b891fe6ce 100644 --- a/apps/insights/src/components/Error/index.tsx +++ b/apps/insights/src/components/Error/index.tsx @@ -1,7 +1,10 @@ +import { Warning } from "@phosphor-icons/react/dist/ssr/Warning"; import { useLogger } from "@pythnetwork/app-logger"; import { Button } from "@pythnetwork/component-library/Button"; import { useEffect } from "react"; +import styles from "./index.module.scss"; + type Props = { error: Error & { digest?: string }; reset?: () => void; @@ -15,13 +18,18 @@ export const Error = ({ error, reset }: Props) => { }, [error, logger]); return ( -
-

Uh oh!

-

Something went wrong

-

- Error Details: {error.digest ?? error.message} -

- {reset && } +
+ +
+

Uh oh!

+

Something went wrong

+ {error.digest ?? error.message} +
+ {reset && ( + + )}
); }; diff --git a/apps/insights/src/components/NoResults/index.module.scss b/apps/insights/src/components/NoResults/index.module.scss new file mode 100644 index 0000000000..a2cc28e7da --- /dev/null +++ b/apps/insights/src/components/NoResults/index.module.scss @@ -0,0 +1,38 @@ +@use "@pythnetwork/component-library/theme"; + +.noResults { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + align-items: center; + text-align: center; + padding: theme.spacing(24) 0; + + .searchIcon { + display: grid; + place-content: center; + padding: theme.spacing(4); + background: theme.color("background", "card-highlight"); + font-size: theme.spacing(6); + color: theme.color("highlight"); + border-radius: theme.border-radius("full"); + } + + .text { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(2); + + .header { + @include theme.text("lg", "medium"); + + color: theme.color("heading"); + } + + .body { + @include theme.text("sm", "normal"); + + color: theme.color("paragraph"); + } + } +} diff --git a/apps/insights/src/components/NoResults/index.tsx b/apps/insights/src/components/NoResults/index.tsx new file mode 100644 index 0000000000..d4793fd045 --- /dev/null +++ b/apps/insights/src/components/NoResults/index.tsx @@ -0,0 +1,28 @@ +import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass"; +import { Button } from "@pythnetwork/component-library/Button"; + +import styles from "./index.module.scss"; + +type Props = { + query: string; + onClearSearch?: (() => void) | undefined; +}; + +export const NoResults = ({ query, onClearSearch }: Props) => ( +
+
+ +
+
+

No results found

+

{`We couldn't find any results for "${query}".`}

+
+ {onClearSearch && ( + + )} +
+); diff --git a/apps/insights/src/components/NotFound/index.module.scss b/apps/insights/src/components/NotFound/index.module.scss new file mode 100644 index 0000000000..2b8bf1872f --- /dev/null +++ b/apps/insights/src/components/NotFound/index.module.scss @@ -0,0 +1,41 @@ +@use "@pythnetwork/component-library/theme"; + +.notFound { + @include theme.max-width; + + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(12); + align-items: center; + text-align: center; + padding: theme.spacing(36) theme.spacing(0); + + .searchIcon { + display: grid; + place-content: center; + padding: theme.spacing(8); + background: theme.color("button", "disabled", "background"); + font-size: theme.spacing(12); + color: theme.color("button", "disabled", "foreground"); + border-radius: theme.border-radius("full"); + } + + .text { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + align-items: center; + + .header { + @include theme.text("5xl", "semibold"); + + color: theme.color("heading"); + } + + .subheader { + @include theme.text("xl", "light"); + + color: theme.color("heading"); + } + } +} diff --git a/apps/insights/src/components/NotFound/index.tsx b/apps/insights/src/components/NotFound/index.tsx index 41369c9c77..786d6c7b8f 100644 --- a/apps/insights/src/components/NotFound/index.tsx +++ b/apps/insights/src/components/NotFound/index.tsx @@ -1,9 +1,21 @@ +import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass"; import { Button } from "@pythnetwork/component-library/Button"; +import styles from "./index.module.scss"; + export const NotFound = () => ( -
-

Not Found

-

{"The page you're looking for isn't here"}

- +
+
+ +
+
+

Not Found

+

+ {"The page you're looking for isn't here"} +

+
+
); diff --git a/apps/insights/src/components/Overview/index.module.scss b/apps/insights/src/components/Overview/index.module.scss index b0bb49b71c..33435d9de1 100644 --- a/apps/insights/src/components/Overview/index.module.scss +++ b/apps/insights/src/components/Overview/index.module.scss @@ -8,6 +8,5 @@ color: theme.color("heading"); font-weight: theme.font-weight("semibold"); - margin: theme.spacing(6) 0; } } diff --git a/apps/insights/src/components/PriceFeed/chart.module.scss b/apps/insights/src/components/PriceFeed/chart.module.scss new file mode 100644 index 0000000000..ba27f8bfb1 --- /dev/null +++ b/apps/insights/src/components/PriceFeed/chart.module.scss @@ -0,0 +1,8 @@ +@use "@pythnetwork/component-library/theme"; + +.chartCard { + .chart { + background: theme.color("background", "primary"); + border-radius: theme.border-radius("lg"); + } +} diff --git a/apps/insights/src/components/PriceFeed/chart.tsx b/apps/insights/src/components/PriceFeed/chart.tsx index a1563a1a58..eb5a4e1479 100644 --- a/apps/insights/src/components/PriceFeed/chart.tsx +++ b/apps/insights/src/components/PriceFeed/chart.tsx @@ -1 +1,11 @@ -export const Chart = () =>

Chart

; +import { Card } from "@pythnetwork/component-library/Card"; + +import styles from "./chart.module.scss"; + +export const Chart = () => ( + +
+

This is a chart

+
+
+); diff --git a/apps/insights/src/components/PriceFeed/layout.module.scss b/apps/insights/src/components/PriceFeed/layout.module.scss index cdd35b7387..7c0f7c67ec 100644 --- a/apps/insights/src/components/PriceFeed/layout.module.scss +++ b/apps/insights/src/components/PriceFeed/layout.module.scss @@ -4,7 +4,7 @@ .header { @include theme.max-width; - margin: theme.spacing(6) auto; + margin-bottom: theme.spacing(6); display: flex; flex-flow: column nowrap; gap: theme.spacing(6); diff --git a/apps/insights/src/components/PriceFeed/price-components-card.tsx b/apps/insights/src/components/PriceFeed/price-components-card.tsx index af5296eb6b..81e0e9ee16 100644 --- a/apps/insights/src/components/PriceFeed/price-components-card.tsx +++ b/apps/insights/src/components/PriceFeed/price-components-card.tsx @@ -4,29 +4,32 @@ import { Card } from "@pythnetwork/component-library/Card"; import { Paginator } from "@pythnetwork/component-library/Paginator"; import { type RowConfig, Table } from "@pythnetwork/component-library/Table"; import { type ReactNode, Suspense, useMemo } from "react"; -import { useFilter } from "react-aria"; +import { useFilter, useCollator } from "react-aria"; +import type { SortDescriptor } from "react-aria-components"; import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination"; +import { FormattedNumber } from "../FormattedNumber"; +import { Score } from "../Score"; + +const PUBLISHER_SCORE_WIDTH = 24; type Props = { className?: string | undefined; priceComponents: PriceComponent[]; nameLoadingSkeleton: ReactNode; - scoreLoadingSkeleton: ReactNode; - scoreWidth: number; slug: string; }; type PriceComponent = { id: string; publisherNameAsString: string | undefined; - score: ReactNode; + score: number; name: ReactNode; - uptimeScore: ReactNode; - deviationPenalty: ReactNode; - deviationScore: ReactNode; - stalledPenalty: ReactNode; - stalledScore: ReactNode; + uptimeScore: number; + deviationPenalty: number | null; + deviationScore: number; + stalledPenalty: number; + stalledScore: number; }; export const PriceComponentsCard = ({ @@ -48,12 +51,15 @@ const ResolvedPriceComponentsCard = ({ slug, ...props }: Props) => { + const collator = useCollator(); const filter = useFilter({ sensitivity: "base", usage: "search" }); const { search, + sortDescriptor, page, pageSize, updateSearch, + updateSortDescriptor, updatePage, updatePageSize, paginatedItems, @@ -66,16 +72,107 @@ const ResolvedPriceComponentsCard = ({ filter.contains(priceComponent.id, search) || (priceComponent.publisherNameAsString !== undefined && filter.contains(priceComponent.publisherNameAsString, search)), - { defaultPageSize: 20 }, + (a, b, { column, direction }) => { + switch (column) { + case "score": + case "uptimeScore": + case "deviationScore": + case "stalledScore": + case "stalledPenalty": { + return ( + (direction === "descending" ? -1 : 1) * (a[column] - b[column]) + ); + } + + case "deviationPenalty": { + if (a.deviationPenalty === null && b.deviationPenalty === null) { + return 0; + } else if (a.deviationPenalty === null) { + return direction === "descending" ? 1 : -1; + } else if (b.deviationPenalty === null) { + return direction === "descending" ? -1 : 1; + } else { + return ( + (direction === "descending" ? -1 : 1) * + (a.deviationPenalty - b.deviationPenalty) + ); + } + } + + case "name": { + return ( + (direction === "descending" ? -1 : 1) * + collator.compare( + a.publisherNameAsString ?? a.id, + b.publisherNameAsString ?? b.id, + ) + ); + } + + default: { + return (direction === "descending" ? -1 : 1) * (a.score - b.score); + } + } + }, + { + defaultPageSize: 20, + defaultSort: "score", + defaultDescending: true, + }, ); const rows = useMemo( () => - paginatedItems.map(({ id, ...data }) => ({ - id, - href: `/price-feeds/${slug}/price-components/${id}`, - data, - })), + paginatedItems.map( + ({ + id, + score, + uptimeScore, + deviationPenalty, + deviationScore, + stalledPenalty, + stalledScore, + ...data + }) => ({ + id, + href: `/price-feeds/${slug}/price-components/${id}`, + data: { + ...data, + score: , + uptimeScore: ( + + ), + deviationPenalty: deviationPenalty ? ( + + ) : // eslint-disable-next-line unicorn/no-null + null, + deviationScore: ( + + ), + stalledPenalty: ( + + ), + stalledScore: ( + + ), + }, + }), + ), [paginatedItems, slug], ); @@ -83,10 +180,12 @@ const ResolvedPriceComponentsCard = ({ & ( | { isLoading: true } @@ -106,10 +205,12 @@ type PriceComponentsCardProps = Pick< isLoading?: false; numResults: number; search: string; + sortDescriptor: SortDescriptor; numPages: number; page: number; pageSize: number; onSearchChange: (newSearch: string) => void; + onSortChange: (newSort: SortDescriptor) => void; onPageSizeChange: (newPageSize: number) => void; onPageChange: (newPage: number) => void; mkPageLink: (page: number) => string; @@ -127,8 +228,6 @@ type PriceComponentsCardProps = Pick< const PriceComponentsCardContents = ({ className, - scoreWidth, - scoreLoadingSkeleton, nameLoadingSkeleton, ...props }: PriceComponentsCardProps) => ( @@ -158,49 +257,61 @@ const PriceComponentsCardContents = ({ id: "score", name: "SCORE", alignment: "center", - width: scoreWidth, - loadingSkeleton: scoreLoadingSkeleton, + width: PUBLISHER_SCORE_WIDTH, + loadingSkeleton: , + allowsSorting: true, }, { id: "name", name: "NAME / ID", alignment: "left", isRowHeader: true, - fill: true, loadingSkeleton: nameLoadingSkeleton, + allowsSorting: true, }, { id: "uptimeScore", name: "UPTIME SCORE", alignment: "center", - width: 25, + width: 40, + allowsSorting: true, }, { id: "deviationScore", - name: "DERIVATION SCORE", + name: "DEVIATION SCORE", alignment: "center", - width: 25, + width: 40, + allowsSorting: true, }, { id: "deviationPenalty", - name: "DERIVATION PENALTY", + name: "DEVIATION PENALTY", alignment: "center", - width: 25, + width: 40, + allowsSorting: true, }, { id: "stalledScore", name: "STALLED SCORE", alignment: "center", - width: 25, + width: 40, + allowsSorting: true, }, { id: "stalledPenalty", name: "STALLED PENALTY", alignment: "center", - width: 25, + width: 40, + allowsSorting: true, }, ]} - {...(props.isLoading ? { isLoading: true } : { rows: props.rows })} + {...(props.isLoading + ? { isLoading: true } + : { + rows: props.rows, + sortDescriptor: props.sortDescriptor, + onSortChange: props.onSortChange, + })} /> ); diff --git a/apps/insights/src/components/PriceFeed/price-components.tsx b/apps/insights/src/components/PriceFeed/price-components.tsx index f1cbd173d5..b700631594 100644 --- a/apps/insights/src/components/PriceFeed/price-components.tsx +++ b/apps/insights/src/components/PriceFeed/price-components.tsx @@ -8,11 +8,7 @@ import { PriceComponentsCard } from "./price-components-card"; import styles from "./price-components.module.scss"; import { getRankings } from "../../services/clickhouse"; import { getData } from "../../services/pyth"; -import { FormattedNumber } from "../FormattedNumber"; import { PublisherTag } from "../PublisherTag"; -import { Score } from "../Score"; - -const PUBLISHER_SCORE_WIDTH = 24; type Props = { children: ReactNode; @@ -34,9 +30,7 @@ export const PriceComponents = async ({ children, params }: Props) => { priceComponents={rankings.map((ranking) => ({ id: ranking.publisher, publisherNameAsString: lookupPublisher(ranking.publisher)?.name, - score: ( - - ), + score: ranking.final_score, name: (
@@ -47,41 +41,13 @@ export const PriceComponents = async ({ children, params }: Props) => { )}
), - uptimeScore: ( - - ), - deviationPenalty: ranking.deviation_penalty ? ( - - ) : // eslint-disable-next-line unicorn/no-null - null, - deviationScore: ( - - ), - stalledPenalty: ( - - ), - stalledScore: ( - - ), + uptimeScore: ranking.uptime_score, + deviationPenalty: ranking.deviation_penalty, + deviationScore: ranking.deviation_score, + stalledPenalty: ranking.stalled_penalty, + stalledScore: ranking.stalled_score, }))} nameLoadingSkeleton={} - scoreLoadingSkeleton={} - scoreWidth={PUBLISHER_SCORE_WIDTH} /> {children} diff --git a/apps/insights/src/components/PriceFeed/reference-data.tsx b/apps/insights/src/components/PriceFeed/reference-data.tsx index 69dca2a91b..e33f6a67f9 100644 --- a/apps/insights/src/components/PriceFeed/reference-data.tsx +++ b/apps/insights/src/components/PriceFeed/reference-data.tsx @@ -107,7 +107,7 @@ export const ReferenceData = ({ feed }: Props) => { ( const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => { const logger = useLogger(); const collator = useCollator(); - const sortedPriceFeeds = useMemo( - () => - priceFeeds.sort((a, b) => - collator.compare(a.displaySymbol, b.displaySymbol), - ), - [priceFeeds, collator], - ); const filter = useFilter({ sensitivity: "base", usage: "search" }); const [assetClass, setAssetClass] = useQueryState( "assetClass", @@ -59,17 +54,17 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => { const feedsFilteredByAssetClass = useMemo( () => assetClass - ? sortedPriceFeeds.filter( - (feed) => feed.assetClassAsString === assetClass, - ) - : sortedPriceFeeds, - [assetClass, sortedPriceFeeds], + ? priceFeeds.filter((feed) => feed.assetClassAsString === assetClass) + : priceFeeds, + [assetClass, priceFeeds], ); const { search, + sortDescriptor, page, pageSize, updateSearch, + updateSortDescriptor, updatePage, updatePageSize, paginatedItems, @@ -87,7 +82,17 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => { filter.contains(priceFeed.symbol, token), ); }, + (a, b, { column, direction }) => { + const field = + column === "assetClass" ? "assetClassAsString" : "displaySymbol"; + return ( + (direction === "descending" ? -1 : 1) * + collator.compare(a[field], b[field]) + ); + }, + { defaultSort: "priceFeedName" }, ); + const rows = useMemo( () => paginatedItems.map(({ id, symbol, ...data }) => ({ @@ -120,12 +125,14 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => { & isLoading?: false; numResults: number; search: string; + sortDescriptor: SortDescriptor; + onSortChange: (newSort: SortDescriptor) => void; assetClass: string; assetClasses: string[]; numPages: number; @@ -242,21 +251,22 @@ const PriceFeedsCardContents = ({ name: "PRICE FEED", isRowHeader: true, alignment: "left", - width: 50, loadingSkeleton: nameLoadingSkeleton, + allowsSorting: true, }, { id: "assetClass", name: "ASSET CLASS", alignment: "left", - width: 60, + width: 75, loadingSkeletonWidth: 20, + allowsSorting: true, }, { id: "priceFeedId", name: "PRICE FEED ID", alignment: "left", - width: 40, + width: 50, loadingSkeletonWidth: 30, }, { @@ -270,7 +280,7 @@ const PriceFeedsCardContents = ({ id: "confidenceInterval", name: "CONFIDENCE INTERVAL", alignment: "left", - width: 40, + width: 50, loadingSkeletonWidth: SKELETON_WIDTH, }, { @@ -292,7 +302,16 @@ const PriceFeedsCardContents = ({ } : { rows: props.rows, - renderEmptyState: () =>

No results!

, + sortDescriptor: props.sortDescriptor, + onSortChange: props.onSortChange, + renderEmptyState: () => ( + { + props.onSearchChange(""); + }} + /> + ), })} /> diff --git a/apps/insights/src/components/Publishers/index.module.scss b/apps/insights/src/components/Publishers/index.module.scss index c70256d1e3..84b82bd78b 100644 --- a/apps/insights/src/components/Publishers/index.module.scss +++ b/apps/insights/src/components/Publishers/index.module.scss @@ -9,7 +9,6 @@ color: theme.color("heading"); font-weight: theme.font-weight("semibold"); - margin: theme.spacing(6) 0; } .body { @@ -17,6 +16,7 @@ flex-flow: row nowrap; gap: theme.spacing(12); align-items: flex-start; + margin-top: theme.spacing(6); .stats { display: grid; @@ -110,26 +110,3 @@ font-weight: theme.font-weight("semibold"); } } - -.ranking, -.rankingLoader { - height: theme.spacing(6); - border-radius: theme.border-radius("md"); - width: 100%; -} - -.ranking { - display: inline-block; - text-align: center; - font-size: theme.font-size("sm"); - font-weight: theme.font-weight("medium"); - line-height: theme.spacing(6); - color: light-dark( - theme.pallette-color("steel", 800), - theme.pallette-color("steel", 300) - ); - background: light-dark( - theme.pallette-color("steel", 200), - theme.pallette-color("steel", 700) - ); -} diff --git a/apps/insights/src/components/Publishers/index.tsx b/apps/insights/src/components/Publishers/index.tsx index 563907229b..6fa8b363c0 100644 --- a/apps/insights/src/components/Publishers/index.tsx +++ b/apps/insights/src/components/Publishers/index.tsx @@ -4,11 +4,8 @@ import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb"; import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert"; import { Button } from "@pythnetwork/component-library/Button"; import { Card } from "@pythnetwork/component-library/Card"; -import { Skeleton } from "@pythnetwork/component-library/Skeleton"; import { StatCard } from "@pythnetwork/component-library/StatCard"; import { lookup as lookupPublisher } from "@pythnetwork/known-publishers"; -import clsx from "clsx"; -import type { ComponentProps } from "react"; import { z } from "zod"; import styles from "./index.module.scss"; @@ -20,11 +17,9 @@ import { CLUSTER, getData } from "../../services/pyth"; import { client as stakingClient } from "../../services/staking"; import { FormattedTokens } from "../FormattedTokens"; import { PublisherTag } from "../PublisherTag"; -import { Score } from "../Score"; import { TokenIcon } from "../TokenIcon"; const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n; -const PUBLISHER_SCORE_WIDTH = 24; export const Publishers = async () => { const [publishers, totalFeeds, oisStats] = await Promise.all([ @@ -156,25 +151,16 @@ export const Publishers = async () => { - } nameLoadingSkeleton={} - scoreLoadingSkeleton={ - - } - scoreWidth={PUBLISHER_SCORE_WIDTH} publishers={publishers.map( ({ key, rank, numSymbols, medianScore }) => ({ id: key, nameAsString: lookupPublisher(key)?.name, name: , - ranking: {rank}, + ranking: rank, activeFeeds: numSymbols, inactiveFeeds: totalFeeds - numSymbols, - medianScore: ( - - ), + medianScore: medianScore, }), )} /> @@ -183,10 +169,6 @@ export const Publishers = async () => { ); }; -const Ranking = ({ className, ...props }: ComponentProps<"span">) => ( - -); - const getPublishers = async () => { const rows = await clickhouseClient.query({ query: diff --git a/apps/insights/src/components/Publishers/publishers-card.tsx b/apps/insights/src/components/Publishers/publishers-card.tsx index 50d35c31a0..278ccc5a3c 100644 --- a/apps/insights/src/components/Publishers/publishers-card.tsx +++ b/apps/insights/src/components/Publishers/publishers-card.tsx @@ -7,16 +7,19 @@ import { Paginator } from "@pythnetwork/component-library/Paginator"; import { SearchInput } from "@pythnetwork/component-library/SearchInput"; import { type RowConfig, Table } from "@pythnetwork/component-library/Table"; import { type ReactNode, Suspense, useMemo } from "react"; -import { useFilter } from "react-aria"; +import { useFilter, useCollator } from "react-aria"; +import type { SortDescriptor } from "react-aria-components"; import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination"; +import { NoResults } from "../NoResults"; +import { Ranking } from "../Ranking"; +import { Score } from "../Score"; + +const PUBLISHER_SCORE_WIDTH = 24; type Props = { className?: string | undefined; - rankingLoadingSkeleton: ReactNode; nameLoadingSkeleton: ReactNode; - scoreLoadingSkeleton: ReactNode; - scoreWidth: number; publishers: Publisher[]; }; @@ -24,10 +27,10 @@ type Publisher = { id: string; nameAsString: string | undefined; name: ReactNode; - ranking: ReactNode; - activeFeeds: ReactNode; - inactiveFeeds: ReactNode; - medianScore: ReactNode; + ranking: number; + activeFeeds: number; + inactiveFeeds: number; + medianScore: number; }; export const PublishersCard = ({ publishers, ...props }: Props) => ( @@ -37,12 +40,15 @@ export const PublishersCard = ({ publishers, ...props }: Props) => ( ); const ResolvedPublishersCard = ({ publishers, ...props }: Props) => { + const collator = useCollator(); const filter = useFilter({ sensitivity: "base", usage: "search" }); const { search, + sortDescriptor, page, pageSize, updateSearch, + updateSortDescriptor, updatePage, updatePageSize, paginatedItems, @@ -55,10 +61,47 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => { filter.contains(publisher.id, search) || (publisher.nameAsString !== undefined && filter.contains(publisher.nameAsString, search)), + (a, b, { column, direction }) => { + switch (column) { + case "ranking": + case "activeFeeds": + case "inactiveFeeds": + case "medianScore": { + return ( + (direction === "descending" ? -1 : 1) * (a[column] - b[column]) + ); + } + + case "name": { + return ( + (direction === "descending" ? -1 : 1) * + collator.compare(a.nameAsString ?? a.id, b.nameAsString ?? b.id) + ); + } + + default: { + return ( + (direction === "descending" ? -1 : 1) * (a.ranking - b.ranking) + ); + } + } + }, + { defaultSort: "ranking" }, ); const rows = useMemo( - () => paginatedItems.map(({ id, ...data }) => ({ id, href: "#", data })), + () => + paginatedItems.map(({ id, ranking, medianScore, ...data }) => ({ + id, + href: "#", + data: { + ...data, + ranking: {ranking}, + medianScore: ( + + ), + }, + })), [paginatedItems], ); @@ -66,10 +109,12 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => { { type PublishersCardContentsProps = Pick< Props, - | "className" - | "rankingLoadingSkeleton" - | "nameLoadingSkeleton" - | "scoreLoadingSkeleton" - | "scoreWidth" + "className" | "nameLoadingSkeleton" > & ( | { isLoading: true } @@ -93,10 +134,12 @@ type PublishersCardContentsProps = Pick< isLoading?: false; numResults: number; search: string; + sortDescriptor: SortDescriptor; numPages: number; page: number; pageSize: number; onSearchChange: (newSearch: string) => void; + onSortChange: (newSort: SortDescriptor) => void; onPageSizeChange: (newPageSize: number) => void; onPageChange: (newPage: number) => void; mkPageLink: (page: number) => string; @@ -108,10 +151,7 @@ type PublishersCardContentsProps = Pick< const PublishersCardContents = ({ className, - rankingLoadingSkeleton, nameLoadingSkeleton, - scoreLoadingSkeleton, - scoreWidth, ...props }: PublishersCardContentsProps) => ( , + allowsSorting: true, }, { id: "name", name: "NAME / ID", isRowHeader: true, - fill: true, alignment: "left", loadingSkeleton: nameLoadingSkeleton, + allowsSorting: true, }, { id: "activeFeeds", name: "ACTIVE FEEDS", alignment: "center", - width: 10, + width: 40, + allowsSorting: true, }, { id: "inactiveFeeds", name: "INACTIVE FEEDS", alignment: "center", - width: 10, + width: 45, + allowsSorting: true, }, { id: "medianScore", name: "MEDIAN SCORE", alignment: "right", - width: scoreWidth, - loadingSkeleton: scoreLoadingSkeleton, + width: PUBLISHER_SCORE_WIDTH, + loadingSkeleton: , + allowsSorting: true, }, ]} {...(props.isLoading @@ -198,7 +242,16 @@ const PublishersCardContents = ({ } : { rows: props.rows, - renderEmptyState: () =>

No results!

, + sortDescriptor: props.sortDescriptor, + onSortChange: props.onSortChange, + renderEmptyState: () => ( + { + props.onSearchChange(""); + }} + /> + ), })} />
diff --git a/apps/insights/src/components/Ranking/index.module.scss b/apps/insights/src/components/Ranking/index.module.scss new file mode 100644 index 0000000000..af92933b6e --- /dev/null +++ b/apps/insights/src/components/Ranking/index.module.scss @@ -0,0 +1,32 @@ +@use "@pythnetwork/component-library/theme"; + +.ranking { + height: theme.spacing(6); + border-radius: theme.border-radius("md"); + width: 100%; + display: inline-block; + text-align: center; + font-size: theme.font-size("sm"); + font-weight: theme.font-weight("medium"); + line-height: theme.spacing(6); + color: light-dark( + theme.pallette-color("steel", 800), + theme.pallette-color("steel", 300) + ); + + .skeleton { + width: 100%; + height: 100%; + border-radius: theme.border-radius("md"); + } + + .content { + width: 100%; + height: 100%; + border-radius: theme.border-radius("md"); + background: light-dark( + theme.pallette-color("steel", 200), + theme.pallette-color("steel", 700) + ); + } +} diff --git a/apps/insights/src/components/Ranking/index.tsx b/apps/insights/src/components/Ranking/index.tsx new file mode 100644 index 0000000000..20d0be1148 --- /dev/null +++ b/apps/insights/src/components/Ranking/index.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from "@pythnetwork/component-library/Skeleton"; +import clsx from "clsx"; +import type { ComponentProps } from "react"; + +import styles from "./index.module.scss"; + +type OwnProps = { + isLoading?: boolean | undefined; +}; + +type Props = Omit, keyof OwnProps> & OwnProps; + +export const Ranking = ({ + isLoading, + className, + children, + ...props +}: Props) => ( + + {isLoading ? ( + + ) : ( +
{children}
+ )} +
+); diff --git a/apps/insights/src/components/Root/index.module.scss b/apps/insights/src/components/Root/index.module.scss index 7f2100d8cd..4067b99f9e 100644 --- a/apps/insights/src/components/Root/index.module.scss +++ b/apps/insights/src/components/Root/index.module.scss @@ -13,6 +13,7 @@ $header-height: theme.spacing(20); .main { isolation: isolate; + padding-top: theme.spacing(6); } .header { diff --git a/apps/insights/src/use-query-param-filter-pagination.ts b/apps/insights/src/use-query-param-filter-pagination.ts index f2c12957e5..ee9497d8a8 100644 --- a/apps/insights/src/use-query-param-filter-pagination.ts +++ b/apps/insights/src/use-query-param-filter-pagination.ts @@ -5,15 +5,24 @@ import { usePathname } from "next/navigation"; import { parseAsString, parseAsInteger, + parseAsBoolean, useQueryStates, createSerializer, } from "nuqs"; import { useCallback, useMemo } from "react"; +import type { SortDescriptor } from "react-aria-components"; export const useQueryParamFilterPagination = ( items: T[], predicate: (item: T, term: string) => boolean, - options?: { defaultPageSize: number }, + doSort: (a: T, b: T, descriptor: SortDescriptor) => number, + options?: + | { + defaultPageSize?: number | undefined; + defaultSort?: string | undefined; + defaultDescending?: boolean; + } + | undefined, ) => { const logger = useLogger(); @@ -22,11 +31,24 @@ export const useQueryParamFilterPagination = ( page: parseAsInteger.withDefault(1), pageSize: parseAsInteger.withDefault(options?.defaultPageSize ?? 30), search: parseAsString.withDefault(""), + sort: parseAsString.withDefault(options?.defaultSort ?? ""), + descending: parseAsBoolean.withDefault( + options?.defaultDescending ?? false, + ), }), [options], ); - const [{ search, page, pageSize }, setQuery] = useQueryStates(queryParams); + const [{ search, page, pageSize, sort, descending }, setQuery] = + useQueryStates(queryParams); + + const sortDescriptor = useMemo( + (): SortDescriptor => ({ + column: sort, + direction: descending ? "descending" : "ascending", + }), + [sort, descending], + ); const updateQuery = useCallback( (...params: Parameters) => { @@ -58,14 +80,31 @@ export const useQueryParamFilterPagination = ( [updateQuery], ); + const updateSortDescriptor = useCallback( + ({ column, direction }: SortDescriptor) => { + updateQuery({ + page: 1, + sort: column.toString(), + descending: direction === "descending", + }); + }, + [updateQuery], + ); + const filteredItems = useMemo( () => search === "" ? items : items.filter((item) => predicate(item, search)), [items, search, predicate], ); + + const sortedItems = useMemo( + () => filteredItems.toSorted((a, b) => doSort(a, b, sortDescriptor)), + [filteredItems, sortDescriptor, doSort], + ); + const paginatedItems = useMemo( - () => filteredItems.slice((page - 1) * pageSize, page * pageSize), - [page, pageSize, filteredItems], + () => sortedItems.slice((page - 1) * pageSize, page * pageSize), + [page, pageSize, sortedItems], ); const numPages = useMemo( @@ -85,9 +124,11 @@ export const useQueryParamFilterPagination = ( return { search, + sortDescriptor, page, pageSize, updateSearch, + updateSortDescriptor, updatePage, updatePageSize, paginatedItems, diff --git a/packages/component-library/src/Table/index.module.scss b/packages/component-library/src/Table/index.module.scss index 28e0dc65e2..67f8c2a17b 100644 --- a/packages/component-library/src/Table/index.module.scss +++ b/packages/component-library/src/Table/index.module.scss @@ -88,9 +88,49 @@ top: 0; z-index: 1; + .divider { + width: 1px; + height: theme.spacing(4); + background: theme.color("background", "secondary"); + position: absolute; + right: 0; + top: theme.spacing(3); + } + &[data-sticky] { z-index: 2; } + + &:last-child .divider { + display: none; + } + + &[data-alignment="right"], + &[data-alignment="center"] { + &[data-allows-sorting] { + padding-right: theme.spacing(10); + } + } + + .sortButton { + position: absolute; + right: theme.spacing(2); + top: theme.spacing(2); + + .ascending, + .descending { + opacity: 0.25; + transition: opacity 100ms linear; + } + } + + &[data-sort-direction="ascending"] .sortButton .ascending { + opacity: 1; + } + + &[data-sort-direction="descending"] .sortButton .descending { + opacity: 1; + } } } diff --git a/packages/component-library/src/Table/index.tsx b/packages/component-library/src/Table/index.tsx index 8f13338871..060693d146 100644 --- a/packages/component-library/src/Table/index.tsx +++ b/packages/component-library/src/Table/index.tsx @@ -1,7 +1,7 @@ "use client"; import clsx from "clsx"; -import type { CSSProperties, ReactNode } from "react"; +import type { ComponentProps, CSSProperties, ReactNode } from "react"; import type { RowProps, ColumnProps, @@ -9,6 +9,7 @@ import type { } from "react-aria-components"; import styles from "./index.module.scss"; +import { Button } from "../Button/index.js"; import { Skeleton } from "../Skeleton/index.js"; import { UnstyledCell, @@ -19,7 +20,7 @@ import { UnstyledTableHeader, } from "../UnstyledTable/index.js"; -type TableProps = { +type TableProps = ComponentProps & { className?: string | undefined; fill?: boolean | undefined; rounded?: boolean | undefined; @@ -30,9 +31,9 @@ type TableProps = { renderEmptyState?: TableBodyProps["renderEmptyState"] | undefined; dependencies?: TableBodyProps["dependencies"] | undefined; } & ( - | { isLoading: true; rows?: RowConfig[] | undefined } - | { isLoading?: false | undefined; rows: RowConfig[] } -); + | { isLoading: true; rows?: RowConfig[] | undefined } + | { isLoading?: false | undefined; rows: RowConfig[] } + ); export type ColumnConfig = Omit & { name: ReactNode; @@ -67,6 +68,7 @@ export const Table = ({ isUpdating, renderEmptyState, dependencies, + ...props }: TableProps) => (
({
)} - + {(column: ColumnConfig) => ( - {column.name} + {({ allowsSorting, sort, sortDirection }) => ( + <> + {column.name} + {allowsSorting && ( + + )} +
+ + )} )} diff --git a/packages/component-library/src/theme.scss b/packages/component-library/src/theme.scss index 9e19889ef0..413b9691d6 100644 --- a/packages/component-library/src/theme.scss +++ b/packages/component-library/src/theme.scss @@ -430,6 +430,8 @@ $color: ( "normal": light-dark(pallette-color("steel", 800), pallette-color("steel", 50)), ), + "highlight": + light-dark(pallette-color("violet", 600), pallette-color("violet", 500)), "muted": light-dark(pallette-color("stone", 700), pallette-color("steel", 300)), "border":