From 9e46cd4c7c20e1a1b871ad7601e108a7bfb45d1b Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Sun, 14 Jul 2024 09:37:40 -0400 Subject: [PATCH] refactor: migrate api playground to using atoms (#1156) --- .../src/api-playground/PlaygroundButton.tsx | 9 +- .../src/api-playground/PlaygroundContext.tsx | 92 +------ .../src/api-playground/PlaygroundDrawer.tsx | 245 +----------------- .../src/api-playground/PlaygroundEndpoint.tsx | 45 ++-- .../PlaygroundEndpointSelectorContent.tsx | 2 +- .../api-playground/PlaygroundWebSocket.tsx | 15 +- packages/ui/app/src/api-playground/utils.ts | 89 ++++++- packages/ui/app/src/atoms/playground.ts | 170 +++++++++++- packages/ui/app/src/next-app/DocsPage.tsx | 5 +- 9 files changed, 302 insertions(+), 370 deletions(-) diff --git a/packages/ui/app/src/api-playground/PlaygroundButton.tsx b/packages/ui/app/src/api-playground/PlaygroundButton.tsx index 9b334c9c18..c9e50d1b07 100644 --- a/packages/ui/app/src/api-playground/PlaygroundButton.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundButton.tsx @@ -2,11 +2,10 @@ import { FernNavigation } from "@fern-api/fdr-sdk"; import { FernButton, FernTooltip, FernTooltipProvider } from "@fern-ui/components"; import { useAtomValue } from "jotai"; import { FC } from "react"; -import { HAS_PLAYGROUND_ATOM } from "../atoms"; -import { useSetAndOpenPlayground } from "./PlaygroundContext"; +import { HAS_PLAYGROUND_ATOM, useSetAndOpenPlayground } from "../atoms"; export const PlaygroundButton: FC<{ state: FernNavigation.NavigationNodeApiLeaf }> = ({ state }) => { - const setSelectionStateAndOpen = useSetAndOpenPlayground(); + const openPlayground = useSetAndOpenPlayground(); const hasPlayground = useAtomValue(HAS_PLAYGROUND_ATOM); if (!hasPlayground) { @@ -23,9 +22,7 @@ export const PlaygroundButton: FC<{ state: FernNavigation.NavigationNodeApiLeaf } > { - setSelectionStateAndOpen(state); - }} + onClick={() => openPlayground(state)} rightIcon="play" variant="outlined" intent="primary" diff --git a/packages/ui/app/src/api-playground/PlaygroundContext.tsx b/packages/ui/app/src/api-playground/PlaygroundContext.tsx index cc85081db0..4a1d4bd7d6 100644 --- a/packages/ui/app/src/api-playground/PlaygroundContext.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundContext.tsx @@ -1,34 +1,22 @@ -import { FernNavigation } from "@fern-api/fdr-sdk"; -import * as Sentry from "@sentry/nextjs"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtomValue } from "jotai"; import dynamic from "next/dynamic"; -import { FC, PropsWithChildren, createContext, useCallback, useContext, useEffect } from "react"; +import { FC, useEffect } from "react"; import useSWR from "swr"; -import { noop } from "ts-essentials"; import urljoin from "url-join"; -import { capturePosthogEvent } from "../analytics/posthog"; -import { APIS_ATOM, FLATTENED_APIS_ATOM, store, useBasePath } from "../atoms"; -import { - HAS_PLAYGROUND_ATOM, - PLAYGROUND_FORM_STATE_ATOM, - useInitPlaygroundRouter, - useOpenPlayground, -} from "../atoms/playground"; -import { ResolvedApiDefinition, ResolvedRootPackage, isEndpoint, isWebSocket } from "../resolver/types"; -import { getInitialEndpointRequestFormStateWithExample } from "./PlaygroundDrawer"; +import { APIS_ATOM, store, useBasePath } from "../atoms"; +import { HAS_PLAYGROUND_ATOM, useInitPlaygroundRouter } from "../atoms/playground"; +import { ResolvedRootPackage } from "../resolver/types"; const PlaygroundDrawer = dynamic(() => import("./PlaygroundDrawer").then((m) => m.PlaygroundDrawer), { ssr: false, }); -const PlaygroundContext = createContext<(state: FernNavigation.NavigationNodeApiLeaf) => void>(noop); - const fetcher = async (url: string) => { const res = await fetch(url); return res.json(); }; -export const PlaygroundContextProvider: FC = ({ children }) => { +export const PlaygroundContextProvider: FC = () => { const basePath = useBasePath(); const key = urljoin(basePath ?? "", "/api/fern-docs/resolve-api"); @@ -41,74 +29,8 @@ export const PlaygroundContextProvider: FC = ({ children }) = } }, [data]); - const flattenedApis = useAtomValue(FLATTENED_APIS_ATOM); - - const [globalFormState, setGlobalFormState] = useAtom(PLAYGROUND_FORM_STATE_ATOM); - useInitPlaygroundRouter(); - const openPlayground = useOpenPlayground(); - - const setSelectionStateAndOpen = useCallback( - async (newSelectionState: FernNavigation.NavigationNodeApiLeaf) => { - const matchedPackage = flattenedApis[newSelectionState.apiDefinitionId]; - if (matchedPackage == null) { - Sentry.captureMessage("Could not find package for API playground selection state", "fatal"); - return; - } - - if (newSelectionState.type === "endpoint") { - const matchedEndpoint = matchedPackage.apiDefinitions.find( - (definition) => isEndpoint(definition) && definition.id === newSelectionState.endpointId, - ) as ResolvedApiDefinition.Endpoint | undefined; - if (matchedEndpoint == null) { - Sentry.captureMessage("Could not find endpoint for API playground selection state", "fatal"); - } - openPlayground(newSelectionState.id); - capturePosthogEvent("api_playground_opened", { - endpointId: newSelectionState.endpointId, - endpointName: matchedEndpoint?.title, - }); - if (matchedEndpoint != null && globalFormState[newSelectionState.id] == null) { - setGlobalFormState((currentFormState) => { - return { - ...currentFormState, - [newSelectionState.id]: getInitialEndpointRequestFormStateWithExample( - matchedPackage?.auth, - matchedEndpoint, - matchedEndpoint?.examples[0], - matchedPackage?.types ?? {}, - ), - }; - }); - } - } else if (newSelectionState.type === "webSocket") { - const matchedWebSocket = matchedPackage.apiDefinitions.find( - (definition) => isWebSocket(definition) && definition.id === newSelectionState.webSocketId, - ) as ResolvedApiDefinition.Endpoint | undefined; - if (matchedWebSocket == null) { - Sentry.captureMessage("Could not find websocket for API playground selection state", "fatal"); - } - openPlayground(newSelectionState.id); - capturePosthogEvent("api_playground_opened", { - webSocketId: newSelectionState.webSocketId, - webSocketName: matchedWebSocket?.title, - }); - } - }, - [flattenedApis, globalFormState, openPlayground, setGlobalFormState], - ); - const hasPlayground = useAtomValue(HAS_PLAYGROUND_ATOM); - - return ( - - {children} - {hasPlayground && } - - ); + return hasPlayground ? : null; }; - -export function useSetAndOpenPlayground(): (state: FernNavigation.NavigationNodeApiLeaf) => void { - return useContext(PlaygroundContext); -} diff --git a/packages/ui/app/src/api-playground/PlaygroundDrawer.tsx b/packages/ui/app/src/api-playground/PlaygroundDrawer.tsx index a317b9a3f0..0e034b94a5 100644 --- a/packages/ui/app/src/api-playground/PlaygroundDrawer.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundDrawer.tsx @@ -1,18 +1,17 @@ -import { APIV1Read, FernNavigation } from "@fern-api/fdr-sdk"; +import { FernNavigation } from "@fern-api/fdr-sdk"; import { FernButton } from "@fern-ui/components"; -import { EMPTY_OBJECT, visitDiscriminatedUnion } from "@fern-ui/core-utils"; +import { EMPTY_OBJECT } from "@fern-ui/core-utils"; import * as Dialog from "@radix-ui/react-dialog"; import { ArrowLeftIcon, Cross1Icon } from "@radix-ui/react-icons"; import { motion, useAnimate, useMotionValue } from "framer-motion"; -import { useAtom, useAtomValue } from "jotai"; -import { mapValues } from "lodash-es"; -import { Dispatch, ReactElement, SetStateAction, memo, useCallback, useEffect, useMemo } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { ReactElement, memo, useCallback, useEffect, useMemo } from "react"; import { useFlattenedApis, useSidebarNodes } from "../atoms"; import { - PLAYGROUND_FORM_STATE_ATOM, useClosePlayground, useHasPlayground, useIsPlaygroundOpen, + usePlaygroundFormStateAtom, usePlaygroundHeight, usePlaygroundNode, useSetPlaygroundHeight, @@ -25,47 +24,12 @@ import { useWindowHeight, } from "../atoms/viewport"; import { FernErrorBoundary } from "../components/FernErrorBoundary"; -import { - ResolvedApiDefinition, - ResolvedEndpointDefinition, - ResolvedExampleEndpointCall, - ResolvedTypeDefinition, - ResolvedWebSocketChannel, - isEndpoint, - isWebSocket, -} from "../resolver/types"; +import { ResolvedApiDefinition, isEndpoint, isWebSocket } from "../resolver/types"; import { PlaygroundEndpoint } from "./PlaygroundEndpoint"; import { PlaygroundEndpointSelectorContent, flattenApiSection } from "./PlaygroundEndpointSelectorContent"; import { PlaygroundWebSocket } from "./PlaygroundWebSocket"; import { HorizontalSplitPane } from "./VerticalSplitPane"; -import { - PlaygroundEndpointRequestFormState, - PlaygroundFormDataEntryValue, - PlaygroundRequestFormAuth, - PlaygroundWebSocketRequestFormState, -} from "./types"; import { useVerticalSplitPane } from "./useSplitPlane"; -import { getDefaultValueForObjectProperties, getDefaultValueForType, getDefaultValuesForBody } from "./utils"; - -const EMPTY_ENDPOINT_FORM_STATE: PlaygroundEndpointRequestFormState = { - type: "endpoint", - auth: undefined, - headers: {}, - pathParameters: {}, - queryParameters: {}, - body: undefined, -}; - -const EMPTY_WEBSOCKET_FORM_STATE: PlaygroundWebSocketRequestFormState = { - type: "websocket", - - auth: undefined, - headers: {}, - pathParameters: {}, - queryParameters: {}, - - messages: {}, -}; export const PlaygroundDrawer = memo((): ReactElement | null => { const windowHeight = useWindowHeight(); @@ -123,52 +87,6 @@ export const PlaygroundDrawer = memo((): ReactElement | null => { }, [animate, height, isMobileScreen, isResizing, justNavigated, scope, windowHeight, x]); const isPlaygroundOpen = useIsPlaygroundOpen(); - const [globalFormState, setGlobalFormState] = useAtom(PLAYGROUND_FORM_STATE_ATOM); - - const setPlaygroundEndpointFormState = useCallback>>( - (newFormState) => { - if (selectionState == null) { - return; - } - setGlobalFormState((currentGlobalFormState) => { - let currentFormState = currentGlobalFormState[selectionState.id]; - if (currentFormState == null || currentFormState.type !== "endpoint") { - currentFormState = EMPTY_ENDPOINT_FORM_STATE; - } - const mutatedFormState = - typeof newFormState === "function" ? newFormState(currentFormState) : newFormState; - return { - ...currentGlobalFormState, - [selectionState.id]: mutatedFormState, - }; - }); - }, - [selectionState, setGlobalFormState], - ); - - const setPlaygroundWebSocketFormState = useCallback>>( - (newFormState) => { - if (selectionState == null) { - return; - } - setGlobalFormState((currentGlobalFormState) => { - let currentFormState = currentGlobalFormState[selectionState.id]; - if (currentFormState == null || currentFormState.type !== "websocket") { - currentFormState = EMPTY_WEBSOCKET_FORM_STATE; - } - const mutatedFormState = - typeof newFormState === "function" ? newFormState(currentFormState) : newFormState; - return { - ...currentGlobalFormState, - [selectionState.id]: mutatedFormState, - }; - }); - }, - [selectionState, setGlobalFormState], - ); - - const playgroundFormState = selectionState != null ? globalFormState[selectionState.id] : undefined; - const togglePlayground = useTogglePlayground(); const matchedEndpoint = @@ -185,51 +103,6 @@ export const PlaygroundDrawer = memo((): ReactElement | null => { ) as ResolvedApiDefinition.WebSocket | undefined) : undefined; - const resetWithExample = useCallback(() => { - if (selectionState?.type === "endpoint") { - setPlaygroundEndpointFormState( - getInitialEndpointRequestFormStateWithExample( - matchedSection?.auth, - matchedEndpoint, - matchedEndpoint?.examples[0], - types, - ), - ); - } else if (selectionState?.type === "webSocket") { - setPlaygroundWebSocketFormState( - getInitialWebSocketRequestFormState(matchedSection?.auth, matchedWebSocket, types), - ); - } - }, [ - matchedEndpoint, - matchedSection?.auth, - matchedWebSocket, - selectionState?.type, - setPlaygroundEndpointFormState, - setPlaygroundWebSocketFormState, - types, - ]); - - const resetWithoutExample = useCallback(() => { - if (selectionState?.type === "endpoint") { - setPlaygroundEndpointFormState( - getInitialEndpointRequestFormState(matchedSection?.auth, matchedEndpoint, types), - ); - } else if (selectionState?.type === "webSocket") { - setPlaygroundWebSocketFormState( - getInitialWebSocketRequestFormState(matchedSection?.auth, matchedWebSocket, types), - ); - } - }, [ - matchedEndpoint, - matchedSection?.auth, - matchedWebSocket, - selectionState?.type, - setPlaygroundEndpointFormState, - setPlaygroundWebSocketFormState, - types, - ]); - useEffect(() => { // if keyboard press "ctrl + `", open playground const togglePlaygroundHandler = (e: KeyboardEvent) => { @@ -250,27 +123,17 @@ export const PlaygroundDrawer = memo((): ReactElement | null => { group: undefined, }; + const setFormState = useSetAtom(usePlaygroundFormStateAtom(selectionState?.id ?? FernNavigation.NodeId(""))); + if (!hasPlayground || apiGroups.length === 0) { return null; } const renderContent = () => selectionState?.type === "endpoint" && matchedEndpoint != null ? ( - + ) : selectionState?.type === "webSocket" && matchedWebSocket != null ? ( - + ) : (
@@ -303,7 +166,9 @@ export const PlaygroundDrawer = memo((): ReactElement | null => { component="PlaygroundDrawer" className="flex h-full items-center justify-center" showError={true} - reset={resetWithoutExample} + reset={() => { + setFormState(undefined); + }} > {isPlaygroundOpen &&
} @@ -358,87 +223,3 @@ export const PlaygroundDrawer = memo((): ReactElement | null => { }); PlaygroundDrawer.displayName = "PlaygroundDrawer"; - -function getInitialEndpointRequestFormState( - auth: APIV1Read.ApiAuth | null | undefined, - endpoint: ResolvedEndpointDefinition | undefined, - types: Record, -): PlaygroundEndpointRequestFormState { - return { - type: "endpoint", - auth: getInitialAuthState(auth), - headers: getDefaultValueForObjectProperties(endpoint?.headers, types), - pathParameters: getDefaultValueForObjectProperties(endpoint?.pathParameters, types), - queryParameters: getDefaultValueForObjectProperties(endpoint?.queryParameters, types), - body: getDefaultValuesForBody(endpoint?.requestBody?.shape, types), - }; -} - -function getInitialWebSocketRequestFormState( - auth: APIV1Read.ApiAuth | null | undefined, - webSocket: ResolvedWebSocketChannel | undefined, - types: Record, -): PlaygroundWebSocketRequestFormState { - return { - type: "websocket", - - auth: getInitialAuthState(auth), - headers: getDefaultValueForObjectProperties(webSocket?.headers, types), - pathParameters: getDefaultValueForObjectProperties(webSocket?.pathParameters, types), - queryParameters: getDefaultValueForObjectProperties(webSocket?.queryParameters, types), - - messages: Object.fromEntries( - webSocket?.messages.map((message) => [message.type, getDefaultValueForType(message.body, types)]) ?? [], - ), - }; -} - -function getInitialAuthState(auth: APIV1Read.ApiAuth | null | undefined): PlaygroundRequestFormAuth | undefined { - if (auth == null) { - return undefined; - } - return visitDiscriminatedUnion(auth, "type")._visit({ - header: (header) => ({ type: "header", headers: { [header.headerWireValue]: "" } }), - bearerAuth: () => ({ type: "bearerAuth", token: "" }), - basicAuth: () => ({ type: "basicAuth", username: "", password: "" }), - _other: () => undefined, - }); -} - -export function getInitialEndpointRequestFormStateWithExample( - auth: APIV1Read.ApiAuth | null | undefined, - endpoint: ResolvedEndpointDefinition | undefined, - exampleCall: ResolvedExampleEndpointCall | undefined, - types: Record, -): PlaygroundEndpointRequestFormState { - if (exampleCall == null) { - return getInitialEndpointRequestFormState(auth, endpoint, types); - } - return { - type: "endpoint", - auth: getInitialAuthState(auth), - headers: exampleCall.headers, - pathParameters: exampleCall.pathParameters, - queryParameters: exampleCall.queryParameters, - body: - exampleCall.requestBody?.type === "form" - ? { - type: "form-data", - value: mapValues( - exampleCall.requestBody.value, - (exampleValue): PlaygroundFormDataEntryValue => - exampleValue.type === "file" - ? { type: "file", value: undefined } - : exampleValue.type === "fileArray" - ? { type: "fileArray", value: [] } - : { type: "json", value: exampleValue.value }, - ), - } - : exampleCall.requestBody?.type === "bytes" - ? { - type: "octet-stream", - value: undefined, - } - : { type: "json", value: exampleCall.requestBody?.value }, - }; -} diff --git a/packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx b/packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx index 18cd656a63..f347f7f601 100644 --- a/packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx @@ -3,11 +3,12 @@ import { assertNever, isNonNullish } from "@fern-ui/core-utils"; import { Loadable, failed, loaded, loading, notStartedLoading } from "@fern-ui/loadable"; import { PaperPlaneIcon } from "@radix-ui/react-icons"; import { compact, once } from "lodash-es"; -import { Dispatch, FC, ReactElement, SetStateAction, useCallback, useState } from "react"; +import { FC, ReactElement, useCallback, useState } from "react"; import urljoin from "url-join"; +import { useCallbackOne } from "use-memo-one"; import { capturePosthogEvent } from "../analytics/posthog"; import { captureSentryError } from "../analytics/sentry"; -import { useBasePath, useDomain, useFeatureFlags } from "../atoms"; +import { useBasePath, useDomain, useFeatureFlags, usePlaygroundEndpointFormState } from "../atoms"; import { ResolvedEndpointDefinition, ResolvedFormDataRequestProperty, @@ -20,22 +21,17 @@ import { blobToDataURL } from "./fetch-utils/blobToDataURL"; import { executeProxyFile } from "./fetch-utils/executeProxyFile"; import { executeProxyRest } from "./fetch-utils/executeProxyRest"; import { executeProxyStream } from "./fetch-utils/executeProxyStream"; -import type { - PlaygroundEndpointRequestFormState, - PlaygroundFormStateBody, - ProxyRequest, - SerializableFile, - SerializableFormDataEntryValue, -} from "./types"; +import type { PlaygroundFormStateBody, ProxyRequest, SerializableFile, SerializableFormDataEntryValue } from "./types"; import { PlaygroundResponse } from "./types/playgroundResponse"; -import { buildEndpointUrl, buildUnredactedHeaders } from "./utils"; +import { + buildEndpointUrl, + buildUnredactedHeaders, + getInitialEndpointRequestFormState, + getInitialEndpointRequestFormStateWithExample, +} from "./utils"; interface PlaygroundEndpointProps { endpoint: ResolvedEndpointDefinition; - formState: PlaygroundEndpointRequestFormState; - setFormState: Dispatch>; - resetWithExample: () => void; - resetWithoutExample: () => void; types: Record; } @@ -55,14 +51,19 @@ const getAppBuildwithfernCom = once((): string => { return `https://${APP_BUILDWITHFERN_COM}`; }); -export const PlaygroundEndpoint: FC = ({ - endpoint, - formState, - setFormState, - resetWithExample, - resetWithoutExample, - types, -}): ReactElement => { +export const PlaygroundEndpoint: FC = ({ endpoint, types }): ReactElement => { + const [formState, setFormState] = usePlaygroundEndpointFormState(endpoint); + + const resetWithExample = useCallbackOne(() => { + setFormState( + getInitialEndpointRequestFormStateWithExample(endpoint.auth, endpoint, endpoint.examples[0], types), + ); + }, [endpoint, types]); + + const resetWithoutExample = useCallbackOne(() => { + setFormState(getInitialEndpointRequestFormState(endpoint.auth, endpoint, types)); + }, []); + const domain = useDomain(); const basePath = useBasePath(); const { proxyShouldUseAppBuildwithfernCom } = useFeatureFlags(); diff --git a/packages/ui/app/src/api-playground/PlaygroundEndpointSelectorContent.tsx b/packages/ui/app/src/api-playground/PlaygroundEndpointSelectorContent.tsx index 46203599d3..09297d5bb0 100644 --- a/packages/ui/app/src/api-playground/PlaygroundEndpointSelectorContent.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundEndpointSelectorContent.tsx @@ -5,10 +5,10 @@ import { Cross1Icon, MagnifyingGlassIcon, SlashIcon } from "@radix-ui/react-icon import cn, { clsx } from "clsx"; import dynamic from "next/dynamic"; import { Fragment, ReactElement, forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { useSetAndOpenPlayground } from "../atoms"; import { HttpMethodTag } from "../commons/HttpMethodTag"; import { ResolvedApiDefinition } from "../resolver/types"; import { BuiltWithFern } from "../sidebar/BuiltWithFern"; -import { useSetAndOpenPlayground } from "./PlaygroundContext"; const Markdown = dynamic(() => import("../mdx/Markdown").then(({ Markdown }) => Markdown), { ssr: true }); diff --git a/packages/ui/app/src/api-playground/PlaygroundWebSocket.tsx b/packages/ui/app/src/api-playground/PlaygroundWebSocket.tsx index bd914fdeb1..6797340afa 100644 --- a/packages/ui/app/src/api-playground/PlaygroundWebSocket.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundWebSocket.tsx @@ -1,13 +1,13 @@ import { FernTooltipProvider } from "@fern-ui/components"; import { usePrevious } from "@fern-ui/react-commons"; import { merge } from "lodash-es"; -import { Dispatch, FC, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react"; +import { FC, ReactElement, useCallback, useEffect, useRef, useState } from "react"; import { Wifi, WifiOff } from "react-feather"; +import { usePlaygroundWebsocketFormState } from "../atoms"; import { ResolvedTypeDefinition, ResolvedWebSocketChannel, ResolvedWebSocketMessage } from "../resolver/types"; import { PlaygroundEndpointPath } from "./PlaygroundEndpointPath"; import { PlaygroundWebSocketContent } from "./PlaygroundWebSocketContent"; import { useWebsocketMessages } from "./hooks/useWebsocketMessages"; -import { PlaygroundWebSocketRequestFormState } from "./types"; import { buildRequestUrl, buildUnredactedHeadersWebsocket, getDefaultValueForType } from "./utils"; // TODO: decide if this should be an env variable, and if we should move REST proxy to the same (or separate) cloudflare worker @@ -15,17 +15,12 @@ const WEBSOCKET_PROXY_URI = "wss://websocket.proxy.ferndocs.com/ws"; interface PlaygroundWebSocketProps { websocket: ResolvedWebSocketChannel; - formState: PlaygroundWebSocketRequestFormState; - setFormState: Dispatch>; types: Record; } -export const PlaygroundWebSocket: FC = ({ - websocket, - formState, - setFormState, - types, -}): ReactElement => { +export const PlaygroundWebSocket: FC = ({ websocket, types }): ReactElement => { + const [formState, setFormState] = usePlaygroundWebsocketFormState(websocket); + const [connectedState, setConnectedState] = useState<"opening" | "opened" | "closed">("closed"); const { messages, pushMessage, clearMessages } = useWebsocketMessages(websocket.id); const [error, setError] = useState(null); diff --git a/packages/ui/app/src/api-playground/utils.ts b/packages/ui/app/src/api-playground/utils.ts index 44ed960b8f..af9e6d92a0 100644 --- a/packages/ui/app/src/api-playground/utils.ts +++ b/packages/ui/app/src/api-playground/utils.ts @@ -1,4 +1,4 @@ -import { Snippets } from "@fern-api/fdr-sdk"; +import { APIV1Read, Snippets } from "@fern-api/fdr-sdk"; import { SnippetTemplateResolver } from "@fern-api/template-resolver"; import { isNonNullish, isPlainObject, visitDiscriminatedUnion } from "@fern-ui/core-utils"; import { isEmpty, mapValues } from "lodash-es"; @@ -7,6 +7,7 @@ import { stringifyHttpRequestExampleToCurl } from "../api-page/examples/stringif import { ResolvedEndpointDefinition, ResolvedEndpointPathParts, + ResolvedExampleEndpointCall, ResolvedExampleEndpointRequest, ResolvedFormValue, ResolvedHttpRequestBodyShape, @@ -24,7 +25,9 @@ import { PlaygroundEndpointRequestFormState, PlaygroundFormDataEntryValue, PlaygroundFormStateBody, + PlaygroundRequestFormAuth, PlaygroundRequestFormState, + PlaygroundWebSocketRequestFormState, convertPlaygroundFormDataEntryValueToResolvedExampleEndpointRequest, } from "./types"; @@ -815,3 +818,87 @@ export function shouldRenderInline( }); } export { unknownToString }; + +export function getInitialEndpointRequestFormState( + auth: APIV1Read.ApiAuth | null | undefined, + endpoint: ResolvedEndpointDefinition | undefined, + types: Record, +): PlaygroundEndpointRequestFormState { + return { + type: "endpoint", + auth: getInitialAuthState(auth), + headers: getDefaultValueForObjectProperties(endpoint?.headers, types), + pathParameters: getDefaultValueForObjectProperties(endpoint?.pathParameters, types), + queryParameters: getDefaultValueForObjectProperties(endpoint?.queryParameters, types), + body: getDefaultValuesForBody(endpoint?.requestBody?.shape, types), + }; +} + +export function getInitialWebSocketRequestFormState( + auth: APIV1Read.ApiAuth | null | undefined, + webSocket: ResolvedWebSocketChannel | undefined, + types: Record, +): PlaygroundWebSocketRequestFormState { + return { + type: "websocket", + + auth: getInitialAuthState(auth), + headers: getDefaultValueForObjectProperties(webSocket?.headers, types), + pathParameters: getDefaultValueForObjectProperties(webSocket?.pathParameters, types), + queryParameters: getDefaultValueForObjectProperties(webSocket?.queryParameters, types), + + messages: Object.fromEntries( + webSocket?.messages.map((message) => [message.type, getDefaultValueForType(message.body, types)]) ?? [], + ), + }; +} + +function getInitialAuthState(auth: APIV1Read.ApiAuth | null | undefined): PlaygroundRequestFormAuth | undefined { + if (auth == null) { + return undefined; + } + return visitDiscriminatedUnion(auth, "type")._visit({ + header: (header) => ({ type: "header", headers: { [header.headerWireValue]: "" } }), + bearerAuth: () => ({ type: "bearerAuth", token: "" }), + basicAuth: () => ({ type: "basicAuth", username: "", password: "" }), + _other: () => undefined, + }); +} + +export function getInitialEndpointRequestFormStateWithExample( + auth: APIV1Read.ApiAuth | null | undefined, + endpoint: ResolvedEndpointDefinition | undefined, + exampleCall: ResolvedExampleEndpointCall | undefined, + types: Record, +): PlaygroundEndpointRequestFormState { + if (exampleCall == null) { + return getInitialEndpointRequestFormState(auth, endpoint, types); + } + return { + type: "endpoint", + auth: getInitialAuthState(auth), + headers: exampleCall.headers, + pathParameters: exampleCall.pathParameters, + queryParameters: exampleCall.queryParameters, + body: + exampleCall.requestBody?.type === "form" + ? { + type: "form-data", + value: mapValues( + exampleCall.requestBody.value, + (exampleValue): PlaygroundFormDataEntryValue => + exampleValue.type === "file" + ? { type: "file", value: undefined } + : exampleValue.type === "fileArray" + ? { type: "fileArray", value: [] } + : { type: "json", value: exampleValue.value }, + ), + } + : exampleCall.requestBody?.type === "bytes" + ? { + type: "octet-stream", + value: undefined, + } + : { type: "json", value: exampleCall.requestBody?.value }, + }; +} diff --git a/packages/ui/app/src/atoms/playground.ts b/packages/ui/app/src/atoms/playground.ts index a416d4ca37..ae656fe00e 100644 --- a/packages/ui/app/src/atoms/playground.ts +++ b/packages/ui/app/src/atoms/playground.ts @@ -1,10 +1,19 @@ import { FernNavigation } from "@fern-api/fdr-sdk"; import { useEventCallback } from "@fern-ui/react-commons"; -import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; +import { captureMessage } from "@sentry/nextjs"; +import { WritableAtom, atom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { atomFamily, atomWithStorage, useAtomCallback } from "jotai/utils"; +import { Dispatch, SetStateAction, useEffect } from "react"; +import { useCallbackOne } from "use-memo-one"; import { capturePosthogEvent } from "../analytics/posthog"; -import { PlaygroundRequestFormState } from "../api-playground/types"; -import { APIS_ATOM } from "./apis"; +import type { + PlaygroundEndpointRequestFormState, + PlaygroundRequestFormState, + PlaygroundWebSocketRequestFormState, +} from "../api-playground/types"; +import { getInitialEndpointRequestFormStateWithExample } from "../api-playground/utils"; +import { isEndpoint, type ResolvedEndpointDefinition, type ResolvedWebSocketChannel } from "../resolver/types"; +import { APIS_ATOM, FLATTENED_APIS_ATOM } from "./apis"; import { FEATURE_FLAGS_ATOM } from "./flags"; import { useAtomEffect } from "./hooks"; import { BELOW_HEADER_HEIGHT_ATOM } from "./layout"; @@ -93,12 +102,6 @@ PLAYGROUND_NODE.debugLabel = "PLAYGROUND_NODE"; export const PREV_PLAYGROUND_NODE_ID = atom(undefined); PREV_PLAYGROUND_NODE_ID.debugLabel = "PREV_PLAYGROUND_NODE_ID"; -export const PLAYGROUND_FORM_STATE_ATOM = atomWithStorage>( - "api-playground-selection-state-alpha", - {}, -); -PLAYGROUND_FORM_STATE_ATOM.debugLabel = "PLAYGROUND_FORM_STATE_ATOM"; - export function useHasPlayground(): boolean { return useAtomValue(HAS_PLAYGROUND_ATOM); } @@ -168,3 +171,150 @@ export function useInitPlaygroundRouter(): void { }), ); } + +const playgroundFormStateFamily = atomFamily((nodeId: FernNavigation.NodeId) => { + const formStateAtom = atomWithStorage(nodeId, undefined); + formStateAtom.debugLabel = `playgroundFormStateAtom-${nodeId}`; + return formStateAtom; +}); + +export const usePlaygroundFormStateAtom = ( + nodeId: FernNavigation.NodeId, +): WritableAtom< + PlaygroundRequestFormState | undefined, + [SetStateAction], + void +> => { + const formStateAtom = playgroundFormStateFamily(nodeId); + + useEffect(() => { + return () => { + playgroundFormStateFamily.remove(nodeId); + }; + }, [formStateAtom, nodeId]); + + return formStateAtom; +}; + +export function useSetAndOpenPlayground(): (node: FernNavigation.NavigationNodeApiLeaf) => void { + return useAtomCallback( + useCallbackOne((get, set, node: FernNavigation.NavigationNodeApiLeaf) => { + const formStateAtom = playgroundFormStateFamily(node.id); + set(PLAYGROUND_NODE_ID, node.id); + const apiPackage = get(FLATTENED_APIS_ATOM)[node.apiDefinitionId]; + const formState = get(formStateAtom); + if (formState != null) { + playgroundFormStateFamily.remove(node.id); + return; + } + + if (apiPackage == null) { + captureMessage("Could not find package for API playground selection state", "fatal"); + playgroundFormStateFamily.remove(node.id); + return; + } + if (node.type === "endpoint") { + const endpoint = apiPackage.apiDefinitions + .filter(isEndpoint) + .find((definition) => definition.id === node.endpointId); + if (endpoint == null) { + captureMessage("Could not find endpoint for API playground selection state", "fatal"); + return; + } + set( + formStateAtom, + getInitialEndpointRequestFormStateWithExample( + endpoint.auth, + endpoint, + endpoint.examples[0], + apiPackage.types, + ), + ); + } else if (node.type === "webSocket") { + const webSocket = apiPackage.apiDefinitions.find( + (definition) => !isEndpoint(definition) && definition.id === node.webSocketId, + ); + if (webSocket == null) { + captureMessage("Could not find websocket for API playground selection state", "fatal"); + playgroundFormStateFamily.remove(node.id); + return; + } + } + playgroundFormStateFamily.remove(node.id); + }, []), + ); +} + +const EMPTY_ENDPOINT_REQUEST_FORM_STATE: PlaygroundEndpointRequestFormState = { + type: "endpoint", + auth: undefined, + headers: {}, + pathParameters: {}, + queryParameters: {}, + body: undefined, +}; + +export function usePlaygroundEndpointFormState( + endpoint: ResolvedEndpointDefinition, +): [PlaygroundEndpointRequestFormState, Dispatch>] { + const formStateAtom = playgroundFormStateFamily(endpoint.nodeId); + const formState = useAtomValue(playgroundFormStateFamily(endpoint.nodeId)); + + return [ + formState?.type === "endpoint" ? formState : EMPTY_ENDPOINT_REQUEST_FORM_STATE, + useAtomCallback( + useCallbackOne( + (get, set, update: SetStateAction) => { + const currentFormState = get(formStateAtom); + const newFormState = + typeof update === "function" + ? update( + currentFormState?.type === "endpoint" + ? currentFormState + : EMPTY_ENDPOINT_REQUEST_FORM_STATE, + ) + : update; + set(formStateAtom, newFormState); + }, + [formStateAtom, endpoint.nodeId], + ), + ), + ]; +} + +const EMPTY_WEBSOCKET_REQUEST_FORM_STATE: PlaygroundWebSocketRequestFormState = { + type: "websocket", + auth: undefined, + headers: {}, + pathParameters: {}, + queryParameters: {}, + messages: {}, +}; + +export function usePlaygroundWebsocketFormState( + channel: ResolvedWebSocketChannel, +): [PlaygroundWebSocketRequestFormState, Dispatch>] { + const formStateAtom = playgroundFormStateFamily(channel.nodeId); + const formState = useAtomValue(playgroundFormStateFamily(channel.nodeId)); + + return [ + formState?.type === "websocket" ? formState : EMPTY_WEBSOCKET_REQUEST_FORM_STATE, + useAtomCallback( + useCallbackOne( + (get, set, update: SetStateAction) => { + const currentFormState = get(formStateAtom); + const newFormState = + typeof update === "function" + ? update( + currentFormState?.type === "websocket" + ? currentFormState + : EMPTY_WEBSOCKET_REQUEST_FORM_STATE, + ) + : update; + set(formStateAtom, newFormState); + }, + [formStateAtom, channel.nodeId], + ), + ), + ]; +} diff --git a/packages/ui/app/src/next-app/DocsPage.tsx b/packages/ui/app/src/next-app/DocsPage.tsx index 6cd124f51f..ea23d31c02 100644 --- a/packages/ui/app/src/next-app/DocsPage.tsx +++ b/packages/ui/app/src/next-app/DocsPage.tsx @@ -29,10 +29,9 @@ export function DocsPage(pageProps: DocsProps): ReactElement | null { - - - + + );