Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: api playground loading state
Browse files Browse the repository at this point in the history
abvthecity committed Sep 13, 2024
1 parent 885eee2 commit 95c1366
Showing 33 changed files with 1,026 additions and 827 deletions.
16 changes: 7 additions & 9 deletions packages/ui/app/src/atoms/playground.ts
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ import {
type ResolvedEndpointDefinition,
type ResolvedWebSocketChannel,
} from "../resolver/types";
import { APIS_ATOM, FLATTENED_APIS_ATOM, useFlattenedApi } from "./apis";
import { FLATTENED_APIS_ATOM, useFlattenedApi } from "./apis";
import { FEATURE_FLAGS_ATOM } from "./flags";
import { useAtomEffect } from "./hooks";
import { HEADER_HEIGHT_ATOM } from "./layout";
@@ -44,10 +44,7 @@ import { IS_MOBILE_SCREEN_ATOM } from "./viewport";
const PLAYGROUND_IS_OPEN_ATOM = atom(false);
PLAYGROUND_IS_OPEN_ATOM.debugLabel = "PLAYGROUND_IS_OPEN_ATOM";

export const HAS_PLAYGROUND_ATOM = atom(
(get) => get(FEATURE_FLAGS_ATOM).isApiPlaygroundEnabled && Object.keys(get(APIS_ATOM)).length > 0,
);
HAS_PLAYGROUND_ATOM.debugLabel = "HAS_PLAYGROUND_ATOM";
export const IS_PLAYGROUND_ENABLED_ATOM = atom((get) => get(FEATURE_FLAGS_ATOM).isApiPlaygroundEnabled);

export const MAX_PLAYGROUND_HEIGHT_ATOM = atom((get) => {
const isMobileScreen = get(IS_MOBILE_SCREEN_ATOM);
@@ -116,10 +113,6 @@ PLAYGROUND_NODE.debugLabel = "PLAYGROUND_NODE";
export const PREV_PLAYGROUND_NODE_ID = atom<FernNavigation.NodeId | undefined>(undefined);
PREV_PLAYGROUND_NODE_ID.debugLabel = "PREV_PLAYGROUND_NODE_ID";

export function useHasPlayground(): boolean {
return useAtomValue(HAS_PLAYGROUND_ATOM);
}

export function usePlaygroundNodeId(): FernNavigation.NodeId | undefined {
return useAtomValue(PLAYGROUND_NODE_ID);
}
@@ -360,3 +353,8 @@ export function usePlaygroundWebsocketFormState(
),
];
}

export const PLAYGROUND_REQUEST_TYPE_ATOM = atomWithStorage<"curl" | "typescript" | "python">(
"api-playground-atom-alpha",
"curl",
);
6 changes: 3 additions & 3 deletions packages/ui/app/src/components/FernLinkButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FernButtonSharedProps, getButtonClassName, renderButtonContent } from "@fern-ui/components";
import { ButtonContent, FernButtonSharedProps, getButtonClassName } from "@fern-ui/components";
import Link from "next/link";
import { ComponentProps, PropsWithChildren, forwardRef } from "react";
import { ComponentProps, PropsWithChildren, createElement, forwardRef } from "react";
import { FernLink } from "./FernLink";

interface FernLinkButtonProps extends ComponentProps<typeof Link>, PropsWithChildren<FernButtonSharedProps> {}
@@ -46,7 +46,7 @@ export const FernLinkButton = forwardRef<HTMLAnchorElement, FernLinkButtonProps>
: undefined
}
>
{renderButtonContent(props)}
{createElement(ButtonContent, { ...props, className: "" })}
</FernLink>
);
});
2 changes: 1 addition & 1 deletion packages/ui/app/src/css/globals.scss
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
@import "./components";
@import "../syntax-highlighting/FernSyntaxHighlighter";
@import "../api-reference";
@import "../playground/PlaygroundEndpoint";
@import "../playground";
@import "../components";
@import "../mdx/components";
@import "./utilities";
Original file line number Diff line number Diff line change
@@ -33,7 +33,7 @@ import { Callout } from "../mdx/components/callout";
import { ResolvedEndpointDefinition, ResolvedTypeDefinition } from "../resolver/types";
import { useApiKeyInjectionConfig } from "../services/useApiKeyInjectionConfig";
import { PasswordInputGroup } from "./PasswordInputGroup";
import { PlaygroundEndpointForm } from "./PlaygroundEndpointForm";
import { PlaygroundEndpointForm } from "./endpoint/PlaygroundEndpointForm";
import { PlaygroundAuthState } from "./types";
import { oAuthClientCredentialReferencedEndpointLoginFlow } from "./utils";

6 changes: 3 additions & 3 deletions packages/ui/app/src/playground/PlaygroundButton.tsx
Original file line number Diff line number Diff line change
@@ -3,17 +3,17 @@ import { FernButton, FernTooltip, FernTooltipProvider } from "@fern-ui/component
import { PlaySolid } from "iconoir-react";
import { useAtomValue } from "jotai";
import { FC } from "react";
import { HAS_PLAYGROUND_ATOM, useSetAndOpenPlayground } from "../atoms";
import { IS_PLAYGROUND_ENABLED_ATOM, useSetAndOpenPlayground } from "../atoms";
import { usePlaygroundSettings } from "../hooks/usePlaygroundSettings";

export const PlaygroundButton: FC<{
state: FernNavigation.NavigationNodeApiLeaf;
}> = ({ state }) => {
const openPlayground = useSetAndOpenPlayground();
const hasPlayground = useAtomValue(HAS_PLAYGROUND_ATOM);
const isPlaygroundEnabled = useAtomValue(IS_PLAYGROUND_ENABLED_ATOM);
const settings = usePlaygroundSettings(state.id);

if (!hasPlayground) {
if (!isPlaygroundEnabled) {
return null;
}

8 changes: 4 additions & 4 deletions packages/ui/app/src/playground/PlaygroundContext.tsx
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import dynamic from "next/dynamic";
import { FC, useEffect } from "react";
import useSWR from "swr";
import { APIS_ATOM, store } from "../atoms";
import { HAS_PLAYGROUND_ATOM, useInitPlaygroundRouter } from "../atoms/playground";
import { IS_PLAYGROUND_ENABLED_ATOM, useInitPlaygroundRouter } from "../atoms/playground";
import { useApiRoute } from "../hooks/useApiRoute";
import { ResolvedRootPackage } from "../resolver/types";

@@ -18,7 +18,7 @@ const fetcher = async (url: string) => {

export const PlaygroundContextProvider: FC = () => {
const key = useApiRoute("/api/fern-docs/resolve-api");
const { data } = useSWR<Record<string, ResolvedRootPackage> | null>(key, fetcher, {
const { data, isLoading } = useSWR<Record<string, ResolvedRootPackage> | null>(key, fetcher, {
revalidateOnFocus: false,
});
useEffect(() => {
@@ -29,6 +29,6 @@ export const PlaygroundContextProvider: FC = () => {

useInitPlaygroundRouter();

const hasPlayground = useAtomValue(HAS_PLAYGROUND_ATOM);
return hasPlayground ? <PlaygroundDrawer /> : null;
const isPlaygroundEnabled = useAtomValue(IS_PLAYGROUND_ENABLED_ATOM);
return isPlaygroundEnabled ? <PlaygroundDrawer isLoading={isLoading} /> : null;
};
19 changes: 10 additions & 9 deletions packages/ui/app/src/playground/PlaygroundDrawer.tsx
Original file line number Diff line number Diff line change
@@ -13,7 +13,6 @@ import { HEADER_HEIGHT_ATOM, useAtomEffect, useFlattenedApis, useSidebarNodes }
import {
MAX_PLAYGROUND_HEIGHT_ATOM,
PLAYGROUND_NODE_ID,
useHasPlayground,
useIsPlaygroundOpen,
usePlaygroundFormStateAtom,
usePlaygroundNode,
@@ -22,14 +21,18 @@ import {
import { IS_MOBILE_SCREEN_ATOM, MOBILE_SIDEBAR_ENABLED_ATOM, VIEWPORT_HEIGHT_ATOM } from "../atoms/viewport";
import { FernErrorBoundary } from "../components/FernErrorBoundary";
import { isEndpoint, isWebSocket, type ResolvedApiEndpointWithPackage } from "../resolver/types";
import { PlaygroundEndpoint } from "./PlaygroundEndpoint";
import { PlaygroundEndpointSelectorContent, flattenApiSection } from "./PlaygroundEndpointSelectorContent";
import { PlaygroundWebSocket } from "./PlaygroundWebSocket";
import { HorizontalSplitPane } from "./VerticalSplitPane";
import { PlaygroundEndpoint } from "./endpoint/PlaygroundEndpoint";
import { PlaygroundEndpointSelectorContent, flattenApiSection } from "./endpoint/PlaygroundEndpointSelectorContent";
import { PlaygroundEndpointSkeleton } from "./endpoint/PlaygroundEndpointSkeleton";
import { useResizeY } from "./useSplitPlane";

export const PlaygroundDrawer = memo((): ReactElement | null => {
const hasPlayground = useHasPlayground();
interface PlaygroundDrawerProps {
isLoading: boolean;
}

export const PlaygroundDrawer = memo(({ isLoading }: PlaygroundDrawerProps): ReactElement | null => {
const selectionState = usePlaygroundNode();
const apis = useFlattenedApis();

@@ -115,15 +118,13 @@ export const PlaygroundDrawer = memo((): ReactElement | null => {

const setFormState = useSetAtom(usePlaygroundFormStateAtom(selectionState?.id ?? FernNavigation.NodeId("")));

if (!hasPlayground || apiGroups.length === 0) {
return null;
}

const renderContent = () =>
selectionState?.type === "endpoint" && matchedEndpoint != null ? (
<PlaygroundEndpoint endpoint={matchedEndpoint} types={types} />
) : selectionState?.type === "webSocket" && matchedWebSocket != null ? (
<PlaygroundWebSocket websocket={matchedWebSocket} types={types} />
) : isLoading ? (
<PlaygroundEndpointSkeleton />
) : (
<div className="size-full flex flex-col items-center justify-center">
<ArrowLeft className="size-8 mb-2 t-muted" />
347 changes: 0 additions & 347 deletions packages/ui/app/src/playground/PlaygroundEndpointContent.tsx

This file was deleted.

400 changes: 0 additions & 400 deletions packages/ui/app/src/playground/PlaygroundEndpointForm.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { FernButton } from "@fern-ui/components";
import clsx from "clsx";
import { FC, ReactNode } from "react";

interface PlaygroundSendRequestButtonProps {
sendRequest: () => void;
sendRequest?: () => void;

sendRequestButtonLabel?: string;
sendRequestIcon?: ReactNode;
@@ -15,12 +16,16 @@ export const PlaygroundSendRequestButton: FC<PlaygroundSendRequestButtonProps> =
}) => {
return (
<FernButton
className="group relative overflow-hidden font-semibold after:absolute after:inset-y-0 after:w-8 after:animate-shine after:bg-white/50 after:blur after:content-['']"
className={clsx("group relative overflow-hidden font-semibold", {
"after:absolute after:inset-y-0 after:w-8 after:animate-shine after:bg-white/50 after:blur after:content-['']":
!!sendRequest,
})}
rightIcon={sendRequestIcon}
onClick={sendRequest}
intent="primary"
rounded
size="large"
skeleton={!sendRequest}
>
{sendRequestButtonLabel ?? "Send Request"}
</FernButton>
2 changes: 1 addition & 1 deletion packages/ui/app/src/playground/PlaygroundWebSocket.tsx
Original file line number Diff line number Diff line change
@@ -11,8 +11,8 @@ import {
ResolvedWebSocketMessage,
resolveEnvironment,
} from "../resolver/types";
import { PlaygroundEndpointPath } from "./PlaygroundEndpointPath";
import { PlaygroundWebSocketContent } from "./PlaygroundWebSocketContent";
import { PlaygroundEndpointPath } from "./endpoint/PlaygroundEndpointPath";
import { useWebsocketMessages } from "./hooks/useWebsocketMessages";
import { buildAuthHeaders, buildRequestUrl } from "./utils";

13 changes: 13 additions & 0 deletions packages/ui/app/src/playground/endpoint/PlaygroundCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import clsx from "clsx";
import { PropsWithChildren, ReactElement } from "react";

export function PlaygroundCardSkeleton({
className,
children,
}: PropsWithChildren<{ className?: string }>): ReactElement {
return (
<div className={clsx("rounded-xl bg-tag-default", className)}>
{children && <div className="contents invisible">{children}</div>}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -5,35 +5,35 @@ import { useSetAtom } from "jotai";
import { mapValues } from "lodash-es";
import { FC, ReactElement, useCallback, useState } from "react";
import { useCallbackOne } from "use-memo-one";
import { captureSentryError } from "../analytics/sentry";
import { captureSentryError } from "../../analytics/sentry";
import {
PLAYGROUND_AUTH_STATE_ATOM,
PLAYGROUND_AUTH_STATE_OAUTH_ATOM,
store,
useBasePath,
useFeatureFlags,
usePlaygroundEndpointFormState,
} from "../atoms";
import { useSelectedEnvironmentId } from "../atoms/environment";
import { useApiRoute } from "../hooks/useApiRoute";
import { usePlaygroundSettings } from "../hooks/usePlaygroundSettings";
import { getAppBuildwithfernCom } from "../hooks/useStandardProxyEnvironment";
import { ResolvedEndpointDefinition, ResolvedTypeDefinition, resolveEnvironment } from "../resolver/types";
import { PlaygroundEndpointContent } from "./PlaygroundEndpointContent";
import { PlaygroundEndpointPath } from "./PlaygroundEndpointPath";
import { executeProxyFile } from "./fetch-utils/executeProxyFile";
import { executeProxyRest } from "./fetch-utils/executeProxyRest";
import { executeProxyStream } from "./fetch-utils/executeProxyStream";
import type { ProxyRequest } from "./types";
import { PlaygroundResponse } from "./types/playgroundResponse";
} from "../../atoms";
import { useSelectedEnvironmentId } from "../../atoms/environment";
import { useApiRoute } from "../../hooks/useApiRoute";
import { usePlaygroundSettings } from "../../hooks/usePlaygroundSettings";
import { getAppBuildwithfernCom } from "../../hooks/useStandardProxyEnvironment";
import { ResolvedEndpointDefinition, ResolvedTypeDefinition, resolveEnvironment } from "../../resolver/types";
import { executeProxyFile } from "../fetch-utils/executeProxyFile";
import { executeProxyRest } from "../fetch-utils/executeProxyRest";
import { executeProxyStream } from "../fetch-utils/executeProxyStream";
import type { ProxyRequest } from "../types";
import { PlaygroundResponse } from "../types/playgroundResponse";
import {
buildAuthHeaders,
buildEndpointUrl,
getInitialEndpointRequestFormState,
getInitialEndpointRequestFormStateWithExample,
serializeFormStateBody,
unknownToString,
} from "./utils";
} from "../utils";
import { PlaygroundEndpointContent } from "./PlaygroundEndpointContent";
import { PlaygroundEndpointPath } from "./PlaygroundEndpointPath";

interface PlaygroundEndpointProps {
endpoint: ResolvedEndpointDefinition;
@@ -67,7 +67,7 @@ export const PlaygroundEndpoint: FC<PlaygroundEndpointProps> = ({ endpoint, type
}
setResponse(loading());
try {
const { capturePosthogEvent } = await import("../analytics/posthog");
const { capturePosthogEvent } = await import("../../analytics/posthog");
capturePosthogEvent("api_playground_request_sent", {
endpointId: endpoint.id,
endpointName: endpoint.title,
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Loadable } from "@fern-ui/loadable";
import { Dispatch, ReactElement, SetStateAction, useDeferredValue } from "react";
import { ResolvedEndpointDefinition, ResolvedTypeDefinition } from "../../resolver/types";
import { PlaygroundAuthorizationFormCard } from "../PlaygroundAuthorizationForm";
import { PlaygroundEndpointRequestFormState } from "../types";
import { PlaygroundResponse } from "../types/playgroundResponse";
import { PlaygroundEndpointContentLayout } from "./PlaygroundEndpointContentLayout";
import { PlaygroundEndpointForm } from "./PlaygroundEndpointForm";
import { PlaygroundEndpointFormButtons } from "./PlaygroundEndpointFormButtons";
import { PlaygroundEndpointRequestCard } from "./PlaygroundEndpointRequestCard";
import { PlaygroundResponseCard } from "./PlaygroundResponseCard";

interface PlaygroundEndpointContentProps {
endpoint: ResolvedEndpointDefinition;
formState: PlaygroundEndpointRequestFormState;
setFormState: Dispatch<SetStateAction<PlaygroundEndpointRequestFormState>>;
resetWithExample: () => void;
resetWithoutExample: () => void;
response: Loadable<PlaygroundResponse>;
sendRequest: () => void;
types: Record<string, ResolvedTypeDefinition>;
}

export function PlaygroundEndpointContent({
endpoint,
formState,
setFormState,
resetWithExample,
resetWithoutExample,
response,
sendRequest,
types,
}: PlaygroundEndpointContentProps): ReactElement {
const deferredFormState = useDeferredValue(formState);

const form = (
<div className="mx-auto w-full max-w-5xl space-y-6 pt-6 max-sm:pt-0 sm:pb-20">
{endpoint.auth != null && <PlaygroundAuthorizationFormCard auth={endpoint.auth} disabled={false} />}

<div className="col-span-2 space-y-8">
<PlaygroundEndpointForm
endpoint={endpoint}
formState={formState}
setFormState={setFormState}
types={types}
/>
</div>

<PlaygroundEndpointFormButtons
endpoint={endpoint}
resetWithExample={resetWithExample}
resetWithoutExample={resetWithoutExample}
/>
</div>
);

const requestCard = <PlaygroundEndpointRequestCard endpoint={endpoint} formState={deferredFormState} />;
const responseCard = <PlaygroundResponseCard response={response} sendRequest={sendRequest} />;

return (
<PlaygroundEndpointContentLayout
sendRequest={sendRequest}
form={form}
requestCard={requestCard}
responseCard={responseCard}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useResizeObserver } from "@fern-ui/react-commons";
import { useAtomValue } from "jotai";
import { ReactElement, ReactNode, useRef, useState } from "react";
import { IS_MOBILE_SCREEN_ATOM } from "../../atoms";
import { PlaygroundSendRequestButton } from "../PlaygroundSendRequestButton";
import { PlaygroundEndpointDesktopLayout } from "./PlaygroundEndpointDesktopLayout";
import { PlaygroundEndpointMobileLayout } from "./PlaygroundEndpointMobileLayout";

interface PlaygroundEndpointContentLayoutProps {
sendRequest: () => void;
form: ReactNode;
requestCard: ReactNode;
responseCard: ReactNode;
}

export function PlaygroundEndpointContentLayout({
sendRequest,
form,
requestCard,
responseCard,
}: PlaygroundEndpointContentLayoutProps): ReactElement {
const isMobileScreen = useAtomValue(IS_MOBILE_SCREEN_ATOM);

const scrollAreaRef = useRef<HTMLDivElement>(null);
const [scrollAreaHeight, setScrollAreaHeight] = useState(0);

useResizeObserver(scrollAreaRef, ([size]) => {
if (size != null) {
setScrollAreaHeight(size.contentRect.height);
}
});

return (
<div className="flex min-h-0 w-full flex-1 shrink items-stretch divide-x">
<div
ref={scrollAreaRef}
className="mask-grad-top-6 w-full overflow-x-hidden overflow-y-scroll overscroll-contain"
>
{!isMobileScreen ? (
<PlaygroundEndpointDesktopLayout
scrollAreaHeight={scrollAreaHeight}
form={form}
requestCard={requestCard}
responseCard={responseCard}
/>
) : (
<PlaygroundEndpointMobileLayout
form={form}
requestCard={requestCard}
responseCard={responseCard}
sendButton={<PlaygroundSendRequestButton sendRequest={sendRequest} />}
/>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -2,9 +2,9 @@ import { useBooleanState, useResizeObserver } from "@fern-ui/react-commons";
import cn from "clsx";
import dynamic from "next/dynamic";
import { ReactElement, useRef, useState } from "react";
import { ResolvedEndpointDefinition } from "../resolver/types";
import { ResolvedEndpointDefinition } from "../../resolver/types";

const Markdown = dynamic(() => import("../mdx/Markdown").then(({ Markdown }) => Markdown), {
const Markdown = dynamic(() => import("../../mdx/Markdown").then(({ Markdown }) => Markdown), {
ssr: true,
});

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ReactElement, ReactNode } from "react";
import { HorizontalSplitPane, VerticalSplitPane } from "../VerticalSplitPane";

interface PlaygroundEndpointDesktopLayoutProps {
scrollAreaHeight: number;
form: ReactNode;
requestCard: ReactNode;
responseCard: ReactNode;
}

export function PlaygroundEndpointDesktopLayout({
scrollAreaHeight,
form,
requestCard,
responseCard,
}: PlaygroundEndpointDesktopLayoutProps): ReactElement {
return (
<HorizontalSplitPane rizeBarHeight={scrollAreaHeight} leftClassName="pl-6 pr-1 mt" rightClassName="pl-1">
{form}

<VerticalSplitPane
className="sticky inset-0 pr-6"
style={{ height: scrollAreaHeight }}
aboveClassName={"pt-6 pb-1 flex items-stretch justify-stretch"}
belowClassName="pb-6 pt-1 flex items-stretch justify-stretch"
>
{requestCard}
{responseCard}
</VerticalSplitPane>
</HorizontalSplitPane>
);
}
217 changes: 217 additions & 0 deletions packages/ui/app/src/playground/endpoint/PlaygroundEndpointForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { titleCase } from "@fern-api/fdr-sdk";
import { Dispatch, FC, SetStateAction, useCallback } from "react";
import {
ResolvedEndpointDefinition,
ResolvedTypeDefinition,
dereferenceObjectProperties,
unwrapReference,
visitResolvedHttpRequestBodyShape,
} from "../../resolver/types";
import { PlaygroundFileUploadForm } from "../form/PlaygroundFileUploadForm";
import { PlaygroundObjectPropertiesForm } from "../form/PlaygroundObjectPropertyForm";
import { PlaygroundTypeReferenceForm } from "../form/PlaygroundTypeReferenceForm";
import { PlaygroundEndpointRequestFormState, PlaygroundFormStateBody } from "../types";
import { PlaygroundEndpointFormSection } from "./PlaygroundEndpointFormSection";
import { PlaygroundEndpointMultipartForm } from "./PlaygroundEndpointMultipartForm";

interface PlaygroundEndpointFormProps {
endpoint: ResolvedEndpointDefinition;
formState: PlaygroundEndpointRequestFormState | undefined;
setFormState: Dispatch<SetStateAction<PlaygroundEndpointRequestFormState>>;
types: Record<string, ResolvedTypeDefinition>;
ignoreHeaders?: boolean;
}

export const PlaygroundEndpointForm: FC<PlaygroundEndpointFormProps> = ({
endpoint,
formState,
setFormState,
types,
ignoreHeaders,
}) => {
const setHeaders = useCallback(
(value: ((old: unknown) => unknown) | unknown) => {
setFormState((state) => ({
...state,
headers: typeof value === "function" ? value(state.headers) : value,
}));
},
[setFormState],
);

const setPathParameters = useCallback(
(value: ((old: unknown) => unknown) | unknown) => {
setFormState((state) => ({
...state,
pathParameters: typeof value === "function" ? value(state.pathParameters) : value,
}));
},
[setFormState],
);

const setQueryParameters = useCallback(
(value: ((old: unknown) => unknown) | unknown) => {
setFormState((state) => ({
...state,
queryParameters: typeof value === "function" ? value(state.queryParameters) : value,
}));
},
[setFormState],
);

const setBody = useCallback(
(
value:
| ((old: PlaygroundFormStateBody | undefined) => PlaygroundFormStateBody | undefined)
| PlaygroundFormStateBody
| undefined,
) => {
setFormState((state) => ({
...state,
body: typeof value === "function" ? value(state.body) : value,
}));
},
[setFormState],
);

const setBodyJson = useCallback(
(value: ((old: unknown) => unknown) | unknown) => {
setBody((old) => {
return {
type: "json",
value: typeof value === "function" ? value(old?.type === "json" ? old.value : undefined) : value,
};
});
},
[setBody],
);

const setBodyOctetStream = useCallback(
(value: ((old: File | undefined) => File | undefined) | File | undefined) => {
setBody((old) => {
return {
type: "octet-stream",
value:
typeof value === "function"
? value(old?.type === "octet-stream" ? old.value : undefined)
: value,
};
});
},
[setBody],
);

return (
<>
{endpoint.headers.length > 0 && (
<PlaygroundEndpointFormSection ignoreHeaders={ignoreHeaders} title="Headers">
<PlaygroundObjectPropertiesForm
id="header"
properties={endpoint.headers}
onChange={setHeaders}
value={formState?.headers}
types={types}
/>
</PlaygroundEndpointFormSection>
)}

{endpoint.pathParameters.length > 0 && (
<PlaygroundEndpointFormSection ignoreHeaders={ignoreHeaders} title="Path Parameters">
<PlaygroundObjectPropertiesForm
id="path"
properties={endpoint.pathParameters}
onChange={setPathParameters}
value={formState?.pathParameters}
types={types}
/>
</PlaygroundEndpointFormSection>
)}

{endpoint.queryParameters.length > 0 && (
<PlaygroundEndpointFormSection ignoreHeaders={ignoreHeaders} title="Query Parameters">
<PlaygroundObjectPropertiesForm
id="query"
properties={endpoint.queryParameters}
onChange={setQueryParameters}
value={formState?.queryParameters}
types={types}
/>
</PlaygroundEndpointFormSection>
)}

{endpoint.requestBody != null &&
visitResolvedHttpRequestBodyShape(endpoint.requestBody.shape, {
formData: (formData) => (
<PlaygroundEndpointFormSection ignoreHeaders={ignoreHeaders} title={titleCase(formData.name)}>
<PlaygroundEndpointMultipartForm
endpoint={endpoint}
formState={formState}
formData={formData}
types={types}
setBody={setBody}
/>
</PlaygroundEndpointFormSection>
),
bytes: (bytes) => (
<PlaygroundEndpointFormSection ignoreHeaders={ignoreHeaders} title="Body">
<PlaygroundFileUploadForm
id="body"
propertyKey="body"
isOptional={bytes.isOptional}
type="file"
onValueChange={(files) => setBodyOctetStream(files?.[0])}
value={
formState?.body?.type === "octet-stream" && formState.body.value != null
? [formState.body.value]
: undefined
}
/>
</PlaygroundEndpointFormSection>
),
typeShape: (shape) => {
shape = unwrapReference(shape, types);

if (shape.type === "object") {
return (
<PlaygroundEndpointFormSection ignoreHeaders={ignoreHeaders} title="Body Parameters">
<PlaygroundObjectPropertiesForm
id="body"
properties={dereferenceObjectProperties(shape, types)}
onChange={setBodyJson}
value={formState?.body?.value}
types={types}
/>
</PlaygroundEndpointFormSection>
);
} else if (shape.type === "optional") {
return (
<PlaygroundEndpointFormSection ignoreHeaders={ignoreHeaders} title="Optional Body">
<PlaygroundTypeReferenceForm
id="body"
shape={shape.shape}
onChange={setBodyJson}
value={formState?.body?.value}
onlyRequired
types={types}
/>
</PlaygroundEndpointFormSection>
);
}

return (
<PlaygroundEndpointFormSection ignoreHeaders={ignoreHeaders} title="Body">
<PlaygroundTypeReferenceForm
id="body"
shape={shape}
onChange={setBodyJson}
value={formState?.body?.value}
onlyRequired
types={types}
/>
</PlaygroundEndpointFormSection>
);
},
})}
</>
);
};
Original file line number Diff line number Diff line change
@@ -3,9 +3,9 @@ import { FernButton, FernButtonGroup } from "@fern-ui/components";
import { ArrowUpRight } from "iconoir-react";
import { useAtomValue } from "jotai";
import { ReactElement } from "react";
import { CURRENT_NODE_ATOM, useClosePlayground } from "../atoms";
import { FernLink } from "../components/FernLink";
import { ResolvedEndpointDefinition } from "../resolver/types";
import { CURRENT_NODE_ATOM, useClosePlayground } from "../../atoms";
import { FernLink } from "../../components/FernLink";
import { ResolvedEndpointDefinition } from "../../resolver/types";

interface PlaygroundEndpointFormButtonsProps {
endpoint: ResolvedEndpointDefinition;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { FernCard } from "@fern-ui/components";
import { PropsWithChildren, ReactElement, ReactNode } from "react";

interface PlaygroundEndpointFormSection {
title?: ReactNode;
ignoreHeaders: boolean | undefined;
}

export function PlaygroundEndpointFormSection({
title,
ignoreHeaders,
children,
}: PropsWithChildren<PlaygroundEndpointFormSection>): ReactElement | null {
if (children == null) {
return null;
}
return (
<section>
{!ignoreHeaders && title && (
<div className="mb-4 px-4">
{typeof title === "string" ? <h5 className="t-muted m-0">{title}</h5> : title}
</div>
)}
<FernCard className="rounded-xl p-4 shadow-sm">{children}</FernCard>
</section>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ReactElement } from "react";
import { PlaygroundCardSkeleton } from "./PlaygroundCardSkeleton";

export function PlaygroundEndpointFormSectionSkeleton(): ReactElement {
return (
<section>
<PlaygroundCardSkeleton className="w-fit mb-4">
<h5 className="inline">Parameters</h5>
</PlaygroundCardSkeleton>
<PlaygroundCardSkeleton className="h-32" />
</section>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { FernTabs } from "@fern-ui/components";
import { ReactElement, ReactNode, useState } from "react";

interface PlaygroundEndpointMobileLayoutProps {
form: ReactNode;
requestCard: ReactNode;
responseCard: ReactNode;
sendButton: ReactNode;
}

export function PlaygroundEndpointMobileLayout({
form,
requestCard,
responseCard,
sendButton,
}: PlaygroundEndpointMobileLayoutProps): ReactElement {
const [tabValue, setTabValue] = useState<string>("0");
return (
<FernTabs
className="px-4"
defaultValue="0"
value={tabValue}
onValueChange={setTabValue}
tabs={[
{
title: "Request",
content: (
<div className="space-y-4 pb-6">
{form}
<div className="border-default flex justify-end border-b pb-4">
{sendButton}
{/* <PlaygroundSendRequestButton
sendRequest={() => {
sendRequest();
setTabValue("1");
}}
sendRequestIcon={
<SendSolid className="transition-transform group-hover:translate-x-0.5" />
}
/> */}
</div>
{requestCard}
</div>
),
},
{ title: "Response", content: responseCard },
]}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { visitDiscriminatedUnion } from "@fern-api/fdr-sdk";
import { ReactElement, useCallback } from "react";
import { ResolvedEndpointDefinition, ResolvedFormData, ResolvedTypeDefinition } from "../../resolver/types";
import { PlaygroundFileUploadForm } from "../form/PlaygroundFileUploadForm";
import { PlaygroundObjectPropertyForm } from "../form/PlaygroundObjectPropertyForm";
import { PlaygroundEndpointRequestFormState, PlaygroundFormDataEntryValue, PlaygroundFormStateBody } from "../types";

interface PlaygroundEndpointMultipartFormProps {
endpoint: ResolvedEndpointDefinition;
formState: PlaygroundEndpointRequestFormState | undefined;
formData: ResolvedFormData;
types: Record<string, ResolvedTypeDefinition>;
setBody: (
value:
| PlaygroundFormStateBody
| ((old: PlaygroundFormStateBody | undefined) => PlaygroundFormStateBody | undefined)
| undefined,
) => void;
}

export function PlaygroundEndpointMultipartForm({
endpoint,
formState,
formData,
types,
setBody,
}: PlaygroundEndpointMultipartFormProps): ReactElement {
const formDataFormValue = formState?.body?.type === "form-data" ? formState?.body.value : {};

const setBodyFormData = useCallback(
(
value:
| ((old: Record<string, PlaygroundFormDataEntryValue>) => Record<string, PlaygroundFormDataEntryValue>)
| Record<string, PlaygroundFormDataEntryValue>,
) => {
setBody((old) => {
return {
type: "form-data",
value: typeof value === "function" ? value(old?.type === "form-data" ? old.value : {}) : value,
};
});
},
[setBody],
);

const setFormDataEntry = useCallback(
(
key: string,
value:
| PlaygroundFormDataEntryValue
| undefined
| ((old: PlaygroundFormDataEntryValue | undefined) => PlaygroundFormDataEntryValue | undefined),
) => {
setBodyFormData((old) => {
const newValue = typeof value === "function" ? value(old[key] ?? undefined) : value;
if (newValue == null) {
// delete the key
const { [key]: _, ...rest } = old;
return rest;
}
return { ...old, [key]: newValue };
});
},
[setBodyFormData],
);

const handleFormDataFileChange = useCallback(
(key: string, files: ReadonlyArray<File> | undefined) => {
const type =
endpoint.requestBody?.shape.type === "formData"
? endpoint.requestBody?.shape.properties.find((p) => p.key === key)?.type
: undefined;
if (files == null || files.length === 0) {
setFormDataEntry(key, undefined);
return;
} else {
setFormDataEntry(
key,
type === "fileArray" ? { type: "fileArray", value: files } : { type: "file", value: files[0] },
);
}
},
[endpoint.requestBody, setFormDataEntry],
);

const handleFormDataJsonChange = useCallback(
(key: string, value: unknown) => {
setFormDataEntry(
key,
value == null
? undefined
: typeof value === "function"
? (oldValue) => ({ type: "json", value: value(oldValue?.value) })
: { type: "json", value },
);
},
[setFormDataEntry],
);

return (
<ul className="list-none space-y-8">
{formData.properties.map((property) =>
visitDiscriminatedUnion(property, "type")._visit({
file: (file) => {
const currentValue = formDataFormValue[property.key];
return (
<li key={property.key}>
<PlaygroundFileUploadForm
id={`body.${property.key}`}
propertyKey={property.key}
type={file.type}
isOptional={file.isOptional}
onValueChange={(files) => handleFormDataFileChange(property.key, files)}
value={
currentValue?.type === "file"
? currentValue.value != null
? [currentValue.value]
: undefined
: undefined
}
/>
</li>
);
},
fileArray: (fileArray) => {
const currentValue = formDataFormValue[property.key];
return (
<li key={property.key}>
<PlaygroundFileUploadForm
id={`body.${property.key}`}
propertyKey={property.key}
type={fileArray.type}
isOptional={fileArray.isOptional}
onValueChange={(files) => handleFormDataFileChange(property.key, files)}
value={currentValue?.type === "fileArray" ? currentValue.value : undefined}
/>
</li>
);
},
bodyProperty: (bodyProperty) => (
<li key={property.key}>
<PlaygroundObjectPropertyForm
id="body"
property={bodyProperty}
onChange={handleFormDataJsonChange}
value={formDataFormValue[property.key]?.value}
types={types}
/>
</li>
),
_other: () => null,
}),
)}
</ul>
);
}
Original file line number Diff line number Diff line change
@@ -6,13 +6,13 @@ import cn from "clsx";
import { Xmark } from "iconoir-react";
import { isUndefined, omitBy } from "lodash-es";
import { FC, Fragment, ReactNode } from "react";
import { useAllEnvironmentIds } from "../atoms/environment";
import { HttpMethodTag } from "../components/HttpMethodTag";
import { MaybeEnvironmentDropdown } from "../components/MaybeEnvironmentDropdown";
import { ResolvedEndpointPathParts, ResolvedObjectProperty } from "../resolver/types";
import { PlaygroundSendRequestButton } from "./PlaygroundSendRequestButton";
import { PlaygroundRequestFormState } from "./types";
import { buildRequestUrl, unknownToString } from "./utils";
import { useAllEnvironmentIds } from "../../atoms/environment";
import { HttpMethodTag } from "../../components/HttpMethodTag";
import { MaybeEnvironmentDropdown } from "../../components/MaybeEnvironmentDropdown";
import { ResolvedEndpointPathParts, ResolvedObjectProperty } from "../../resolver/types";
import { PlaygroundSendRequestButton } from "../PlaygroundSendRequestButton";
import { PlaygroundRequestFormState } from "../types";
import { buildRequestUrl, unknownToString } from "../utils";

interface PlaygroundEndpointPathProps {
method: APIV1Read.HttpMethod | undefined;
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { CopyToClipboardButton, FernButton, FernButtonGroup, FernCard } from "@fern-ui/components";
import { useAtom, useSetAtom } from "jotai";
import { ReactElement } from "react";
import {
PLAYGROUND_AUTH_STATE_ATOM,
PLAYGROUND_AUTH_STATE_OAUTH_ATOM,
PLAYGROUND_REQUEST_TYPE_ATOM,
store,
useFeatureFlags,
} from "../../atoms";
import { useStandardProxyEnvironment } from "../../hooks/useStandardProxyEnvironment";
import { ResolvedEndpointDefinition } from "../../resolver/types";
import { PlaygroundRequestPreview } from "../PlaygroundRequestPreview";
import { PlaygroundCodeSnippetResolverBuilder } from "../code-snippets/resolver";
import { PlaygroundEndpointRequestFormState } from "../types";

interface PlaygroundEndpointRequestCardProps {
endpoint: ResolvedEndpointDefinition;
formState: PlaygroundEndpointRequestFormState;
}

export function PlaygroundEndpointRequestCard({
endpoint,
formState,
}: PlaygroundEndpointRequestCardProps): ReactElement {
const { isSnippetTemplatesEnabled, isFileForgeHackEnabled } = useFeatureFlags();
const [requestType, setRequestType] = useAtom(PLAYGROUND_REQUEST_TYPE_ATOM);
const setOAuthValue = useSetAtom(PLAYGROUND_AUTH_STATE_OAUTH_ATOM);
const proxyEnvironment = useStandardProxyEnvironment();
return (
<FernCard className="flex min-w-0 flex-1 shrink flex-col overflow-hidden rounded-xl shadow-sm">
<div className="border-default flex h-10 w-full shrink-0 items-center justify-between border-b px-3 py-2">
<span className="t-muted text-xs uppercase">Request</span>

<FernButtonGroup>
<FernButton
onClick={() => setRequestType("curl")}
size="small"
variant="minimal"
intent={requestType === "curl" ? "primary" : "none"}
active={requestType === "curl"}
>
cURL
</FernButton>
<FernButton
onClick={() => setRequestType("typescript")}
size="small"
variant="minimal"
intent={requestType === "typescript" ? "primary" : "none"}
active={requestType === "typescript"}
>
TypeScript
</FernButton>
<FernButton
onClick={() => setRequestType("python")}
size="small"
variant="minimal"
intent={requestType === "python" ? "primary" : "none"}
active={requestType === "python"}
>
Python
</FernButton>
</FernButtonGroup>

<CopyToClipboardButton
content={() => {
const authState = store.get(PLAYGROUND_AUTH_STATE_ATOM);
const resolver = new PlaygroundCodeSnippetResolverBuilder(
endpoint,
isSnippetTemplatesEnabled,
isFileForgeHackEnabled,
).create(authState, formState, proxyEnvironment, setOAuthValue);
return resolver.resolve(requestType);
}}
className="-mr-2"
/>
</div>
<PlaygroundRequestPreview endpoint={endpoint} formState={formState} requestType={requestType} />
</FernCard>
);
}
Original file line number Diff line number Diff line change
@@ -5,12 +5,12 @@ import cn, { clsx } from "clsx";
import { Search, Slash, Xmark } from "iconoir-react";
import dynamic from "next/dynamic";
import { Fragment, ReactElement, forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { useSetAndOpenPlayground } from "../atoms";
import { HttpMethodTag } from "../components/HttpMethodTag";
import { type ResolvedApiEndpointWithPackage } from "../resolver/types";
import { BuiltWithFern } from "../sidebar/BuiltWithFern";
import { useSetAndOpenPlayground } from "../../atoms";
import { HttpMethodTag } from "../../components/HttpMethodTag";
import { type ResolvedApiEndpointWithPackage } from "../../resolver/types";
import { BuiltWithFern } from "../../sidebar/BuiltWithFern";

const Markdown = dynamic(() => import("../mdx/Markdown").then(({ Markdown }) => Markdown), { ssr: true });
const Markdown = dynamic(() => import("../../mdx/Markdown").then(({ Markdown }) => Markdown), { ssr: true });

export interface PlaygroundEndpointSelectorContentProps {
apiGroups: ApiGroup[];
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { FernButton } from "@fern-ui/components";
import * as Dialog from "@radix-ui/react-dialog";
import { Xmark } from "iconoir-react";
import { ReactElement } from "react";
import { noop } from "ts-essentials";
import { HttpMethodTag } from "../../components/HttpMethodTag";
import { PlaygroundSendRequestButton } from "../PlaygroundSendRequestButton";
import { PlaygroundCardSkeleton } from "./PlaygroundCardSkeleton";
import { PlaygroundEndpointContentLayout } from "./PlaygroundEndpointContentLayout";
import { PlaygroundEndpointFormSectionSkeleton } from "./PlaygroundEndpointFormSectionSkeleton";

function PlaygroundEndpointPath() {
return (
<div className="playground-endpoint">
<div className="flex h-10 min-w-0 flex-1 shrink gap-2 rounded-lg bg-tag-default px-4 py-2 max-sm:h-8 max-sm:px-2 max-sm:py-1 sm:rounded-[20px] items-center">
<HttpMethodTag method="POST" className="playground-endpoint-method" skeleton />
</div>

<div className="max-sm:hidden">
<PlaygroundSendRequestButton />
</div>

<Dialog.Close asChild className="max-sm:hidden">
<FernButton icon={<Xmark />} size="large" rounded variant="outlined" />
</Dialog.Close>
</div>
);
}

export function PlaygroundEndpointSkeleton(): ReactElement {
const form = (
<div className="mx-auto w-full max-w-5xl space-y-6 pt-6 max-sm:pt-0 sm:pb-20">
<div className="col-span-2 space-y-8">
<PlaygroundEndpointFormSectionSkeleton />
<PlaygroundEndpointFormSectionSkeleton />
</div>
</div>
);
return (
<div className="flex min-h-0 flex-1 shrink flex-col size-full">
<div className="flex-0">
<PlaygroundEndpointPath />
</div>
<div className="flex min-h-0 flex-1 shrink">
<PlaygroundEndpointContentLayout
sendRequest={noop}
form={form}
requestCard={<PlaygroundCardSkeleton className="flex-1" />}
responseCard={<PlaygroundCardSkeleton className="flex-1" />}
/>
</div>
</div>
);
}
154 changes: 154 additions & 0 deletions packages/ui/app/src/playground/endpoint/PlaygroundResponseCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
CopyToClipboardButton,
FernAudioPlayer,
FernButton,
FernCard,
FernTooltip,
FernTooltipProvider,
} from "@fern-ui/components";
import { Loadable, visitLoadable } from "@fern-ui/loadable";
import clsx from "clsx";
import { Download } from "iconoir-react";
import { isEmpty, round } from "lodash-es";
import { ReactElement } from "react";
import { useFeatureFlags } from "../../atoms";
import { FernErrorTag } from "../../components/FernErrorBoundary";
import { PlaygroundResponsePreview } from "../PlaygroundResponsePreview";
import { PlaygroundSendRequestButton } from "../PlaygroundSendRequestButton";
import { PlaygroundResponse } from "../types/playgroundResponse";
import { ProxyResponse } from "../types/proxy";

interface PlaygroundResponseCard {
response: Loadable<PlaygroundResponse>;
sendRequest: () => void;
}

export function PlaygroundResponseCard({ response, sendRequest }: PlaygroundResponseCard): ReactElement {
const { isBinaryOctetStreamAudioPlayer } = useFeatureFlags();
return (
<FernCard className="flex min-w-0 flex-1 shrink flex-col overflow-hidden rounded-xl shadow-sm">
<div className="border-default flex h-10 w-full shrink-0 items-center justify-between border-b px-3 py-2">
<span className="t-muted text-xs uppercase">Response</span>

{response.type === "loaded" && (
<div className="flex items-center gap-2 text-xs">
<span
className={clsx("font-mono flex items-center py-1 px-1.5 rounded-md h-5", {
["bg-method-get/10 text-method-get dark:bg-method-get-dark/10 dark:text-method-get-dark"]:
response.value.response.status >= 200 && response.value.response.status < 300,
["bg-method-delete/10 text-method-delete dark:bg-method-delete-dark/10 dark:text-method-delete-dark"]:
response.value.response.status > 300,
})}
>
status: {response.value.response.status}
</span>
<span className={"flex h-5 items-center rounded-md bg-tag-default px-1.5 py-1 font-mono"}>
time: {round(response.value.time, 2)}ms
</span>
{response.value.type === "json" && !isEmpty(response.value.size) && (
<span className={"flex h-5 items-center rounded-md bg-tag-default px-1.5 py-1 font-mono"}>
size: {response.value.size}b
</span>
)}
</div>
)}

{visitLoadable(response, {
loading: () => <div />,
loaded: (response) =>
response.type === "file" ? (
<FernTooltipProvider>
<FernTooltip content="Download file">
<FernButton
icon={<Download />}
size="small"
variant="minimal"
onClick={() => {
const a = document.createElement("a");
a.href = response.response.body;
a.download = createFilename(response.response, response.contentType);
a.click();
}}
/>
</FernTooltip>
</FernTooltipProvider>
) : (
<CopyToClipboardButton
content={() =>
response.type === "json"
? JSON.stringify(response.response.body, null, 2)
: response.type === "stream"
? response.response.body
: ""
}
className="-mr-2"
/>
),
failed: () => (
<span className="flex items-center rounded-[4px] bg-tag-danger p-1 font-mono text-xs uppercase leading-none text-intent-danger">
Failed
</span>
),
})}
</div>
{visitLoadable(response, {
loading: () =>
response.type === "notStartedLoading" ? (
<div className="flex flex-1 items-center justify-center">
<PlaygroundSendRequestButton sendRequest={sendRequest} />
</div>
) : (
<div className="flex flex-1 items-center justify-center">Loading...</div>
),
loaded: (response) =>
response.type !== "file" ? (
<PlaygroundResponsePreview response={response} />
) : response.contentType.startsWith("audio/") ||
(isBinaryOctetStreamAudioPlayer && response.contentType === "binary/octet-stream") ? (
<FernAudioPlayer
src={response.response.body}
className="flex h-full items-center justify-center p-4"
/>
) : response.contentType.includes("application/pdf") ? (
<iframe
src={response.response.body}
className="size-full"
title="PDF preview"
allowFullScreen
/>
) : (
<FernErrorTag
component="PlaygroundEndpointContent"
error={`File preview not supported for ${response.contentType}`}
className="flex h-full items-center justify-center"
showError
/>
),
failed: (e) => (
<FernErrorTag
component="PlaygroundEndpointContent"
error={e}
className="flex h-full items-center justify-center"
showError={true}
/>
),
})}
</FernCard>
);
}

function createFilename(body: ProxyResponse.SerializableFileBody, contentType: string): string {
const headers = new Headers(body.headers);
const contentDisposition = headers.get("Content-Disposition");

if (contentDisposition != null) {
const filename = contentDisposition.split("filename=")[1];
if (filename != null) {
return filename;
}
}

// TODO: use a more deterministic way to generate filenames
const extension = contentType.split("/")[1];
return `${crypto.randomUUID()}.${extension}`;
}
1 change: 1 addition & 0 deletions packages/ui/app/src/playground/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "./endpoint/PlaygroundEndpoint";
4 changes: 2 additions & 2 deletions packages/ui/components/src/FernButton.scss
Original file line number Diff line number Diff line change
@@ -126,12 +126,12 @@
}
}

&.disabled {
&[disabled] {
@apply cursor-not-allowed bg-black/20 text-text-default/40 hover:text-text-default/40;
@apply dark:bg-white/10 dark:text-text-default/50 dark:hover:text-text-default/50;
}

&:not(.disabled) {
&:not([disabled]) {
&.minimal {
@apply bg-transparent t-muted;

31 changes: 21 additions & 10 deletions packages/ui/components/src/FernButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import cn from "clsx";
import { ComponentProps, forwardRef, PropsWithChildren, ReactElement, ReactNode, useRef } from "react";
import cn, { clsx } from "clsx";
import { ComponentProps, createElement, forwardRef, PropsWithChildren, ReactElement, ReactNode, useRef } from "react";
import { FernTooltip, FernTooltipProvider } from "./FernTooltip";
import { RemoteFontAwesomeIcon } from "./FontAwesomeIcon";

@@ -20,6 +20,7 @@ export interface FernButtonSharedProps {
// children replaces text
text?: React.ReactNode;
disableAutomaticTooltip?: boolean;
skeleton?: boolean;
}

export interface FernButtonProps
@@ -50,6 +51,7 @@ export const FernButton = forwardRef<HTMLButtonElement, FernButtonProps>(functio
full,
rounded,
disableAutomaticTooltip,
skeleton,
...buttonProps
} = props;
const buttonTextRef = useRef<HTMLSpanElement>(null);
@@ -60,15 +62,15 @@ export const FernButton = forwardRef<HTMLButtonElement, FernButtonProps>(functio
<button
tabIndex={0}
ref={ref}
disabled={disabled}
disabled={disabled || skeleton}
data-state={active ? "on" : "off"}
aria-disabled={disabled}
aria-selected={active}
data-selected={active}
{...buttonProps}
className={getButtonClassName(props)}
onClick={
props.onClick != null
props.onClick != null && !skeleton
? (e) => {
if (disabled) {
e.preventDefault();
@@ -80,7 +82,7 @@ export const FernButton = forwardRef<HTMLButtonElement, FernButtonProps>(functio
: undefined
}
>
{renderButtonContent(props, buttonTextRef)}
{createElement(ButtonContent, { ...props, buttonTextRef, className: skeleton ? "contents invisible" : "" })}
</button>
);

@@ -108,13 +110,22 @@ export const FernButtonGroup = forwardRef<HTMLSpanElement, ComponentProps<"div">
);
});

export function renderButtonContent(
{ icon: leftIcon, rightIcon, mono = false, text, children }: PropsWithChildren<FernButtonSharedProps>,
buttonTextRef?: React.RefObject<HTMLSpanElement>,
): ReactElement {
export function ButtonContent({
icon: leftIcon,
rightIcon,
mono = false,
text,
children,
buttonTextRef,
className,
}: PropsWithChildren<
FernButtonSharedProps & {
buttonTextRef?: React.RefObject<HTMLSpanElement>;
}
>): ReactElement {
children = children ?? text;
return (
<span className="fern-button-content">
<span className={clsx("fern-button-content", className)}>
{renderIcon(leftIcon)}
{children && (
<span
12 changes: 10 additions & 2 deletions packages/ui/components/src/FernTag.tsx
Original file line number Diff line number Diff line change
@@ -25,13 +25,21 @@ export interface FernTagProps extends PropsWithChildren {
variant?: "subtle" | "solid";
colorScheme?: FernTagColorScheme;
className?: string;
skeleton?: boolean;
}

/**
* The `FernTag` component is used for items that need to be labeled, categorized, or organized using keywords that describe them.
*/
export const FernTag = forwardRef<HTMLSpanElement, FernTagProps>(
({ children, rounded = false, size = "lg", variant = "subtle", colorScheme = "gray", className }, ref) => {
(
{ children, rounded = false, size = "lg", variant = "subtle", colorScheme = "gray", className, skeleton },
ref,
) => {
if (skeleton) {
colorScheme = "gray";
}

return (
<span
ref={ref}
@@ -70,7 +78,7 @@ export const FernTag = forwardRef<HTMLSpanElement, FernTagProps>(
className,
)}
>
{children}
{skeleton ? <span className="contents invisible">{children}</span> : children}
</span>
);
},

0 comments on commit 95c1366

Please sign in to comment.