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: resolve-api should let you open all APIs
Browse files Browse the repository at this point in the history
abvthecity committed May 23, 2024
1 parent 06ba918 commit f614441
Showing 25 changed files with 323 additions and 326 deletions.
4 changes: 3 additions & 1 deletion packages/commons/fdr-utils/src/traverser.ts
Original file line number Diff line number Diff line change
@@ -97,7 +97,9 @@ function visitNode(
}
}

const apiSectionBreadcrumbs = [...sectionTitleBreadcrumbs, apiSection.title];
const apiSectionBreadcrumbs = apiSection.isSidebarFlattened
? sectionTitleBreadcrumbs
: [...sectionTitleBreadcrumbs, apiSection.title];

if (apiSection.changelog != null) {
traverseState = visitPage(apiSection.changelog, currentNode, traverseState, apiSectionBreadcrumbs);
1 change: 0 additions & 1 deletion packages/ui/app/src/api-page/ApiPage.tsx
Original file line number Diff line number Diff line change
@@ -18,7 +18,6 @@ export const ApiPage: React.FC<ApiPage.Props> = ({ initialApi, showErrors }) =>
const hydrated = useIsReady();
const { isApiScrollingDisabled } = useFeatureFlags();
const setDefinitions = useSetAtom(APIS);
// const definition = apis[initialApi.api];

useEffect(() => {
setDefinitions((prev) => ({ ...prev, [initialApi.api]: initialApi }));
46 changes: 25 additions & 21 deletions packages/ui/app/src/api-playground/PlaygroundContext.tsx
Original file line number Diff line number Diff line change
@@ -2,12 +2,11 @@ import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { mapValues, noop } from "lodash-es";
import dynamic from "next/dynamic";
import { FC, PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
import { FC, PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import urljoin from "url-join";
import { capturePosthogEvent } from "../analytics/posthog";
import { useFeatureFlags } from "../contexts/FeatureFlagContext";
import { useDocsContext } from "../contexts/docs-context/useDocsContext";
import { useNavigationContext } from "../contexts/navigation-context";
import {
ResolvedApiDefinition,
ResolvedRootPackage,
@@ -53,10 +52,29 @@ export const PLAYGROUND_FORM_STATE_ATOM = atomWithStorage<Record<string, Playgro
export const PlaygroundContextProvider: FC<PropsWithChildren> = ({ children }) => {
const { isApiPlaygroundEnabled } = useFeatureFlags();
const [apis, setApis] = useAtom(APIS);
const { basePath } = useDocsContext();
const { selectedSlug } = useNavigationContext();
const { basePath, apis: apiIds } = useDocsContext();
const [selectionState, setSelectionState] = useState<PlaygroundSelectionState | undefined>();

useEffect(() => {
const unfetchedApis = apiIds.filter((apiId) => apis[apiId] == null);
if (unfetchedApis.length === 0) {
return;
}

void Promise.all(
unfetchedApis.map(async (apiId) => {
const r = await fetch(
urljoin(basePath ?? "", "/api/fern-docs/resolve-api?path=" + (basePath ?? "/") + "&api=" + apiId),
);
const data: Record<string, ResolvedRootPackage> | null = await r.json();
if (data == null) {
return;
}
setApis((currentApis) => ({ ...currentApis, ...data }));
}),
);
}, [apiIds, apis, basePath, setApis]);

const flattenedApis = useMemo(() => mapValues(apis, flattenRootPackage), [apis]);

const [isPlaygroundOpen, setPlaygroundOpen] = useAtom(PLAYGROUND_OPEN_ATOM);
@@ -75,23 +93,9 @@ export const PlaygroundContextProvider: FC<PropsWithChildren> = ({ children }) =

const setSelectionStateAndOpen = useCallback(
async (newSelectionState: PlaygroundSelectionState) => {
let matchedPackage = flattenedApis[newSelectionState.api];
const matchedPackage = flattenedApis[newSelectionState.api];
if (matchedPackage == null) {
const r = await fetch(
urljoin(
basePath ?? "",
"/api/fern-docs/resolve-api?path=/" + selectedSlug + "&api=" + newSelectionState.api,
),
);

const data: ResolvedRootPackage | null = await r.json();

if (data == null) {
return;
}

setApis((currentApis) => ({ ...currentApis, [newSelectionState.api]: data }));
matchedPackage = flattenRootPackage(data);
return;
}

if (newSelectionState.type === "endpoint") {
@@ -131,7 +135,7 @@ export const PlaygroundContextProvider: FC<PropsWithChildren> = ({ children }) =
});
}
},
[basePath, expandPlayground, flattenedApis, globalFormState, selectedSlug, setApis, setGlobalFormState],
[expandPlayground, flattenedApis, globalFormState, setGlobalFormState],
);

if (!isApiPlaygroundEnabled) {
255 changes: 96 additions & 159 deletions packages/ui/app/src/api-playground/PlaygroundDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { APIV1Read, FdrAPI } from "@fern-api/fdr-sdk";
import { EMPTY_OBJECT, visitDiscriminatedUnion } from "@fern-ui/core-utils";
import { Portal, Transition } from "@headlessui/react";
import { Cross1Icon } from "@radix-ui/react-icons";
import { TooltipProvider } from "@radix-ui/react-tooltip";
import clsx from "clsx";
// import { Portal, Transition } from "@headlessui/react";
import * as Dialog from "@radix-ui/react-dialog";
import { ArrowLeftIcon, Cross1Icon } from "@radix-ui/react-icons";
import { atom, useAtom } from "jotai";
import { mapValues } from "lodash-es";
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo } from "react";
import { capturePosthogEvent } from "../analytics/posthog";
import { FernButton, FernButtonGroup } from "../components/FernButton";
import { FernButton } from "../components/FernButton";
import { FernErrorBoundary } from "../components/FernErrorBoundary";
import { FernTooltip, FernTooltipProvider } from "../components/FernTooltip";
import { useDocsContext } from "../contexts/docs-context/useDocsContext";
import { useLayoutBreakpoint } from "../contexts/layout-breakpoint/useLayoutBreakpoint";
import {
@@ -25,9 +23,9 @@ import {
} from "../resolver/types";
import { PLAYGROUND_FORM_STATE_ATOM, PLAYGROUND_OPEN_ATOM, usePlaygroundContext } from "./PlaygroundContext";
import { PlaygroundEndpoint } from "./PlaygroundEndpoint";
import { PlaygroundEndpointSelector } from "./PlaygroundEndpointSelector";
import { PlaygroundEndpointSelectorContent, flattenApiSection } from "./PlaygroundEndpointSelectorContent";
import { PlaygroundWebSocket } from "./PlaygroundWebSocket";
import { HorizontalSplitPane } from "./VerticalSplitPane";
import {
PlaygroundEndpointRequestFormState,
PlaygroundFormDataEntryValue,
@@ -85,7 +83,9 @@ export function usePlaygroundHeight(): [number, Dispatch<SetStateAction<number>>
const [playgroundHeight, setHeight] = useAtom(PLAYGROUND_HEIGHT_ATOM);
const windowHeight = useWindowHeight();
const height =
windowHeight != null ? Math.max(Math.min(windowHeight - headerHeight, playgroundHeight), 40) : playgroundHeight;
windowHeight != null
? Math.max(Math.min(windowHeight - headerHeight, playgroundHeight), windowHeight / 3)
: playgroundHeight;

return [height, setHeight];
}
@@ -95,7 +95,7 @@ interface PlaygroundDrawerProps {
}

export const PlaygroundDrawer: FC<PlaygroundDrawerProps> = ({ apis }) => {
const { selectionState, hasPlayground, collapsePlayground } = usePlaygroundContext();
const { selectionState, hasPlayground } = usePlaygroundContext();
const windowHeight = useWindowHeight();

const { sidebarNodes } = useDocsContext();
@@ -249,164 +249,101 @@ export const PlaygroundDrawer: FC<PlaygroundDrawerProps> = ({ apis }) => {
};
}, [togglePlayground]);

const { endpoint: selectedEndpoint } = apiGroups
.flatMap((group) => [
...group.items
.filter((item) => item.apiType === "endpoint" || item.apiType === "websocket")
.map((endpoint) => ({ group, endpoint })),
])
.find(({ endpoint }) =>
selectionState?.type === "endpoint"
? endpoint.slug.join("/") === selectionState?.endpointId
: selectionState?.type === "websocket"
? endpoint.slug.join("/") === selectionState?.webSocketId
: false,
) ?? {
endpoint: undefined,
group: undefined,
};

if (!hasPlayground) {
return null;
}

const mobileHeader = (
<div className="grid h-10 grid-cols-2 gap-2 px-4">
<div className="flex items-center">
<div className="-ml-3">
{selectionState != null ? (
<PlaygroundEndpointSelector apiGroups={apiGroups} />
) : (
<h6 className="t-accent">Select an endpoint to get started</h6>
)}
</div>
</div>

<div className="flex items-center justify-end">
<FernTooltipProvider>
<FernButtonGroup>
<FernTooltip
content={
<span className="space-x-4">
<span>Close API Playground</span>
<span className="font-mono text-faded">CTRL + `</span>
</span>
}
>
<FernButton
variant="minimal"
className="-mr-3"
icon={<Cross1Icon />}
onClick={collapsePlayground}
rounded
/>
</FernTooltip>
</FernButtonGroup>
</FernTooltipProvider>
</div>
</div>
);

const desktopHeader = (
<div className="grid h-10 grid-cols-3 gap-2 px-4">
<div className="flex items-center">
<span className="inline-flex items-baseline gap-2">
<span className="t-accent text-sm font-semibold">API Playground</span>
</span>
</div>

<div className="flex items-center justify-center">
{selectionState != null ? (
<PlaygroundEndpointSelector apiGroups={apiGroups} />
) : (
<h6 className="t-accent">Select an endpoint to get started</h6>
)}
</div>

<div className="flex items-center justify-end">
<FernTooltipProvider>
<FernButtonGroup>
<FernTooltip
content={
<span className="space-x-4">
<span>Close API Playground</span>
<span className="font-mono text-faded">CTRL + `</span>
</span>
}
>
<FernButton
variant="minimal"
className="-mr-2"
icon={<Cross1Icon />}
onClick={collapsePlayground}
rounded
/>
</FernTooltip>
</FernButtonGroup>
</FernTooltipProvider>
</div>
</div>
);

return (
<Portal>
<Transition
show={isPlaygroundOpen}
className={clsx(
"bg-background-translucent border-default fixed inset-x-0 bottom-0 z-50 border-t backdrop-blur-xl",
{
"max-h-vh-minus-header": layoutBreakpoint !== "mobile",
"h-screen": layoutBreakpoint === "mobile",
},
)}
style={{ height: layoutBreakpoint !== "mobile" ? height : undefined }}
enter="ease-out transition-transform duration-300 transform"
enterFrom="translate-y-full"
enterTo="translate-y-0"
leave="ease-in transition-transform duration-200 transform"
leaveFrom="translate-y-0"
leaveTo="translate-y-full"
>
{layoutBreakpoint !== "mobile" && (
<div
className="group absolute inset-x-0 -top-0.5 h-0.5 cursor-row-resize after:absolute after:inset-x-0 after:-top-3 after:h-4 after:content-['']"
onMouseDown={handleVerticalResize}
>
<div className="bg-accent absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100 group-active:opacity-100" />
<div className="relative -top-6 z-30 mx-auto w-fit p-4 pb-0">
<div className="bg-accent h-1 w-10 rounded-full" />
</div>
</div>
)}
<div className="flex h-full flex-col rounded-lg">
<div>{layoutBreakpoint === "mobile" ? mobileHeader : desktopHeader}</div>
<FernErrorBoundary
component="PlaygroundDrawer"
className="flex h-full items-center justify-center"
showError={true}
reset={resetWithoutExample}
<FernErrorBoundary
component="PlaygroundDrawer"
className="flex h-full items-center justify-center"
showError={true}
reset={resetWithoutExample}
>
<Dialog.Root open={isPlaygroundOpen} onOpenChange={setPlaygroundOpen} modal={false}>
<Dialog.Portal>
<Dialog.Content
className="data-[state=open]:animate-content-show-from-bottom fixed bottom-0 inset-x-0 bg-background-translucent backdrop-blur-2xl shadow-xl border-t border-default"
style={{ height: layoutBreakpoint !== "mobile" ? height : undefined }}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
{selectionState?.type === "endpoint" && matchedEndpoint != null ? (
<PlaygroundEndpoint
endpoint={matchedEndpoint}
formState={
playgroundFormState?.type === "endpoint"
? playgroundFormState
: EMPTY_ENDPOINT_FORM_STATE
}
setFormState={setPlaygroundEndpointFormState}
resetWithExample={resetWithExample}
resetWithoutExample={resetWithoutExample}
types={types}
/>
) : selectionState?.type === "websocket" && matchedWebSocket != null ? (
<PlaygroundWebSocket
websocket={matchedWebSocket}
formState={
playgroundFormState?.type === "websocket"
? playgroundFormState
: EMPTY_WEBSOCKET_FORM_STATE
}
setFormState={setPlaygroundWebSocketFormState}
types={types}
/>
) : (
<TooltipProvider>
<div className="flex min-h-0 flex-1 shrink flex-col items-center justify-start">
<PlaygroundEndpointSelectorContent
apiGroups={apiGroups}
className="fern-card mb-6 min-h-0 shrink p-px"
/>
{layoutBreakpoint !== "mobile" && (
<>
<div
className="group absolute inset-x-0 -top-0.5 h-0.5 cursor-row-resize after:absolute after:inset-x-0 after:-top-3 after:h-4 after:content-['']"
onMouseDown={handleVerticalResize}
>
<div className="bg-accent absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100 group-active:opacity-100" />
<div className="relative -top-6 z-30 mx-auto w-fit p-4 pb-0">
<div className="bg-accent h-1 w-10 rounded-full" />
</div>
</div>
</TooltipProvider>
<Dialog.Close asChild className="absolute -translate-y-full -top-2 right-2">
<FernButton icon={<Cross1Icon />} size="large" rounded variant="minimal" />
</Dialog.Close>
</>
)}
</FernErrorBoundary>
</div>
</Transition>
</Portal>
<HorizontalSplitPane mode="pixel" className="size-full" leftClassName="border-default border-r">
<PlaygroundEndpointSelectorContent
apiGroups={apiGroups}
selectedEndpoint={selectedEndpoint}
className="h-full"
/>

{selectionState?.type === "endpoint" && matchedEndpoint != null ? (
<PlaygroundEndpoint
endpoint={matchedEndpoint}
formState={
playgroundFormState?.type === "endpoint"
? playgroundFormState
: EMPTY_ENDPOINT_FORM_STATE
}
setFormState={setPlaygroundEndpointFormState}
resetWithExample={resetWithExample}
resetWithoutExample={resetWithoutExample}
types={types}
/>
) : selectionState?.type === "websocket" && matchedWebSocket != null ? (
<PlaygroundWebSocket
websocket={matchedWebSocket}
formState={
playgroundFormState?.type === "websocket"
? playgroundFormState
: EMPTY_WEBSOCKET_FORM_STATE
}
setFormState={setPlaygroundWebSocketFormState}
types={types}
/>
) : (
<div className="size-full flex flex-col items-center justify-center">
<ArrowLeftIcon className="size-8 mb-2 t-muted" />
<h6 className="t-muted">Select an endpoint to get started</h6>
</div>
)}
</HorizontalSplitPane>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</FernErrorBoundary>
);
};

2 changes: 1 addition & 1 deletion packages/ui/app/src/api-playground/PlaygroundEndpoint.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.playground-endpoint {
@apply flex min-w-0 flex-1 shrink items-start gap-2;
@apply px-2 w-full;
@apply p-3 pb-0 w-full;
}

.playground-endpoint-url {
2 changes: 1 addition & 1 deletion packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx
Original file line number Diff line number Diff line change
@@ -162,7 +162,7 @@ export const PlaygroundEndpoint: FC<PlaygroundEndpointProps> = ({

return (
<FernTooltipProvider>
<div className="flex min-h-0 flex-1 shrink flex-col">
<div className="flex min-h-0 flex-1 shrink flex-col size-full">
<div className="flex-0">
<PlaygroundEndpointPath
method={endpoint.method}
28 changes: 8 additions & 20 deletions packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx
Original file line number Diff line number Diff line change
@@ -18,7 +18,6 @@ import { ResolvedEndpointDefinition, ResolvedTypeDefinition } from "../resolver/
import { CopyToClipboardButton } from "../syntax-highlighting/CopyToClipboardButton";
import { PlaygroundAuthorizationFormCard } from "./PlaygroundAuthorizationForm";
import { PlaygroundEndpointForm } from "./PlaygroundEndpointForm";
import { PlaygroundEndpointFormAside } from "./PlaygroundEndpointFormAside";
import { PlaygroundRequestPreview } from "./PlaygroundRequestPreview";
import { PlaygroundResponsePreview } from "./PlaygroundResponsePreview";
import { PlaygroundSendRequestButton } from "./PlaygroundSendRequestButton";
@@ -44,8 +43,8 @@ export const PlaygroundEndpointContent: FC<PlaygroundEndpointContentProps> = ({
endpoint,
formState,
setFormState,
resetWithExample,
resetWithoutExample,
// resetWithExample,
// resetWithoutExample,
response,
sendRequest,
types,
@@ -90,23 +89,12 @@ export const PlaygroundEndpointContent: FC<PlaygroundEndpointContentProps> = ({
/>
)}

<div className="grid grid-cols-3 gap-4 max-sm:grid-cols-2">
<PlaygroundEndpointFormAside
className="col-span-1 -mt-6 max-sm:hidden"
endpoint={endpoint}
formState={formState}
scrollAreaHeight={scrollAreaHeight}
resetWithExample={resetWithExample}
resetWithoutExample={resetWithoutExample}
types={types}
/>
<PlaygroundEndpointForm
endpoint={endpoint}
formState={formState}
setFormState={setFormState}
types={types}
/>
</div>
<PlaygroundEndpointForm
endpoint={endpoint}
formState={formState}
setFormState={setFormState}
types={types}
/>
</div>
);

Original file line number Diff line number Diff line change
@@ -2,15 +2,16 @@ import { FdrAPI } from "@fern-api/fdr-sdk";
import { isNonNullish, visitDiscriminatedUnion } from "@fern-ui/core-utils";
import { SidebarNode } from "@fern-ui/fdr-utils";
import { Cross1Icon, MagnifyingGlassIcon, SlashIcon } from "@radix-ui/react-icons";
import cn from "clsx";
import cn, { clsx } from "clsx";
import { compact, noop } from "lodash-es";
import dynamic from "next/dynamic";
import { Fragment, ReactElement, forwardRef, useImperativeHandle, useRef, useState } from "react";
import { Fragment, ReactElement, forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { HttpMethodTag } from "../commons/HttpMethodTag";
import { FernButton } from "../components/FernButton";
import { FernInput } from "../components/FernInput";
import { FernScrollArea } from "../components/FernScrollArea";
import { FernTooltip } from "../components/FernTooltip";
import { FernTooltip, FernTooltipProvider } from "../components/FernTooltip";
import { BuiltWithFern } from "../sidebar/BuiltWithFern";
import { usePlaygroundContext } from "./PlaygroundContext";

const Markdown = dynamic(() => import("../mdx/Markdown").then(({ Markdown }) => Markdown), { ssr: true });
@@ -49,7 +50,7 @@ export function flattenApiSection(navigation: SidebarNode[]): ApiGroup[] {
result.push({
api: apiSection.api,
id: apiSection.id,
breadcrumbs: [apiSection.title],
breadcrumbs: apiSection.isSidebarFlattened ? [] : [apiSection.title],
items: apiSection.items
.filter((item): item is SidebarNode.ApiPage => item.type === "page")
.flatMap((item): SidebarNode.ApiPage[] =>
@@ -64,7 +65,9 @@ export function flattenApiSection(navigation: SidebarNode[]): ApiGroup[] {
),
).map((group) => ({
...group,
breadcrumbs: [apiSection.title, ...group.breadcrumbs],
breadcrumbs: apiSection.isSidebarFlattened
? group.breadcrumbs
: [apiSection.title, ...group.breadcrumbs],
})),
);
},
@@ -84,13 +87,21 @@ function matchesEndpoint(query: string, group: ApiGroup, endpoint: SidebarNode.A
}

export const PlaygroundEndpointSelectorContent = forwardRef<HTMLDivElement, PlaygroundEndpointSelectorContentProps>(
function PlaygroundEndpointSelectorContent({ apiGroups, closeDropdown, selectedEndpoint, className }, ref) {
({ apiGroups, closeDropdown, selectedEndpoint, className }, ref) => {
const scrollRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
useImperativeHandle(ref, () => scrollRef.current!);

const { setSelectionStateAndOpen } = usePlaygroundContext();

const [filterValue, setFilterValue] = useState<string>("");

const selectedItemRef = useRef<HTMLLIElement>(null);

useEffect(() => {
selectedItemRef.current?.scrollIntoView({ block: "center" });
}, []);

const createSelectEndpoint = (group: ApiGroup, endpoint: SidebarNode.EndpointPage) => () => {
setSelectionStateAndOpen({
type: "endpoint",
@@ -117,9 +128,9 @@ export const PlaygroundEndpointSelectorContent = forwardRef<HTMLDivElement, Play
return null;
}
return (
<li key={apiGroup.id} className="gap-2">
<li key={apiGroup.id}>
{apiGroup.breadcrumbs.length > 0 && (
<div className="bg-background-translucent sticky top-0 z-10 flex h-[30px] items-center px-3 py-1">
<div className="flex h-[30px] items-center px-3 py-1 truncate">
{apiGroup.breadcrumbs.map((breadcrumb, idx) => (
<Fragment key={idx}>
{idx > 0 && <SlashIcon className="mx-0.5 size-3 text-faded" />}
@@ -147,12 +158,18 @@ export const PlaygroundEndpointSelectorContent = forwardRef<HTMLDivElement, Play
>
<FernButton
text={text}
className="w-full rounded-none text-left"
className="w-full text-left"
variant="minimal"
intent={active ? "primary" : "none"}
active={active}
onClick={createSelectEndpoint(apiGroup, endpointItem)}
rightIcon={<HttpMethodTag method={endpointItem.method} size="sm" />}
rightIcon={
<HttpMethodTag
method={endpointItem.method}
size="sm"
active={active}
/>
}
/>
</FernTooltip>
</li>
@@ -190,45 +207,52 @@ export const PlaygroundEndpointSelectorContent = forwardRef<HTMLDivElement, Play
}

const renderedListItems = apiGroups.map((group) => renderApiDefinitionPackage(group)).filter(isNonNullish);
const menuRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
useImperativeHandle(ref, () => menuRef.current!);

return (
<div className={cn("min-w-[300px] overflow-hidden rounded-xl flex flex-col", className)} ref={menuRef}>
<div
className={cn("relative z-20 px-1 pt-1", {
"pb-1": renderedListItems.length === 0,
"pb-0": renderedListItems.length > 0,
})}
>
<FernInput
leftIcon={<MagnifyingGlassIcon />}
data-1p-ignore="true"
autoFocus={true}
value={filterValue}
onValueChange={setFilterValue}
rightElement={
filterValue.length > 0 && (
<FernButton
icon={<Cross1Icon />}
variant="minimal"
onClick={() => setFilterValue("")}
/>
)
}
/>
<FernTooltipProvider>
<div className={clsx("flex flex-col size-full relative", className)} ref={scrollRef}>
<div className={cn("relative z-20 px-3 pt-3 pb-0")}>
<FernInput
leftIcon={<MagnifyingGlassIcon />}
data-1p-ignore="true"
autoFocus={true}
value={filterValue}
onValueChange={setFilterValue}
rightElement={
filterValue.length > 0 && (
<FernButton
icon={<Cross1Icon />}
variant="minimal"
onClick={() => setFilterValue("")}
/>
)
}
placeholder="Search for endpoints..."
/>
</div>
<FernScrollArea
rootClassName="min-h-0 flex-1 shrink w-full"
className="mask-grad-y-6 w-full !flex"
scrollbars="vertical"
asChild
ref={ref}
>
<ul className="list-none p-3 flex flex-col gap-2 w-full h-fit">{renderedListItems}</ul>
<div className="!h-6"></div>
</FernScrollArea>
<BuiltWithFern className="border-t border-default py-4 px-10 bg-background" />
</div>
<FernScrollArea rootClassName="min-h-0 flex-1 shrink">
<ul className="list-none">{renderedListItems}</ul>
</FernScrollArea>
</div>
</FernTooltipProvider>
);
},
);

PlaygroundEndpointSelectorContent.displayName = "PlaygroundEndpointSelectorContent";

function renderTextWithHighlight(text: string, highlight: string): ReactElement[] {
highlight = highlight.trim();
if (highlight === "") {
return [<span key={0}>{text}</span>];
}
// Split text on higlight term, include term itself into parts, ignore case
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
return parts.map((part, idx) =>
38 changes: 27 additions & 11 deletions packages/ui/app/src/api-playground/VerticalSplitPane.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import cn from "clsx";
import cn, { clsx } from "clsx";
import { Children, ComponentProps, PropsWithChildren, ReactElement, useCallback, useRef, useState } from "react";
import { useHorizontalSplitPane, useVerticalSplitPane } from "./useSplitPlane";

@@ -63,6 +63,8 @@ interface HorizontalSplitPaneProps extends ComponentProps<"div"> {
leftClassName?: string;
rightClassName?: string;
rizeBarHeight?: number;
mode?: "pixel" | "percent";
initialLeftWidth?: number;
}

export function HorizontalSplitPane({
@@ -71,18 +73,23 @@ export function HorizontalSplitPane({
rightClassName,
children,
rizeBarHeight,
mode = "percent",
initialLeftWidth = mode === "percent" ? 0.6 : 300,
...props
}: PropsWithChildren<HorizontalSplitPaneProps>): ReactElement | null {
const [leftHeightPercent, setLeftHeightPercent] = useState(0.6);
const [leftWidth, setLeftWidth] = useState(initialLeftWidth);

const ref = useRef<HTMLDivElement>(null);

const setWidth = useCallback((clientX: number) => {
if (ref.current != null) {
const { left, width } = ref.current.getBoundingClientRect();
setLeftHeightPercent((clientX - left - 6) / width);
}
}, []);
const setWidth = useCallback(
(clientX: number) => {
if (ref.current != null) {
const { left, width } = ref.current.getBoundingClientRect();
setLeftWidth(mode === "percent" ? (clientX - left - 6) / width : clientX - left - 6);
}
},
[mode],
);

const handleVerticalResize = useHorizontalSplitPane(setWidth);

@@ -104,13 +111,22 @@ export function HorizontalSplitPane({

return (
<div ref={ref} className={cn("flex justify-stretch shrink", className)} {...props}>
<div style={{ width: `${leftHeightPercent * 100}%` }} className={cn(leftClassName, "shrink-0")}>
<div
style={{ width: mode === "percent" ? `${leftWidth * 100}%` : `${leftWidth}px` }}
className={cn(leftClassName, "shrink-0")}
>
{left}
</div>
<div
className="shink-0 group sticky top-0 z-10 flex w-3 flex-none cursor-col-resize items-center justify-center py-8 opacity-0 transition-opacity after:absolute after:inset-y-0 after:-left-1 after:w-6 after:content-[''] hover:opacity-100 hover:delay-300"
className={clsx(
"shink-0 group sticky top-0 z-10 flex w-3 flex-none cursor-col-resize items-center justify-center opacity-0 transition-opacity after:absolute after:inset-y-0 after:-left-1 after:w-6 after:content-[''] hover:opacity-100 hover:delay-300",
{
"py-8": rizeBarHeight != null,
"-mx-1.5": rizeBarHeight == null,
},
)}
onMouseDown={handleVerticalResize}
style={{ height: rizeBarHeight }}
style={{ height: rizeBarHeight ?? "100%" }}
>
<div className="bg-border-primary relative z-10 h-full w-0.5 rounded-full group-active:bg-accent group-active:transition-[background]" />
</div>
Original file line number Diff line number Diff line change
@@ -349,12 +349,12 @@ export const PlaygroundTypeReferenceForm = memo<PlaygroundTypeReferenceFormProps
visitDiscriminatedUnion(literal.value, "type")._visit({
stringLiteral: (stringLiteral) => (
<WithLabel property={property} value={value} onRemove={onRemove} types={types} htmlFor={id}>
<span>{stringLiteral.value}</span>
<code>{stringLiteral.value}</code>
</WithLabel>
),
booleanLiteral: (stringLiteral) => (
<WithLabel property={property} value={value} onRemove={onRemove} types={types} htmlFor={id}>
<span>{stringLiteral.value ? "TRUE" : "FALSE"}</span>
<code>{stringLiteral.value ? "true" : "false"}</code>
</WithLabel>
),
_other: () => null,
5 changes: 5 additions & 0 deletions packages/ui/app/src/api-playground/useSplitPlane.ts
Original file line number Diff line number Diff line change
@@ -68,6 +68,11 @@ export function useHorizontalSplitPane(
): (e: React.MouseEvent<HTMLDivElement>) => void {
return useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// disable if the event is not a left click
if (e.button !== 0) {
return;
}

e.preventDefault();
e.stopPropagation();
const handleMouseMove = (e: MouseEvent | TouchEvent) => {
2 changes: 1 addition & 1 deletion packages/ui/app/src/commons/HttpMethodTag.tsx
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ const UnmemoizedHttpMethodTag: React.FC<HttpMethodTag.Props> = ({
<FernTag
colorScheme={METHOD_COLOR_SCHEMES[method]}
variant={active ? "solid" : "subtle"}
className={clsx("uppercase", { "w-11": size === "sm" }, className)}
className={clsx("uppercase", { "w-11": size === "sm", "font-semibold": active }, className)}
size={size}
{...rest}
>
6 changes: 3 additions & 3 deletions packages/ui/app/src/components/FernTag.tsx
Original file line number Diff line number Diff line change
@@ -55,19 +55,19 @@ export const FernTag: FC<FernTagProps> = ({

// Green
"bg-green-a3 text-green-a11": colorScheme === "green" && variant === "subtle",
"bg-green-a10 text-green-1": colorScheme === "green" && variant === "solid",
"bg-green-a10 text-green-1 dark:text-green-12": colorScheme === "green" && variant === "solid",

// Blue
"bg-blue-a3 text-blue-a11": colorScheme === "blue" && variant === "subtle",
"bg-blue-a10 text-blue-1": colorScheme === "blue" && variant === "solid",
"bg-blue-a10 text-blue-1 dark:text-blue-12": colorScheme === "blue" && variant === "solid",

// Amber
"bg-amber-a3 text-amber-a11": colorScheme === "amber" && variant === "subtle",
"bg-amber-a10 text-amber-1 dark:text-amber-12": colorScheme === "amber" && variant === "solid",

// Red
"bg-red-a3 text-red-a11": colorScheme === "red" && variant === "subtle",
"bg-red-a10 text-red-1": colorScheme === "red" && variant === "solid",
"bg-red-a10 text-red-1 dark:text-red-12": colorScheme === "red" && variant === "solid",

// Accent
"bg-accent/20 text-accent-aaa": colorScheme === "accent" && variant === "subtle",
4 changes: 3 additions & 1 deletion packages/ui/app/src/contexts/docs-context/DocsContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DocsV1Read } from "@fern-api/fdr-sdk";
import { DocsV1Read, FdrAPI } from "@fern-api/fdr-sdk";
import { ColorsConfig, SidebarNavigation } from "@fern-ui/fdr-utils";
import React from "react";

@@ -21,6 +21,7 @@ export const DocsContext = React.createContext<DocsContextValue>({
sidebarNodes: [],
searchInfo: undefined,
navbarLinks: [],
apis: [],
});

export interface DocsContextValue extends SidebarNavigation {
@@ -33,6 +34,7 @@ export interface DocsContextValue extends SidebarNavigation {
files: Record<DocsV1Read.FileId, DocsV1Read.File_>;
searchInfo: DocsV1Read.SearchInfo | undefined;
navbarLinks: DocsV1Read.NavbarLink[];
apis: FdrAPI.ApiDefinitionId[];

resolveFile: (fileId: DocsV1Read.FileId) => DocsV1Read.File_ | undefined;
}
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@ export const DocsContextProvider: React.FC<DocsContextProvider.Props> = ({ child
const versions = useDeepCompareMemoize(pageProps.navigation.versions);
const searchInfo = useDeepCompareMemoize(pageProps.search);
const navbarLinks = useDeepCompareMemoize(pageProps.navbarLinks);
const apis = useDeepCompareMemoize(pageProps.apis);
const { resolvedTheme: theme } = useTheme();

const { baseUrl, title, favicon } = pageProps;
@@ -67,6 +68,7 @@ export const DocsContextProvider: React.FC<DocsContextProvider.Props> = ({ child
sidebarNodes,
searchInfo,
navbarLinks,
apis,
}),
[
baseUrl.basePath,
@@ -84,6 +86,7 @@ export const DocsContextProvider: React.FC<DocsContextProvider.Props> = ({ child
versions,
searchInfo,
navbarLinks,
apis,
],
);

2 changes: 1 addition & 1 deletion packages/ui/app/src/docs/Docs.tsx
Original file line number Diff line number Diff line change
@@ -102,7 +102,7 @@ export const Docs: React.FC<DocsProps> = memo<DocsProps>(function UnmemoizedDocs
docsMainContent
)}
</main>
<BuiltWithFern />
<BuiltWithFern className="absolute bottom-0 left-1/2 z-50 my-8 flex w-fit -translate-x-1/2 justify-center" />
</div>

{/* Enables footer DOM injection */}
4 changes: 2 additions & 2 deletions packages/ui/app/src/mdx/MdxContent.tsx
Original file line number Diff line number Diff line change
@@ -10,9 +10,9 @@ export declare namespace MdxContent {
}
}

const COMPONENTS = { ...HTML_COMPONENTS, ...JSX_COMPONENTS };

export const MdxContent = React.memo<MdxContent.Props>(function MdxContent({ mdx }) {
const COMPONENTS = { ...HTML_COMPONENTS, ...JSX_COMPONENTS };

if (typeof mdx === "string") {
return <span className="whitespace-pre-wrap">{mdx}</span>;
}
3 changes: 2 additions & 1 deletion packages/ui/app/src/next-app/DocsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DocsV1Read, DocsV2Read } from "@fern-api/fdr-sdk";
import { DocsV1Read, DocsV2Read, FdrAPI } from "@fern-api/fdr-sdk";
import type { ColorsConfig, SidebarNavigation } from "@fern-ui/fdr-utils";
import { useDeepCompareMemoize } from "@fern-ui/react-commons";
import { Redirect } from "next";
@@ -33,6 +33,7 @@ export declare namespace DocsPage {
resolvedPath: ResolvedPath;

featureFlags: FeatureFlags;
apis: FdrAPI.ApiDefinitionId[];
}
}

10 changes: 9 additions & 1 deletion packages/ui/app/src/next-app/globals.scss
Original file line number Diff line number Diff line change
@@ -490,9 +490,17 @@
&:not(.disabled) {
&.minimal {
@apply bg-transparent;
@apply t-muted hover:t-default;
// @apply t-muted hover:t-default;
@apply hover:bg-tag-default data-[state=on]:bg-tag-default data-[state=checked]:bg-tag-default data-[state=open]:bg-tag-default data-[state=opening]:bg-tag-default data-[selected=true]:bg-tag-default;

.fern-button-text {
@apply t-muted;

&:hover {
@apply t-default;
}
}

.fa-icon {
@apply bg-text-default/60;
}
4 changes: 2 additions & 2 deletions packages/ui/app/src/sidebar/BuiltWithFern.tsx
Original file line number Diff line number Diff line change
@@ -25,13 +25,13 @@ export const BuiltWithFern: React.FC<BuiltWithFern.Props> = ({ className }) => {
}

return (
<div className="absolute bottom-0 left-1/2 z-50 my-8 flex w-fit -translate-x-1/2 justify-center">
<div className={className}>
<FernTooltipProvider>
<FernTooltip content={BUILT_WITH_FERN_TOOLTIP_CONTENT} side="top">
<span>
<FernLink
href={`https://buildwithfern.com/?utm_campaign=buildWith&utm_medium=docs&utm_source=${encodeURIComponent(domain)}`}
className={cn("inline-flex items-center gap-2", className)}
className={"inline-flex items-center gap-2"}
{...containerCallbacks}
>
<span className={cn("text-xs t-muted whitespace-nowrap")}>Built with</span>
4 changes: 3 additions & 1 deletion packages/ui/docs-bundle/src/pages/_error.tsx
Original file line number Diff line number Diff line change
@@ -8,6 +8,8 @@ export function parseResolvedUrl(resolvedUrl: string): string {
return match?.[2] ?? resolvedUrl;
}

export const dynamic = "force-dynamic";

export const getServerSideProps: GetServerSideProps<ErrorProps> = async ({ req, res, resolvedUrl, query }) => {
if (
res.statusCode >= 500 &&
@@ -27,7 +29,7 @@ export const getServerSideProps: GetServerSideProps<ErrorProps> = async ({ req,
return {
props: {
statusCode: res.statusCode,
title: res.statusMessage,
title: res.statusMessage ?? "",
},
};
};
109 changes: 54 additions & 55 deletions packages/ui/docs-bundle/src/pages/api/fern-docs/resolve-api.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { flattenApiDefinition, getNavigationRoot, type SidebarNode } from "@fern-ui/fdr-utils";
import {
ApiDefinitionResolver,
REGISTRY_SERVICE,
serializeSidebarNodeDescriptionMdx,
type ResolvedRootPackage,
} from "@fern-ui/ui";
import { resolveSidebarNodesRoot, visitSidebarNodeRaw } from "@fern-ui/fdr-utils";
import { ApiDefinitionResolver, REGISTRY_SERVICE, type ResolvedRootPackage } from "@fern-ui/ui";
import { NextApiHandler, NextApiResponse } from "next";
import { getFeatureFlags } from "./feature-flags";

const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse<ResolvedRootPackage | null>) => {
export const dynamic = "force-dynamic";

const resolveApiHandler: NextApiHandler = async (
req,
res: NextApiResponse<Record<string, ResolvedRootPackage> | null>,
) => {
try {
if (req.method !== "GET") {
res.status(400).json(null);
@@ -35,58 +35,57 @@ const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse<Resol
const url = `${hostWithoutTrailingSlash}${pathname}`;
// eslint-disable-next-line no-console
console.log("[resolve-api] Loading docs for", url);
const docs = await REGISTRY_SERVICE.docs.v2.read.getDocsForUrl({
const docsResponse = await REGISTRY_SERVICE.docs.v2.read.getDocsForUrl({
url,
});

if (!docs.ok) {
if (!docsResponse.ok) {
res.status(404).json(null);
return;
}

const docsDefinition = docs.body.definition;
const basePath = docs.body.baseUrl.basePath;
const pages = docs.body.definition.pages;

const docs = docsResponse.body;
const docsDefinition = docs.definition;
const apiDefinition = docsDefinition.apis[api];
const docsConfig = docsDefinition.config;

if (apiDefinition == null) {
res.status(404).json(null);
return;
}

const navigation = getNavigationRoot(
pathname.slice(1).split("/"),
docs.body.baseUrl.domain,
basePath,
docsDefinition.config.navigation,
docsDefinition.apis,
pages,
);

if (navigation == null || navigation.type === "redirect") {
res.status(404).json(null);
return;
}
const basePathSlug =
docs.baseUrl.basePath != null ? docs.baseUrl.basePath.split("/").filter((t) => t.length > 0) : [];

const sidebarNodes = await Promise.all(
navigation.found.sidebarNodes.map((node) => serializeSidebarNodeDescriptionMdx(node)),
const root = resolveSidebarNodesRoot(
docsConfig.navigation,
docs.definition.apis,
docs.definition.pages,
basePathSlug,
docs.baseUrl.domain,
);

const apiSection = findApiSection(api, sidebarNodes);

const featureFlags = await getFeatureFlags(docs.body.baseUrl.domain);
const entryPromises: Promise<[string, ResolvedRootPackage]>[] = [];

const featureFlags = await getFeatureFlags(docs.baseUrl.domain);

visitSidebarNodeRaw(root, (node) => {
if (node.type === "apiSection" && node.flattenedApiDefinition != null) {
const entry = ApiDefinitionResolver.resolve(
node.title,
node.flattenedApiDefinition,
docsDefinition.pages,
undefined,
featureFlags,
docs.baseUrl.domain,
).then((resolved) => [node.api, resolved] as [string, ResolvedRootPackage]);
entryPromises.push(entry);
return "skip";
}
return undefined;
});

res.status(200).json(
await ApiDefinitionResolver.resolve(
apiSection?.title ?? "",
flattenApiDefinition(apiDefinition, apiSection?.slug ?? [], undefined, docs.body.baseUrl.domain),
pages,
undefined,
featureFlags,
docs.body.baseUrl.domain,
),
);
res.status(200).json(Object.fromEntries(await Promise.all(entryPromises)));
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
@@ -96,16 +95,16 @@ const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse<Resol

export default resolveApiHandler;

function findApiSection(api: string, sidebarNodes: SidebarNode[]): SidebarNode.ApiSection | undefined {
for (const node of sidebarNodes) {
if (node.type === "apiSection" && node.api === api) {
return node;
} else if (node.type === "section") {
const found = findApiSection(api, node.items);
if (found != null) {
return found;
}
}
}
return undefined;
}
// function findApiSection(api: string, sidebarNodes: SidebarNode[]): SidebarNode.ApiSection | undefined {
// for (const node of sidebarNodes) {
// if (node.type === "apiSection" && node.api === api) {
// return node;
// } else if (node.type === "section") {
// const found = findApiSection(api, node.items);
// if (found != null) {
// return found;
// }
// }
// }
// return undefined;
// }
1 change: 1 addition & 0 deletions packages/ui/docs-bundle/src/utils/getDocsPageProps.ts
Original file line number Diff line number Diff line change
@@ -265,6 +265,7 @@ async function convertDocsToDocsPageProps({
sidebarNodes,
},
featureFlags,
apis: Object.keys(docs.definition.apis),
};

return {
Original file line number Diff line number Diff line change
@@ -126,6 +126,7 @@ export async function getDocsPageProps(
sidebarNodes,
},
featureFlags,
apis: Object.keys(docs.definition.apis),
};

return {
5 changes: 5 additions & 0 deletions packages/ui/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -312,6 +312,10 @@ module.exports = {
from: { opacity: "0", transform: "translate(-50%, -48%) scale(0.96)" },
to: { opacity: "1", transform: "translate(-50%, -50%) scale(1)" },
},
"content-show-from-bottom": {
from: { opacity: "0", transform: "translate(0, 50%)" },
to: { opacity: "1", transform: "translate(0, 0)" },
},
},
transitionTimingFunction: {
shift: "cubic-bezier(0.16, 1, 0.3, 1)",
@@ -328,6 +332,7 @@ module.exports = {
"thumb-rock": "thumb-rock 500ms both",
"overlay-show": "overlay-show 150ms cubic-bezier(0.16, 1, 0.3, 1)",
"content-show": "content-show 150ms cubic-bezier(0.16, 1, 0.3, 1)",
"content-show-from-bottom": "content-show-from-bottom 150ms cubic-bezier(0.16, 1, 0.3, 1)",
},
},
},

0 comments on commit f614441

Please sign in to comment.