diff --git a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx index 40cf87b008b..bb7eaa2c459 100644 --- a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx +++ b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx @@ -85,17 +85,29 @@ export function MultiNetworkSelector(props: { export function SingleNetworkSelector(props: { chainId: number | undefined; onChange: (chainId: number) => void; + className?: string; + popoverContentClassName?: string; + // if specified - only these chains will be shown + chainIds?: number[]; }) { const { allChains, idToChain } = useAllChainsData(); + const chainsToShow = useMemo(() => { + if (!props.chainIds) { + return allChains; + } + const chainIdSet = new Set(props.chainIds); + return allChains.filter((chain) => chainIdSet.has(chain.chainId)); + }, [allChains, props.chainIds]); + const options = useMemo(() => { - return allChains.map((chain) => { + return chainsToShow.map((chain) => { return { label: chain.name, value: String(chain.chainId), }; }); - }, [allChains]); + }, [chainsToShow]); const searchFn = useCallback( (option: Option, searchValue: string) => { @@ -132,17 +144,22 @@ export function SingleNetworkSelector(props: { [idToChain], ); + const isLoadingChains = allChains.length === 0; + return ( { props.onChange(Number(chainId)); }} - placeholder="Select Chain" + placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"} overrideSearchFn={searchFn} renderOption={renderOption} + className={props.className} + popoverContentClassName={props.popoverContentClassName} + disabled={isLoadingChains} /> ); } diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.tsx index 73896e6edb5..086491ef35b 100644 --- a/apps/dashboard/src/@/components/blocks/select-with-search.tsx +++ b/apps/dashboard/src/@/components/blocks/select-with-search.tsx @@ -30,6 +30,7 @@ interface SelectWithSearchProps searchTerm: string, ) => boolean; renderOption?: (option: { value: string; label: string }) => React.ReactNode; + popoverContentClassName?: string; } export const SelectWithSearch = React.forwardRef< @@ -37,7 +38,18 @@ export const SelectWithSearch = React.forwardRef< SelectWithSearchProps >( ( - { options, onValueChange, placeholder, className, value, ...props }, + { + options, + onValueChange, + placeholder, + className, + value, + renderOption, + overrideSearchFn, + popoverContentClassName, + searchPlaceholder, + ...props + }, ref, ) => { const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); @@ -49,7 +61,6 @@ export const SelectWithSearch = React.forwardRef< // show 50 initially and then 20 more when reaching the end const { itemsToShow, lastItemRef } = useShowMore(50, 20); - const { overrideSearchFn } = props; const optionsToShow = useMemo(() => { const filteredOptions: { @@ -122,7 +133,7 @@ export const SelectWithSearch = React.forwardRef< setIsPopoverOpen(false)} @@ -136,7 +147,7 @@ export const SelectWithSearch = React.forwardRef< {/* Search */}
setSearchValue(e.target.value)} className="!h-auto rounded-b-none border-0 border-border border-b py-3 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0" @@ -175,9 +186,7 @@ export const SelectWithSearch = React.forwardRef<
- {props.renderOption - ? props.renderOption(option) - : option.label} + {renderOption ? renderOption(option) : option.label}
); diff --git a/apps/dashboard/src/@/components/ui/DynamicHeight.tsx b/apps/dashboard/src/@/components/ui/DynamicHeight.tsx index 144b592cf54..9d39437e496 100644 --- a/apps/dashboard/src/@/components/ui/DynamicHeight.tsx +++ b/apps/dashboard/src/@/components/ui/DynamicHeight.tsx @@ -34,7 +34,7 @@ export function DynamicHeight(props: { ); } -function useHeightObserver() { +export function useHeightObserver() { const elementRef = useRef(null); const [height, setHeight] = useState(); diff --git a/apps/dashboard/src/@/components/ui/ScrollShadow/ScrollShadow.module.css b/apps/dashboard/src/@/components/ui/ScrollShadow/ScrollShadow.module.css index 97b4ad5170c..29638d30d84 100644 --- a/apps/dashboard/src/@/components/ui/ScrollShadow/ScrollShadow.module.css +++ b/apps/dashboard/src/@/components/ui/ScrollShadow/ScrollShadow.module.css @@ -2,14 +2,14 @@ position: absolute; left: 0; width: 100%; - height: 40px; + height: 32px; pointer-events: none; } .scrollShadowX { position: absolute; top: 0; - width: 40px; + width: 32px; height: 100%; pointer-events: none; } diff --git a/apps/dashboard/src/@/components/ui/code/CodeBlockContainer.tsx b/apps/dashboard/src/@/components/ui/code/CodeBlockContainer.tsx index f00f0ffd2f9..d927a06cf90 100644 --- a/apps/dashboard/src/@/components/ui/code/CodeBlockContainer.tsx +++ b/apps/dashboard/src/@/components/ui/code/CodeBlockContainer.tsx @@ -11,7 +11,9 @@ export function CodeBlockContainer(props: { children: React.ReactNode; className?: string; scrollableClassName?: string; + scrollableContainerClassName?: string; copyButtonClassName?: string; + shadowColor?: string; }) { const { hasCopied, onCopy } = useClipboard(props.codeToCopy); @@ -24,8 +26,11 @@ export function CodeBlockContainer(props: { > {props.children} diff --git a/apps/dashboard/src/@/components/ui/code/RenderCode.tsx b/apps/dashboard/src/@/components/ui/code/RenderCode.tsx index 8a0197c2409..c938bc67b02 100644 --- a/apps/dashboard/src/@/components/ui/code/RenderCode.tsx +++ b/apps/dashboard/src/@/components/ui/code/RenderCode.tsx @@ -6,6 +6,8 @@ export function RenderCode(props: { className?: string; scrollableClassName?: string; copyButtonClassName?: string; + scrollableContainerClassName?: string; + shadowColor?: string; }) { return (
= ({ keepPreviousDataOnCodeChange = false, copyButtonClassName, ignoreFormattingErrors, + scrollableContainerClassName, + shadowColor, }) => { const codeQuery = useQuery({ queryKey: ["html", code], @@ -43,6 +47,8 @@ export const CodeClient: React.FC = ({ className={className} scrollableClassName={scrollableClassName} copyButtonClassName={copyButtonClassName} + scrollableContainerClassName={scrollableContainerClassName} + shadowColor={shadowColor} /> ); } @@ -54,6 +60,8 @@ export const CodeClient: React.FC = ({ className={className} scrollableClassName={scrollableClassName} copyButtonClassName={copyButtonClassName} + scrollableContainerClassName={scrollableContainerClassName} + shadowColor={shadowColor} /> ); }; diff --git a/apps/dashboard/src/@/components/ui/code/getCodeHtml.tsx b/apps/dashboard/src/@/components/ui/code/getCodeHtml.tsx index bcc6bf5780a..17686e129eb 100644 --- a/apps/dashboard/src/@/components/ui/code/getCodeHtml.tsx +++ b/apps/dashboard/src/@/components/ui/code/getCodeHtml.tsx @@ -10,9 +10,7 @@ function isPrettierSupportedLang(lang: BundledLanguage) { lang === "ts" || lang === "tsx" || lang === "javascript" || - lang === "typescript" || - lang === "css" || - lang === "json" + lang === "typescript" ); } diff --git a/apps/dashboard/src/@/components/ui/code/plaintext-code.tsx b/apps/dashboard/src/@/components/ui/code/plaintext-code.tsx index 9d5f6111ca2..cac994785a9 100644 --- a/apps/dashboard/src/@/components/ui/code/plaintext-code.tsx +++ b/apps/dashboard/src/@/components/ui/code/plaintext-code.tsx @@ -7,6 +7,8 @@ export function PlainTextCodeBlock(props: { className?: string; scrollableClassName?: string; codeClassName?: string; + scrollableContainerClassName?: string; + shadowColor?: string; }) { return ( {props.code} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/blueprint-playground.client.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/blueprint-playground.client.tsx new file mode 100644 index 00000000000..aecd1cc031e --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/blueprint-playground.client.tsx @@ -0,0 +1,751 @@ +"use client"; + +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { useHeightObserver } from "@/components/ui/DynamicHeight"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { + ArrowDownLeftIcon, + ArrowLeftIcon, + ArrowUpRightIcon, + CheckIcon, + CircleAlertIcon, + CopyIcon, + InfoIcon, + PlayIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { type UseFormReturn, useForm } from "react-hook-form"; +import { z } from "zod"; +import { getVercelEnv } from "../../../../../../lib/vercel-utils"; +import type { BlueprintParameter, BlueprintPathMetadata } from "../utils"; + +export function BlueprintPlayground(props: { + metadata: BlueprintPathMetadata; + backLink: string; + clientId: string; + path: string; + isInsightEnabled: boolean; + projectSettingsLink: string; + supportedChainIds: number[]; + authToken: string; +}) { + const [abortController, setAbortController] = + useState(null); + const requestMutation = useMutation({ + mutationFn: async (url: string) => { + const controller = new AbortController(); + setAbortController(controller); + const start = performance.now(); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { + Authorization: `Bearer ${props.authToken}`, + }, + }); + return { + status: res.status, + data: await res.text(), + time: performance.now() - start, + }; + } catch (e) { + const time = performance.now() - start; + if (e instanceof Error) { + return { + data: e.message, + time: time, + }; + } + return { + data: "Failed to fetch", + time: time, + }; + } + }, + }); + + const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + + return ( + { + requestMutation.mutate(url); + }} + response={ + abortController?.signal.aborted ? undefined : requestMutation.data + } + abortRequest={() => { + if (abortController) { + // just abort it - don't set a new controller + abortController.abort(); + } + }} + domain={`https://{chainId}.insight.${thirdwebDomain}.com`} + path={props.path} + isInsightEnabled={props.isInsightEnabled} + projectSettingsLink={props.projectSettingsLink} + supportedChainIds={props.supportedChainIds} + /> + ); +} + +function modifyParametersForPlayground(_parameters: BlueprintParameter[]) { + const parameters = [..._parameters]; + // if chainId parameter is not already present - add it, because we need it for the domain + const chainIdParameter = parameters.find((p) => p.name === "chainId"); + if (!chainIdParameter) { + parameters.unshift({ + name: "chainId", + in: "path", + required: true, + description: "Chain ID", + type: "integer", + }); + } + + // remove the client id parameter if it is present - we will always replace the parameter with project's client id + const clientIdParameterIndex = parameters.findIndex( + (p) => p.name === "clientId", + ); + if (clientIdParameterIndex !== -1) { + parameters.splice(clientIdParameterIndex, 1); + } + + return parameters; +} + +export function BlueprintPlaygroundUI(props: { + backLink: string; + isPending: boolean; + onRun: (url: string) => void; + response: + | { + time: number; + data: undefined | string; + status?: number; + } + | undefined; + clientId: string; + abortRequest: () => void; + domain: string; + path: string; + metadata: BlueprintPathMetadata; + isInsightEnabled: boolean; + projectSettingsLink: string; + supportedChainIds: number[]; +}) { + const parameters = useMemo(() => { + return modifyParametersForPlayground(props.metadata.parameters); + }, [props.metadata.parameters]); + + const formSchema = useMemo(() => { + return createParametersFormSchema(parameters); + }, [parameters]); + + const defaultValues = useMemo(() => { + const values: Record = {}; + for (const param of parameters) { + values[param.name] = param.default || ""; + } + return values; + }, [parameters]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: defaultValues, + }); + + function onSubmit(values: z.infer) { + const url = createBlueprintUrl({ + parameters: parameters, + values: values, + clientId: props.clientId, + domain: props.domain, + path: props.path, + intent: "run", + }); + + props.onRun(url); + } + + // This allows us to always limit the grid height to whatever is the height of left section on desktop + // so that entire left section is always visible, but the right section has a scrollbar if it exceeds the height of left section + const { height, elementRef: leftSectionRef } = useHeightObserver(); + const isMobile = useIsMobileViewport(); + + return ( +
+ +
+ + +
+ {!props.isInsightEnabled && ( + + + + Insight service is disabled for this project + + + You can enable Insight service in{" "} + + {" "} + Project Settings{" "} + + + + )} + +
+ form.getValues()} + clientId={props.clientId} + /> +
+
+ +
+ +
+ +
+
+
+
+
+
+ + ); +} + +function BlueprintMetaHeader(props: { + title: string; + description: string; + backLink: string; +}) { + return ( +
+
+
+ + +
+

+ {props.title} +

+

+ {props.description} +

+
+
+
+
+ ); +} + +function PlaygroundHeader(props: { + parameters: BlueprintParameter[]; + isPending: boolean; + getFormValues: () => Record; + clientId: string; + domain: string; + path: string; +}) { + const [hasCopied, setHasCopied] = useState(false); + return ( +
+
+
+ {/* copy url */} + + + {/* vertical line */} +
+ + {/* domain + path */} +
+
+ {props.domain} + ... +
+
+ {props.path} +
+
+ + {/* Run */} + +
+ + +
+
+ ); +} + +function RequestConfigSection(props: { + parameters: BlueprintParameter[]; + form: ParamtersForm; + domain: string; + path: string; + supportedChainIds: number[]; +}) { + const pathVariables = props.parameters.filter((param) => param.in === "path"); + + const queryParams = props.parameters.filter((param) => param.in === "query"); + + return ( +
+
+ + Request +
+ + {pathVariables.length > 0 && ( + + )} + + {pathVariables.length > 0 && queryParams.length > 0 && } + + {queryParams.length > 0 && ( + + )} +
+ ); +} + +type ParamtersForm = UseFormReturn<{ + [x: string]: string | number; +}>; + +function ParameterSection(props: { + parameters: BlueprintParameter[]; + title: string; + form: ParamtersForm; + domain: string; + path: string; + supportedChainIds: number[]; +}) { + const url = `${props.domain}${props.path}`; + return ( +
+

{props.title}

+
+ {props.parameters.map((param, i) => { + const hasError = !!props.form.formState.errors[param.name]; + return ( + ( + +
+
+
+ {param.name === "chainId" ? "chainId" : param.name} +
+ {param.required && ( + + Required + + )} +
+
+ {param.name === "chainId" ? ( + { + field.onChange({ + target: { value: chainId.toString() }, + }); + }} + className="rounded-none border-0 border-t lg:border-none" + popoverContentClassName="min-w-[calc(100vw-20px)] lg:min-w-[500px]" + chainIds={ + props.supportedChainIds.length > 0 + ? props.supportedChainIds + : undefined + } + /> + ) : ( + <> + + {param.description && ( + + + + )} + + )} +
+
+ +
+ )} + /> + ); + })} +
+
+ ); +} + +function formatMilliseconds(ms: number) { + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + +function ResponseSection(props: { + isPending: boolean; + response: + | { data: undefined | string; status?: number; time: number } + | undefined; + abortRequest: () => void; +}) { + const formattedData = useMemo(() => { + if (!props.response?.data) return undefined; + try { + return JSON.stringify(JSON.parse(props.response.data), null, 2); + } catch { + return props.response.data; + } + }, [props.response]); + + return ( +
+
+
+ + Response + {props.isPending && } + {props.response?.time && !props.isPending && ( + + {formatMilliseconds(props.response.time)} + + )} +
+ {!props.isPending && props.response?.status && ( + = 200 && props.response.status < 300 + ? "success" + : "destructive" + } + > + {props.response.status} + + )} +
+ + {props.isPending && ( +
+ + +
+ )} + + {!props.isPending && !props.response && ( +
+
+
+
+
+ +
+
+
+

Click Run to start a request

+
+
+ )} + + {!props.isPending && props.response && ( + + )} +
+ ); +} + +function createParametersFormSchema(parameters: BlueprintParameter[]) { + const shape: z.ZodRawShape = {}; + for (const param of parameters) { + // integer + if (param.type === "integer") { + const intSchema = z.coerce + .number({ + message: "Must be an integer", + }) + .int({ + message: "Must be an integer", + }); + shape[param.name] = param.required + ? intSchema.min(1, { + message: "Required", + }) + : intSchema.optional(); + } + + // default: string + else { + shape[param.name] = param.required + ? z.string().min(1, { + message: "Required", + }) + : z.string().optional(); + } + } + + return z.object(shape); +} + +function createBlueprintUrl(options: { + parameters: BlueprintParameter[]; + values: Record; + clientId: string; + domain: string; + path: string; + intent: "copy" | "run"; +}) { + const { parameters, domain, path, values, clientId } = options; + + let url = `${domain}${path}`; + // loop over the values and replace {x} or :x with the actual values for paths + // and add query parameters + const pathVariables = parameters.filter((param) => param.in === "path"); + + const queryParams = parameters.filter((param) => param.in === "query"); + + for (const parameter of pathVariables) { + const value = values[parameter.name]; + if (value) { + url = url.replace(`{${parameter.name}}`, value); + url = url.replace(`:${parameter.name}`, value); + } + } + + const searchParams = new URLSearchParams(); + for (const parameter of queryParams) { + const value = values[parameter.name]; + if (value) { + searchParams.append(parameter.name, value); + } + } + + // add client Id search param + if (options.intent === "copy") { + searchParams.append("clientId", clientId); + } + + if (searchParams.toString()) { + url = `${url}?${searchParams.toString()}`; + } + + return url; +} + +function ElapsedTimeCounter() { + const [ms, setMs] = useState(0); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const internal = 100; + const id = setInterval(() => { + setMs((prev) => prev + internal); + }, internal); + + return () => clearInterval(id); + }, []); + + return ( + + {formatMilliseconds(ms)} + + ); +} + +const isMobileMedia = () => { + if (typeof window === "undefined") return false; + return window.matchMedia("(max-width: 640px)").matches; +}; + +function useIsMobileViewport() { + const [state, setState] = useState(isMobileMedia); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (typeof window === "undefined") return; + const handleResize = () => setState(isMobileMedia()); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return state; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/blueprint-playground.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/blueprint-playground.stories.tsx new file mode 100644 index 00000000000..114f823a481 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/blueprint-playground.stories.tsx @@ -0,0 +1,189 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { randomLorem } from "../../../../../../stories/stubs"; +import { mobileViewport } from "../../../../../../stories/utils"; +import type { BlueprintPathMetadata } from "../utils"; +import { BlueprintPlaygroundUI } from "./blueprint-playground.client"; + +const meta = { + title: "Insight/BlueprintPlayground", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: { + metadata: getBlueprintMetadata().test1, + }, +}; + +export const Mobile: Story = { + args: { + metadata: getBlueprintMetadata().test1, + }, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +function Story() { + return ( +
+ + + + +
+ ); +} + +function Variant(props: { + metadata: BlueprintPathMetadata; + isInsightEnabled: boolean; +}) { + const [abortController, setAbortController] = + useState(null); + + const mutation = useMutation({ + mutationFn: async () => { + const controller = new AbortController(); + setAbortController(controller); + const start = performance.now(); + const promise = new Promise((resolve) => + setTimeout(resolve, Math.random() * 4000), + ); + await Promise.race([ + promise, + new Promise((_, reject) => + controller.signal.addEventListener("abort", reject), + ), + ]); + + const dummyResponse = { + data: { + title: "This is a dummy response", + content: crypto.getRandomValues(new Uint8Array(100)), + }, + }; + + return { + data: JSON.stringify(dummyResponse, null, 2), + status: 200, + time: performance.now() - start, + }; + }, + }); + return ( +
+ { + mutation.mutateAsync(); + }} + response={mutation.data} + clientId="68665db28327c771c9a1bd5fc4580a0a" + abortRequest={() => { + abortController?.abort(); + }} + domain="https://insight.thirdweb.com" + path="/foo/bar" + isInsightEnabled={props.isInsightEnabled} + projectSettingsLink="/foo" + supportedChainIds={[ + 2039, 30, 98865, 42793, 1, 1952959480, 37714555429, 8008135, 55244, + 42019, 8453, 480, 84532, 7897, + ]} + /> +
+ ); +} + +function getBlueprintMetadata() { + const fewParams: BlueprintPathMetadata = { + parameters: [ + { + type: "string", + description: "Filter parameters", + name: "filter", + in: "query", + }, + { + type: "string", + description: "Field to group results by", + name: "group_by", + in: "query", + }, + { + type: "string", + description: "Field to sort results by", + name: "sort_by", + in: "query", + }, + { + type: "string", + description: "Sort order (asc or desc)", + name: "sort_order", + in: "query", + }, + { + type: "integer", + description: "Page number for pagination", + name: "page", + in: "query", + }, + { + type: "integer", + default: 5, + description: "Number of items per page", + name: "limit", + in: "query", + }, + { + type: "array", + description: "List of aggregate functions to apply", + name: "aggregate", + in: "query", + }, + ], + description: randomLorem(15), + summary: "Test 1", + }; + + const largeNumberOfParamsMetadata: BlueprintPathMetadata = { + parameters: new Array(20).fill(null).map((_v, i) => { + return { + name: `param-name-${i}`, + in: Math.random() > 0.5 ? "path" : "query", + type: "string", + required: Math.random() > 0.5, + description: `Description for param ${i}`, + }; + }), + description: "This blueprint has a large number of parameters", + summary: "Large number of parameters", + }; + + return { + test1: fewParams, + largeNumberOfParamsMetadata, + }; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/page.tsx new file mode 100644 index 00000000000..9a79d8f5d6a --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/[blueprint_slug]/page.tsx @@ -0,0 +1,83 @@ +import { getProject } from "@/api/projects"; +import { notFound, redirect } from "next/navigation"; +import { getAPIKeyForProjectId } from "../../../../../api/lib/getAPIKeys"; +import { getAuthToken } from "../../../../../api/lib/getAuthToken"; +import { loginRedirect } from "../../../../../login/loginRedirect"; +import { fetchBlueprintSpec } from "../utils"; +import { BlueprintPlayground } from "./blueprint-playground.client"; + +export default async function Page(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + blueprint_slug: string; + }>; + searchParams: Promise<{ path: string }>; +}) { + const [params, searchParams, authToken] = await Promise.all([ + props.params, + props.searchParams, + getAuthToken(), + ]); + + if (!authToken) { + loginRedirect( + `/team/${params.team_slug}/${params.project_slug}/insight/${params.blueprint_slug}?path=${searchParams.path}`, + ); + } + + // invalid url + if (!searchParams.path) { + redirect(`/team/${params.team_slug}/${params.project_slug}/insight`); + } + + const project = await getProject(params.team_slug, params.project_slug); + + if (!project) { + return redirect(`/team/${params.team_slug}`); + } + + const [blueprintSpec, apiKey] = await Promise.all([ + fetchBlueprintSpec({ + authToken, + blueprintId: params.blueprint_slug, + }), + getAPIKeyForProjectId(project.id), + ]); + + // unexpected error - should never happen + if (!apiKey) { + console.error("Failed to get API key for project", { + projectId: project.id, + teamSlug: params.team_slug, + projectSlug: params.project_slug, + }); + notFound(); + } + + const pathMetadata = blueprintSpec.openapiJson.paths[searchParams.path]?.get; + + // invalid url + if (!pathMetadata) { + redirect(`/team/${params.team_slug}/${params.project_slug}/insight`); + } + + const isInsightEnabled = !!apiKey.services?.find((s) => s.name === "insight"); + + const supportedChainIds = + blueprintSpec.openapiJson.servers[0]?.variables.chainId.enum.map(Number) || + []; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsExplorer.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsExplorer.tsx deleted file mode 100644 index 578e1893d4e..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsExplorer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { Layers3 } from "lucide-react"; -import Link from "next/link"; - -export type Blueprint = { - id: string; - name: string; - slug: string; - description: string; -}; - -export function BlueprintsExplorer(props: { - blueprints: Blueprint[]; -}) { - const { blueprints } = props; - return ( -
- {/* Blueprints */} - {blueprints.length === 0 ? ( -
- No blueprints found -
- ) : ( -
- {blueprints.map((blueprint) => { - return ; - })} -
- )} - -
-
- ); -} - -function BlueprintCard(props: { - blueprint: Blueprint; -}) { - const { blueprint } = props; - return ( -
- - -
- -

{blueprint.name}

- - -

- {blueprint.description} -

-
-
- ); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsPage.tsx deleted file mode 100644 index d20105c7738..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsPage.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { type Blueprint, BlueprintsExplorer } from "./BlueprintsExplorer"; -import { BlueprintsPageHeader } from "./BlueprintsPageHeader"; - -export function BlueprintsPage() { - return ( -
- -
- -
- ); -} - -function getProjectBlueprints() { - return [ - { - id: "1", - name: "Transactions", - slug: "transactions-blueprint", - description: "Query transaction data", - }, - { - id: "2", - name: "Events", - slug: "events-blueprint", - description: "Query event data", - }, - { - id: "3", - name: "Tokens", - slug: "tokens-blueprint", - description: "Query ERC-20, ERC-721, and ERC-1155 tokens", - }, - ] as Blueprint[]; -} - -function BlueprintsPageContent() { - const blueprints = getProjectBlueprints(); - - return ; -} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsPageHeader.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsPageHeader.tsx deleted file mode 100644 index ab68babe21b..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/components/BlueprintsPageHeader.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { PlusIcon } from "lucide-react"; - -export function BlueprintsPageHeader() { - return ( -
-
-
-

- Insight -

- -
-
-
- ); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/page.tsx index 4722c8cfab8..9f14d1d1c22 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/page.tsx @@ -1,18 +1,124 @@ +import { Button } from "@/components/ui/button"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { BoxIcon, PlusIcon } from "lucide-react"; +import Link from "next/link"; import { redirect } from "next/navigation"; -import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken"; -import { BlueprintsPage } from "./components/BlueprintsPage"; +import { getAuthToken } from "../../../../api/lib/getAuthToken"; +import { fetchAllBlueprints } from "./utils"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { - const accountAddress = await getAuthTokenWalletAddress(); + const params = await props.params; + const authToken = await getAuthToken(); - if (!accountAddress) { + if (!authToken) { const { team_slug, project_slug } = await props.params; return redirect( `/login?next=${encodeURIComponent(`/team/${team_slug}/${project_slug}/insight`)}`, ); } - return ; + return ( +
+
+
+

+ Insight +

+ + + +
+
+ +
+ +
+
+ ); +} + +async function BlueprintsSection(params: { + authToken: string; + layoutPath: string; +}) { + const blueprints = await fetchAllBlueprints(params); + + return ( +
+

+ Explore Blueprints +

+
+ {blueprints.map((blueprint) => { + const paths = Object.keys(blueprint.openapiJson.paths); + return ( +
+

+ {blueprint.name} +

+

+ {blueprint.description} +

+ +
+ {paths.map((pathName) => { + const pathObj = blueprint.openapiJson.paths[pathName]; + if (!pathObj) { + return null; + } + return ( + + ); + })} +
+
+ ); + })} +
+
+ ); +} + +function BlueprintCard(props: { + href: string; + title: string; + description: string; +}) { + return ( +
+
+
+
+ +
+
+ +
+ +

{props.title}

+ + +

+ {props.description} +

+
+
+
+ ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/utils.ts b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/utils.ts new file mode 100644 index 00000000000..aa8101a5570 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/insight/utils.ts @@ -0,0 +1,117 @@ +import "server-only"; + +import { getVercelEnv } from "../../../../../lib/vercel-utils"; + +type BlueprintListItem = { + id: string; + name: string; + description: string; + slug: string; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +async function fetchBlueprintList(props: { + authToken: string; +}) { + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/blueprints`, + { + headers: { + Authorization: `Bearer ${props.authToken}`, + }, + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to fetch blueprints: ${text}`); + } + + const json = (await res.json()) as { data: BlueprintListItem[] }; + + return json.data; +} + +export type BlueprintParameter = { + type: string; + description: string; + name: string; + in: "path" | "query"; + required?: boolean; + default?: string | number; +}; + +// NOTE: this is not the full object type, irrelevant fields are omitted +type BlueprintSpec = { + id: string; + name: string; + description: string; + openapiJson: { + servers: Array<{ + url: string; + variables: { + chainId: { + // Note: This list is current empty on dev, but works on prod + // so we show all chains in playground if this list is empty, and only show chains in this list if it's not empty + enum: string[]; + }; + }; + }>; + paths: Record< + string, + { + get: BlueprintPathMetadata; + } + >; + }; +}; + +export type BlueprintPathMetadata = { + description: string; + summary: string; + parameters: BlueprintParameter[]; +}; + +export async function fetchBlueprintSpec(params: { + blueprintId: string; + authToken: string; +}) { + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/blueprints/${params.blueprintId}`, + { + headers: { + Authorization: `Bearer ${params.authToken}`, + }, + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to fetch blueprint: ${text}`); + } + + const json = (await res.json()) as { data: BlueprintSpec }; + + return json.data; +} + +export async function fetchAllBlueprints(params: { + authToken: string; +}) { + // fetch list + const blueprintSpecs = await fetchBlueprintList(params); + + // fetch all blueprints + const blueprints = await Promise.all( + blueprintSpecs.map((spec) => + fetchBlueprintSpec({ + blueprintId: spec.id, + authToken: params.authToken, + }), + ), + ); + + return blueprints; +}