diff --git a/packages/ui/app/src/api-page/endpoints/EndpointContent.tsx b/packages/ui/app/src/api-page/endpoints/EndpointContent.tsx index 179534ac7c..def9e0fd07 100644 --- a/packages/ui/app/src/api-page/endpoints/EndpointContent.tsx +++ b/packages/ui/app/src/api-page/endpoints/EndpointContent.tsx @@ -297,7 +297,10 @@ export const EndpointContent: React.FC = ({ requestCurlJson={requestJson} hoveredRequestPropertyPath={hoveredRequestPropertyPath} hoveredResponsePropertyPath={hoveredResponsePropertyPath} + showErrors={showErrors} selectedError={selectedError} + errors={endpoint.errors} + setSelectedError={setSelectedError} /> )} diff --git a/packages/ui/app/src/api-page/endpoints/EndpointContentCodeSnippets.tsx b/packages/ui/app/src/api-page/endpoints/EndpointContentCodeSnippets.tsx index 6f19659dd4..52e3c1b095 100644 --- a/packages/ui/app/src/api-page/endpoints/EndpointContentCodeSnippets.tsx +++ b/packages/ui/app/src/api-page/endpoints/EndpointContentCodeSnippets.tsx @@ -1,21 +1,29 @@ "use client"; -import { visitDiscriminatedUnion } from "@fern-ui/core-utils"; -import { ReactNode, memo, useMemo } from "react"; +import { APIV1Read } from "@fern-api/fdr-sdk"; +import { EMPTY_OBJECT, visitDiscriminatedUnion } from "@fern-ui/core-utils"; +import { ReactNode, memo, useEffect, useMemo, useState } from "react"; import { PlaygroundButton } from "../../api-playground/PlaygroundButton"; +import { StatusCodeTag, statusCodeToIntent } from "../../commons/StatusCodeTag"; import { FernButton, FernButtonGroup } from "../../components/FernButton"; import { FernErrorTag } from "../../components/FernErrorBoundary"; import { mergeEndpointSchemaWithExample } from "../../resolver/SchemaWithExample"; -import { ResolvedEndpointDefinition, ResolvedError, ResolvedExampleEndpointCall } from "../../resolver/types"; +import { + ResolvedEndpointDefinition, + ResolvedError, + ResolvedExampleEndpointCall, + ResolvedExampleError, +} from "../../resolver/types"; 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 { lineNumberOf } from "../examples/utils"; -import { getSuccessMessageForStatus } from "../utils/getSuccessMessageForStatus"; +import { getMessageForStatus } from "../utils/getMessageForStatus"; import { WebSocketMessages } from "../web-socket/WebSocketMessages"; import { CodeExampleClientDropdown } from "./CodeExampleClientDropdown"; import { EndpointUrlWithOverflow } from "./EndpointUrlWithOverflow"; -import { ErrorCodeSnippetExample } from "./ErrorCodeSnippetExample"; +import { ErrorExampleSelect } from "./ErrorExampleSelect"; export declare namespace EndpointContentCodeSnippets { export interface Props { @@ -29,7 +37,10 @@ export declare namespace EndpointContentCodeSnippets { requestCurlJson: unknown; hoveredRequestPropertyPath: JsonPropertyPath | undefined; hoveredResponsePropertyPath: JsonPropertyPath | undefined; + showErrors: boolean; + errors: ResolvedError[]; selectedError: ResolvedError | undefined; + setSelectedError: (error: ResolvedError | undefined) => void; } } @@ -44,10 +55,63 @@ const UnmemoizedEndpointContentCodeSnippets: React.FC { + const [selectedErrorExample, setSelectedErrorExample] = useState(undefined); + + const handleSelectErrorAndExample = ( + error: ResolvedError | undefined, + example: ResolvedExampleError | undefined, + ) => { + setSelectedError(error); + setSelectedErrorExample(example); + }; + + // if the selected error is not in the list of errors, reset the selected error + useEffect(() => { + setSelectedErrorExample((prevSelectedErrorExample) => { + if (selectedError == null) { + return undefined; + } else if ( + prevSelectedErrorExample != null && + selectedError.examples.findIndex((e) => e === prevSelectedErrorExample) === -1 + ) { + return selectedError.examples[0]; + } + return prevSelectedErrorExample; + }); + }, [selectedError]); + const exampleWithSchema = useMemo(() => mergeEndpointSchemaWithExample(endpoint, example), [endpoint, example]); const selectedClientGroup = clients.find((client) => client.language === selectedClient.language); + + const successTitle = + exampleWithSchema.responseBody != null + ? visitDiscriminatedUnion(exampleWithSchema.responseBody, "type")._visit({ + json: (value) => renderResponseTitle(value.statusCode, endpoint.method), + filename: (value) => renderResponseTitle(value.statusCode, endpoint.method), + stream: () => "Streamed Response", + sse: () => "Server-Sent Events", + _other: () => "Response", + }) + : "Response"; + + const errorSelector = showErrors ? ( + + {successTitle} + + ) : ( + {successTitle} + ); + return (
{/* TODO: Replace this with a proper segmented control component */} @@ -109,13 +173,23 @@ const UnmemoizedEndpointContentCodeSnippets: React.FC - {selectedError != null && } + {selectedError != null && ( + { + e.stopPropagation(); + }} + hoveredPropertyPath={hoveredResponsePropertyPath} + json={selectedErrorExample?.responseBody ?? EMPTY_OBJECT} + intent={statusCodeToIntent(selectedError.statusCode)} + /> + )} {exampleWithSchema.responseBody != null && selectedError == null && visitDiscriminatedUnion(exampleWithSchema.responseBody, "type")._visit({ json: (value) => ( { e.stopPropagation(); }} @@ -124,28 +198,30 @@ const UnmemoizedEndpointContentCodeSnippets: React.FC ), // TODO: support other media types - filename: (value) => ( - - ), + filename: () => , stream: (value) => ( - ({ - type: undefined, - origin: undefined, - displayName: undefined, - data: event, - }))} - /> + + ({ + type: undefined, + origin: undefined, + displayName: undefined, + data: event, + }))} + /> + ), sse: (value) => ( - ({ - type: event, - origin: undefined, - displayName: undefined, - data, - }))} - /> + + ({ + type: event, + origin: undefined, + displayName: undefined, + data, + }))} + /> + ), _other: () => ( - {"Response - "} - - {`${statusCode} ${getSuccessMessageForStatus(statusCode, method)}`} - + + + {getMessageForStatus(statusCode, method)} ); } diff --git a/packages/ui/app/src/api-page/endpoints/ErrorCodeSnippetExample.tsx b/packages/ui/app/src/api-page/endpoints/ErrorCodeSnippetExample.tsx deleted file mode 100644 index 13670cf02b..0000000000 --- a/packages/ui/app/src/api-page/endpoints/ErrorCodeSnippetExample.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { EMPTY_OBJECT } from "@fern-ui/core-utils"; -import { CaretDownIcon } from "@radix-ui/react-icons"; -import { ReactElement, useMemo, useState } from "react"; -import { FernButton } from "../../components/FernButton"; -import { FernDropdown } from "../../components/FernDropdown"; -import { FernTag } from "../../components/FernTag"; -import { ResolvedError, ResolvedExampleError } from "../../resolver/types"; -import { CodeSnippetExample } from "../examples/CodeSnippetExample"; - -export interface ErrorCodeSnippetExampleProps { - resolvedError: ResolvedError; -} - -export function ErrorCodeSnippetExample({ resolvedError }: ErrorCodeSnippetExampleProps): ReactElement | null { - const [selectedExample, setSelectedExample] = useState(resolvedError.examples[0]); - const selectedIndex = selectedExample != null ? resolvedError.examples.indexOf(selectedExample) : -1; - - const handleValueChange = (value: string) => { - setSelectedExample(resolvedError.examples[parseInt(value, 10)]); - }; - - const value = selectedExample?.responseBody ?? EMPTY_OBJECT; - - const options = useMemo( - () => - resolvedError.examples.map((example, idx): FernDropdown.Option => { - return { - type: "value", - value: `${idx}`, - label: example.name, - helperText: example.description, - }; - }), - [resolvedError.examples], - ); - - return ( - - {resolvedError.statusCode} - {options.length === 0 ? ( - {resolvedError.name} - ) : ( - - }> - {selectedExample?.name} - - - )} - - } - isError={resolvedError.statusCode >= 400} - onClick={(e) => { - e.stopPropagation(); - }} - code={JSON.stringify(value, null, 2)} - language="json" - json={value} - /> - ); -} diff --git a/packages/ui/app/src/api-page/endpoints/ErrorExampleSelect.tsx b/packages/ui/app/src/api-page/endpoints/ErrorExampleSelect.tsx new file mode 100644 index 0000000000..4f25364a4d --- /dev/null +++ b/packages/ui/app/src/api-page/endpoints/ErrorExampleSelect.tsx @@ -0,0 +1,159 @@ +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; +import * as Select from "@radix-ui/react-select"; +import clsx from "clsx"; +import { FC, Fragment, PropsWithChildren, forwardRef } from "react"; +import { StatusCodeTag, statusCodeToIntent } from "../../commons/StatusCodeTag"; +import { FernButton, Intent } from "../../components/FernButton"; +import { ResolvedError, ResolvedExampleError } from "../../resolver/types"; +import { getMessageForStatus } from "../utils/getMessageForStatus"; + +export declare namespace ErrorExampleSelect { + export interface Props { + selectedError: ResolvedError | undefined; + selectedErrorExample: ResolvedExampleError | undefined; + errors: ResolvedError[]; + setSelectedErrorAndExample: ( + error: ResolvedError | undefined, + example: ResolvedExampleError | undefined, + ) => void; + } +} + +export const ErrorExampleSelect: FC> = ({ + selectedError, + selectedErrorExample, + errors, + setSelectedErrorAndExample, + children, +}) => { + const handleValueChange = (value: string) => { + const [errorIndex, exampleIndex] = value.split(":").map((v) => parseInt(v, 10)); + setSelectedErrorAndExample(errors[errorIndex], errors[errorIndex]?.examples[exampleIndex]); + }; + const selectedErrorIndex = selectedError != null ? errors.indexOf(selectedError) : -1; + const selectedExampleIndex = + selectedError != null && selectedErrorExample != null + ? selectedError?.examples.indexOf(selectedErrorExample) + : -1; + + const value = `${selectedErrorIndex}:${selectedExampleIndex}`; + + if (errors.length === 0) { + return {children}; + } + + const renderValue = () => { + if (selectedError != null) { + const content = `${ + selectedError.examples.length > 1 + ? `${selectedError.name ?? getMessageForStatus(selectedError.statusCode)} Example ${selectedExampleIndex + 1}` + : selectedError.name ?? getMessageForStatus(selectedError.statusCode) + }`; + return ( + + + + {selectedErrorExample?.name ?? content} + + + ); + } else { + return children ?? "Response"; + } + }; + + return ( + + + + + + } + variant="minimal" + className="-ml-1 pl-1" + intent={selectedError != null ? statusCodeToIntent(selectedError.statusCode) : "none"} + > + {renderValue()} + + + + + + + + + + {children ?? "Response"} + + {errors.map((error, i) => ( + + + + {error.examples.map((example, j) => ( + + + + + {example.name ?? + (error.examples.length > 1 + ? `${error.name ?? getMessageForStatus(error.statusCode)} Example ${j + 1}` + : error.name ?? getMessageForStatus(error.statusCode))} + + + + ))} + {error.examples.length === 0 && ( + + + + {`${error.name ?? getMessageForStatus(error.statusCode)}`} + + + )} + + + ))} + + + + + + + + ); +}; + +export const FernSelectItem = forwardRef< + HTMLDivElement, + Select.SelectItemProps & { textClassName?: string; intent?: Intent } +>(function FernSelectItem({ children, className, textClassName, intent = "none", ...props }, forwardedRef) { + return ( + + {children} + + + + + ); +}); diff --git a/packages/ui/app/src/api-page/examples/TitledExample.tsx b/packages/ui/app/src/api-page/examples/TitledExample.tsx index 6a345278ea..325d19f199 100644 --- a/packages/ui/app/src/api-page/examples/TitledExample.tsx +++ b/packages/ui/app/src/api-page/examples/TitledExample.tsx @@ -1,11 +1,12 @@ import cn from "clsx"; import { forwardRef, MouseEventHandler, PropsWithChildren, ReactElement, ReactNode } from "react"; +import { Intent } from "../../components/FernButton"; import { CopyToClipboardButton } from "../../syntax-highlighting/CopyToClipboardButton"; export declare namespace TitledExample { export interface Props { title: ReactNode; - isError?: boolean; + intent?: Intent; actions?: ReactElement; className?: string; copyToClipboardText?: () => string; // use provider to lazily compute clipboard text @@ -17,7 +18,7 @@ export declare namespace TitledExample { } export const TitledExample = forwardRef>(function TitledExample( - { title, isError, className, actions, children, copyToClipboardText, onClick, disableClipboard = false }, + { title, intent = "none", className, actions, children, copyToClipboardText, onClick, disableClipboard = false }, ref, ) { return ( @@ -31,16 +32,20 @@ export const TitledExample = forwardRef
{typeof title === "string" ? (
{title} diff --git a/packages/ui/app/src/api-page/utils/getMessageForStatus.ts b/packages/ui/app/src/api-page/utils/getMessageForStatus.ts new file mode 100644 index 0000000000..48226c250d --- /dev/null +++ b/packages/ui/app/src/api-page/utils/getMessageForStatus.ts @@ -0,0 +1,117 @@ +import { APIV1Read } from "@fern-api/fdr-sdk"; + +type StatusCodeMessagesByMethod = Partial>; + +type StatusCodeMessages = Record; + +type DefaultStatusCodeMessages = Record; + +export const STATUS_CODE_MESSAGES: DefaultStatusCodeMessages = { + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 103: "Early Hints", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 208: "Already Reported", + 226: "IM Used", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 306: "Switch Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 421: "Misdirected Request", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Too Early", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 510: "Not Extended", + 511: "Network Authentication Required", +}; + +export const STATUS_CODE_MESSAGES_METHOD_OVERRIDES: StatusCodeMessages = { + 200: { + GET: "Retrieved", + POST: "Successful", // more accurate than "Created" for POST + PUT: "Updated", + PATCH: "Updated", + DELETE: "Deleted", + }, +}; + +export function getMessageForStatus(statusCode: number, method?: APIV1Read.HttpMethod): string { + // return the method-specific message if it exists + if (method != null) { + const methodOverrides = STATUS_CODE_MESSAGES_METHOD_OVERRIDES[statusCode]; + if (methodOverrides != null) { + const message = methodOverrides[method]; + if (message != null) { + return message; + } + } + } + + // return the official status message if it exists + const message = STATUS_CODE_MESSAGES[statusCode]; + if (message != null) { + return message; + } + + // return the default message if it exists + if (statusCode >= 100 && statusCode < 200) { + return "Informational"; + } else if (statusCode >= 200 && statusCode < 300) { + return "Success"; + } else if (statusCode >= 300 && statusCode < 400) { + return "Redirection"; + } else if (statusCode >= 400 && statusCode < 500) { + return "Client Error"; + } else if (statusCode >= 500 && statusCode < 600) { + return "Server Error"; + } else { + return "Unknown Status"; + } +} diff --git a/packages/ui/app/src/api-page/utils/getSuccessMessageForStatus.ts b/packages/ui/app/src/api-page/utils/getSuccessMessageForStatus.ts deleted file mode 100644 index fd54b9fc53..0000000000 --- a/packages/ui/app/src/api-page/utils/getSuccessMessageForStatus.ts +++ /dev/null @@ -1,51 +0,0 @@ -type StatusCodeMessages = { - [method: string]: string; -}; - -type SuccessMessages = { - [code: number]: StatusCodeMessages; -}; - -export const COMMON_SUCCESS_NAMES: SuccessMessages = { - 200: { - PUT: "Updated", - DELETE: "Deleted", - default: "Success", - }, - 201: { - PUT: "Updated", - default: "Created", - }, - 202: { - default: "Accepted", - }, - 203: { - default: "Non-Authoritative Information", - }, - 204: { - default: "No Content", - }, - 205: { - default: "Reset Content", - }, - 206: { - default: "Partial Content", - }, - 207: { - default: "Multi-Status", - }, - 208: { - default: "Already Reported", - }, - 226: { - default: "IM Used", - }, -}; - -export function getSuccessMessageForStatus(statusCode: number, method: string): string { - const messages = COMMON_SUCCESS_NAMES[statusCode]; - if (!messages) { - return "Success"; - } - return messages[method] ?? messages.default; -} diff --git a/packages/ui/app/src/commons/HttpMethodTag.tsx b/packages/ui/app/src/commons/HttpMethodTag.tsx index 098e3cdd0a..89504ecb9f 100644 --- a/packages/ui/app/src/commons/HttpMethodTag.tsx +++ b/packages/ui/app/src/commons/HttpMethodTag.tsx @@ -1,4 +1,4 @@ -import { FdrAPI } from "@fern-api/fdr-sdk"; +import { APIV1Read, FdrAPI } from "@fern-api/fdr-sdk"; import clsx from "clsx"; import { ReactNode, memo } from "react"; import { FernTag, FernTagColorScheme, FernTagProps } from "../components/FernTag"; @@ -6,7 +6,7 @@ import { FernTooltip } from "../components/FernTooltip"; export declare namespace HttpMethodTag { export interface Props extends FernTagProps { - method: FdrAPI.api.v1.read.HttpMethod | "STREAM" | "WSS"; + method: APIV1Read.HttpMethod | "STREAM" | "WSS"; active?: boolean; } } @@ -24,7 +24,7 @@ const METHOD_COLOR_SCHEMES: Record = ({ method, active, - size = "sm", + size = "lg", className, ...rest }) => { diff --git a/packages/ui/app/src/commons/StatusCodeTag.tsx b/packages/ui/app/src/commons/StatusCodeTag.tsx new file mode 100644 index 0000000000..837c22433f --- /dev/null +++ b/packages/ui/app/src/commons/StatusCodeTag.tsx @@ -0,0 +1,41 @@ +import { ReactElement } from "react"; +import { Intent } from "../components/FernButton"; +import { FernTag, FernTagProps } from "../components/FernTag"; + +export declare namespace StatusCodeTag { + export interface Props extends Omit { + statusCode: number; + } +} + +export function StatusCodeTag({ statusCode, className, ...rest }: StatusCodeTag.Props): ReactElement { + return ( + + {statusCode} + + ); +} + +function statusCodeToColorScheme(statusCode: number): FernTagProps["colorScheme"] { + if (statusCode >= 200 && statusCode < 300) { + return "green"; + } else if (statusCode >= 300 && statusCode < 400) { + return "amber"; + } else if (statusCode >= 400) { + return "red"; + } else { + return "gray"; + } +} + +export function statusCodeToIntent(statusCode: number): Intent { + if (statusCode >= 200 && statusCode < 300) { + return "success"; + } else if (statusCode >= 300 && statusCode < 400) { + return "warning"; + } else if (statusCode >= 400) { + return "danger"; + } else { + return "none"; + } +} diff --git a/packages/ui/app/src/components/FernButton.tsx b/packages/ui/app/src/components/FernButton.tsx index 625a72bcd8..9364d16426 100644 --- a/packages/ui/app/src/components/FernButton.tsx +++ b/packages/ui/app/src/components/FernButton.tsx @@ -5,7 +5,7 @@ import { RemoteFontAwesomeIcon } from "../commons/FontAwesomeIcon"; import { FernLink } from "./FernLink"; import { FernTooltip, FernTooltipProvider } from "./FernTooltip"; -type Intent = "none" | "primary" | "success" | "warning" | "danger"; +export type Intent = "none" | "primary" | "success" | "warning" | "danger"; interface FernButtonSharedProps { className?: string; diff --git a/packages/ui/app/src/components/FernDropdown.tsx b/packages/ui/app/src/components/FernDropdown.tsx index 219392006d..9c38c2bc48 100644 --- a/packages/ui/app/src/components/FernDropdown.tsx +++ b/packages/ui/app/src/components/FernDropdown.tsx @@ -1,8 +1,8 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { CheckIcon, InfoCircledIcon } from "@radix-ui/react-icons"; import cn from "clsx"; -import Link from "next/link"; import { PropsWithChildren, ReactElement, ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { FernLink } from "./FernLink"; import { FernScrollArea } from "./FernScrollArea"; import { FernTooltip, FernTooltipProvider } from "./FernTooltip"; @@ -114,7 +114,7 @@ function FernDropdownItemValue({ option, value }: { option: FernDropdown.ValueOp function renderButtonContent() { return ( -
+
{value != null && ( @@ -157,26 +157,24 @@ function FernDropdownItemValue({ option, value }: { option: FernDropdown.ValueOp side="right" sideOffset={8} > -
- - {option.href != null ? ( - - {renderButtonContent()} - - ) : ( - - )} - -
+ + {option.href != null ? ( + + {renderButtonContent()} + + ) : ( + + )} + ); } diff --git a/packages/ui/app/src/next-app/globals.scss b/packages/ui/app/src/next-app/globals.scss index d06fb42d1b..89210d0af4 100644 --- a/packages/ui/app/src/next-app/globals.scss +++ b/packages/ui/app/src/next-app/globals.scss @@ -188,7 +188,7 @@ @apply shadow-xl; .fern-dropdown-item { - @apply flex justify-start py-1 px-2 rounded text-sm w-full cursor-default items-center; + @apply flex justify-start py-1 px-2 rounded text-sm w-full cursor-default items-center text-left; @apply data-[highlighted]:bg-accent data-[highlighted]:t-accent-contrast will-change-[background-color,color]; .fern-dropdown-item-indicator { diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js index a80ae6b419..0dc22e973e 100644 --- a/packages/ui/tailwind.config.js +++ b/packages/ui/tailwind.config.js @@ -322,6 +322,12 @@ module.exports = { ".t-danger": { "@apply text-intent-danger": {}, }, + ".text-intent-default": { + "@apply text-text-default": {}, + }, + ".text-intent-none": { + "@apply text-text-default": {}, + }, // Background // ".bg-background": { // "@apply bg-background-light dark:bg-background-dark": {},