From 9b5b581e970854d7120238192ed614181aed589d Mon Sep 17 00:00:00 2001 From: Kien Ngo Date: Mon, 9 Dec 2024 06:35:54 +0700 Subject: [PATCH] update --- .changeset/hip-houses-hear.md | 5 + .../app/react/v5/components/onchain/page.mdx | 23 +++ packages/thirdweb/src/exports/react.ts | 14 ++ .../src/react/web/ui/prebuilt/Chain/name.tsx | 2 +- .../react/web/ui/prebuilt/Chain/provider.tsx | 1 + .../src/react/web/ui/prebuilt/Wallet/icon.tsx | 120 +++++++++++++++ .../web/ui/prebuilt/Wallet/name.test.tsx | 13 ++ .../src/react/web/ui/prebuilt/Wallet/name.tsx | 144 ++++++++++++++++++ .../web/ui/prebuilt/Wallet/provider.test.tsx | 61 ++++++++ .../react/web/ui/prebuilt/Wallet/provider.tsx | 65 ++++++++ 10 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 .changeset/hip-houses-hear.md create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Wallet/icon.tsx create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.test.tsx create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.tsx create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Wallet/provider.test.tsx create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Wallet/provider.tsx diff --git a/.changeset/hip-houses-hear.md b/.changeset/hip-houses-hear.md new file mode 100644 index 00000000000..f6b5453b9fb --- /dev/null +++ b/.changeset/hip-houses-hear.md @@ -0,0 +1,5 @@ +--- +"thirdweb": minor +--- + +Add headless components for Wallets: WalletProvider, WalletIcon and WalletName diff --git a/apps/portal/src/app/react/v5/components/onchain/page.mdx b/apps/portal/src/app/react/v5/components/onchain/page.mdx index 17c72de5d27..301490bd514 100644 --- a/apps/portal/src/app/react/v5/components/onchain/page.mdx +++ b/apps/portal/src/app/react/v5/components/onchain/page.mdx @@ -136,4 +136,27 @@ Build your own UI and interact with onchain data using headless components. description="Component to display the name of a chain" /> +### Wallets + + + + + + + diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index b4bac2d22fc..67618468478 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -269,3 +269,17 @@ export { ChainIcon, type ChainIconProps, } from "../react/web/ui/prebuilt/Chain/icon.js"; + +// Wallet +export { + WalletProvider, + type WalletProviderProps, +} from "../react/web/ui/prebuilt/Wallet/provider.js"; +export { + WalletIcon, + type WalletIconProps, +} from "../react/web/ui/prebuilt/Wallet/icon.js"; +export { + WalletName, + type WalletNameProps, +} from "../react/web/ui/prebuilt/Wallet/name.js"; diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx index 7cbfa0e6a0f..505f7f25554 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx @@ -48,7 +48,7 @@ export interface ChainNameProps * name was not fetched succesfully * @example * ```tsx - * Failed to load} * /> * ``` */ diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.tsx index 97c21ca52d6..a1079668aa6 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.tsx @@ -48,6 +48,7 @@ const ChainProviderContext = /* @__PURE__ */ createContext< * ``` * @component * @chain + * @beta */ export function ChainProvider( props: React.PropsWithChildren, diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/icon.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/icon.tsx new file mode 100644 index 00000000000..4ca48be4848 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/icon.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type { JSX } from "react"; +import { getWalletInfo } from "../../../../../wallets/__generated__/getWalletInfo.js"; +import type { WalletId } from "../../../../../wallets/wallet-types.js"; +import { useWalletContext } from "./provider.js"; + +export interface WalletIconProps + extends Omit, "src"> { + /** + * This component will be shown while the icon of the wallet is being fetched + * If not passed, the component will return `null`. + * + * You can/should pass a loading sign or spinner to this prop. + * @example + * ```tsx + * } /> + * ``` + */ + loadingComponent?: JSX.Element; + /** + * This component will be shown if the icon fails to be retreived + * If not passed, the component will return `null`. + * + * You can/should pass a descriptive text/component to this prop, indicating that the + * icon was not fetched succesfully + * @example + * ```tsx + * Failed to load} + * /> + * ``` + */ + fallbackComponent?: JSX.Element; + /** + * Optional `useQuery` params + */ + queryOptions?: Omit, "queryFn" | "queryKey">; +} + +/** + * This component tries to resolve the icon of a given wallet, then return an image. + * @returns an with the src of the wallet icon + * + * @example + * ### Basic usage + * ```tsx + * import { WalletProvider, WalletIcon } from "thirdweb/react"; + * + * + * + * + * ``` + * + * Result: An component with the src of the icon + * ```html + * + * ``` + * + * ### Show a loading sign while the icon is being loaded + * ```tsx + * } /> + * ``` + * + * ### Fallback to a dummy image if the wallet icon fails to resolve + * ```tsx + * } /> + * ``` + * + * ### Usage with queryOptions + * WalletIcon uses useQuery() from tanstack query internally. + * It allows you to pass a custom queryOptions of your choice for more control of the internal fetching logic + * ```tsx + * + * ``` + * + * @component + * @wallet + * @beta + */ +export function WalletIcon({ + loadingComponent, + fallbackComponent, + queryOptions, + ...restProps +}: WalletIconProps) { + const imageQuery = useWalletIcon({ queryOptions }); + if (imageQuery.isLoading) { + return loadingComponent || null; + } + if (!imageQuery.data) { + return fallbackComponent || null; + } + return {restProps.alt}; +} + +/** + * @internal + */ +function useWalletIcon(props: { + queryOptions?: Omit, "queryFn" | "queryKey">; +}) { + const { id } = useWalletContext(); + const imageQuery = useQuery({ + queryKey: ["walletIcon", id], + queryFn: async () => fetchWalletImage({ id }), + ...props.queryOptions, + }); + return imageQuery; +} + +/** + * @internal Exported for tests only + */ +async function fetchWalletImage(props: { + id: WalletId; +}) { + const image_src = await getWalletInfo(props.id, true); + return image_src; +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.test.tsx new file mode 100644 index 00000000000..985343e13aa --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchWalletName } from "./name.js"; + +vi.mock("./WalletName", () => ({ + useWalletName: vi.fn(), +})); + +describe.runIf(process.env.TW_SECRET_KEY)("WalletName", () => { + it("fetchWalletName: should fetch wallet name from id", async () => { + const name = await fetchWalletName({ id: "io.metamask" }); + expect(name).toBe("MetaMask"); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.tsx new file mode 100644 index 00000000000..29a0d1387e5 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/name.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type { JSX } from "react"; +import { getWalletInfo } from "../../../../../wallets/__generated__/getWalletInfo.js"; +import type { WalletId } from "../../../../../wallets/wallet-types.js"; +import { useWalletContext } from "./provider.js"; + +/** + * Props for the WalletName component + * @component + * @wallet + */ +export interface WalletNameProps + extends Omit, "children"> { + /** + * This component will be shown while the name of the wallet name is being fetched + * If not passed, the component will return `null`. + * + * You can/should pass a loading sign or spinner to this prop. + * @example + * ```tsx + * } /> + * ``` + */ + loadingComponent?: JSX.Element; + /** + * This component will be shown if the name fails to be retreived + * If not passed, the component will return `null`. + * + * You can/should pass a descriptive text/component to this prop, indicating that the + * name was not fetched succesfully + * @example + * ```tsx + * Failed to load} + * /> + * ``` + */ + fallbackComponent?: JSX.Element; + /** + * Optional `useQuery` params + */ + queryOptions?: Omit, "queryFn" | "queryKey">; + /** + * A function to format the name's display value + * ```tsx + * doSomething()} /> + * ``` + */ + formatFn?: (str: string) => string; +} + +/** + * This component fetches then shows the name of a wallet. + * It inherits all the attributes of a HTML component, hence you can style it just like how you would style a normal + * + * @example + * ### Basic usage + * ```tsx + * import { WalletProvider, WalletName } from "thirdweb/react"; + * + * + * + * + * ``` + * Result: + * ```html + * MetaMask + * ``` + * + * ### Show a loading sign when the name is being fetched + * ```tsx + * import { WalletProvider, WalletName } from "thirdweb/react"; + * + * + * } /> + * + * ``` + * + * ### Fallback to something when the name fails to resolve + * ```tsx + * + * Failed to load} /> + * + * ``` + * + * ### Custom query options for useQuery + * This component uses `@tanstack-query`'s useQuery internally. + * You can use the `queryOptions` prop for more fine-grained control + * ```tsx + * + * @component + * @beta + * @wallet + */ +export function WalletName({ + loadingComponent, + fallbackComponent, + queryOptions, + formatFn, + ...restProps +}: WalletNameProps) { + const nameQuery = useWalletName({ queryOptions }); + if (nameQuery.isLoading) { + return loadingComponent || null; + } + if (!nameQuery.data) { + return fallbackComponent || null; + } + if (typeof formatFn === "function") { + return {formatFn(nameQuery.data)}; + } + return {nameQuery.data}; +} + +/** + * @internal + */ +function useWalletName(props: { + queryOptions?: Omit, "queryFn" | "queryKey">; +}) { + const { id } = useWalletContext(); + const nameQuery = useQuery({ + queryKey: ["walletName", id], + queryFn: async () => fetchWalletName({ id }), + ...props.queryOptions, + }); + return nameQuery; +} + +/** + * @internal Exported for tests only + */ +export async function fetchWalletName(props: { + id: WalletId; +}) { + const info = await getWalletInfo(props.id); + return info.name; +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/provider.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/provider.test.tsx new file mode 100644 index 00000000000..db97a5ce152 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/provider.test.tsx @@ -0,0 +1,61 @@ +import { type FC, useContext } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render, renderHook, screen } from "~test/react-render.js"; +import { + WalletProvider, + WalletProviderContext, + useWalletContext, +} from "./provider.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("WalletProvider", () => { + it("useWalletContext should throw an error when used outside of WalletProvider", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + expect(() => { + renderHook(() => useWalletContext()); + }).toThrow( + "WalletProviderContext not found. Make sure you are using WalletIcon, WalletName, etc. inside a component", + ); + + consoleErrorSpy.mockRestore(); + }); + + it("useWalletContext should return the context value when used within WalletProvider", () => { + const wrapper: FC = ({ children }: React.PropsWithChildren) => ( + {children} + ); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + + expect(result.current.id).toStrictEqual("io.metamask"); + }); + + it("should render children correctly", () => { + render( + +
Child Component
+
, + ); + + expect(screen.getByText("Child Component")).toBeInTheDocument(); + }); + + it("should provide context values to children", () => { + function WalletConsumer() { + const context = useContext(WalletProviderContext); + if (!context) { + return
No context
; + } + return
{String(context.id)}
; + } + render( + + + , + ); + + expect(screen.getByText("io.metamask")).toBeInTheDocument(); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/provider.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/provider.tsx new file mode 100644 index 00000000000..75761cc86ae --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Wallet/provider.tsx @@ -0,0 +1,65 @@ +"use client"; + +import type React from "react"; +import { createContext, useContext } from "react"; +import type { WalletId } from "../../../../../wallets/wallet-types.js"; + +/** + * Props for the WalletProvider component + * @component + * @wallet + */ +export type WalletProviderProps = { + id: WalletId; +}; + +/** + * @internal Exported for tests only + */ +export const WalletProviderContext = /* @__PURE__ */ createContext< + WalletProviderProps | undefined +>(undefined); + +/** +/** + * A React context provider component that supplies Wallet-related data to its child components. + * + * This component serves as a wrapper around the `WalletProviderContext.Provider` and passes + * the provided wallet data down to all of its child components through the context API. + * + * @example + * ### Basic usage + * ```tsx + * import { WalletProvider, WalletIcon, WaleltName } from "thirdweb/react"; + * + * + * + * + * + * ``` + * @beta + * @component + * @wallet + */ +export function WalletProvider( + props: React.PropsWithChildren, +) { + return ( + + {props.children} + + ); +} + +/** + * @internal + */ +export function useWalletContext() { + const ctx = useContext(WalletProviderContext); + if (!ctx) { + throw new Error( + "WalletProviderContext not found. Make sure you are using WalletIcon, WalletName, etc. inside a component", + ); + } + return ctx; +}