Ver código fonte

feat(component-library, insights-hub): added a <DocumentTitle /> component and incorporated it with the full-page price feeds view

benduran 1 semana atrás
pai
commit
b366f26835

+ 46 - 55
apps/insights/src/components/LivePrices/index.tsx

@@ -3,12 +3,20 @@
 import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
 import type { PriceData, PriceComponent } from "@pythnetwork/client";
 import { PriceStatus } from "@pythnetwork/client";
+import { DocumentTitle } from "@pythnetwork/component-library/DocumentTitle";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import type { ReactNode } from "react";
 import { useMemo } from "react";
 import { useDateFormatter } from "react-aria";
 
 import styles from "./index.module.scss";
+import type {
+  LiveAggregatedPriceOrConfidenceProps,
+  LiveComponentConfidenceProps,
+  LiveComponentValueProps,
+  LivePriceOrConfidenceProps,
+  LiveValueProps,
+  PriceProps,
+} from "./types";
 import {
   useLivePriceComponent,
   useLivePriceData,
@@ -21,11 +29,7 @@ export const SKELETON_WIDTH = 20;
 export const LivePrice = ({
   publisherKey,
   ...props
-}: {
-  feedKey: string;
-  publisherKey?: string | undefined;
-  cluster: Cluster;
-}) =>
+}: LivePriceOrConfidenceProps) =>
   publisherKey === undefined ? (
     <LiveAggregatePrice {...props} />
   ) : (
@@ -35,16 +39,15 @@ export const LivePrice = ({
 const LiveAggregatePrice = ({
   feedKey,
   cluster,
-}: {
-  feedKey: string;
-  cluster: Cluster;
-}) => {
+  ...rest
+}: LiveAggregatedPriceOrConfidenceProps) => {
   const { prev, current } = useLivePriceData(cluster, feedKey);
   if (current === undefined) {
-    return <Price />;
+    return <Price {...rest} />;
   } else if (current.status === PriceStatus.Trading) {
     return (
       <Price
+        {...rest}
         current={current.price}
         prev={prev?.price}
         exponent={current.exponent}
@@ -52,7 +55,11 @@ const LiveAggregatePrice = ({
     );
   } else {
     return (
-      <Price current={current.previousPrice} exponent={current.exponent} />
+      <Price
+        {...rest}
+        current={current.previousPrice}
+        exponent={current.exponent}
+      />
     );
   }
 };
@@ -84,30 +91,36 @@ const Price = ({
   prev,
   current,
   exponent,
-}: {
-  prev?: number | undefined;
-  current?: number | undefined;
-  exponent?: number | undefined;
-}) =>
-  current === undefined ? (
-    <Skeleton width={SKELETON_WIDTH} />
-  ) : (
-    <span
-      className={styles.price}
-      data-direction={prev ? getChangeDirection(prev, current) : "flat"}
-    >
-      <FormattedPriceValue n={current} exponent={exponent} />
-    </span>
+  pageTitlePrecision,
+  updatePageTitle = false,
+}: PriceProps) => {
+  if (!current) return <Skeleton width={SKELETON_WIDTH} />;
+
+  return (
+    <>
+      {updatePageTitle && (
+        <DocumentTitle
+          prefix={
+            typeof pageTitlePrecision === "number"
+              ? current.toFixed(pageTitlePrecision)
+              : String(current)
+          }
+        />
+      )}
+      <span
+        className={styles.price}
+        data-direction={prev ? getChangeDirection(prev, current) : "flat"}
+      >
+        <FormattedPriceValue n={current} exponent={exponent} />
+      </span>
+    </>
   );
+};
 
 export const LiveConfidence = ({
   publisherKey,
   ...props
-}: {
-  feedKey: string;
-  publisherKey?: string | undefined;
-  cluster: Cluster;
-}) =>
+}: LivePriceOrConfidenceProps) =>
   publisherKey === undefined ? (
     <LiveAggregateConfidence {...props} />
   ) : (
@@ -117,10 +130,7 @@ export const LiveConfidence = ({
 const LiveAggregateConfidence = ({
   feedKey,
   cluster,
-}: {
-  feedKey: string;
-  cluster: Cluster;
-}) => {
+}: LiveAggregatedPriceOrConfidenceProps) => {
   const { current } = useLivePriceData(cluster, feedKey);
   return (
     <Confidence
@@ -139,11 +149,7 @@ const LiveComponentConfidence = ({
   feedKey,
   publisherKey,
   cluster,
-}: {
-  feedKey: string;
-  publisherKey: string;
-  cluster: Cluster;
-}) => {
+}: LiveComponentConfidenceProps) => {
   const { current } = useLivePriceComponent(cluster, feedKey, publisherKey);
   const { current: priceData } = useLivePriceData(cluster, feedKey);
   return (
@@ -220,13 +226,6 @@ export const LiveLastUpdated = ({
   return formattedTimestamp ?? <Skeleton width={SKELETON_WIDTH} />;
 };
 
-type LiveValueProps<T extends keyof PriceData> = {
-  field: T;
-  feedKey: string;
-  defaultValue?: ReactNode | undefined;
-  cluster: Cluster;
-};
-
 export const LiveValue = <T extends keyof PriceData>({
   feedKey,
   field,
@@ -249,14 +248,6 @@ export const LiveValue = <T extends keyof PriceData>({
   }
 };
 
-type LiveComponentValueProps<T extends keyof PriceComponent["latest"]> = {
-  field: T;
-  feedKey: string;
-  publisherKey: string;
-  defaultValue?: ReactNode | undefined;
-  cluster: Cluster;
-};
-
 export const LiveComponentValue = <T extends keyof PriceComponent["latest"]>({
   feedKey,
   field,

+ 59 - 0
apps/insights/src/components/LivePrices/types.ts

@@ -0,0 +1,59 @@
+import type { PriceComponent, PriceData } from "@pythnetwork/client";
+import type { ReactNode } from "react";
+
+import type { Cluster } from "../../services/pyth";
+
+export type LiveAggregatedPriceOrConfidenceProps = Pick<
+  PriceProps,
+  "updatePageTitle"
+> & {
+  cluster: Cluster;
+  feedKey: string;
+};
+
+export type LivePriceOrConfidenceProps =
+  LiveAggregatedPriceOrConfidenceProps & {
+    publisherKey?: string | undefined;
+  };
+
+export type LiveComponentConfidenceProps = {
+  [key in keyof Omit<
+    Required<LivePriceOrConfidenceProps>,
+    "updatePageTitle"
+  >]: NonNullable<LivePriceOrConfidenceProps[key]>;
+};
+
+export type LiveValueProps<T extends keyof PriceData> = {
+  field: T;
+  feedKey: string;
+  defaultValue?: ReactNode | undefined;
+  cluster: Cluster;
+};
+
+export type LiveComponentValueProps<T extends keyof PriceComponent["latest"]> =
+  {
+    field: T;
+    feedKey: string;
+    publisherKey: string;
+    defaultValue?: ReactNode | undefined;
+    cluster: Cluster;
+  };
+
+export type PriceProps = {
+  current?: number | undefined;
+  exponent?: number | undefined;
+  /**
+   * if specified and `updatePageTitle` is true,
+   * this will truncate the decimal points
+   * to this value.
+   * By default, this is Infinity
+   */
+  pageTitlePrecision?: number | undefined;
+  prev?: number | undefined;
+  /**
+   * if true, will automatically update the document.title
+   * to be prefixed with the price when it changes.
+   * Defaults to false
+   */
+  updatePageTitle?: boolean | undefined;
+};

+ 1 - 0
apps/insights/src/components/PriceFeed/header.tsx

@@ -185,6 +185,7 @@ const PriceFeedHeaderImpl = (props: PriceFeedHeaderImplProps) => (
             <LivePrice
               feedKey={props.feed.product.price_account}
               cluster={Cluster.Pythnet}
+              updatePageTitle
             />
           )
         }

+ 4 - 1
packages/component-library/jest.config.js

@@ -1,4 +1,7 @@
 import { defineJestConfigForNextJs } from "@pythnetwork/jest-config/define-next-config";
 ("@pythnetwork/jest-config");
 
-export default defineJestConfigForNextJs();
+export default defineJestConfigForNextJs({
+  testEnvironment: "jsdom",
+  setupFiles: ["./jest.setup.js"],
+});

+ 3 - 0
packages/component-library/jest.setup.js

@@ -0,0 +1,3 @@
+import React from "react";
+
+globalThis.React = React;

+ 10 - 2
packages/component-library/package.json

@@ -67,6 +67,10 @@
       "types": "./dist/CrossfadeTabPanels/index.d.ts",
       "default": "./dist/CrossfadeTabPanels/index.mjs"
     },
+    "./DocumentTitle": {
+      "types": "./dist/DocumentTitle/index.d.ts",
+      "default": "./dist/DocumentTitle/index.mjs"
+    },
     "./DropdownCaretDown": {
       "types": "./dist/DropdownCaretDown/index.d.ts",
       "default": "./dist/DropdownCaretDown/index.mjs"
@@ -319,7 +323,8 @@
     "test:format": "prettier --check .",
     "test:lint:eslint": "eslint . --max-warnings 0",
     "test:lint:stylelint": "stylelint 'src/**/*.scss' --max-warnings 0",
-    "test:types": "tsc"
+    "test:types": "tsc",
+    "test:unit": "jest"
   },
   "peerDependencies": {
     "next": "catalog:",
@@ -359,6 +364,9 @@
     "@storybook/nextjs": "catalog:",
     "@storybook/react": "catalog:",
     "@svgr/webpack": "catalog:",
+    "@testing-library/dom": "catalog:",
+    "@testing-library/react": "catalog:",
+    "@testing-library/user-event": "catalog:",
     "@types/jest": "catalog:",
     "@types/node": "catalog:",
     "@types/react": "catalog:",
@@ -386,4 +394,4 @@
       "./theme": "./dist/theme.scss"
     }
   }
-}
+}

+ 59 - 0
packages/component-library/src/DocumentTitle/document-title.test.tsx

@@ -0,0 +1,59 @@
+import { setTimeout } from "node:timers/promises";
+
+import { render } from "@testing-library/react";
+
+import { DocumentTitle } from ".";
+
+describe("<DocumentTitle /> tests", () => {
+  afterEach(() => {
+    document.title = "";
+  });
+
+  it("should fully overwrite the page title on load", () => {
+    const title = "pizza pasta pepperoni";
+    render(<DocumentTitle title={title} />);
+
+    expect(document.title).toBe(title);
+  });
+
+  it("should update the title once, then after a delay, do it again", async () => {
+    const title1 = "uno";
+    const { rerender } = render(<DocumentTitle title={title1} />);
+
+    expect(document.title).toBe(title1);
+
+    const title2 = "dos";
+
+    await setTimeout(500);
+    rerender(<DocumentTitle title={title2} />);
+
+    expect(document.title).toBe(title2);
+  });
+
+  it("should prepend a prefix", () => {
+    const initial = "initial title";
+    document.title = initial;
+
+    const prefix = "pizza pasta pepperoni";
+    render(<DocumentTitle prefix={prefix} />);
+
+    expect(document.title).toBe(`${prefix} | ${initial}`);
+  });
+
+  it("should prepend a prefix once, wait a bit, then do it again", async () => {
+    const initial = "initial title";
+    document.title = initial;
+
+    const prefix1 = "pizza pasta pepperoni";
+    const { rerender } = render(<DocumentTitle prefix={prefix1} />);
+
+    expect(document.title).toBe(`${prefix1} | ${initial}`);
+
+    await setTimeout(200);
+
+    const prefix2 = "tacos and burritos";
+    rerender(<DocumentTitle prefix={prefix2} />);
+
+    expect(document.title).toBe(`${prefix2} | ${initial}`);
+  });
+});

+ 45 - 0
packages/component-library/src/DocumentTitle/index.tsx

@@ -0,0 +1,45 @@
+import { useEffect, useRef } from "react";
+
+type DocumentTitleProps = Partial<{
+  /**
+   * set this if you want to keep the existing
+   * title but want some dynamic content prepended
+   * to it
+   */
+  prefix: string;
+
+  /**
+   * set this if you want to explicitly fully-reset the
+   * page title and want to fully control it.
+   */
+  title: string;
+}>;
+
+/**
+ * updates the html document.title property
+ * to whatever you need.
+ * useful if you want to hagve the title become dynamic,
+ * based on user actions or current page information or
+ * events
+ */
+export function DocumentTitle({ prefix, title }: DocumentTitleProps) {
+  /** refs */
+  const initialPageTitleRef = useRef(document.title);
+
+  /** effects */
+  useEffect(() => {
+    if (prefix && title) {
+      throw new Error(
+        "<DocumentTitle /> supports either the prefix or title prop, but not both at the same time",
+      );
+    }
+    if (prefix) {
+      document.title = `${prefix} | ${initialPageTitleRef.current}`;
+    } else if (title) {
+      document.title = title;
+    }
+  }, [prefix, title]);
+
+  // eslint-disable-next-line unicorn/no-null
+  return null;
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 144 - 205
pnpm-lock.yaml


+ 3 - 0
pnpm-workspace.yaml

@@ -90,6 +90,9 @@ catalog:
   "@tailwindcss/forms": ^0.5.10
   "@tailwindcss/postcss": ^4.1.6
   "@tanstack/react-query": ^5.71.5
+  "@testing-library/dom": ^10.4.1
+  "@testing-library/react": ^16.3.0
+  "@testing-library/user-event": ^14.6.1
   "@types/fs-extra": ^11.0.4
   "@types/jest": ^29.5.14
   "@types/mdx": ^2.0.13

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff