diff --git a/packages/ui/app/src/api-reference/endpoints/CodeExampleClientDropdown.tsx b/packages/ui/app/src/api-reference/endpoints/CodeExampleClientDropdown.tsx index 4232cc7728..071a2d9727 100644 --- a/packages/ui/app/src/api-reference/endpoints/CodeExampleClientDropdown.tsx +++ b/packages/ui/app/src/api-reference/endpoints/CodeExampleClientDropdown.tsx @@ -1,51 +1,41 @@ import { FernButton, FernDropdown, RemoteFontAwesomeIcon } from "@fern-ui/components"; import { NavArrowDown } from "iconoir-react"; -import type { CodeExample, CodeExampleGroup } from "../examples/code-example"; +import { getIconForClient, getLanguageDisplayName } from "../examples/code-example"; export declare namespace CodeExampleClientDropdown { export interface Props { - clients: CodeExampleGroup[]; - selectedClient: CodeExample; - onClickClient: (example: CodeExample) => void; + languages: string[]; + value: string; + onValueChange: (language: string) => void; } } export const CodeExampleClientDropdown: React.FC = ({ - clients, - selectedClient, - onClickClient, + languages, + value, + onValueChange, }) => { - const selectedClientGroup = clients.find((client) => client.language === selectedClient.language); + const options = languages.map((language) => ({ + type: "value" as const, + label: getLanguageDisplayName(language), + value: language, + className: "group/option", + icon: ( + + ), + })); + + const selectedOption = options.find((option) => option.value === value); return (
- ({ - type: "value", - label: client.languageDisplayName, - value: client.language, - className: "group/option", - icon: ( - - ), - }))} - onValueChange={(value) => { - const client = clients.find((client) => client.language === value); - if (client?.examples[0] != null) { - onClickClient( - client.examples.find((example) => example.exampleIndex === selectedClient.exampleIndex) ?? - client.examples[0], - ); - } - }} - > + } + icon={} rightIcon={} - text={selectedClientGroup?.languageDisplayName ?? selectedClient.language} + text={selectedOption?.label ?? getLanguageDisplayName(value)} size="small" variant="outlined" mono={true} diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointContent.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointContent.tsx index 1d37488fd6..6e2ed0861b 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointContent.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointContent.tsx @@ -2,11 +2,9 @@ import type * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { EndpointContext } from "@fern-api/fdr-sdk/api-definition"; import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import cn from "clsx"; -import { groupBy } from "es-toolkit/array"; -import { mapValues } from "es-toolkit/object"; import { isEqual } from "es-toolkit/predicate"; import { useInView } from "framer-motion"; -import { atom, useAtom, useAtomValue } from "jotai"; +import { atom, useAtomValue } from "jotai"; import { selectAtom } from "jotai/utils"; import dynamic from "next/dynamic"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -16,19 +14,16 @@ import { BREAKPOINT_ATOM, CONTENT_HEIGHT_ATOM, CURRENT_NODE_ID_ATOM, - FERN_LANGUAGE_ATOM, MOBILE_SIDEBAR_ENABLED_ATOM, store, useAtomEffect, - useFeatureFlags, } from "../../atoms"; import { useHref } from "../../hooks/useHref"; import { JsonPropertyPath } from "../examples/JsonPropertyPath"; -import { CodeExample, generateCodeExamples } from "../examples/code-example"; -import { ExamplesByClientAndTitleAndStatusCode, Language, SelectedExampleKey } from "../types/EndpointContent"; import { useApiPageCenterElement } from "../useApiPageCenterElement"; import { EndpointContentHeader } from "./EndpointContentHeader"; import { EndpointContentLeft, convertNameToAnchorPart } from "./EndpointContentLeft"; +import { useExampleSelection } from "./useExampleSelection"; const EndpointContentCodeSnippets = dynamic( () => import("./EndpointContentCodeSnippets").then((mod) => mod.EndpointContentCodeSnippets), @@ -101,7 +96,30 @@ export const EndpointContent = memo((props) => { [setHoveredResponsePropertyPath], ); - const [selectedError, setSelectedError] = useState(); + const { + selectedExample, + examplesByStatusCode, + examplesByKeyAndStatusCode, + selectedExampleKey, + availableLanguages, + setSelectedExampleKey, + } = useExampleSelection(endpoint); + + const setStatusCode = useCallback( + (statusCode: number | string | undefined) => { + setSelectedExampleKey((prev) => { + if (prev.statusCode === String(statusCode)) { + return prev; + } + return { + ...prev, + statusCode: statusCode != null ? String(statusCode) : undefined, + responseIndex: 0, + }; + }); + }, + [setSelectedExampleKey], + ); useAtomEffect( useCallbackOne( @@ -115,222 +133,36 @@ export const EndpointContent = memo((props) => { : convertNameToAnchorPart(e.name) === statusCodeOrName, ); if (error != null) { - setSelectedError(error); + setStatusCode(error.statusCode); } } }, - [endpoint.errors], + [endpoint.errors, setStatusCode], ), ); - // TODO: remove after pinecone demo - const { grpcEndpoints } = useFeatureFlags(); - const [contentType, setContentType] = useState(endpoint.request?.contentType); - const clients = useMemo( - () => - generateCodeExamples( - endpoint.examples, - grpcEndpoints?.includes(endpoint.id) && - !( - endpoint.examples != null && - endpoint.examples.length === 1 && - endpoint.examples[0]?.snippets?.["curl"] != null && - endpoint.examples[0]?.snippets["curl"].length > 0 - ), - ), - [endpoint.examples, grpcEndpoints, endpoint.id], - ); - - const [selectedLanguage, setSelectedLanguage] = useAtom(FERN_LANGUAGE_ATOM); - const [selectedClient, setSelectedClient] = useState(() => { - const curlExample = clients[0]?.examples[0]; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return clients.find((c) => c.language === selectedLanguage)?.examples[0] ?? curlExample!; - }); - useEffect(() => { - setSelectedClient((prev) => clients.find((c) => c.language === selectedLanguage)?.examples[0] ?? prev); - }, [clients, selectedLanguage]); - - // We use a string here with the intention that this can be used in a query param to deeplink to a particular example - const [selectedExampleKey, setSelectedExampleKey] = useState([ - selectedClient.language, - selectedClient.key, - selectedClient.exampleCall.responseStatusCode, - 0, - ]); - - useEffect(() => { - setSelectedExampleKey([ - selectedClient.language, - selectedClient.key, - selectedClient.exampleCall.responseStatusCode, - 0, - ]); - }, [selectedClient]); - - const convertErrorResponseToCodeExamples = useCallback( - (errorResponse: ApiDefinition.ErrorResponse, language: Language, index: number): CodeExample[] => { - return errorResponse.examples - ? errorResponse.examples.map((example, j) => ({ - key: `error-${j}/${index}`, - exampleIndex: index, - language, - name: example.name ?? errorResponse.name ?? `Error ${index}`, - code: "", - install: null, - exampleCall: { - path: endpoint.path.map((p) => p.value).join("/"), - responseStatusCode: errorResponse.statusCode, - name: example.name ?? errorResponse.name ?? `Error ${index}`, - pathParameters: {}, - queryParameters: {}, - headers: {}, - requestBody: undefined, - responseBody: example.responseBody, - snippets: {}, - description: errorResponse.description, - }, - globalError: true, - })) - : []; - }, - [endpoint.path], + const selectedError = endpoint.errors?.find( + (e) => e.statusCode === (selectedExample?.exampleCall.responseStatusCode ?? selectedExampleKey.statusCode), ); - const examplesByClientAndTitleAndStatusCode = useMemo(() => { - return clients.reduce((acc, client) => { - acc[client.language] = client.examples - ? mapValues( - groupBy( - client.examples.filter( - (e) => e.exampleCall.responseStatusCode >= 200 && e.exampleCall.responseStatusCode < 300, - ), - (e) => e.key, - ), - (examples) => ({ - ...groupBy(examples, (e) => e.exampleCall.responseStatusCode), - ...groupBy( - client.examples.filter((e) => e.exampleCall.responseStatusCode >= 400), - (e) => e.exampleCall.responseStatusCode, - ), - }), - ) - : {}; - - const allGlobalErrors = endpoint.errors - ? mapValues( - groupBy(endpoint.errors, (e) => e.statusCode), - (errorResponses) => - errorResponses.flatMap((e, idx) => - convertErrorResponseToCodeExamples(e, client.language, idx), - ), - ) - : {}; - - const examplesAcc = acc[client.language]; - if (examplesAcc != null) { - Object.keys(examplesAcc).forEach((exampleId) => { - const examplesByStatusCode = examplesAcc[exampleId]; - if (examplesByStatusCode != null) { - Object.keys(allGlobalErrors).forEach((statusCode) => { - const globalErrorCount = allGlobalErrors[Number(statusCode)]; - if (globalErrorCount != null && globalErrorCount.length > 0) { - if (examplesByStatusCode[Number(statusCode)] == null) { - examplesByStatusCode[Number(statusCode)] = []; - } - examplesByStatusCode[Number(statusCode)]?.push( - ...(allGlobalErrors?.[Number(statusCode)] ?? []), - ); - } - }); - } - }); - } - - return acc; - }, {}); - }, [clients, endpoint, convertErrorResponseToCodeExamples]); - - useEffect(() => { - setSelectedError(undefined); - setSelectedExampleKey((selectedExampleKey) => { - const [currentLanguage, exampleId, statusCode, exampleIndex] = selectedExampleKey ?? []; - if (examplesByClientAndTitleAndStatusCode != null && currentLanguage !== selectedLanguage) { - setSelectedError(undefined); - const examplesByTitleAndStatusCode = examplesByClientAndTitleAndStatusCode[selectedLanguage]; - if ( - (exampleId != null && - statusCode != null && - exampleIndex != null && - examplesByTitleAndStatusCode?.[exampleId]?.[statusCode]?.[exampleIndex] == null) || - (statusCode == null && exampleIndex == null && exampleId == null) - ) { - const firstExampleKey = Object.keys(examplesByTitleAndStatusCode ?? {})[0]; - const examplesByStatusCodes = firstExampleKey - ? examplesByTitleAndStatusCode?.[firstExampleKey] - : undefined; - if (examplesByStatusCodes == null) { - return; - } - const statusCode = - examplesByTitleAndStatusCode != null - ? Number( - Object.keys(examplesByStatusCodes ?? {}) - .filter((statusCode) => Number(statusCode) >= 200 && Number(statusCode) < 300) - .sort((statusCode1, statusCode2) => Number(statusCode1) - Number(statusCode2))[0], - ) - : 200; - return [selectedLanguage, firstExampleKey, statusCode, 0]; - } else { - return [selectedLanguage, exampleId, statusCode, exampleIndex]; - } - } - - return selectedExampleKey; - }); - }, [selectedLanguage, examplesByClientAndTitleAndStatusCode]); - const handleSelectError = useCallback( (error: ApiDefinition.ErrorResponse | undefined) => { - if (error && error !== selectedError) { - const foundExample = - examplesByClientAndTitleAndStatusCode?.[selectedClient.language]?.[selectedClient.key]?.[ - error.statusCode - ]?.[0]; - if (foundExample) { - setSelectedExampleKey((selectedExampleKey) => [ - foundExample.language, - selectedExampleKey?.[1], - foundExample.exampleCall.responseStatusCode, - 0, - ]); - } - } - setSelectedError(error); - }, - [examplesByClientAndTitleAndStatusCode, selectedClient, selectedError], - ); - - const setSelectedExampleClientAndScrollToTop = useCallback( - (nextClient: CodeExample) => { - setSelectedClient(nextClient); - setSelectedLanguage(nextClient.language); + setStatusCode(error?.statusCode); }, - [setSelectedLanguage], + [setStatusCode], ); const requestJson = - selectedClient.exampleCall.requestBody?.type === "json" - ? selectedClient.exampleCall.requestBody.value + selectedExample?.exampleCall.requestBody?.type === "json" + ? selectedExample.exampleCall.requestBody.value : undefined; - const responseJson = selectedClient.exampleCall.responseBody?.value; + const responseJson = selectedExample?.exampleCall.responseBody?.value; // const responseHast = selectedClient.exampleCall.responseHast; const responseCodeSnippet = useMemo(() => JSON.stringify(responseJson, undefined, 2), [responseJson]); - const selectedExampleClientLineCount = selectedClient.code.split("\n").length; + const selectedExampleClientLineCount = selectedExample?.code.split("\n").length ?? 0; - const selectorHeight = - (clients.find((c) => c.language === selectedClient.language)?.examples.length ?? 0) > 1 ? GAP_6 + 24 : 0; + const selectorHeight = Object.keys(examplesByKeyAndStatusCode).length > 1 ? GAP_6 + 24 : 0; const jsonLineLength = responseCodeSnippet?.split("\n").length ?? 0; const [requestHeight, responseHeight] = useAtomValue( @@ -352,7 +184,7 @@ export const EndpointContent = memo((props) => { const maxResponseContainerHeight = jsonLineLength * LINE_HEIGHT + CONTENT_PADDING; const containerHeight = contentHeight - PADDING_TOP - PADDING_BOTTOM - selectorHeight; const halfContainerHeight = (containerHeight - GAP_6) / 2; - if (selectedClient.exampleCall?.responseBody == null) { + if (selectedExample?.exampleCall?.responseBody == null) { return [Math.min(maxRequestContainerHeight, containerHeight), 0]; } if ( @@ -381,7 +213,12 @@ export const EndpointContent = memo((props) => { (v) => v, isEqual, ), - [jsonLineLength, selectedClient.exampleCall?.responseBody, selectedExampleClientLineCount, selectorHeight], + [ + jsonLineLength, + selectedExample?.exampleCall?.responseBody, + selectedExampleClientLineCount, + selectorHeight, + ], ), ); @@ -415,12 +252,7 @@ export const EndpointContent = memo((props) => { }, [initialExampleHeight]); return ( -
setSelectedError(undefined)} - ref={ref} - id={useHref(node.slug)} - > +
((props) => {
@@ -458,20 +288,19 @@ export const EndpointContent = memo((props) => { {isInViewport && ( diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointContentCodeSnippets.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointContentCodeSnippets.tsx index 4cfe066c09..ae03dc8f81 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointContentCodeSnippets.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointContentCodeSnippets.tsx @@ -1,10 +1,11 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import type { APIV1Read } from "@fern-api/fdr-sdk/client/types"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { EMPTY_ARRAY, EMPTY_OBJECT, visitDiscriminatedUnion } from "@fern-api/ui-core-utils"; -import { FernButton, FernButtonGroup, FernScrollArea } from "@fern-ui/components"; +import { FernScrollArea } from "@fern-ui/components"; import { useResizeObserver } from "@fern-ui/react-commons"; -import { ReactNode, memo, useMemo, useRef } from "react"; +import { sortBy } from "es-toolkit"; +import { RESET } from "jotai/utils"; +import { ReactNode, SetStateAction, memo, useCallback, useMemo, useRef } from "react"; import { FernErrorTag } from "../../components/FernErrorBoundary"; import { StatusCodeTag, statusCodeToIntent } from "../../components/StatusCodeTag"; import { PlaygroundButton } from "../../playground/PlaygroundButton"; @@ -13,16 +14,17 @@ import { AudioExample } from "../examples/AudioExample"; import { CodeSnippetExample, JsonCodeSnippetExample } from "../examples/CodeSnippetExample"; import { JsonPropertyPath } from "../examples/JsonPropertyPath"; import { TitledExample } from "../examples/TitledExample"; -import type { CodeExample, CodeExampleGroup } from "../examples/code-example"; +import type { CodeExample } from "../examples/code-example"; import { lineNumberOf } from "../examples/utils"; import { - ExampleIndex, - ExamplesByClientAndTitleAndStatusCode, + ExamplesByKeyAndStatusCode, + ExamplesByStatusCode, SelectedExampleKey, StatusCode, } from "../types/EndpointContent"; import { WebSocketMessages } from "../web-socket/WebSocketMessages"; import { CodeExampleClientDropdown } from "./CodeExampleClientDropdown"; +import { EndpointExampleSegmentedControl } from "./EndpointExampleSegmentedControl"; import { EndpointUrlWithOverflow } from "./EndpointUrlWithOverflow"; import { ErrorExampleSelect } from "./ErrorExampleSelect"; @@ -30,12 +32,12 @@ export declare namespace EndpointContentCodeSnippets { export interface Props { node: FernNavigation.EndpointNode; endpoint: ApiDefinition.EndpointDefinition; - examplesByClientAndTitleAndStatusCode: ExamplesByClientAndTitleAndStatusCode | undefined; - selectedExampleKey: SelectedExampleKey | undefined; - setSelectedExampleKey: (exampleKey: SelectedExampleKey | undefined) => void; - clients: CodeExampleGroup[]; - selectedClient: CodeExample; - onClickClient: (example: CodeExample) => void; + languages: string[]; + examplesByKeyAndStatusCode: ExamplesByKeyAndStatusCode; + examplesByStatusCode: ExamplesByStatusCode; + selectedExample: CodeExample | undefined; + selectedLanguage: string; + setSelectedExampleKey: (exampleKey: SetStateAction | typeof RESET) => void; requestCodeSnippet: string; requestCurlJson: unknown; hoveredRequestPropertyPath: JsonPropertyPath | undefined; @@ -43,7 +45,6 @@ export declare namespace EndpointContentCodeSnippets { showErrors: boolean; errors: ApiDefinition.ErrorResponse[] | undefined; selectedError: ApiDefinition.ErrorResponse | undefined; - setSelectedError: (error: ApiDefinition.ErrorResponse | undefined) => void; measureHeight: (height: number) => void; } } @@ -51,20 +52,17 @@ export declare namespace EndpointContentCodeSnippets { const UnmemoizedEndpointContentCodeSnippets: React.FC = ({ node, endpoint, - examplesByClientAndTitleAndStatusCode, - selectedExampleKey, + examplesByKeyAndStatusCode, + examplesByStatusCode, + selectedExample, + selectedLanguage, setSelectedExampleKey, - clients, - selectedClient, - onClickClient, + languages, requestCodeSnippet, requestCurlJson, hoveredRequestPropertyPath = EMPTY_ARRAY, hoveredResponsePropertyPath = EMPTY_ARRAY, showErrors, - errors = EMPTY_ARRAY, - selectedError, - setSelectedError, measureHeight, }) => { const ref = useRef(null); @@ -74,123 +72,83 @@ const UnmemoizedEndpointContentCodeSnippets: React.FC { - setSelectedError(error); - }; - const handleSelectExample = (statusCode: StatusCode, exampleIndex: ExampleIndex) => { - setSelectedExampleKey([selectedClient.language, selectedExampleKey?.[1], statusCode, exampleIndex]); - }; - - const getExampleId = useMemo( - () => (example: CodeExample | undefined, errorName: string | undefined, exampleIndex: number | undefined) => - example?.exampleCall.responseBody != null - ? visitDiscriminatedUnion(example.exampleCall.responseBody)._visit({ - json: () => - example.globalError || errorName - ? renderErrorTitle(errorName, example.exampleCall.responseStatusCode, exampleIndex) - : renderExampleTitle( - example.exampleCall.responseStatusCode, - endpoint.method, - exampleIndex, - ), - filename: () => - example.globalError || errorName - ? renderErrorTitle(errorName, example.exampleCall.responseStatusCode, exampleIndex) - : renderExampleTitle( - example.exampleCall.responseStatusCode, - endpoint.method, - exampleIndex, - ), - stream: () => "Streamed Response", - sse: () => "Server-Sent Events", - _other: () => "Response", - }) - : "Response", - [endpoint], + const handleSelectExample = useCallback( + (statusCode: StatusCode, responseIndex: number) => { + setSelectedExampleKey((prev) => ({ ...prev, statusCode, responseIndex })); + }, + [setSelectedExampleKey], ); - const selectedExample = useMemo(() => { - if (selectedExampleKey == null) { - return undefined; - } - const [language, title, statusCode, exampleIndex] = selectedExampleKey; - if (language == null || title == null || statusCode == null || exampleIndex == null) { - return undefined; - } - return examplesByClientAndTitleAndStatusCode?.[language]?.[title]?.[statusCode]?.[exampleIndex]; - }, [selectedExampleKey, examplesByClientAndTitleAndStatusCode]); - - const errorSelector = showErrors ? ( - { + switch (example?.exampleCall.responseBody?.type) { + case "json": + case "filename": { + const title = + example.exampleCall.name ?? + ApiDefinition.getMessageForStatus(example.exampleCall.responseStatusCode, endpoint.method) ?? + "Response"; + return renderResponseTitle(title, example.exampleCall.responseStatusCode); + } + case "stream": + return "Streamed Response"; + case "sse": + return "Server-Sent Events"; + default: + return "Response"; } - getExampleId={getExampleId} - /> - ) : ( - - {getExampleId(selectedExample, selectedError?.examples?.[0]?.name, undefined)} - + }, + [endpoint.method], ); + const errorSelector = + showErrors && Object.keys(examplesByStatusCode).length > 1 ? ( + + ) : ( + {getExampleId(selectedExample)} + ); + const [baseUrl, environmentId] = usePlaygroundBaseUrl(endpoint); - const filteredClientExamples = useMemo(() => { - const examples = examplesByClientAndTitleAndStatusCode?.[selectedClient.language]; - if (examples == null) { - return []; - } - return Object.values(examples).flatMap((examplesByStatusCode) => { - const statusCode = selectedExample?.exampleCall.responseStatusCode; - if (statusCode == null || statusCode >= 400) { - return []; - } - return examplesByStatusCode[statusCode]?.filter((e) => !e.globalError) ?? []; - }); - }, [examplesByClientAndTitleAndStatusCode, selectedClient.language, selectedExample]); + const segmentedControlExamples = useMemo(() => { + return Object.entries(examplesByKeyAndStatusCode) + .map(([exampleKey, examples]) => { + const examplesSorted = sortBy(Object.values(examples).flat(), [ + (example) => example.exampleCall.responseStatusCode, + ]); + return { exampleKey, examples: examplesSorted }; + }) + .filter( + ({ examples }) => + examples.length > 0 && + (examples.some((example) => example.exampleCall.responseStatusCode < 400) || + examples[0]?.name != null), + ); + }, [examplesByKeyAndStatusCode]); + // note: .fern-endpoint-code-snippets is used to detect clicks outside of the code snippets + // this is used to clear the selected error when the user clicks outside of the error return (
- {/* TODO: Replace this with a proper segmented control component */} - {filteredClientExamples != null && filteredClientExamples.length > 1 && ( - - {filteredClientExamples.map( - (example) => - example && ( - { - onClickClient(example); - const foundExampleIndex = examplesByClientAndTitleAndStatusCode?.[ - example.language - ]?.[example.key]?.[example.exampleCall.responseStatusCode ?? 0]?.findIndex( - (e) => e?.key === example.key, - ); - setSelectedExampleKey([ - example.language, - example.key, - example.exampleCall.responseStatusCode, - foundExampleIndex && foundExampleIndex >= 0 ? foundExampleIndex : 0, - ]); - }} - className="min-w-0 shrink truncate" - mono - size="small" - variant={example === selectedClient ? "outlined" : "minimal"} - intent={example === selectedClient ? "primary" : "none"} - > - {example.name} - - ), - )} - + {segmentedControlExamples.length > 1 && ( + { + setSelectedExampleKey((prev) => { + if (prev.exampleKey === exampleKey) { + return prev; + } + return { ...prev, exampleKey }; + }); + }} + /> )} + )} + {languages.length > 1 && ( + { + setSelectedExampleKey((prev) => ({ ...prev, language })); + }} /> )} - { - // filteredClientsByStatusCode != null && filteredClientsByStatusCode.length > 1 ? ( - clients.length > 1 ? ( - - ) : undefined - } } code={resolveEnvironmentUrlInCodeSnippet(endpoint, requestCodeSnippet, baseUrl)} - language={selectedClient.language} - hoveredPropertyPath={selectedClient.language === "curl" ? hoveredRequestPropertyPath : undefined} + language={selectedLanguage} + hoveredPropertyPath={selectedLanguage === "curl" ? hoveredRequestPropertyPath : undefined} json={requestCurlJson} - jsonStartLine={ - selectedClient.language === "curl" ? lineNumberOf(requestCodeSnippet, "-d '{") : undefined - } + jsonStartLine={selectedLanguage === "curl" ? lineNumberOf(requestCodeSnippet, "-d '{") : undefined} /> {selectedExample != null && selectedExample.exampleCall.responseStatusCode >= 400 && ( diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointContentLeft.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointContentLeft.tsx index 14a475bc9e..4f679fe100 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointContentLeft.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointContentLeft.tsx @@ -3,7 +3,7 @@ import { EndpointContext } from "@fern-api/fdr-sdk/api-definition"; import { visitDiscriminatedUnion } from "@fern-api/ui-core-utils"; import { sortBy } from "es-toolkit/array"; import { camelCase, upperFirst } from "es-toolkit/string"; -import { memo, useMemo } from "react"; +import { memo, useEffect, useRef } from "react"; import { useFeatureFlags } from "../../atoms"; import { Markdown } from "../../mdx/Markdown"; import { JsonPropertyPath } from "../examples/JsonPropertyPath"; @@ -21,14 +21,14 @@ export interface HoveringProps { export declare namespace EndpointContentLeft { export interface Props { context: EndpointContext; - example: ApiDefinition.ExampleEndpointCall; + example: ApiDefinition.ExampleEndpointCall | undefined; showErrors: boolean; onHoverRequestProperty: (jsonPropertyPath: JsonPropertyPath, hovering: HoveringProps) => void; onHoverResponseProperty: (jsonPropertyPath: JsonPropertyPath, hovering: HoveringProps) => void; selectedError: ApiDefinition.ErrorResponse | undefined; setSelectedError: (idx: ApiDefinition.ErrorResponse | undefined) => void; - contentType: string | undefined; - setContentType: (contentType: string) => void; + // contentType: string | undefined; + // setContentType: (contentType: string) => void; } } @@ -52,6 +52,40 @@ const UnmemoizedEndpointContentLeft: React.FC = ({ // contentType, // setContentType, }) => { + // if the user clicks outside of the error, clear the selected error + const errorRef = useRef(null); + useEffect(() => { + if (selectedError == null || errorRef.current == null) { + return; + } + const handleClick = (event: MouseEvent) => { + if (event.target == null) { + return; + } + + if (event.target instanceof Node && errorRef.current?.contains(event.target)) { + return; + } + + // check that target is not inside of ".fern-endpoint-code-snippets" + if (event.target instanceof HTMLElement && event.target.closest(".fern-endpoint-code-snippets") != null) { + return; + } + + // if the target is the body, then the event propagation was prevented by a radix button + if (event.target === window.document.body) { + return; + } + + setSelectedError(undefined); + }; + + window.addEventListener("click", handleClick); + return () => { + window.removeEventListener("click", handleClick); + }; + }, [selectedError, setSelectedError]); + const { isAuthEnabledInDocs } = useFeatureFlags(); let authHeader: ApiDefinition.ObjectProperty | undefined; @@ -115,15 +149,7 @@ const UnmemoizedEndpointContentLeft: React.FC = ({ }); } - const headers = useMemo(() => { - return [...(authHeader ? [authHeader] : []), ...globalHeaders, ...(endpoint.requestHeaders ?? [])]; - }, [authHeader, endpoint.requestHeaders, globalHeaders]); - - // let headers = [...globalHeaders, ...(endpoint.requestHeaders ?? [])]; - - // if (authHeaders) { - // headers = [authHeaders, ...headers]; - // } + const headers = [...(authHeader ? [authHeader] : []), ...globalHeaders, ...(endpoint.requestHeaders ?? [])]; return (
@@ -280,7 +306,7 @@ const UnmemoizedEndpointContentLeft: React.FC = ({ = ({ )} {showErrors && endpoint.errors && endpoint.errors.length > 0 && ( -
+
{sortBy(endpoint.errors, [(e) => e.statusCode, (e) => e.name]).map((error, idx) => { return ( void; +}): ReactElement { + // TODO: Replace this with a proper segmented control component + return ( + + {segmentedControlExamples.map(({ exampleKey, examples }) => { + const exampleIndex = examples[0]?.exampleIndex ?? 0; + return ( + { + onSelectExample(exampleKey); + }} + className="min-w-0 shrink truncate" + mono + size="small" + variant={exampleKey === selectedExample?.exampleKey ? "outlined" : "minimal"} + intent={exampleKey === selectedExample?.exampleKey ? "primary" : "none"} + > + {(exampleKey === selectedExample?.exampleKey ? selectedExample?.name : undefined) ?? + examples[0]?.name ?? + examples[0]?.exampleCall.name ?? + `Example ${exampleIndex + 1}`} + + ); + })} + + ); +} diff --git a/packages/ui/app/src/api-reference/endpoints/ErrorExampleSelect.tsx b/packages/ui/app/src/api-reference/endpoints/ErrorExampleSelect.tsx index 5b2113cbd3..f5ba541d0e 100644 --- a/packages/ui/app/src/api-reference/endpoints/ErrorExampleSelect.tsx +++ b/packages/ui/app/src/api-reference/endpoints/ErrorExampleSelect.tsx @@ -1,75 +1,41 @@ -import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { FernButton, Intent } from "@fern-ui/components"; import * as Select from "@radix-ui/react-select"; import clsx from "clsx"; -import { Check, NavArrowDown, NavArrowUp } from "iconoir-react"; -import { FC, Fragment, PropsWithChildren, ReactNode, forwardRef, useMemo } from "react"; +import { NavArrowDown, NavArrowUp } from "iconoir-react"; +import { FC, Fragment, PropsWithChildren, ReactNode, forwardRef } from "react"; import { statusCodeToIntent } from "../../components/StatusCodeTag"; import { CodeExample } from "../examples/code-example"; -import { ExampleIndex, ExamplesByStatusCode, SelectedExampleKey, StatusCode } from "../types/EndpointContent"; +import { ExamplesByStatusCode, StatusCode } from "../types/EndpointContent"; export declare namespace ErrorExampleSelect { export interface Props { - errors: readonly ApiDefinition.ErrorResponse[]; - selectedError: ApiDefinition.ErrorResponse | undefined; - setSelectedErrorAndExample: (error: ApiDefinition.ErrorResponse | undefined) => void; - selectedExampleKey: SelectedExampleKey | undefined; - setSelectedExampleKey: (statusCode: StatusCode, exampleIndex: ExampleIndex) => void; - examplesByStatusCode: ExamplesByStatusCode | undefined; - getExampleId: ( - example: CodeExample | undefined, - errorName: string | undefined, - exampleIndex: number | undefined, - ) => ReactNode; + selectedExample: CodeExample | undefined; + examplesByStatusCode: ExamplesByStatusCode; + setSelectedExampleKey: (statusCode: StatusCode, responseIndex: number) => void; + getExampleId: (example: CodeExample | undefined) => ReactNode; } } export const ErrorExampleSelect: FC> = ({ - errors, - selectedError, - setSelectedErrorAndExample, - selectedExampleKey, + selectedExample, setSelectedExampleKey, examplesByStatusCode, getExampleId, }) => { - const errorName = useMemo( - () => (example: ApiDefinition.ExampleEndpointCall | undefined) => { - const errorResponse = errors.find((e) => e.statusCode === example?.responseStatusCode); - - return errorResponse?.examples?.find((e) => e.name === example?.name)?.name ?? errorResponse?.name; - }, - [errors], - ); const handleValueChange = (value: string) => { - const [statusCode = 200, exampleIndex = 0] = value.split(":").map((v) => parseInt(v, 10)); - if (statusCode >= 400) { - const errorIndex = errors.findIndex((e) => e.statusCode === statusCode); - setSelectedErrorAndExample(errors[errorIndex]); - } else { - setSelectedErrorAndExample(undefined); - } - setSelectedExampleKey(statusCode, exampleIndex); + const [statusCode, responseIndex] = value.split(":"); + setSelectedExampleKey(String(statusCode ?? ""), Number(responseIndex ?? 0)); }; - const selectedExample = useMemo(() => { - if (selectedExampleKey == null) { - return undefined; - } - const [_client, _title, statusCode = 200, exampleIndex = 0] = selectedExampleKey; - return examplesByStatusCode?.[statusCode]?.[exampleIndex]; - }, [selectedExampleKey, examplesByStatusCode]); - - if (errors.length === 0) { - return ( - - {getExampleId(selectedExample, selectedError?.examples?.[0]?.name ?? selectedError?.name, undefined)} - - ); - } + const statusCode = selectedExample?.exampleCall.responseStatusCode; + const responseIndex = + examplesByStatusCode[statusCode ?? ""]?.findIndex((example) => example.key === selectedExample?.key) ?? -1; return ( - + = 0 ? `${statusCode}:${responseIndex}` : undefined} + > > : "none" } > - - {getExampleId(selectedExample, errorName(selectedExample?.exampleCall), undefined)} - + {getExampleId(selectedExample)} @@ -96,29 +60,24 @@ export const ErrorExampleSelect: FC> - {examplesByStatusCode && - Object.entries(examplesByStatusCode).map(([statusCode, examples], idx) => ( - - {idx > 0 ? ( - - ) : ( -
- )} - - {examples?.map((example, j) => { - return ( - - {getExampleId(example, errorName(example.exampleCall), undefined)} - - ); - })} - - - ))} + {Object.entries(examplesByStatusCode).map(([statusCode, examples], idx) => ( + + {idx > 0 && } + + {examples.map((example, j) => { + return ( + + {getExampleId(example)} + + ); + })} + + + ))} @@ -136,7 +95,7 @@ export const FernSelectItem = forwardRef< return ( {children} - - - ); }); - -const getExampleKey = (key: SelectedExampleKey | undefined) => { - if (key == null) { - return undefined; - } - const [_, statusCode, exampleIndex] = key; - if (statusCode == null || exampleIndex == null) { - return undefined; - } - return `${statusCode}:${exampleIndex}`; -}; diff --git a/packages/ui/app/src/api-reference/endpoints/useExampleSelection.ts b/packages/ui/app/src/api-reference/endpoints/useExampleSelection.ts new file mode 100644 index 0000000000..b72b2d0132 --- /dev/null +++ b/packages/ui/app/src/api-reference/endpoints/useExampleSelection.ts @@ -0,0 +1,126 @@ +import { EndpointDefinition } from "@fern-api/fdr-sdk/api-definition"; +import { SetStateAction, atom, useAtom, useAtomValue } from "jotai"; +import { RESET, atomWithDefault } from "jotai/utils"; +import { useMemo } from "react"; +import { useCallbackOne, useMemoOne } from "use-memo-one"; +import { DEFAULT_LANGUAGE_ATOM, FERN_LANGUAGE_ATOM, useAtomEffect } from "../../atoms"; +import { CodeExample } from "../examples/code-example"; +import { + getAvailableLanguages, + groupExamplesByLanguageKeyAndStatusCode, + selectExampleToRender, +} from "../examples/example-groups"; +import { ExamplesByKeyAndStatusCode, ExamplesByStatusCode, SelectedExampleKey } from "../types/EndpointContent"; + +export function useExampleSelection( + endpoint: EndpointDefinition, + initialExampleId?: string, +): { + selectedExample: CodeExample | undefined; + examplesByStatusCode: ExamplesByStatusCode; + examplesByKeyAndStatusCode: ExamplesByKeyAndStatusCode; + selectedExampleKey: SelectedExampleKey; + defaultLanguage: string; + availableLanguages: string[]; + setSelectedExampleKey: (update: typeof RESET | SetStateAction) => void; +} { + const examplesByLanguageKeyAndStatusCode = useMemo( + () => groupExamplesByLanguageKeyAndStatusCode(endpoint), + [endpoint], + ); + + const getInitialExampleKey = useCallbackOne( + (language: string): SelectedExampleKey => { + if (initialExampleId == null) { + return { + language, + exampleKey: undefined, + statusCode: undefined, + responseIndex: undefined, + }; + } + const allExamples = Object.values(examplesByLanguageKeyAndStatusCode[language] ?? {}) + .flatMap((e) => Object.values(e)) + .flat(); + + const example = allExamples.find( + (e) => e.name === initialExampleId || e.exampleCall.name === initialExampleId, + ); + if (example == null) { + return { + language, + exampleKey: undefined, + statusCode: undefined, + responseIndex: undefined, + }; + } + + return { + language, + exampleKey: example.exampleKey, + statusCode: String(example.exampleCall.responseStatusCode), + responseIndex: undefined, + }; + }, + [examplesByLanguageKeyAndStatusCode, initialExampleId], + ); + + // We use a string here with the intention that this can be used in a query param to deeplink to a particular example + const [internalSelectedExampleKey, setSelectedExampleKey] = useAtom( + useMemoOne(() => { + const internalAtom = atomWithDefault((get) => { + return getInitialExampleKey(get(FERN_LANGUAGE_ATOM) ?? get(DEFAULT_LANGUAGE_ATOM)); + }); + + return atom( + (get) => get(internalAtom), + (get, set, update: SetStateAction | typeof RESET) => { + const prev = get(internalAtom); + const next = typeof update === "function" ? update(prev) : update; + if (next !== RESET) { + set(FERN_LANGUAGE_ATOM, next.language); + } + set(internalAtom, next); + }, + ); + }, [getInitialExampleKey]), + ); + + // when the language changes, we'd want to update the selected example key to the new language + useAtomEffect( + useCallbackOne( + (get) => { + setSelectedExampleKey((prev) => { + const language = get(FERN_LANGUAGE_ATOM) ?? get(DEFAULT_LANGUAGE_ATOM); + if (prev.language !== language) { + return { ...prev, language }; + } + return prev; + }); + }, + [setSelectedExampleKey], + ), + ); + + const defaultLanguage = useAtomValue(DEFAULT_LANGUAGE_ATOM); + + const availableLanguages = useMemo( + () => getAvailableLanguages(examplesByLanguageKeyAndStatusCode, defaultLanguage), + [examplesByLanguageKeyAndStatusCode, defaultLanguage], + ); + + const { selectedExample, examplesByStatusCode, examplesByKeyAndStatusCode, selectedExampleKey } = useMemo( + () => selectExampleToRender(examplesByLanguageKeyAndStatusCode, internalSelectedExampleKey, defaultLanguage), + [defaultLanguage, examplesByLanguageKeyAndStatusCode, internalSelectedExampleKey], + ); + + return { + selectedExample, + examplesByStatusCode, + examplesByKeyAndStatusCode, + selectedExampleKey, + defaultLanguage, + availableLanguages, + setSelectedExampleKey, + }; +} diff --git a/packages/ui/app/src/api-reference/examples/code-example.ts b/packages/ui/app/src/api-reference/examples/code-example.ts index c29c8c74de..6ac92ff613 100644 --- a/packages/ui/app/src/api-reference/examples/code-example.ts +++ b/packages/ui/app/src/api-reference/examples/code-example.ts @@ -1,15 +1,17 @@ import type * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import titleCase from "@fern-api/ui-core-utils/titleCase"; -import { sortBy } from "es-toolkit/array"; export interface CodeExample { key: string; exampleIndex: number; + snippetIndex: number; + exampleKey: string; language: string; - name: string; + name: string | undefined; code: string; // hast: Root; install: string | null | undefined; + // TODO: it's a bit excessive to include the full example call here. this should be refactored to include just the relevant properties exampleCall: ApiDefinition.ExampleEndpointCall; globalError?: boolean; } @@ -21,68 +23,8 @@ export interface CodeExampleGroup { examples: CodeExample[]; } -// key is the language -export function generateCodeExamples( - examples: ApiDefinition.ExampleEndpointCall[] | undefined, - grpc: boolean = false, -): CodeExampleGroup[] { - const codeExamples = new Map(); - examples?.forEach((example, i) => { - if (example.snippets == null) { - return; - } - Object.values(example.snippets).forEach((snippets) => { - snippets.forEach((snippet, j) => { - if (!grpc || snippet.language !== "curl") { - codeExamples.set(snippet.language, [ - ...(codeExamples.get(snippet.language) ?? []), - { - key: `${snippet.language}-${i}/${j}`, - exampleIndex: i, - language: snippet.language, - name: snippet.name ?? example.name ?? `Example ${i + 1}`, - code: snippet.code, - // hast: snippet.hast, - install: snippet.install, - exampleCall: example, - }, - ]); - } - }); - }); - }); - - // always keep curl at the top - const curlExamples = codeExamples.get("curl"); - codeExamples.delete("curl"); - - // TODO: remove after pinecone examples - const examplesByLanguage = grpc - ? [] - : [ - { - language: "curl", - languageDisplayName: "cURL", - icon: getIconForClient("curl"), - examples: [...(curlExamples ?? [])], - }, - ]; - - return examplesByLanguage.concat([ - ...sortBy( - Array.from(codeExamples.entries()).map(([language, examples]) => ({ - language, - languageDisplayName: getLanguageDisplayName(language), - icon: getIconForClient(language), - examples, - })), - ["language"], - ), - ]); -} - -function getIconForClient(clientId: string) { - switch (clientId) { +export function getIconForClient(language: string): string { + switch (language) { case "curl": case "shell": case "bash": @@ -117,8 +59,10 @@ function getIconForClient(clientId: string) { } } -function getLanguageDisplayName(language: string) { +export function getLanguageDisplayName(language: string): string { switch (language) { + case "curl": + return "cURL"; case "go": case "golang": return "Go"; @@ -131,26 +75,3 @@ function getLanguageDisplayName(language: string) { return titleCase(language); } } - -// export interface CodeExampleClientCurl { -// id: "curl"; -// name: string; -// } - -// export interface PythonCodeExample { -// id: "python" | "python-async"; -// name: string; -// language: string; -// example: string; -// } - -// export interface TypescriptCodeExample { -// id: "typescript"; -// name: string; -// language: string; -// example: string; -// } - -// export type CodeExampleClient = CodeExampleClientCurl | PythonCodeExample | TypescriptCodeExample; - -// export type CodeExampleClientId = CodeExampleClient["id"]; diff --git a/packages/ui/app/src/api-reference/examples/example-groups.ts b/packages/ui/app/src/api-reference/examples/example-groups.ts new file mode 100644 index 0000000000..49de8b4985 --- /dev/null +++ b/packages/ui/app/src/api-reference/examples/example-groups.ts @@ -0,0 +1,222 @@ +import { ApiDefinition } from "@fern-api/fdr-sdk"; +import { isNonNullish } from "@fern-api/ui-core-utils"; +import { isEqual } from "es-toolkit"; +import { sortBy } from "es-toolkit/array"; +import { + ExamplesByKeyAndStatusCode, + ExamplesByLanguageKeyAndStatusCode, + ExamplesByStatusCode, + SelectedExampleKey, +} from "../types/EndpointContent"; +import { CodeExample } from "./code-example"; + +/** + * Group examples by language, title, and status code. + * + * @param endpoint - The endpoint to group examples for. + * @returns An object where the keys are the language, exampleId, and statusCode, and the value is an array of code examples. + */ +export function groupExamplesByLanguageKeyAndStatusCode( + endpoint: ApiDefinition.EndpointDefinition, +): ExamplesByLanguageKeyAndStatusCode { + const toRet: ExamplesByLanguageKeyAndStatusCode = {}; + + function addCodeExample( + key: { language: string; exampleKey: string; statusCode: number }, + codeExample: CodeExample, + ): void { + const existing = (((toRet[key.language] ??= {})[key.exampleKey] ??= {})[key.statusCode] ??= []); + if (existing.some((e) => isEqual(e.exampleCall.responseBody, codeExample.exampleCall.responseBody))) { + return; + } + existing.push(codeExample); + } + + endpoint.examples?.forEach((example, i) => { + if (example.snippets == null) { + return; + } + + Object.entries(example.snippets).forEach(([language, snippets]) => { + snippets.forEach((snippet, j) => { + const statusCode = example.responseStatusCode; + + const exampleKey = snippet.code; + + const codeExample: CodeExample = { + key: `${language}-${i},${j}`, + exampleIndex: i, + snippetIndex: j, + exampleKey, + language, + name: snippet.name ?? example.name, + code: snippet.code, + install: snippet.install, + exampleCall: example, + }; + addCodeExample({ language, exampleKey, statusCode }, codeExample); + + endpoint.errors?.forEach((error, k) => { + error.examples?.forEach((errorExample, l) => { + const codeExample: CodeExample = { + key: `${language}-${i},${j},${k},${l}`, + exampleIndex: i, + snippetIndex: j, + exampleKey, + language, + name: snippet.name ?? example.name, + code: snippet.code, + install: snippet.install, + // HACK: this is a bit of a hack to append the global error to every example + exampleCall: { + ...example, + responseStatusCode: error.statusCode, + responseBody: errorExample.responseBody, + name: errorExample.name ?? error.name, + }, + globalError: true, + }; + addCodeExample({ language, exampleKey, statusCode: error.statusCode }, codeExample); + }); + }); + }); + }); + }); + + return toRet; +} + +/** + * Get the available languages for a given endpoint. + * + * @param examples - The examples to get the available languages for. + * @param defaultLanguage - The default language to promote to the top of the list. + * @returns The available languages for the given endpoint in the order they should be displayed. + */ +export function getAvailableLanguages(examples: ExamplesByLanguageKeyAndStatusCode, defaultLanguage: string): string[] { + return sortBy( + Object.keys(examples).map((l) => ({ language: l })), + [ + // promote the default language to the top of the list, otherwise promote curl + (l) => (examples[defaultLanguage] != null ? l.language !== defaultLanguage : l.language !== "curl"), + // sort the rest alphabetically + (l) => l.language, + ], + ).map((l) => l.language); +} + +interface SelectExampleToRenderResponse { + selectedExampleKey: SelectedExampleKey; + selectedExample: CodeExample | undefined; + examplesByStatusCode: ExamplesByStatusCode; + examplesByKeyAndStatusCode: ExamplesByKeyAndStatusCode; +} + +/** + * Select the example to render for a given key. + * + * @param examplesByLanguageKeyAndStatusCode - The examples to select the example to render for. + * @param key - The key to select the example to render for. + * @param defaultLanguage - The default language to use if the selected language is not found. + * @returns The selected example to render + additional metadata about the selected example. + */ +export function selectExampleToRender( + examplesByLanguageKeyAndStatusCode: ExamplesByLanguageKeyAndStatusCode, + key: SelectedExampleKey, + defaultLanguage: string, +): SelectExampleToRenderResponse { + const { language, exampleKey, statusCode, responseIndex } = key; + + // prefer the selected language, otherwise pick the first available language + const examplesByKeyAndStatusCode = + examplesByLanguageKeyAndStatusCode[language] ?? + examplesByLanguageKeyAndStatusCode[ + getAvailableLanguages(examplesByLanguageKeyAndStatusCode, defaultLanguage)[0] ?? "" + ] ?? + {}; + + // prefer the selected exampleId, otherwise pick the first available exampleId + const examplesByStatusCode = + examplesByKeyAndStatusCode[exampleKey ?? ""] ?? + examplesByKeyAndStatusCode[Object.keys(examplesByKeyAndStatusCode)[0] ?? ""] ?? + {}; + + // if the status code is defined and there are examples for it, we attempt to use the example at the given index. Otherwise, fall back to the first example in that list. + // this is the most specific example we can find + let selectedExample = examplesByStatusCode[statusCode ?? ""]?.[responseIndex ?? 0]; + + // if the status code is not found, we should attempt to find a different example that has the same status code + if (statusCode != null) { + selectedExample ??= Object.values(examplesByKeyAndStatusCode).find((examplesByStatusCode) => { + const examples = examplesByStatusCode[statusCode]; + return examples != null && examples.length > 0; + })?.[statusCode]?.[0]; + } + + // as a fallback, we attempt to use the first example under the current exampleId. + // the exampleIndex is no longer relevant here, since we're using a fallback, so just return the first found example. + selectedExample ??= Object.keys(examplesByStatusCode) + .sort() + .map((statusCode) => examplesByStatusCode[statusCode]) + .filter(isNonNullish) + .find((examples) => examples.length > 0)?.[0]; + + // if all else fails, lets return the first example that can be found under the selected language + selectedExample ??= Object.values(examplesByKeyAndStatusCode) + .flatMap((examples) => Object.values(examples)) + .flat()[0]; + + // if that fails, then the current language has no examples, so we'll choose the first language + selectedExample ??= Object.values( + examplesByLanguageKeyAndStatusCode[ + getAvailableLanguages(examplesByLanguageKeyAndStatusCode, defaultLanguage)[0] ?? "" + ] ?? {}, + ) + .flatMap((examples) => Object.values(examples)) + .flat()[0]; + + // reverse lookup the selected example to get the actual key, examplesByStatusCode, and examplesByKeyAndStatusCode + const reverseLookup = + selectedExample != null + ? reverseLookupSelectedExample(examplesByLanguageKeyAndStatusCode, selectedExample) + : undefined; + + return { + selectedExampleKey: reverseLookup?.key ?? key, + selectedExample, + examplesByStatusCode: reverseLookup?.examplesByStatusCode ?? examplesByStatusCode, + examplesByKeyAndStatusCode: reverseLookup?.examplesByKeyAndStatusCode ?? examplesByKeyAndStatusCode, + }; +} + +/** + * Reverse lookup the selected example to get the key, examplesByStatusCode, and examplesByKeyAndStatusCode. + * + * @param examplesByLanguageKeyAndStatusCode - The examples to reverse lookup. + * @param selectedExample - The example to reverse lookup. + * @returns The key, examplesByStatusCode, and examplesByKeyAndStatusCode for the selected example. + */ +export function reverseLookupSelectedExample( + examplesByLanguageKeyAndStatusCode: ExamplesByLanguageKeyAndStatusCode, + selectedExample: CodeExample, +): { + key: SelectedExampleKey; + examplesByStatusCode: ExamplesByStatusCode; + examplesByKeyAndStatusCode: ExamplesByKeyAndStatusCode; +} { + const examplesByKeyAndStatusCode = examplesByLanguageKeyAndStatusCode[selectedExample.language] ?? {}; + const examplesByStatusCode = examplesByKeyAndStatusCode[selectedExample.exampleKey] ?? {}; + const statusCode = String(selectedExample.exampleCall.responseStatusCode); + const examples = examplesByStatusCode[statusCode] ?? []; + const index = examples.findIndex((e) => e.key === selectedExample.key); + return { + key: { + language: selectedExample.language, + exampleKey: selectedExample.exampleKey, + statusCode, + responseIndex: index, + }, + examplesByStatusCode, + examplesByKeyAndStatusCode, + }; +} diff --git a/packages/ui/app/src/api-reference/types/EndpointContent.tsx b/packages/ui/app/src/api-reference/types/EndpointContent.tsx index 4c04d8135a..334c1373e9 100644 --- a/packages/ui/app/src/api-reference/types/EndpointContent.tsx +++ b/packages/ui/app/src/api-reference/types/EndpointContent.tsx @@ -1,14 +1,31 @@ import { CodeExample } from "../examples/code-example"; export type Language = string; -export type ExampleId = string; -export type StatusCode = number; -export type ExampleIndex = number; +export type StatusCode = string; +export type ExampleKey = string; export type ExamplesByStatusCode = Record; -export type ExamplesByTitleAndStatusCode = Record>; -export type ExamplesByClientAndTitleAndStatusCode = Record< - Language, - Record> ->; -export type SelectedExampleKey = [Language, ExampleId | undefined, StatusCode | undefined, ExampleIndex | undefined]; +export type ExamplesByKeyAndStatusCode = Record; +export type ExamplesByLanguageKeyAndStatusCode = Record; + +/** + * This is a compound key that is used to index into ExamplesByLanguageKeyAndStatusCode. + */ +export type SelectedExampleKey = { + /** + * language of the example i.e. "typescript" or "curl" + */ + language: Language; + /** + * join of exampleIndex and snippetIndex + */ + exampleKey: ExampleKey | undefined; + /** + * status code of the example (as a string) i.e. "200" + */ + statusCode: StatusCode | undefined; + /** + * index of the example in the values of ExamplesByStatusCode + */ + responseIndex: number | undefined; +}; diff --git a/packages/ui/app/src/atoms/lang.ts b/packages/ui/app/src/atoms/lang.ts index 5a9e9df0c8..fe4d384b82 100644 --- a/packages/ui/app/src/atoms/lang.ts +++ b/packages/ui/app/src/atoms/lang.ts @@ -1,3 +1,4 @@ +import { ApiDefinition } from "@fern-api/fdr-sdk"; import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { DOCS_ATOM } from "./docs"; @@ -8,8 +9,16 @@ const INTERNAL_FERN_LANGUAGE_ATOM = atomWithStorage("fern-la INTERNAL_FERN_LANGUAGE_ATOM.debugLabel = "INTERNAL_FERN_LANGUAGE_ATOM"; export const FERN_LANGUAGE_ATOM = atom( - (get) => get(INTERNAL_FERN_LANGUAGE_ATOM) ?? get(DOCS_ATOM).defaultLang, + (get) => { + const lang = get(INTERNAL_FERN_LANGUAGE_ATOM); + if (lang == null) { + return undefined; + } + return ApiDefinition.cleanLanguage(lang); + }, (_get, set, update: string) => { set(INTERNAL_FERN_LANGUAGE_ATOM, update); }, ); + +export const DEFAULT_LANGUAGE_ATOM = atom((get) => ApiDefinition.cleanLanguage(get(DOCS_ATOM).defaultLang)); diff --git a/packages/ui/app/src/mdx/components/client-libraries/ClientLibraries.tsx b/packages/ui/app/src/mdx/components/client-libraries/ClientLibraries.tsx index 02e87b5951..0da12e9201 100644 --- a/packages/ui/app/src/mdx/components/client-libraries/ClientLibraries.tsx +++ b/packages/ui/app/src/mdx/components/client-libraries/ClientLibraries.tsx @@ -1,9 +1,10 @@ import { FernSdk } from "@fern-ui/components"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { ComponentProps, FC } from "react"; -import { FERN_LANGUAGE_ATOM } from "../../../atoms"; +import { DEFAULT_LANGUAGE_ATOM, FERN_LANGUAGE_ATOM } from "../../../atoms"; export const ClientLibraries: FC, "sdks">> = ({ sdks }) => { const [selectedLanguage, setSelectedLanguage] = useAtom(FERN_LANGUAGE_ATOM); - return ; + const defaultLanguage = useAtomValue(DEFAULT_LANGUAGE_ATOM); + return ; }; diff --git a/packages/ui/app/src/mdx/components/snippets/EndpointRequestSnippet.tsx b/packages/ui/app/src/mdx/components/snippets/EndpointRequestSnippet.tsx index 87f66439dd..2287b606d6 100644 --- a/packages/ui/app/src/mdx/components/snippets/EndpointRequestSnippet.tsx +++ b/packages/ui/app/src/mdx/components/snippets/EndpointRequestSnippet.tsx @@ -1,14 +1,14 @@ import type * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { EMPTY_OBJECT } from "@fern-api/ui-core-utils"; -import { ReactElement, useMemo } from "react"; +import { ReactElement } from "react"; import { CodeExampleClientDropdown } from "../../../api-reference/endpoints/CodeExampleClientDropdown"; import { EndpointUrlWithOverflow } from "../../../api-reference/endpoints/EndpointUrlWithOverflow"; +import { useExampleSelection } from "../../../api-reference/endpoints/useExampleSelection"; import { CodeSnippetExample } from "../../../api-reference/examples/CodeSnippetExample"; -import { generateCodeExamples } from "../../../api-reference/examples/code-example"; import { usePlaygroundBaseUrl } from "../../../playground/utils/select-environment"; import { RequestSnippet } from "./types"; import { useFindEndpoint } from "./useFindEndpoint"; -import { extractEndpointPathAndMethod, useSelectedClient } from "./utils"; +import { extractEndpointPathAndMethod } from "./utils"; export const EndpointRequestSnippet: React.FC> = ({ endpoint: endpointLocator, @@ -44,11 +44,14 @@ export function EndpointRequestSnippetInternal({ endpoint: ApiDefinition.EndpointDefinition; example: string | undefined; }): ReactElement | null { - const clients = useMemo(() => generateCodeExamples(endpoint.examples), [endpoint.examples]); - const [selectedClient, setSelectedClient] = useSelectedClient(clients, example); + const { selectedExample, selectedExampleKey, availableLanguages, setSelectedExampleKey } = useExampleSelection( + endpoint, + example, + ); + const [baseUrl, selectedEnvironmentId] = usePlaygroundBaseUrl(endpoint); - if (selectedClient == null) { + if (selectedExample == null) { return null; } @@ -66,19 +69,19 @@ export function EndpointRequestSnippetInternal({ } actions={ <> - {clients.length > 1 && ( + {availableLanguages.length > 1 && ( setSelectedExampleKey((prev) => ({ ...prev, language }))} + value={selectedExampleKey.language} /> )} {/* TODO: Restore this button */} {/* */} } - code={selectedClient.code} - language={selectedClient.language} + code={selectedExample.code} + language={selectedExampleKey.language} json={EMPTY_OBJECT} scrollAreaStyle={{ maxHeight: "500px" }} /> diff --git a/packages/ui/app/src/mdx/components/snippets/EndpointResponseSnippet.tsx b/packages/ui/app/src/mdx/components/snippets/EndpointResponseSnippet.tsx index 5c95171644..6e6d3e6a51 100644 --- a/packages/ui/app/src/mdx/components/snippets/EndpointResponseSnippet.tsx +++ b/packages/ui/app/src/mdx/components/snippets/EndpointResponseSnippet.tsx @@ -1,9 +1,9 @@ -import { useMemo } from "react"; +import { EndpointDefinition, HttpMethod } from "@fern-api/fdr-sdk/api-definition"; +import { useExampleSelection } from "../../../api-reference/endpoints/useExampleSelection"; import { CodeSnippetExample } from "../../../api-reference/examples/CodeSnippetExample"; -import { generateCodeExamples } from "../../../api-reference/examples/code-example"; import { RequestSnippet } from "./types"; import { useFindEndpoint } from "./useFindEndpoint"; -import { extractEndpointPathAndMethod, useSelectedClient } from "./utils"; +import { extractEndpointPathAndMethod } from "./utils"; export const EndpointResponseSnippet: React.FC> = ({ endpoint: endpointLocator, @@ -18,21 +18,34 @@ export const EndpointResponseSnippet: React.FC; }; -const EndpointResponseSnippetInternal: React.FC> = ({ +function EndpointResponseSnippetInternal({ path, method, example, -}) => { +}: { + path: string; + method: HttpMethod; + example: string | undefined; +}) { const endpoint = useFindEndpoint(method, path); - const clients = useMemo(() => generateCodeExamples(endpoint?.examples ?? []), [endpoint?.examples]); - const [selectedClient] = useSelectedClient(clients, example); - if (endpoint == null) { return null; } - const responseJson = selectedClient?.exampleCall.responseBody?.value; + return ; +} + +function EndpointResponseSnippetRenderer({ + endpoint, + example, +}: { + endpoint: EndpointDefinition; + example: string | undefined; +}) { + const { selectedExample } = useExampleSelection(endpoint, example); + + const responseJson = selectedExample?.exampleCall.responseBody?.value; if (responseJson == null) { return null; @@ -52,4 +65,4 @@ const EndpointResponseSnippetInternal: React.FC
); -}; +} diff --git a/packages/ui/app/src/mdx/components/snippets/utils.tsx b/packages/ui/app/src/mdx/components/snippets/utils.tsx index 4bf2cf961c..8f75204ec1 100644 --- a/packages/ui/app/src/mdx/components/snippets/utils.tsx +++ b/packages/ui/app/src/mdx/components/snippets/utils.tsx @@ -1,26 +1,4 @@ import { APIV1Read } from "@fern-api/fdr-sdk/client/types"; -import { useAtom } from "jotai"; -import { useCallback } from "react"; -import { CodeExample, CodeExampleGroup } from "../../../api-reference/examples/code-example"; -import { FERN_LANGUAGE_ATOM } from "../../../atoms"; - -export function useSelectedClient( - clients: CodeExampleGroup[], - exampleName: string | undefined, -): [CodeExample | undefined, (nextClient: CodeExample) => void] { - const [selectedLanguage, setSelectedLanguage] = useAtom(FERN_LANGUAGE_ATOM); - const client = clients.find((c) => c.language === selectedLanguage) ?? clients[0]; - const selectedClient = exampleName ? client?.examples.find((e) => e.name === exampleName) : client?.examples[0]; - - const handleClickClient = useCallback( - (nextClient: CodeExample) => { - setSelectedLanguage(nextClient.language); - }, - [setSelectedLanguage], - ); - - return [selectedClient, handleClickClient]; -} export function extractEndpointPathAndMethod(endpoint: string): [APIV1Read.HttpMethod | undefined, string | undefined] { const [maybeMethod, path] = endpoint.split(" ");