Pārlūkot izejas kodu

Merge pull request #3207 from pyth-network/bduran/dynamic-page-title-price-feeds

feat(insights-hub): added dynamic page / tab title when pricing changes occur
Ben Duran 4 dienas atpakaļ
vecāks
revīzija
987e93f104

+ 54 - 75
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,33 @@ 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>
+  updatePageTitle = false,
+}: PriceProps) => {
+  /** hooks */
+  const formatter = usePriceFormatter(exponent);
+
+  if (!current) return <Skeleton width={SKELETON_WIDTH} />;
+
+  /** local variables */
+  const val = formatter.format(current);
+
+  return (
+    <>
+      {updatePageTitle && <DocumentTitle prefix={val} />}
+      <span
+        className={styles.price}
+        data-direction={prev ? getChangeDirection(prev, current) : "flat"}
+      >
+        {val}
+      </span>
+    </>
   );
+};
 
 export const LiveConfidence = ({
   publisherKey,
   ...props
-}: {
-  feedKey: string;
-  publisherKey?: string | undefined;
-  cluster: Cluster;
-}) =>
+}: LivePriceOrConfidenceProps) =>
   publisherKey === undefined ? (
     <LiveAggregateConfidence {...props} />
   ) : (
@@ -117,10 +127,7 @@ export const LiveConfidence = ({
 const LiveAggregateConfidence = ({
   feedKey,
   cluster,
-}: {
-  feedKey: string;
-  cluster: Cluster;
-}) => {
+}: LiveAggregatedPriceOrConfidenceProps) => {
   const { current } = useLivePriceData(cluster, feedKey);
   return (
     <Confidence
@@ -139,11 +146,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 (
@@ -160,29 +163,20 @@ const Confidence = ({
 }: {
   confidence?: number | undefined;
   exponent?: number | undefined;
-}) => (
-  <span className={styles.confidence}>
-    <PlusMinus className={styles.plusMinus} />
-    {confidence === undefined ? (
-      <Skeleton width={SKELETON_WIDTH} />
-    ) : (
-      <span>
-        <FormattedPriceValue n={confidence} exponent={exponent} />
-      </span>
-    )}
-  </span>
-);
-
-const FormattedPriceValue = ({
-  n,
-  exponent,
-}: {
-  n: number;
-  exponent?: number | undefined;
 }) => {
+  /** hooks */
   const formatter = usePriceFormatter(exponent);
 
-  return useMemo(() => formatter.format(n), [n, formatter]);
+  return (
+    <span className={styles.confidence}>
+      <PlusMinus className={styles.plusMinus} />
+      {confidence === undefined ? (
+        <Skeleton width={SKELETON_WIDTH} />
+      ) : (
+        <span>{formatter.format(confidence)}</span>
+      )}
+    </span>
+  );
 };
 
 export const LiveLastUpdated = ({
@@ -220,13 +214,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 +236,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,

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

@@ -0,0 +1,52 @@
+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;
+  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
             />
           )
         }

+ 1 - 1
apps/staking/package.json

@@ -16,7 +16,7 @@
     "test:format": "prettier --check .",
     "test:lint": "eslint . --max-warnings 0",
     "test:types": "tsc",
-    "test:unit": "jest"
+    "test:unit": "test-unit"
   },
   "dependencies": {
     "@amplitude/analytics-browser": "catalog:",

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

@@ -1,4 +1,3 @@
 import { defineJestConfigForNextJs } from "@pythnetwork/jest-config/define-next-config";
-("@pythnetwork/jest-config");
 
 export default defineJestConfigForNextJs();

+ 13 - 1
packages/component-library/package.json

@@ -67,6 +67,14 @@
       "types": "./dist/CrossfadeTabPanels/index.d.ts",
       "default": "./dist/CrossfadeTabPanels/index.mjs"
     },
+    "./DocumentTitle/document-title.test": {
+      "types": "./dist/DocumentTitle/document-title.test.d.ts",
+      "default": "./dist/DocumentTitle/document-title.test.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 +327,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": "test-unit"
   },
   "peerDependencies": {
     "next": "catalog:",
@@ -359,6 +368,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:",

+ 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;
+}

+ 1 - 1
packages/create-pyth-package/src/templates/cli/package.json

@@ -26,7 +26,7 @@
     "start:dev": "tsx ./src/cli.ts",
     "test:lint": "eslint src/ --max-warnings 0",
     "test:format": "prettier --check \"src/**/*.ts\"",
-    "test:unit": "jest"
+    "test:unit": "test-unit"
   },
   "devDependencies": {
     "@cprussin/eslint-config": "catalog:",

+ 1 - 1
packages/create-pyth-package/src/templates/library/package.json

@@ -22,7 +22,7 @@
     "test:lint": "eslint . --max-warnings 0",
     "test:format": "prettier --check .",
     "test:types": "tsc",
-    "test:unit": "jest"
+    "test:unit": "test-unit"
   },
   "devDependencies": {
     "@cprussin/eslint-config": "catalog:",

+ 1 - 1
packages/create-pyth-package/src/templates/web-app/package.json

@@ -25,7 +25,7 @@
     "test:lint:eslint": "eslint . --max-warnings 0",
     "test:lint:stylelint": "stylelint 'src/**/*.scss' --max-warnings 0",
     "test:types": "tsc",
-    "test:unit": "jest"
+    "test:unit": "test-unit"
   },
   "devDependencies": {
     "@axe-core/react": "catalog:",

+ 26 - 1
packages/jest-config/package.json

@@ -12,15 +12,20 @@
     "@cprussin/eslint-config": "catalog:",
     "@cprussin/jest-config": "catalog:",
     "@cprussin/tsconfig": "catalog:",
-    "@types/jest": "^29"
+    "@types/jest": "^29",
+    "@types/react": "catalog:"
   },
   "dependencies": {
     "@swc/core": "^1.15.0",
     "@swc/jest": "^0.2.39",
     "jest-ts-webcompat-resolver": "^1.0.1",
     "jest": "^29",
+    "react": "catalog:",
     "ts-deepmerge": "^7.0.3"
   },
+  "bin": {
+    "test-unit": "./test-unit.mjs"
+  },
   "exports": {
     "./define-config": {
       "require": {
@@ -42,6 +47,26 @@
         "default": "./dist/esm/define-next-config.mjs"
       }
     },
+    "./define-react-config": {
+      "require": {
+        "types": "./dist/cjs/define-react-config.d.ts",
+        "default": "./dist/cjs/define-react-config.cjs"
+      },
+      "import": {
+        "types": "./dist/esm/define-react-config.d.ts",
+        "default": "./dist/esm/define-react-config.mjs"
+      }
+    },
+    "./setup-file-react": {
+      "require": {
+        "types": "./dist/cjs/setup-file-react.d.ts",
+        "default": "./dist/cjs/setup-file-react.cjs"
+      },
+      "import": {
+        "types": "./dist/esm/setup-file-react.d.ts",
+        "default": "./dist/esm/setup-file-react.mjs"
+      }
+    },
     "./package.json": "./package.json"
   },
   "module": "./dist/esm/index.mjs",

+ 4 - 1
packages/jest-config/src/define-next-config.ts

@@ -2,7 +2,10 @@ import type { Config } from "jest";
 import { nextjs } from "@cprussin/jest-config/next";
 import { defineJestConfig } from "./define-config.js";
 import { merge } from "ts-deepmerge";
+import { defineReactConfig } from "./define-react-config.js";
 
 export function defineJestConfigForNextJs(config?: Config): Config {
-  return defineJestConfig(merge(nextjs, config ?? {}) as Config);
+  return defineJestConfig(
+    merge(nextjs, defineReactConfig(), config ?? {}) as Config,
+  );
 }

+ 37 - 0
packages/jest-config/src/define-react-config.ts

@@ -0,0 +1,37 @@
+import type { Config } from "jest";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineJestConfig } from "./define-config.js";
+
+function getThisDirname(): string {
+  // Works in ESM
+  if (typeof import.meta.url === "string") {
+    return path.dirname(fileURLToPath(import.meta.url));
+  }
+  // Works in CJS
+  return __dirname;
+}
+
+/**
+ * sets up a jest test environment that works
+ * for testing react components
+ */
+export function defineReactConfig(config?: Config): Config {
+  const dirname = getThisDirname();
+  const allFiles = fs.readdirSync(dirname);
+
+  const setupFiles = allFiles
+    .filter(
+      (fp) =>
+        fp.endsWith("setup-file-react.mjs") ||
+        fp.endsWith("setup-file-react.cjs"),
+    )
+    .map((fp) => path.join(dirname, fp));
+
+  return defineJestConfig({
+    ...config,
+    setupFiles,
+    testEnvironment: "jsdom",
+  });
+}

+ 5 - 0
packages/jest-config/src/setup-file-react.ts

@@ -0,0 +1,5 @@
+// jest prefers CJS environments, so this is a bit of a hack
+// to properly assign React as a global for all tests
+import("react").then(({ default: React }) => {
+  globalThis.React = React;
+});

+ 21 - 0
packages/jest-config/test-unit.mjs

@@ -0,0 +1,21 @@
+import { execSync } from "node:child_process";
+import path from "node:path";
+
+const jestBinFilePath = path.join(
+  import.meta.dirname,
+  "node_modules",
+  ".bin",
+  "jest",
+);
+
+// necessary evil to allow all Jest users
+// to benefit from ESM imports and use
+// our shared configs (especially for React component tests)
+execSync(
+  `NODE_OPTIONS="--experimental-vm-modules" '${jestBinFilePath}' ${process.argv.slice(2).join(" ")}`.trim(),
+  {
+    cwd: process.cwd(),
+    shell: true,
+    stdio: "inherit",
+  },
+);

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 147 - 202
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

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels