Skip to content

Commit 886e204

Browse files
authored
Update OpenAPI operation path design (#2972)
1 parent c59947a commit 886e204

File tree

10 files changed

+240
-89
lines changed

10 files changed

+240
-89
lines changed

.changeset/healthy-houses-judge.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
'gitbook': patch
4+
---
5+
6+
Update OpenAPI operation path design

packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css

+6-2
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,18 @@
264264
gap: 6px;
265265
}
266266
.scalar-activate-button {
267-
@apply flex gap-1.5 items-center;
267+
@apply flex gap-2 items-center;
268268
@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;
269269
@apply ring-1 ring-tint hover:ring-tint-hover;
270270
@apply shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none;
271271
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
272272
@apply hover:scale-105 active:scale-100 transition-all;
273273
@apply grow-0 shrink-0 truncate;
274-
@apply text-sm px-2.5 py-1;
274+
@apply text-[13px] px-2 py-0.5 font-mono font-medium [word-spacing:-2px];
275+
}
276+
277+
.scalar-activate-button svg {
278+
@apply size-2.5;
275279
}
276280

277281
.scalar-app-loading {

packages/gitbook/src/components/DocumentView/OpenAPI/style.css

+56-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* Layout Components */
22
.openapi-operation {
3-
@apply flex-1 flex flex-col gap-4 mb-14;
3+
@apply flex-1 flex flex-col gap-8 mb-14;
44
}
55

66
.openapi-schemas {
@@ -17,7 +17,7 @@
1717
}
1818

1919
.openapi-summary {
20-
@apply flex flex-col items-start justify-start gap-2;
20+
@apply flex flex-col items-start justify-start gap-3;
2121
}
2222

2323
.openapi-deprecated {
@@ -391,17 +391,30 @@
391391
@apply flex flex-row items-center h-fit;
392392
}
393393

394+
.openapi-codesample-footer {
395+
@apply flex w-full justify-end;
396+
}
397+
394398
/* Path */
395399
.openapi-path {
396-
@apply flex items-center bg-transparent text-sm gap-2 p-2 border rounded-md border-tint-subtle;
400+
@apply flex items-center text-sm gap-2 h-fit;
401+
}
402+
403+
.openapi-path-variable {
404+
@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;
405+
}
406+
407+
.openapi-path-server {
408+
@apply text-tint hidden md:inline;
397409
}
398410

399411
.openapi-path .openapi-method {
400-
@apply text-[0.813rem] m-0 px-1;
412+
@apply text-[0.813rem] m-0 h-full items-center flex px-2;
401413
}
402414

403415
.openapi-path-title {
404-
@apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong;
416+
@apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong/10;
417+
@apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors;
405418
scrollbar-width: none;
406419
-ms-overflow-style: none;
407420
}
@@ -520,6 +533,10 @@
520533
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint;
521534
}
522535

536+
.openapi-tabs-footer .openapi-markdown {
537+
@apply text-[0.813rem] text-tint;
538+
}
539+
523540
/* Disclosure group */
524541
.openapi-disclosure-group {
525542
@apply border-b border-tint-subtle relative;
@@ -631,3 +648,37 @@
631648
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema {
632649
@apply p-2.5;
633650
}
651+
652+
.openapi-tooltip {
653+
@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];
654+
}
655+
656+
.openapi-tooltip svg {
657+
@apply size-3 text-tint-strong;
658+
}
659+
660+
.openapi-tooltip[data-entering] {
661+
animation: tooltip-enter 0.2s ease-in-out forwards;
662+
}
663+
664+
.openapi-tooltip[data-exiting] {
665+
animation: tooltip-leave 0.2s ease-in-out forwards;
666+
}
667+
668+
@keyframes tooltip-enter {
669+
0% {
670+
opacity: 0;
671+
}
672+
100% {
673+
opacity: 1;
674+
}
675+
}
676+
677+
@keyframes tooltip-leave {
678+
0% {
679+
opacity: 1;
680+
}
681+
100% {
682+
opacity: 0;
683+
}
684+
}

packages/react-openapi/src/OpenAPICodeSample.tsx

+32
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
12
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
3+
import { ScalarApiButton } from './ScalarApiButton';
24
import { StaticSection } from './StaticSection';
35
import { type CodeSampleInput, codeSampleGenerators } from './code-samples';
46
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
@@ -79,6 +81,7 @@ export function OpenAPICodeSample(props: {
7981
code: generator.generate(input),
8082
syntax: generator.syntax,
8183
}),
84+
footer: <OpenAPICodeSampleFooter data={data} context={context} />,
8285
}));
8386

8487
// Use custom samples if defined
@@ -105,6 +108,7 @@ export function OpenAPICodeSample(props: {
105108
code: sample.source,
106109
syntax: sample.lang,
107110
}),
111+
footer: <OpenAPICodeSampleFooter data={data} context={context} />,
108112
}));
109113
}
110114
});
@@ -128,6 +132,30 @@ export function OpenAPICodeSample(props: {
128132
);
129133
}
130134

135+
function OpenAPICodeSampleFooter(props: {
136+
data: OpenAPIOperationData;
137+
context: OpenAPIContextProps;
138+
}) {
139+
const { data, context } = props;
140+
const { method, path } = data;
141+
const { specUrl } = context;
142+
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
143+
144+
if (hideTryItPanel) {
145+
return null;
146+
}
147+
148+
if (!validateHttpMethod(method)) {
149+
return null;
150+
}
151+
152+
return (
153+
<div className="openapi-codesample-footer">
154+
<ScalarApiButton method={method} path={path} specUrl={specUrl} />
155+
</div>
156+
);
157+
}
158+
131159
function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
132160
[key: string]: string;
133161
} {
@@ -169,3 +197,7 @@ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
169197
}
170198
}
171199
}
200+
201+
function validateHttpMethod(method: string): method is OpenAPIV3.HttpMethods {
202+
return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method);
203+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Button, type ButtonProps, Tooltip, TooltipTrigger } from 'react-aria-components';
5+
6+
export function OpenAPICopyButton(
7+
props: ButtonProps & {
8+
value: string;
9+
}
10+
) {
11+
const { value } = props;
12+
const { children, onPress, className } = props;
13+
const [copied, setCopied] = useState(false);
14+
const [isOpen, setIsOpen] = useState(false);
15+
16+
const handleCopy = () => {
17+
if (!value) return;
18+
navigator.clipboard.writeText(value).then(() => {
19+
setIsOpen(true);
20+
setCopied(true);
21+
22+
setTimeout(() => {
23+
setCopied(false);
24+
}, 2000);
25+
});
26+
};
27+
28+
return (
29+
<TooltipTrigger isOpen={isOpen} onOpenChange={setIsOpen} closeDelay={200} delay={200}>
30+
<Button
31+
type="button"
32+
preventFocusOnPress
33+
onPress={(e) => {
34+
handleCopy();
35+
onPress?.(e);
36+
}}
37+
className={`openapi-copy-button ${className}`}
38+
{...props}
39+
>
40+
{children}
41+
</Button>
42+
43+
<Tooltip
44+
isOpen={isOpen}
45+
onOpenChange={setIsOpen}
46+
placement="top"
47+
offset={4}
48+
className="openapi-tooltip"
49+
>
50+
{copied ? 'Copied' : 'Copy to clipboard'}{' '}
51+
</Tooltip>
52+
</TooltipTrigger>
53+
);
54+
}

packages/react-openapi/src/OpenAPIOperation.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function OpenAPIOperation(props: {
3535
title: operation.summary,
3636
})
3737
: null}
38+
<OpenAPIPath data={data} context={context} />
3839
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
3940
</div>
4041
<div className="openapi-columns">
@@ -49,7 +50,6 @@ export function OpenAPIOperation(props: {
4950
</div>
5051
) : null}
5152
<OpenAPIOperationDescription operation={operation} context={context} />
52-
<OpenAPIPath data={data} context={context} />
5353
<OpenAPISpec data={data} context={clientContext} />
5454
</div>
5555
<div className="openapi-column-preview">
+40-42
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
2-
import type React from 'react';
3-
import { ScalarApiButton } from './ScalarApiButton';
1+
import { OpenAPICopyButton } from './OpenAPICopyButton';
42
import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
3+
import { getDefaultServerURL } from './util/server';
54

65
/**
76
* Display the path of an operation.
@@ -10,63 +9,62 @@ export function OpenAPIPath(props: {
109
data: OpenAPIOperationData;
1110
context: OpenAPIContextProps;
1211
}) {
13-
const { data, context } = props;
14-
const { method, path } = data;
15-
const { specUrl } = context;
16-
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
12+
const { data } = props;
13+
const { method, path, operation } = data;
14+
15+
const server = getDefaultServerURL(data.servers);
16+
const formattedPath = formatPath(path);
1717

1818
return (
1919
<div className="openapi-path">
2020
<div className={`openapi-method openapi-method-${method}`}>{method}</div>
21-
<div className="openapi-path-title" data-deprecated={data.operation.deprecated}>
22-
<p>{formatPath(path)}</p>
23-
</div>
24-
{!hideTryItPanel && validateHttpMethod(method) && (
25-
<ScalarApiButton method={method} path={path} specUrl={specUrl} />
26-
)}
21+
22+
<OpenAPICopyButton
23+
value={server + path}
24+
className="openapi-path-title"
25+
data-deprecated={operation.deprecated}
26+
>
27+
<span className="openapi-path-server">{server}</span>
28+
{formattedPath}
29+
</OpenAPICopyButton>
2730
</div>
2831
);
2932
}
3033

31-
function validateHttpMethod(method: string): method is OpenAPIV3_1.HttpMethods {
32-
return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method);
33-
}
34-
35-
// Format the path to highlight placeholders
34+
/**
35+
* Format the path by wrapping placeholders in <span> tags.
36+
*/
3637
function formatPath(path: string) {
3738
// Matches placeholders like {id}, {userId}, etc.
38-
const regex = /\{(\w+)\}/g;
39+
const regex = /\{\s*(\w+)\s*\}|:\w+/g;
3940

4041
const parts: (string | React.JSX.Element)[] = [];
4142
let lastIndex = 0;
4243

43-
// Replace placeholders with <em> tags
44-
path.replace(regex, (match, key, offset) => {
45-
parts.push(path.slice(lastIndex, offset));
46-
parts.push(<em key={key}>{`{${key}}`}</em>);
44+
//Wrap the variables in <span> tags and maintain either {variable} or :variable
45+
path.replace(regex, (match, _, offset) => {
46+
if (offset > lastIndex) {
47+
parts.push(path.slice(lastIndex, offset));
48+
}
49+
parts.push(
50+
<span key={offset} className="openapi-path-variable">
51+
{match}
52+
</span>
53+
);
4754
lastIndex = offset + match.length;
4855
return match;
4956
});
5057

51-
// Push remaining text after the last placeholder
52-
parts.push(path.slice(lastIndex));
53-
54-
// Join parts with separators wrapped in <span>
55-
const formattedPath = parts.reduce(
56-
(acc, part, index) => {
57-
if (typeof part === 'string' && index > 0 && part === '/') {
58-
acc.push(
59-
<span className="openapi-path-separator" key={`sep-${index}`}>
60-
/
61-
</span>
62-
);
63-
}
58+
if (lastIndex < path.length) {
59+
parts.push(path.slice(lastIndex));
60+
}
6461

65-
acc.push(part);
66-
return acc;
67-
},
68-
[] as (string | React.JSX.Element)[]
69-
);
62+
const formattedPath = parts.map((part, index) => {
63+
if (typeof part === 'string') {
64+
return <span key={index}>{part}</span>;
65+
}
66+
return part;
67+
});
7068

71-
return <span>{formattedPath}</span>;
69+
return formattedPath;
7270
}

0 commit comments

Comments
 (0)