Update OpenAPI operation path design (#2972)

This commit is contained in:
Nolann B.
2025-03-17 10:18:24 +01:00
committed by GitHub
parent c59947ab39
commit 886e204e68
10 changed files with 240 additions and 89 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Update OpenAPI operation path design
@@ -264,14 +264,18 @@
gap: 6px; gap: 6px;
} }
.scalar-activate-button { .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 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 ring-1 ring-tint hover:ring-tint-hover;
@apply shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none; @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 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 hover:scale-105 active:scale-100 transition-all;
@apply grow-0 shrink-0 truncate; @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 { .scalar-app-loading {
@@ -1,6 +1,6 @@
/* Layout Components */ /* Layout Components */
.openapi-operation { .openapi-operation {
@apply flex-1 flex flex-col gap-4 mb-14; @apply flex-1 flex flex-col gap-8 mb-14;
} }
.openapi-schemas { .openapi-schemas {
@@ -17,7 +17,7 @@
} }
.openapi-summary { .openapi-summary {
@apply flex flex-col items-start justify-start gap-2; @apply flex flex-col items-start justify-start gap-3;
} }
.openapi-deprecated { .openapi-deprecated {
@@ -391,17 +391,30 @@
@apply flex flex-row items-center h-fit; @apply flex flex-row items-center h-fit;
} }
.openapi-codesample-footer {
@apply flex w-full justify-end;
}
/* Path */ /* Path */
.openapi-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 { .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 { .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; scrollbar-width: none;
-ms-overflow-style: 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; @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 */ /* Disclosure group */
.openapi-disclosure-group { .openapi-disclosure-group {
@apply border-b border-tint-subtle relative; @apply border-b border-tint-subtle relative;
@@ -631,3 +648,37 @@
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema { .openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema {
@apply p-2.5; @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;
}
}
@@ -1,4 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { ScalarApiButton } from './ScalarApiButton';
import { StaticSection } from './StaticSection'; import { StaticSection } from './StaticSection';
import { type CodeSampleInput, codeSampleGenerators } from './code-samples'; import { type CodeSampleInput, codeSampleGenerators } from './code-samples';
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample'; import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
@@ -79,6 +81,7 @@ export function OpenAPICodeSample(props: {
code: generator.generate(input), code: generator.generate(input),
syntax: generator.syntax, syntax: generator.syntax,
}), }),
footer: <OpenAPICodeSampleFooter data={data} context={context} />,
})); }));
// Use custom samples if defined // Use custom samples if defined
@@ -105,6 +108,7 @@ export function OpenAPICodeSample(props: {
code: sample.source, code: sample.source,
syntax: sample.lang, syntax: sample.lang,
}), }),
footer: <OpenAPICodeSampleFooter data={data} context={context} />,
})); }));
} }
}); });
@@ -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 (
<div className="openapi-codesample-footer">
<ScalarApiButton method={method} path={path} specUrl={specUrl} />
</div>
);
}
function getSecurityHeaders(securities: OpenAPIOperationData['securities']): { function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
[key: string]: string; [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);
}
@@ -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 (
<TooltipTrigger isOpen={isOpen} onOpenChange={setIsOpen} closeDelay={200} delay={200}>
<Button
type="button"
preventFocusOnPress
onPress={(e) => {
handleCopy();
onPress?.(e);
}}
className={`openapi-copy-button ${className}`}
{...props}
>
{children}
</Button>
<Tooltip
isOpen={isOpen}
onOpenChange={setIsOpen}
placement="top"
offset={4}
className="openapi-tooltip"
>
{copied ? 'Copied' : 'Copy to clipboard'}{' '}
</Tooltip>
</TooltipTrigger>
);
}
@@ -35,6 +35,7 @@ export function OpenAPIOperation(props: {
title: operation.summary, title: operation.summary,
}) })
: null} : null}
<OpenAPIPath data={data} context={context} />
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>} {operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
</div> </div>
<div className="openapi-columns"> <div className="openapi-columns">
@@ -49,7 +50,6 @@ export function OpenAPIOperation(props: {
</div> </div>
) : null} ) : null}
<OpenAPIOperationDescription operation={operation} context={context} /> <OpenAPIOperationDescription operation={operation} context={context} />
<OpenAPIPath data={data} context={context} />
<OpenAPISpec data={data} context={clientContext} /> <OpenAPISpec data={data} context={clientContext} />
</div> </div>
<div className="openapi-column-preview"> <div className="openapi-column-preview">
+40 -42
View File
@@ -1,7 +1,6 @@
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser'; import { OpenAPICopyButton } from './OpenAPICopyButton';
import type React from 'react';
import { ScalarApiButton } from './ScalarApiButton';
import type { OpenAPIContextProps, OpenAPIOperationData } from './types'; import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
import { getDefaultServerURL } from './util/server';
/** /**
* Display the path of an operation. * Display the path of an operation.
@@ -10,63 +9,62 @@ export function OpenAPIPath(props: {
data: OpenAPIOperationData; data: OpenAPIOperationData;
context: OpenAPIContextProps; context: OpenAPIContextProps;
}) { }) {
const { data, context } = props; const { data } = props;
const { method, path } = data; const { method, path, operation } = data;
const { specUrl } = context;
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel']; const server = getDefaultServerURL(data.servers);
const formattedPath = formatPath(path);
return ( return (
<div className="openapi-path"> <div className="openapi-path">
<div className={`openapi-method openapi-method-${method}`}>{method}</div> <div className={`openapi-method openapi-method-${method}`}>{method}</div>
<div className="openapi-path-title" data-deprecated={data.operation.deprecated}>
<p>{formatPath(path)}</p> <OpenAPICopyButton
</div> value={server + path}
{!hideTryItPanel && validateHttpMethod(method) && ( className="openapi-path-title"
<ScalarApiButton method={method} path={path} specUrl={specUrl} /> data-deprecated={operation.deprecated}
)} >
<span className="openapi-path-server">{server}</span>
{formattedPath}
</OpenAPICopyButton>
</div> </div>
); );
} }
function validateHttpMethod(method: string): method is OpenAPIV3_1.HttpMethods { /**
return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method); * Format the path by wrapping placeholders in <span> tags.
} */
// Format the path to highlight placeholders
function formatPath(path: string) { function formatPath(path: string) {
// Matches placeholders like {id}, {userId}, etc. // Matches placeholders like {id}, {userId}, etc.
const regex = /\{(\w+)\}/g; const regex = /\{\s*(\w+)\s*\}|:\w+/g;
const parts: (string | React.JSX.Element)[] = []; const parts: (string | React.JSX.Element)[] = [];
let lastIndex = 0; let lastIndex = 0;
// Replace placeholders with <em> tags //Wrap the variables in <span> tags and maintain either {variable} or :variable
path.replace(regex, (match, key, offset) => { path.replace(regex, (match, _, offset) => {
parts.push(path.slice(lastIndex, offset)); if (offset > lastIndex) {
parts.push(<em key={key}>{`{${key}}`}</em>); parts.push(path.slice(lastIndex, offset));
}
parts.push(
<span key={offset} className="openapi-path-variable">
{match}
</span>
);
lastIndex = offset + match.length; lastIndex = offset + match.length;
return match; return match;
}); });
// Push remaining text after the last placeholder if (lastIndex < path.length) {
parts.push(path.slice(lastIndex)); parts.push(path.slice(lastIndex));
}
// Join parts with separators wrapped in <span> const formattedPath = parts.map((part, index) => {
const formattedPath = parts.reduce( if (typeof part === 'string') {
(acc, part, index) => { return <span key={index}>{part}</span>;
if (typeof part === 'string' && index > 0 && part === '/') { }
acc.push( return part;
<span className="openapi-path-separator" key={`sep-${index}`}> });
/
</span>
);
}
acc.push(part); return formattedPath;
return acc;
},
[] as (string | React.JSX.Element)[]
);
return <span>{formattedPath}</span>;
} }
@@ -1,4 +1,5 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { Markdown } from './Markdown';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { StaticSection } from './StaticSection'; import { StaticSection } from './StaticSection';
import { generateSchemaExample } from './generateSchemaExample'; import { generateSchemaExample } from './generateSchemaExample';
@@ -39,44 +40,40 @@ export function OpenAPIResponseExample(props: {
return Number(a) - Number(b); return Number(a) - Number(b);
}); });
const tabs = responses const tabs = responses.map(([key, responseObject]) => {
.map(([key, responseObject]) => { const description = resolveDescription(responseObject);
const description = resolveDescription(responseObject);
if (checkIsReference(responseObject)) {
return {
key: key,
label: key,
description,
body: (
<OpenAPIExample
example={getExampleFromReference(responseObject)}
context={context}
syntax="json"
/>
),
};
}
if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
return {
key: key,
label: key,
description,
body: <OpenAPIEmptyResponseExample />,
};
}
if (checkIsReference(responseObject)) {
return { return {
key: key, key: key,
label: key, label: key,
description: resolveDescription(responseObject), body: (
body: <OpenAPIResponse context={context} content={responseObject.content} />, <OpenAPIExample
example={getExampleFromReference(responseObject)}
context={context}
syntax="json"
/>
),
footer: description ? <Markdown source={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: <OpenAPIEmptyResponseExample />,
footer: description ? <Markdown source={description} /> : undefined,
};
}
return {
key: key,
label: key,
body: <OpenAPIResponse context={context} content={responseObject.content} />,
footer: description ? <Markdown source={description} /> : undefined,
};
});
if (tabs.length === 0) { if (tabs.length === 0) {
return null; return null;
+13 -4
View File
@@ -3,14 +3,13 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components'; import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components';
import { useEventCallback } from 'usehooks-ts'; import { useEventCallback } from 'usehooks-ts';
import { Markdown } from './Markdown';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState'; import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
export type TabItem = { export type TabItem = {
key: Key; key: Key;
label: string; label: string;
body: React.ReactNode; body: React.ReactNode;
description?: string; footer?: React.ReactNode;
}; };
type OpenAPITabsContextData = { type OpenAPITabsContextData = {
@@ -140,9 +139,19 @@ export function OpenAPITabsPanels() {
return ( return (
<TabPanel key={key} id={key} className="openapi-tabs-panel"> <TabPanel key={key} id={key} className="openapi-tabs-panel">
{selectedTab.body} {selectedTab.body}
{selectedTab.description ? ( {selectedTab.footer ? (
<Markdown source={selectedTab.description} className="openapi-tabs-footer" /> <OpenAPITabsPanelFooter>{selectedTab.footer}</OpenAPITabsPanelFooter>
) : null} ) : null}
</TabPanel> </TabPanel>
); );
} }
/**
* 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 <div className="openapi-tabs-footer">{children}</div>;
}
@@ -27,14 +27,14 @@ export function ScalarApiButton(props: {
setIsOpen(true); setIsOpen(true);
}} }}
> >
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" fill="none"> Test it
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 12" fill="currentColor">
<path <path
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
d="M1 10.05V1.43c0-.2.2-.31.37-.22l7.26 4.08c.17.1.17.33.01.43l-7.26 4.54a.25.25 0 0 1-.38-.21Z" d="M1 10.05V1.43c0-.2.2-.31.37-.22l7.26 4.08c.17.1.17.33.01.43l-7.26 4.54a.25.25 0 0 1-.38-.21Z"
/> />
</svg> </svg>
Test it
</button> </button>
{isOpen && {isOpen &&