diff --git a/.changeset/healthy-houses-judge.md b/.changeset/healthy-houses-judge.md new file mode 100644 index 0000000000..a93a415431 --- /dev/null +++ b/.changeset/healthy-houses-judge.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-openapi': patch +'gitbook': patch +--- + +Update OpenAPI operation path design diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css index d2142ecef8..3724dfb951 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css @@ -264,14 +264,18 @@ gap: 6px; } .scalar-activate-button { - @apply flex gap-1.5 items-center; + @apply flex gap-2 items-center; @apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none place-self-start; @apply ring-1 ring-tint hover:ring-tint-hover; @apply shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none; @apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12; @apply hover:scale-105 active:scale-100 transition-all; @apply grow-0 shrink-0 truncate; - @apply text-sm px-2.5 py-1; + @apply text-[13px] px-2 py-0.5 font-mono font-medium [word-spacing:-2px]; +} + +.scalar-activate-button svg { + @apply size-2.5; } .scalar-app-loading { diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index edaccf8156..baf61b081a 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -1,6 +1,6 @@ /* Layout Components */ .openapi-operation { - @apply flex-1 flex flex-col gap-4 mb-14; + @apply flex-1 flex flex-col gap-8 mb-14; } .openapi-schemas { @@ -17,7 +17,7 @@ } .openapi-summary { - @apply flex flex-col items-start justify-start gap-2; + @apply flex flex-col items-start justify-start gap-3; } .openapi-deprecated { @@ -391,17 +391,30 @@ @apply flex flex-row items-center h-fit; } +.openapi-codesample-footer { + @apply flex w-full justify-end; +} + /* Path */ .openapi-path { - @apply flex items-center bg-transparent text-sm gap-2 p-2 border rounded-md border-tint-subtle; + @apply flex items-center text-sm gap-2 h-fit; +} + +.openapi-path-variable { + @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-none before:!content-none after:!content-none; +} + +.openapi-path-server { + @apply text-tint hidden md:inline; } .openapi-path .openapi-method { - @apply text-[0.813rem] m-0 px-1; + @apply text-[0.813rem] m-0 h-full items-center flex px-2; } .openapi-path-title { - @apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong; + @apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong/10; + @apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors; scrollbar-width: none; -ms-overflow-style: none; } @@ -520,6 +533,10 @@ @apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint; } +.openapi-tabs-footer .openapi-markdown { + @apply text-[0.813rem] text-tint; +} + /* Disclosure group */ .openapi-disclosure-group { @apply border-b border-tint-subtle relative; @@ -631,3 +648,37 @@ .openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema { @apply p-2.5; } + +.openapi-tooltip { + @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md font-medium px-1.5 py-0.5 shadow-sm text-[13px]; +} + +.openapi-tooltip svg { + @apply size-3 text-tint-strong; +} + +.openapi-tooltip[data-entering] { + animation: tooltip-enter 0.2s ease-in-out forwards; +} + +.openapi-tooltip[data-exiting] { + animation: tooltip-leave 0.2s ease-in-out forwards; +} + +@keyframes tooltip-enter { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes tooltip-leave { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 0f4161a2c5..bb6042e52a 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -1,4 +1,6 @@ +import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; +import { ScalarApiButton } from './ScalarApiButton'; import { StaticSection } from './StaticSection'; import { type CodeSampleInput, codeSampleGenerators } from './code-samples'; import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample'; @@ -79,6 +81,7 @@ export function OpenAPICodeSample(props: { code: generator.generate(input), syntax: generator.syntax, }), + footer: , })); // Use custom samples if defined @@ -105,6 +108,7 @@ export function OpenAPICodeSample(props: { code: sample.source, syntax: sample.lang, }), + footer: , })); } }); @@ -128,6 +132,30 @@ export function OpenAPICodeSample(props: { ); } +function OpenAPICodeSampleFooter(props: { + data: OpenAPIOperationData; + context: OpenAPIContextProps; +}) { + const { data, context } = props; + const { method, path } = data; + const { specUrl } = context; + const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel']; + + if (hideTryItPanel) { + return null; + } + + if (!validateHttpMethod(method)) { + return null; + } + + return ( +
+ +
+ ); +} + function getSecurityHeaders(securities: OpenAPIOperationData['securities']): { [key: string]: string; } { @@ -169,3 +197,7 @@ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): { } } } + +function validateHttpMethod(method: string): method is OpenAPIV3.HttpMethods { + return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method); +} diff --git a/packages/react-openapi/src/OpenAPICopyButton.tsx b/packages/react-openapi/src/OpenAPICopyButton.tsx new file mode 100644 index 0000000000..6cf93b3116 --- /dev/null +++ b/packages/react-openapi/src/OpenAPICopyButton.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useState } from 'react'; +import { Button, type ButtonProps, Tooltip, TooltipTrigger } from 'react-aria-components'; + +export function OpenAPICopyButton( + props: ButtonProps & { + value: string; + } +) { + const { value } = props; + const { children, onPress, className } = props; + const [copied, setCopied] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const handleCopy = () => { + if (!value) return; + navigator.clipboard.writeText(value).then(() => { + setIsOpen(true); + setCopied(true); + + setTimeout(() => { + setCopied(false); + }, 2000); + }); + }; + + return ( + + + + + {copied ? 'Copied' : 'Copy to clipboard'}{' '} + + + ); +} diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx index 103a41b7fc..6c186f1a5f 100644 --- a/packages/react-openapi/src/OpenAPIOperation.tsx +++ b/packages/react-openapi/src/OpenAPIOperation.tsx @@ -35,6 +35,7 @@ export function OpenAPIOperation(props: { title: operation.summary, }) : null} + {operation.deprecated &&
Deprecated
}
@@ -49,7 +50,6 @@ export function OpenAPIOperation(props: {
) : null} -
diff --git a/packages/react-openapi/src/OpenAPIPath.tsx b/packages/react-openapi/src/OpenAPIPath.tsx index 528d561bdb..7fc24490eb 100644 --- a/packages/react-openapi/src/OpenAPIPath.tsx +++ b/packages/react-openapi/src/OpenAPIPath.tsx @@ -1,7 +1,6 @@ -import type { OpenAPIV3_1 } from '@gitbook/openapi-parser'; -import type React from 'react'; -import { ScalarApiButton } from './ScalarApiButton'; +import { OpenAPICopyButton } from './OpenAPICopyButton'; import type { OpenAPIContextProps, OpenAPIOperationData } from './types'; +import { getDefaultServerURL } from './util/server'; /** * Display the path of an operation. @@ -10,63 +9,62 @@ export function OpenAPIPath(props: { data: OpenAPIOperationData; context: OpenAPIContextProps; }) { - const { data, context } = props; - const { method, path } = data; - const { specUrl } = context; - const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel']; + const { data } = props; + const { method, path, operation } = data; + + const server = getDefaultServerURL(data.servers); + const formattedPath = formatPath(path); return (
{method}
-
-

{formatPath(path)}

-
- {!hideTryItPanel && validateHttpMethod(method) && ( - - )} + + + {server} + {formattedPath} +
); } -function validateHttpMethod(method: string): method is OpenAPIV3_1.HttpMethods { - return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method); -} - -// Format the path to highlight placeholders +/** + * Format the path by wrapping placeholders in tags. + */ function formatPath(path: string) { // Matches placeholders like {id}, {userId}, etc. - const regex = /\{(\w+)\}/g; + const regex = /\{\s*(\w+)\s*\}|:\w+/g; const parts: (string | React.JSX.Element)[] = []; let lastIndex = 0; - // Replace placeholders with tags - path.replace(regex, (match, key, offset) => { - parts.push(path.slice(lastIndex, offset)); - parts.push({`{${key}}`}); + //Wrap the variables in tags and maintain either {variable} or :variable + path.replace(regex, (match, _, offset) => { + if (offset > lastIndex) { + parts.push(path.slice(lastIndex, offset)); + } + parts.push( + + {match} + + ); lastIndex = offset + match.length; return match; }); - // Push remaining text after the last placeholder - parts.push(path.slice(lastIndex)); - - // Join parts with separators wrapped in - const formattedPath = parts.reduce( - (acc, part, index) => { - if (typeof part === 'string' && index > 0 && part === '/') { - acc.push( - - / - - ); - } + if (lastIndex < path.length) { + parts.push(path.slice(lastIndex)); + } - acc.push(part); - return acc; - }, - [] as (string | React.JSX.Element)[] - ); + const formattedPath = parts.map((part, index) => { + if (typeof part === 'string') { + return {part}; + } + return part; + }); - return {formattedPath}; + return formattedPath; } diff --git a/packages/react-openapi/src/OpenAPIResponseExample.tsx b/packages/react-openapi/src/OpenAPIResponseExample.tsx index d6bb785e74..fe7f2666e2 100644 --- a/packages/react-openapi/src/OpenAPIResponseExample.tsx +++ b/packages/react-openapi/src/OpenAPIResponseExample.tsx @@ -1,4 +1,5 @@ import type { OpenAPIV3 } from '@gitbook/openapi-parser'; +import { Markdown } from './Markdown'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; import { StaticSection } from './StaticSection'; import { generateSchemaExample } from './generateSchemaExample'; @@ -39,44 +40,40 @@ export function OpenAPIResponseExample(props: { return Number(a) - Number(b); }); - const tabs = responses - .map(([key, responseObject]) => { - const description = resolveDescription(responseObject); - - if (checkIsReference(responseObject)) { - return { - key: key, - label: key, - description, - body: ( - - ), - }; - } - - if (!responseObject.content || Object.keys(responseObject.content).length === 0) { - return { - key: key, - label: key, - description, - body: , - }; - } + const tabs = responses.map(([key, responseObject]) => { + const description = resolveDescription(responseObject); + if (checkIsReference(responseObject)) { return { key: key, label: key, - description: resolveDescription(responseObject), - body: , + body: ( + + ), + footer: description ? : undefined, }; - }) - .filter((val): val is { key: string; label: string; body: any; description: string } => - Boolean(val) - ); + } + + if (!responseObject.content || Object.keys(responseObject.content).length === 0) { + return { + key: key, + label: key, + body: , + footer: description ? : undefined, + }; + } + + return { + key: key, + label: key, + body: , + footer: description ? : undefined, + }; + }); if (tabs.length === 0) { return null; diff --git a/packages/react-openapi/src/OpenAPITabs.tsx b/packages/react-openapi/src/OpenAPITabs.tsx index 28bd5fd3fb..700dcfe817 100644 --- a/packages/react-openapi/src/OpenAPITabs.tsx +++ b/packages/react-openapi/src/OpenAPITabs.tsx @@ -3,14 +3,13 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components'; import { useEventCallback } from 'usehooks-ts'; -import { Markdown } from './Markdown'; import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState'; export type TabItem = { key: Key; label: string; body: React.ReactNode; - description?: string; + footer?: React.ReactNode; }; type OpenAPITabsContextData = { @@ -140,9 +139,19 @@ export function OpenAPITabsPanels() { return ( {selectedTab.body} - {selectedTab.description ? ( - + {selectedTab.footer ? ( + {selectedTab.footer} ) : null} ); } + +/** + * The OpenAPI Tabs panel footer component. + * This component should be used as a child of the OpenAPITabs component. + */ +function OpenAPITabsPanelFooter(props: { children: React.ReactNode }) { + const { children } = props; + + return
{children}
; +} diff --git a/packages/react-openapi/src/ScalarApiButton.tsx b/packages/react-openapi/src/ScalarApiButton.tsx index 28118447dc..04d7bb1aaa 100644 --- a/packages/react-openapi/src/ScalarApiButton.tsx +++ b/packages/react-openapi/src/ScalarApiButton.tsx @@ -27,14 +27,14 @@ export function ScalarApiButton(props: { setIsOpen(true); }} > - + Test it + - Test it {isOpen &&