);
};
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) => (
+
+);
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":