Skip to content

Update OpenAPI operation path design #2972

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/healthy-houses-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---

Update OpenAPI operation path design
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
61 changes: 56 additions & 5 deletions packages/gitbook/src/components/DocumentView/OpenAPI/style.css
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
32 changes: 32 additions & 0 deletions packages/react-openapi/src/OpenAPICodeSample.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -79,6 +81,7 @@ export function OpenAPICodeSample(props: {
code: generator.generate(input),
syntax: generator.syntax,
}),
footer: <OpenAPICodeSampleFooter data={data} context={context} />,
}));

// Use custom samples if defined
Expand All @@ -105,6 +108,7 @@ export function OpenAPICodeSample(props: {
code: sample.source,
syntax: sample.lang,
}),
footer: <OpenAPICodeSampleFooter data={data} context={context} />,
}));
}
});
Expand All @@ -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']): {
[key: string]: string;
} {
Expand Down Expand Up @@ -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);
}
54 changes: 54 additions & 0 deletions packages/react-openapi/src/OpenAPICopyButton.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
2 changes: 1 addition & 1 deletion packages/react-openapi/src/OpenAPIOperation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function OpenAPIOperation(props: {
title: operation.summary,
})
: null}
<OpenAPIPath data={data} context={context} />
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
</div>
<div className="openapi-columns">
Expand All @@ -49,7 +50,6 @@ export function OpenAPIOperation(props: {
</div>
) : null}
<OpenAPIOperationDescription operation={operation} context={context} />
<OpenAPIPath data={data} context={context} />
<OpenAPISpec data={data} context={clientContext} />
</div>
<div className="openapi-column-preview">
Expand Down
82 changes: 40 additions & 42 deletions packages/react-openapi/src/OpenAPIPath.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 (
<div className="openapi-path">
<div className={`openapi-method openapi-method-${method}`}>{method}</div>
<div className="openapi-path-title" data-deprecated={data.operation.deprecated}>
<p>{formatPath(path)}</p>
</div>
{!hideTryItPanel && validateHttpMethod(method) && (
<ScalarApiButton method={method} path={path} specUrl={specUrl} />
)}

<OpenAPICopyButton
value={server + path}
className="openapi-path-title"
data-deprecated={operation.deprecated}
>
<span className="openapi-path-server">{server}</span>
{formattedPath}
</OpenAPICopyButton>
</div>
);
}

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 <span> 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 <em> tags
path.replace(regex, (match, key, offset) => {
parts.push(path.slice(lastIndex, offset));
parts.push(<em key={key}>{`{${key}}`}</em>);
//Wrap the variables in <span> tags and maintain either {variable} or :variable
path.replace(regex, (match, _, offset) => {
if (offset > lastIndex) {
parts.push(path.slice(lastIndex, offset));
}
parts.push(
<span key={offset} className="openapi-path-variable">
{match}
</span>
);
lastIndex = offset + match.length;
return match;
});

// Push remaining text after the last placeholder
parts.push(path.slice(lastIndex));

// Join parts with separators wrapped in <span>
const formattedPath = parts.reduce(
(acc, part, index) => {
if (typeof part === 'string' && index > 0 && part === '/') {
acc.push(
<span className="openapi-path-separator" key={`sep-${index}`}>
/
</span>
);
}
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 <span key={index}>{part}</span>;
}
return part;
});

return <span>{formattedPath}</span>;
return formattedPath;
}
Loading