diff --git a/.changeset/metal-needles-occur.md b/.changeset/metal-needles-occur.md new file mode 100644 index 000000000..2fc545ef6 --- /dev/null +++ b/.changeset/metal-needles-occur.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-openapi': patch +'gitbook': patch +--- + +Improve OpenAPIResponses examples and schemas diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index e84e43718..8daebeba3 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -8,6 +8,10 @@ @apply flex flex-col mb-14 flex-1; } +.openapi-schemas-title { + @apply tabular-nums text-[0.813rem] leading-4 font-mono shrink-0 font-medium text-tint-strong; +} + .openapi-columns { @apply grid grid-cols-1 lg:grid-cols-2 gap-6 print-mode:grid-cols-1 justify-stretch; } @@ -46,10 +50,7 @@ @apply font-semibold font-mono truncate; } -.openapi-description.openapi-markdown { - @apply prose-sm text-[0.938rem]; -} - +.openapi-description.openapi-markdown, .openapi-description.openapi-markdown code { @apply prose-sm; } @@ -89,21 +90,23 @@ } /* Method Tags */ -.openapi-method { - @apply rounded uppercase font-mono shrink-0 font-bold text-xs px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap; +.openapi-method, +.openapi-statuscode { + @apply rounded uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap; } -.openapi-method-get { - /* @apply bg-[hsl(215,54%,86%)] dark:bg-[hsla(215,54%,45%,0.24)] dark:text-[hsl(215,54%,86%)]; */ +.openapi-method-get, +.openapi-statuscode-success { @apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100; } -.openapi-method-post { - /* @apply bg-[hsl(120,25%,80%)] dark:bg-[hsla(120,54%,32%,0.24)] dark:text-[hsl(120,25%,80%)]; */ +.openapi-method-post, +.openapi-statuscode-redirection { @apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100; } -.openapi-method-put { +.openapi-method-put, +.openapi-statuscode-informational { @apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100; } @@ -111,8 +114,9 @@ @apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100; } -.openapi-method-delete { - @apply bg-pomegranate-100 text-pomegranate-800 dark:bg-pomegranate-900 dark:text-pomegranate-100; +.openapi-method-delete, +.openapi-statuscode-error { + @apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100; } .openapi-method-head, @@ -140,7 +144,7 @@ } .openapi-column-preview-body { - @apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header-sections:page-has-toc:top-32 page-api-block:xl:max-2xl:top-32 print-mode:static; + @apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header-sections:top-32 page-api-block:xl:max-2xl:top-32 print-mode:static; } .openapi-column-preview pre { @@ -157,7 +161,7 @@ } .openapi-section-schemas > .openapi-section-body > .openapi-schema-root-description { - @apply px-2.5 pt-1 !text-sm; + @apply px-2.5 pt-2.5 mt-0 !text-sm; } .openapi-schema-properties { @@ -360,7 +364,7 @@ /* Responses */ .openapi-responses-header { - @apply py-2 border-b border-tint-subtle max-w-full flex-1; + @apply py-2 max-w-full flex-1; } .openapi-responses-header-content { @@ -368,7 +372,8 @@ } .openapi-response-tab-content { - @apply overflow-hidden max-w-full flex items-baseline gap-2; + @apply overflow-hidden max-w-full flex items-baseline; + @apply text-left text-pretty relative leading-[1.125rem] text-tint !font-normal truncate select-text; } .openapi-response-description.openapi-markdown { @@ -383,10 +388,6 @@ @apply truncate max-w-full inline pr-1; } -.openapi-response-statuscode { - @apply tabular-nums text-sm font-normal font-mono shrink-0; -} - .openapi-response-content-type { @apply text-xs text-tint-8 ml-auto shrink-0; } @@ -416,15 +417,23 @@ /* Code Sample */ .openapi-codesample { - @apply border rounded bg-tint border-tint-subtle; + @apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle; } .openapi-codesample-header { @apply flex flex-row items-center; } -.openapi-codesample-header .openapi-select > button { - @apply border-none; +.openapi-response-media-types-examples-footer-content { + @apply flex flex-row items-center gap-2.5; +} + +.openapi-response-examples-header .openapi-select > button { + @apply max-w-full overflow-hidden shrink pl-0.5 py-0.5; +} + +.openapi-response-examples-header .openapi-select > button .openapi-statuscode { + @apply h-full; } .openapi-codesample-header-content { @@ -432,7 +441,7 @@ } .openapi-codesample-header-content .openapi-path { - @apply flex items-center font-mono *:text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong; + @apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong; } .openapi-codesample-header-content .openapi-path .openapi-path-variable { @@ -463,7 +472,7 @@ } .openapi-path .openapi-method { - @apply text-[0.813rem] m-0 mt-0.5 items-center flex px-1; + @apply m-0 mt-0.5 items-center flex px-1; } .openapi-path-title { @@ -494,7 +503,7 @@ /* Panel */ .openapi-panel { - @apply border rounded bg-tint border-tint-subtle; + @apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle; } .openapi-panel-heading { @@ -516,13 +525,41 @@ } /* Example */ +.openapi-response-examples { + @apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle; +} + +.openapi-response-examples-header { + @apply flex flex-row items-center p-2.5; +} + +.openapi-response-examples-header-content { + @apply max-w-full overflow-hidden truncate; +} + +.openapi-response-examples-statuscode-title { + @apply flex items-center; +} + +.openapi-response-examples-header .openapi-select > button, +.openapi-response-examples-header .openapi-markdown, +.openapi-response-examples-statuscode-title { + @apply text-[0.813rem] truncate text-tint font-normal; +} + +.openapi-response-examples-panel, +.openapi-codesample-panel { + @apply flex-1 text-sm relative focus-visible:outline-none; + @apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10; +} + .openapi-example-empty { @apply relative text-tint bg-tint min-h-20 flex flex-col justify-center items-center; } /* Common Elements */ .openapi-select { - /* unstyled */ + @apply w-auto max-w-full; } /* Prevent react-aria popover from setting overflow:auto on body */ @@ -531,24 +568,32 @@ body:has(.openapi-select-popover) { } .openapi-select > button { - @apply flex items-center cursor-pointer gap-1.5 text-tint-strong max-w-60 rounded text-xs leading-6 px-1.5 truncate border border-tint-subtle bg-tint; + @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong max-w-32 rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint; @apply hover:bg-tint-hover transition-all; } +.openapi-select > button[data-focused="true"] { + @apply outline-primary -outline-offset-1 outline outline-1; +} + .openapi-select > button > span.react-aria-SelectValue { - @apply shrink truncate; + @apply shrink truncate flex items-center; } -.openapi-select > button > .gb-icon { - @apply shrink-0; +.openapi-select > button > span.react-aria-SelectValue span:not(.openapi-statuscode) { + @apply truncate; } -.openapi-select > button svg { - @apply size-2.5; +.openapi-select > button .openapi-markdown { + @apply *:leading-none; +} + +.openapi-select > button .gb-icon { + @apply size-2.5 shrink-0; } .openapi-select-popover { - @apply min-w-32 max-w-fit w-auto max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md; + @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none; @apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1; } @@ -561,11 +606,17 @@ body:has(.openapi-select-popover) { } .openapi-select-item { - @apply text-sm cursor-pointer px-1.5 py-0.5 truncate text-tint ring-0 border-none rounded !outline-none; + @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 *:truncate text-tint ring-0 border-none rounded !outline-none; @apply hover:bg-tint-hover theme-gradient:hover:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current; } -.openapi-select-item-selected { +.openapi-select button .openapi-markdown, +.openapi-select-item .openapi-markdown { + @apply text-[0.813rem] *:truncate; +} + +.openapi-select-item-selected, +.openapi-select-item-selected .openapi-markdown { @apply text-primary-subtle hover:text-primary hover:bg-primary-hover; @apply theme-muted:hover:bg-primary-active theme-gradient:hover:bg-primary-active tint:font-semibold; @apply contrast-more:text-primary contrast-more:hover:text-primary-strong contrast-more:font-semibold; @@ -600,6 +651,14 @@ body:has(.openapi-select-popover) { @apply text-tint; } +.openapi-section-footer { + @apply flex flex-row items-center p-2.5 gap-2.5 text-sm text-tint-strong border-t border-tint-subtle; +} + +.openapi-section-footer-content { + @apply text-sm text-tint-strong; +} + .openapi-section-toggle { @apply text-tint-subtle contrast-more:text-tint-strong; } @@ -621,6 +680,7 @@ body:has(.openapi-select-popover) { } /* Tabs */ +.openapi-panel-header, .openapi-tabs-list { @apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-scroll; scrollbar-width: none; @@ -632,18 +692,17 @@ body:has(.openapi-select-popover) { } .openapi-tabs-tab[aria-selected="true"] { - @apply text-primary after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all; + @apply !text-primary-subtle after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all; } -.openapi-tabs-panel, -.openapi-codesample-panel { +.openapi-tabs-panel { @apply flex-1 text-sm relative focus-visible:outline-none; @apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10; } /* Disclosure group */ .openapi-disclosure-group { - @apply border-b border-tint-subtle relative; + @apply border-tint-subtle border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none first:rounded-t-md straight-corners:first:rounded-none first:border-t relative; } .openapi-disclosure-group-header { @@ -651,16 +710,11 @@ body:has(.openapi-select-popover) { } .openapi-disclosure-group-trigger { - @apply flex items-baseline relative flex-1 gap-2.5 py-2 truncate -outline-offset-1; + @apply flex items-baseline transition-all hover:bg-tint-subtle relative flex-1 gap-2.5 p-3 truncate -outline-offset-1; } .openapi-disclosure-group-trigger:disabled { - @apply cursor-default; -} - -.openapi-disclosure-group:only-child, -.openapi-disclosure-group:last-child { - @apply border-b-0; + @apply cursor-default hover:bg-inherit; } .openapi-disclosure-group-trigger:disabled .openapi-disclosure-group-icon { @@ -680,28 +734,42 @@ body:has(.openapi-select-popover) { } .openapi-disclosure-group-panel { - @apply pb-2.5; + @apply p-3 pt-1 transition-all; } .openapi-disclosure-group-trigger[aria-expanded="true"] > .openapi-disclosure-group-icon > svg { @apply rotate-90; } -.openapi-disclosure-group:hover .openapi-disclosure-group-mediatype { - @apply opacity-11 flex; +.openapi-disclosure-group:hover .openapi-disclosure-group-mediatype, +.openapi-disclosure-group-mediatype:has(> .openapi-select[data-open="true"]) { + @apply opacity-11 visible flex; } .openapi-disclosure-group-mediatype { - @apply opacity-0 hidden text-xs transition-all duration-200 shrink-0 absolute right-0 top-2.5; + @apply opacity-0 invisible text-xs transition-opacity duration-300 shrink-0 absolute right-2.5 top-2.5; } .openapi-disclosure-group-mediatype > span { - @apply px-1 bg-tint-6 text-tint-12 rounded-full; + @apply px-1 bg-tint-6 text-tint-12 rounded-full straight-corners:rounded-md; } /* Disclosure */ +.openapi-schemas-disclosure > .openapi-disclosure-trigger { + @apply flex items-center font-mono !w-full transition-all text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-3 truncate -outline-offset-1; +} + +.openapi-schemas-disclosure > .openapi-disclosure-trigger, +.openapi-schemas-disclosure .openapi-disclosure-panel { + @apply straight-corners:!rounded-none; +} + +.openapi-schemas-disclosure > .openapi-disclosure-trigger[aria-expanded="true"] > svg { + @apply rotate-90; +} + .openapi-disclosure-trigger { - @apply transition-all truncate duration-300 max-w-full hover:text-tint-strong rounded-2xl border border-tint-subtle px-2.5 py-1 text-[0.813rem] text-tint flex flex-row items-center gap-1.5 -outline-offset-1; + @apply transition-all truncate duration-300 max-w-full hover:text-tint-strong rounded-2xl straight-corners:rounded border border-tint-subtle px-2.5 py-1 text-[0.813rem] text-tint flex flex-row items-center gap-1.5 -outline-offset-1; } .openapi-disclosure-trigger span { @@ -717,7 +785,7 @@ body:has(.openapi-select-popover) { } .openapi-disclosure-trigger[aria-expanded="true"] { - @apply w-full rounded-lg border-b rounded-b-none; + @apply w-full rounded-lg border-b rounded-b-none straight-corners:rounded-b-none; } .openapi-disclosure-trigger[aria-expanded="false"] { @@ -725,7 +793,7 @@ body:has(.openapi-select-popover) { } .openapi-disclosure-panel[aria-hidden="false"] { - @apply border-b border-x border-tint-subtle rounded-b-lg; + @apply border-b border-x border-tint-subtle rounded-b-lg straight-corners:rounded-b; } .openapi-disclosure-panel .openapi-schema { @@ -745,17 +813,13 @@ body:has(.openapi-select-popover) { @apply space-y-2.5; } -.openapi-section-schemas { - @apply border border-tint-subtle rounded-lg; -} - .openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema, .openapi-section-schemas > .openapi-section-body > .openapi-schema-root { @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]; + @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px]; } .openapi-tooltip svg { diff --git a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx index a2adf315e..d9b02ebdf 100644 --- a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx +++ b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx @@ -123,7 +123,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) { > {section.tag ? ( {section.tag} diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx index 8ae60649c..bb41d9aaf 100644 --- a/packages/react-openapi/src/InteractiveSection.tsx +++ b/packages/react-openapi/src/InteractiveSection.tsx @@ -1,10 +1,10 @@ 'use client'; import clsx from 'clsx'; -import { useRef, useState } from 'react'; +import { useRef } from 'react'; import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria'; import { useDisclosureState } from 'react-stately'; -import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect'; +import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect'; import { Section, SectionBody, SectionHeader, SectionHeaderContent } from './StaticSection'; interface InteractiveSectionTab { @@ -35,6 +35,10 @@ export function InteractiveSection(props: { header?: React.ReactNode; /** Children to display within the container */ overlay?: React.ReactNode; + /** State key to use with a store */ + stateKey?: string; + /** Icon for the tabs select */ + selectIcon?: React.ReactNode; }) { const { id, @@ -46,12 +50,9 @@ export function InteractiveSection(props: { header, overlay, toggleIcon = '▶', + selectIcon, + stateKey = 'interactive-section', } = props; - - const [selectedTabKey, setSelectedTab] = useState(defaultTab); - const selectedTab: InteractiveSectionTab | undefined = - tabs.find((tab) => tab.key === selectedTabKey) ?? tabs[0]; - const state = useDisclosureState({ defaultExpanded: defaultOpened, }); @@ -60,6 +61,10 @@ export function InteractiveSection(props: { const { buttonProps: triggerProps, panelProps } = useDisclosure({}, state, panelRef); const { buttonProps } = useButton(triggerProps, triggerRef); const { isFocusVisible, focusProps } = useFocusRing(); + const store = useSelectState(stateKey, defaultTab); + + const selectedTab: InteractiveSectionTab | undefined = + tabs.find((tab) => tab.key === store.key) ?? tabs[0]; return (
{tabs.length > 1 ? ( { - setSelectedTab(String(key)); + onSelectionChange={() => { state.expand(); }} + icon={selectIcon} placement="bottom end" > {tabs.map((tab) => ( diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 9a99f970b..17cb66454 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -206,6 +206,7 @@ function OpenAPICodeSampleFooter(props: { method={data.method} path={data.path} renderers={renderers} + selectIcon={context.icons.chevronDown} /> ) : ( diff --git a/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx index 6476cc42c..109df423c 100644 --- a/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx +++ b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx @@ -1,70 +1,41 @@ 'use client'; import clsx from 'clsx'; -import { useCallback } from 'react'; -import { useStore } from 'zustand'; import type { MediaTypeRenderer } from './OpenAPICodeSample'; -import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect'; -import { getOrCreateStoreByKey } from './getOrCreateStoreByKey'; - -type MediaTypeState = { - mediaType: string; - setMediaType: (mediaType: string) => void; -}; - -function useMediaTypeState( - data: { method: string; path: string }, - defaultKey: string -): MediaTypeState { - const { method, path } = data; - const store = useStore(getOrCreateStoreByKey(`media-type-${method}-${path}`, defaultKey)); - if (typeof store.key !== 'string') { - throw new Error('Media type key is not a string'); - } - return { - mediaType: store.key, - setMediaType: useCallback((index: string) => store.setKey(index), [store.setKey]), - }; -} - -function useMediaTypeSampleIndexState(data: { method: string; path: string }, mediaType: string) { - const { method, path } = data; - const store = useStore( - getOrCreateStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0) - ); - if (typeof store.key !== 'number') { - throw new Error('Example key is not a number'); - } - return { - index: store.key, - setIndex: useCallback((index: number) => store.setKey(index), [store.setKey]), - }; -} +import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect'; export function OpenAPIMediaTypeExamplesSelector(props: { method: string; path: string; renderers: MediaTypeRenderer[]; + selectIcon?: React.ReactNode; }) { - const { method, path, renderers } = props; + const { method, path, renderers, selectIcon } = props; if (!renderers[0]) { throw new Error('No renderers provided'); } - const state = useMediaTypeState({ method, path }, renderers[0].mediaType); - const selected = renderers.find((r) => r.mediaType === state.mediaType) || renderers[0]; + const stateKey = `media-type-${method}-${path}`; + const state = useSelectState(stateKey, renderers[0].mediaType); + const selected = renderers.find((r) => r.mediaType === state.key) || renderers[0]; return (
- - + +
); } function MediaTypeSelector(props: { - state: MediaTypeState; + stateKey: string; renderers: MediaTypeRenderer[]; + selectIcon?: React.ReactNode; }) { - const { renderers, state } = props; + const { renderers, stateKey, selectIcon } = props; if (renderers.length < 2) { return null; @@ -78,12 +49,12 @@ function MediaTypeSelector(props: { return ( ({ key: renderer.mediaType, label: renderer.mediaType, }))} - onSelectionChange={(e) => state.setMediaType(String(e))} + icon={selectIcon} + stateKey={stateKey} placement="bottom start" > {items.map((item) => ( @@ -99,9 +70,9 @@ function ExamplesSelector(props: { method: string; path: string; renderer: MediaTypeRenderer; + selectIcon?: React.ReactNode; }) { - const { method, path, renderer } = props; - const state = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType); + const { method, path, renderer, selectIcon } = props; if (renderer.examples.length < 2) { return null; } @@ -114,8 +85,8 @@ function ExamplesSelector(props: { return ( state.setIndex(Number(e))} + icon={selectIcon} + stateKey={`media-type-sample-${renderer.mediaType}-${method}-${path}`} placement="bottom start" > {items.map((item) => ( @@ -136,9 +107,9 @@ export function OpenAPIMediaTypeExamplesBody(props: { if (!renderers[0]) { throw new Error('No renderers provided'); } - const mediaTypeState = useMediaTypeState({ method, path }, renderers[0].mediaType); - const selected = - renderers.find((r) => r.mediaType === mediaTypeState.mediaType) ?? renderers[0]; + + const mediaTypeState = useSelectState(`media-type-${method}-${path}`, renderers[0].mediaType); + const selected = renderers.find((r) => r.mediaType === mediaTypeState.key) ?? renderers[0]; if (selected.examples.length === 0) { return selected.element; } @@ -147,10 +118,13 @@ export function OpenAPIMediaTypeExamplesBody(props: { function ExamplesBody(props: { method: string; path: string; renderer: MediaTypeRenderer }) { const { method, path, renderer } = props; - const exampleState = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType); - const example = renderer.examples[exampleState.index] ?? renderer.examples[0]; + const exampleState = useSelectState( + `media-type-sample-${renderer.mediaType}-${method}-${path}`, + renderer.mediaType + ); + const example = renderer.examples[Number(exampleState.key)] ?? renderer.examples[0]; if (!example) { - throw new Error(`No example found for index ${exampleState.index}`); + throw new Error(`No example found for key ${exampleState.key}`); } return example.element; } diff --git a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx index f3f18ffc9..4625b7ac6 100644 --- a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx +++ b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx @@ -22,25 +22,21 @@ type CodeSampleItem = OpenAPISelectItem & { footer?: React.ReactNode; }; -export function OpenAPICodeSampleHeader(props: { +function OpenAPICodeSampleHeader(props: { items: CodeSampleItem[]; data: OpenAPIOperationData; + selectIcon?: React.ReactNode; }) { - const { data, items } = props; - - const state = useCodeSampleState(items[0]?.key ?? ''); - const selected = items.find((item) => item.key === state.key) || items[0]; + const { data, items, selectIcon } = props; return ( <> {items.length > 1 ? ( { - state.setKey(key); - }} + icon={selectIcon} items={items} + stateKey="codesample" placement="bottom end" > {items.map((item) => ( @@ -59,8 +55,9 @@ export function OpenAPICodeSampleHeader(props: { export function OpenAPICodeSampleBody(props: { items: CodeSampleItem[]; data: OpenAPIOperationData; + selectIcon?: React.ReactNode; }) { - const { items, data } = props; + const { items, data, selectIcon } = props; if (!items[0]) { throw new Error('No items provided'); } @@ -75,7 +72,7 @@ export function OpenAPICodeSampleBody(props: { return ( } + header={} className="openapi-codesample" >
diff --git a/packages/react-openapi/src/OpenAPIDisclosure.tsx b/packages/react-openapi/src/OpenAPIDisclosure.tsx index 09babaf98..d84b4523b 100644 --- a/packages/react-openapi/src/OpenAPIDisclosure.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosure.tsx @@ -1,41 +1,39 @@ 'use client'; +import clsx from 'clsx'; +import type React from 'react'; import { useState } from 'react'; -import { Button, Disclosure, DisclosurePanel, Heading } from 'react-aria-components'; -import type { OpenAPIClientContext } from './types'; +import { Button, Disclosure, DisclosurePanel } from 'react-aria-components'; /** * Display an interactive OpenAPI disclosure. */ export function OpenAPIDisclosure(props: { - context: OpenAPIClientContext; + icon: React.ReactNode; children: React.ReactNode; - label: string; + label: string | ((isExpanded: boolean) => string); + className?: string; }): React.JSX.Element { - const { context, children, label } = props; + const { icon, children, label, className } = props; const [isExpanded, setIsExpanded] = useState(false); return ( - - - + {isExpanded ? children : null} diff --git a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx index 82c89c28a..6fe0dcd81 100644 --- a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createContext, useContext, useRef, useState } from 'react'; +import { createContext, useContext, useRef } from 'react'; import { mergeProps, useButton, useDisclosure, useFocusRing, useId } from 'react-aria'; import { type DisclosureGroupProps, @@ -8,18 +8,23 @@ import { useDisclosureGroupState, useDisclosureState, } from 'react-stately'; +import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect'; interface Props { groups: TDisclosureGroup[]; icon?: React.ReactNode; + /** State key to use with a store */ + selectStateKey?: string; + /** Icon to display for the select */ + selectIcon?: React.ReactNode; } type TDisclosureGroup = { - id: string; + key: string; label: string | React.ReactNode; tabs?: { - id: string; - label?: string | React.ReactNode; + key: string; + label: string | React.ReactNode; body?: React.ReactNode; }[]; }; @@ -30,24 +35,35 @@ const DisclosureGroupStateContext = createContext(n * Display an interactive OpenAPI disclosure group. */ export function OpenAPIDisclosureGroup(props: DisclosureGroupProps & Props) { - const { icon, groups } = props; + const { icon, groups, selectStateKey, selectIcon } = props; const state = useDisclosureGroupState(props); return ( {groups.map((group) => ( - + ))} ); } -function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode }) { - const { icon, group } = props; +function DisclosureItem(props: { + group: TDisclosureGroup; + icon?: React.ReactNode; + selectStateKey?: string; + selectIcon?: React.ReactNode; +}) { + const { icon, group, selectStateKey, selectIcon } = props; const defaultId = useId(); - const id = group.id || defaultId; + const id = group.key || defaultId; const groupState = useContext(DisclosureGroupStateContext); const isExpanded = groupState?.expandedKeys.has(id) || false; const state = useDisclosureState({ @@ -74,9 +90,9 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode const { buttonProps } = useButton(triggerProps, triggerRef); const { isFocusVisible, focusProps } = useFocusRing(); - const defaultTab = group.tabs?.[0]?.id || ''; - const [selectedTabKey, setSelectedTabKey] = useState(defaultTab); - const selectedTab = group.tabs?.find((tab) => tab.id === selectedTabKey); + const defaultTab = group.tabs?.[0]?.key || ''; + const store = useSelectState(selectStateKey, defaultTab); + const selectedTab = group.tabs?.find((tab) => tab.key === store.key) || group.tabs?.[0]; return (
@@ -104,23 +120,25 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode {group.label} {group.tabs ? ( -
+
e.stopPropagation()} + > {group.tabs?.length > 1 ? ( - + ) : group.tabs[0]?.label ? ( {group.tabs[0].label} ) : null} diff --git a/packages/react-openapi/src/OpenAPIRequestBody.tsx b/packages/react-openapi/src/OpenAPIRequestBody.tsx index c7c2515e4..7be68a3a5 100644 --- a/packages/react-openapi/src/OpenAPIRequestBody.tsx +++ b/packages/react-openapi/src/OpenAPIRequestBody.tsx @@ -1,7 +1,7 @@ import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import { InteractiveSection } from './InteractiveSection'; import { OpenAPIRootSchema } from './OpenAPISchemaServer'; -import type { OpenAPIClientContext } from './types'; +import type { OpenAPIClientContext, OpenAPIOperationData } from './types'; import { checkIsReference } from './utils'; /** @@ -10,8 +10,10 @@ import { checkIsReference } from './utils'; export function OpenAPIRequestBody(props: { requestBody: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject; context: OpenAPIClientContext; + data: OpenAPIOperationData; }) { - const { requestBody, context } = props; + const { requestBody, context, data } = props; + const { method, path } = data; if (checkIsReference(requestBody)) { return null; @@ -21,6 +23,8 @@ export function OpenAPIRequestBody(props: { { return { diff --git a/packages/react-openapi/src/OpenAPIResponse.tsx b/packages/react-openapi/src/OpenAPIResponse.tsx index 77f44d5dd..ddec3f011 100644 --- a/packages/react-openapi/src/OpenAPIResponse.tsx +++ b/packages/react-openapi/src/OpenAPIResponse.tsx @@ -27,7 +27,7 @@ export function OpenAPIResponse(props: { return (
{headers.length > 0 ? ( - + parameterToProperty({ name, ...header }) diff --git a/packages/react-openapi/src/OpenAPIResponseExample.tsx b/packages/react-openapi/src/OpenAPIResponseExample.tsx index 8f7ae857d..8640f568d 100644 --- a/packages/react-openapi/src/OpenAPIResponseExample.tsx +++ b/packages/react-openapi/src/OpenAPIResponseExample.tsx @@ -6,10 +6,11 @@ import { getExampleFromReference, getExamplesFromMediaTypeObject, } from './OpenAPIExample'; -import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; -import { StaticSection } from './StaticSection'; +import { OpenAPIResponseExampleContent } from './OpenAPIResponseExampleContent'; +import { OpenAPIResponseMediaTypeContent } from './OpenAPIResponseMediaType'; import type { OpenAPIContext, OpenAPIOperationData } from './types'; -import { checkIsReference, createStateKey, resolveDescription } from './utils'; +import { getStatusCodeDefaultLabel } from './utils'; +import { checkIsReference, resolveDescription } from './utils'; /** * Display an example of the response content. @@ -45,11 +46,17 @@ export function OpenAPIResponseExample(props: { const tabs = responses.map(([key, responseObject]) => { const description = resolveDescription(responseObject); + const label = description ? ( + + ) : ( + getStatusCodeDefaultLabel(key) + ); if (checkIsReference(responseObject)) { return { key: key, - label: key, + label, + statusCode: key, body: ( ), - footer: description ? : undefined, }; } if (!responseObject.content || Object.keys(responseObject.content).length === 0) { return { key: key, - label: key, + label, + statusCode: key, body: , - footer: description ? : undefined, }; } return { key: key, - label: key, + label, + statusCode: key, body: , - footer: description ? : undefined, }; }); @@ -83,11 +89,11 @@ export function OpenAPIResponseExample(props: { } return ( - - } className="openapi-panel"> - - - + ); } @@ -106,42 +112,30 @@ function OpenAPIResponse(props: { throw new Error('One media type is required'); } - if (entries.length === 1) { - const [mediaType, mediaTypeObject] = firstEntry; - return ( - - ); - } - const tabs = entries.map((entry) => { const [mediaType, mediaTypeObject] = entry; return { key: mediaType, label: mediaType, - body: ( - - ), + body: <>, + examples: getExamples({ + mediaTypeObject, + mediaType, + context, + }), }; }); return ( - - } className="openapi-response-media-types"> - - - + ); } -function OpenAPIResponseMediaType(props: { +function getExamples(props: { mediaTypeObject: OpenAPIV3.MediaTypeObject; mediaType: string; context: OpenAPIContext; @@ -149,23 +143,8 @@ function OpenAPIResponseMediaType(props: { const { mediaTypeObject, mediaType } = props; const examples = getExamplesFromMediaTypeObject({ mediaTypeObject, mediaType }); const syntax = getSyntaxFromMediaType(mediaType); - const firstExample = examples[0]; - if (!firstExample) { - return ; - } - - if (examples.length === 1) { - return ( - - ); - } - - const tabs = examples.map((example) => { + return examples.map((example) => { return { key: example.key, label: example.example.summary || example.key, @@ -174,17 +153,6 @@ function OpenAPIResponseMediaType(props: { ), }; }); - - return ( - - } - className="openapi-response-media-type-examples" - > - - - - ); } /** diff --git a/packages/react-openapi/src/OpenAPIResponseExampleContent.tsx b/packages/react-openapi/src/OpenAPIResponseExampleContent.tsx new file mode 100644 index 000000000..0b193ed3f --- /dev/null +++ b/packages/react-openapi/src/OpenAPIResponseExampleContent.tsx @@ -0,0 +1,123 @@ +'use client'; + +import clsx from 'clsx'; +import type { Key } from 'react-aria'; +import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect'; +import { StaticSection } from './StaticSection'; +import { createStateKey, getStatusCodeClassName } from './utils'; + +type OpenAPIResponseExampleItem = OpenAPISelectItem & { + statusCode: string; + body: React.ReactNode; +}; + +/** + * Get the state of the response examples select. + */ +export function useResponseExamplesState( + blockKey: string | undefined, + initialKey: Key = 'default' +) { + return useSelectState(getResponseExampleStateKey(blockKey), initialKey); +} + +export function OpenAPIResponseExampleContent(props: { + items: OpenAPIResponseExampleItem[]; + blockKey?: string; + selectIcon?: React.ReactNode; +}) { + const { blockKey, items, selectIcon } = props; + + return ( + + } + className="openapi-response-examples" + > + + + ); +} + +function OpenAPIResponseExampleHeader(props: { + items: OpenAPIResponseExampleItem[]; + blockKey?: string; + selectIcon?: React.ReactNode; +}) { + const { items, blockKey, selectIcon } = props; + + if (items.length === 1) { + const item = items[0]; + + if (!item) { + return null; + } + + return ( + + + {item.statusCode} + + {item.label} + + ); + } + + return ( + + {items.map((item) => ( + + + {item.statusCode} + + {item.label} + + ))} + + ); +} + +function OpenAPIResponseExampleBody(props: { + items: OpenAPIResponseExampleItem[]; + blockKey?: string; +}) { + const { blockKey, items } = props; + const state = useResponseExamplesState(blockKey, items[0]?.key); + + const selectedItem = items.find((item) => item.key === state.key) ?? items[0]; + + if (!selectedItem) { + return null; + } + + return
{selectedItem.body}
; +} + +/** + * Return the state key for the response examples. + */ +function getResponseExampleStateKey(blockKey: string | undefined) { + return createStateKey('openapi-responses', blockKey); +} diff --git a/packages/react-openapi/src/OpenAPIResponseMediaType.tsx b/packages/react-openapi/src/OpenAPIResponseMediaType.tsx new file mode 100644 index 000000000..b2ca31fcd --- /dev/null +++ b/packages/react-openapi/src/OpenAPIResponseMediaType.tsx @@ -0,0 +1,148 @@ +'use client'; +import type { Key } from 'react-aria'; +import { OpenAPIEmptyExample } from './OpenAPIExample'; +import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect'; +import { StaticSection } from './StaticSection'; +import { createStateKey } from './utils'; + +type OpenAPIResponseMediaTypeItem = OpenAPISelectItem & { + body: React.ReactNode; + examples?: OpenAPIResponseMediaTypeItem[]; +}; + +/** + * Get the state of the response examples select. + */ +export function useResponseMediaTypesState( + blockKey: string | undefined, + initialKey: Key = 'default' +) { + return useSelectState(getResponseMediaTypeStateKey(blockKey), initialKey); +} + +function useResponseMediaTypeExamplesState( + blockKey: string | undefined, + initialKey: Key = 'default' +) { + return useSelectState(getResponseMediaTypeExamplesStateKey(blockKey), initialKey); +} + +export function OpenAPIResponseMediaTypeContent(props: { + items: OpenAPIResponseMediaTypeItem[]; + blockKey?: string; + selectIcon?: React.ReactNode; +}) { + const { blockKey, items, selectIcon } = props; + const state = useResponseMediaTypesState(blockKey, items[0]?.key); + + const examples = items.find((item) => item.key === state.key)?.examples ?? []; + + if (!items.length && !examples.length) { + return null; + } + + return ( + 1 || examples.length > 1 ? ( + + ) : null + } + className="openapi-response-media-types-examples" + > + + + ); +} + +function OpenAPIResponseMediaTypeFooter(props: { + items: OpenAPIResponseMediaTypeItem[]; + examples?: OpenAPIResponseMediaTypeItem[]; + blockKey?: string; + selectIcon?: React.ReactNode; +}) { + const { items, examples, blockKey, selectIcon } = props; + + return ( + <> + {items.length > 1 && ( + + {items.map((item) => ( + + {item.label} + + ))} + + )} + + {examples && examples.length > 1 ? ( + + {examples.map((example) => ( + + {example.label} + + ))} + + ) : null} + + ); +} + +function OpenAPIResponseMediaTypeBody(props: { + items: OpenAPIResponseMediaTypeItem[]; + examples?: OpenAPIResponseMediaTypeItem[]; + blockKey?: string; +}) { + const { blockKey, items, examples } = props; + const state = useResponseMediaTypesState(blockKey, items[0]?.key); + + const selectedItem = items.find((item) => item.key === state.key) ?? items[0]; + + const exampleState = useResponseMediaTypeExamplesState( + blockKey, + selectedItem?.examples?.[0]?.key + ); + + if (!selectedItem) { + return null; + } + + if (examples) { + const selectedExample = + examples.find((example) => example.key === exampleState.key) ?? examples[0]; + + if (!selectedExample) { + return ; + } + + return selectedExample.body; + } + + return selectedItem.body; +} + +/** + * Return the state key for the response media types. + */ +function getResponseMediaTypeStateKey(blockKey: string | undefined) { + return createStateKey('response-media-types', blockKey); +} + +function getResponseMediaTypeExamplesStateKey(blockKey: string | undefined) { + return createStateKey('response-media-types-examples', blockKey); +} diff --git a/packages/react-openapi/src/OpenAPIResponses.tsx b/packages/react-openapi/src/OpenAPIResponses.tsx index cc577e53d..303a242bf 100644 --- a/packages/react-openapi/src/OpenAPIResponses.tsx +++ b/packages/react-openapi/src/OpenAPIResponses.tsx @@ -1,9 +1,14 @@ +'use client'; + import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser'; +import clsx from 'clsx'; import { Markdown } from './Markdown'; import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup'; import { OpenAPIResponse } from './OpenAPIResponse'; +import { useResponseExamplesState } from './OpenAPIResponseExampleContent'; import { StaticSection } from './StaticSection'; import type { OpenAPIClientContext } from './types'; +import { createStateKey, getStatusCodeClassName, getStatusCodeDefaultLabel } from './utils'; /** * Display an interactive response body. @@ -14,70 +19,85 @@ export function OpenAPIResponses(props: { }) { const { responses, context } = props; + const groups = Object.entries(responses).map( + ([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => { + const tabs = (() => { + // If there is no content, but there are headers, we need to show the headers + if ( + (!response.content || !Object.keys(response.content).length) && + response.headers && + Object.keys(response.headers).length + ) { + return [ + { + key: 'default', + label: '', + body: ( + + ), + }, + ]; + } + + return Object.entries(response.content ?? {}).map(([contentType, mediaType]) => ({ + key: contentType, + label: contentType, + body: ( + + ), + })); + })(); + + const description = response.description; + + return { + key: statusCode, + label: ( +
+ + {statusCode} + + {description ? ( + + ) : ( + getStatusCodeDefaultLabel(statusCode) + )} +
+ ), + tabs, + }; + } + ); + + const state = useResponseExamplesState(context.blockKey, groups[0]?.key); + return ( { - const tabs = (() => { - // If there is no content, but there are headers, we need to show the headers - if ( - (!response.content || !Object.keys(response.content).length) && - response.headers && - Object.keys(response.headers).length - ) { - return [ - { - id: 'default', - body: ( - - ), - }, - ]; - } - - return Object.entries(response.content ?? {}).map( - ([contentType, mediaType]) => ({ - id: contentType, - label: contentType, - body: ( - - ), - }) - ); - })(); - - const description = response.description; - - return { - id: statusCode, - label: ( -
- - {statusCode} - - {description ? ( - - ) : null} -
- ), - tabs, - }; - } - )} + expandedKeys={state.key ? new Set([state.key]) : new Set()} + onExpandedChange={(keys) => { + const key = keys.values().next().value ?? null; + state.setKey(key); + }} + groups={groups} + selectStateKey={createStateKey('response-media-types', context.blockKey)} />
); diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index 89fe40199..0b76efc14 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -54,7 +54,10 @@ function OpenAPISchemaProperty(props: { const properties = getSchemaProperties(schema); if (properties?.length) { return ( - + getDisclosureLabel(schema, isExpanded)} + > ) : null} - + getDisclosureLabel(schema, isExpanded)} + > {properties?.length ? ( { const description = resolveDescription(security); diff --git a/packages/react-openapi/src/OpenAPISelect.tsx b/packages/react-openapi/src/OpenAPISelect.tsx index 14b83b77d..e8551e9b9 100644 --- a/packages/react-openapi/src/OpenAPISelect.tsx +++ b/packages/react-openapi/src/OpenAPISelect.tsx @@ -1,6 +1,7 @@ 'use client'; import clsx from 'clsx'; +import { useCallback } from 'react'; import { Button, type Key, @@ -13,38 +14,63 @@ import { type SelectProps, SelectValue, } from 'react-aria-components'; +import { useStore } from 'zustand'; +import { getOrCreateStoreByKey } from './getOrCreateStoreByKey'; export type OpenAPISelectItem = { key: Key; - label: string; + label: string | React.ReactNode; }; interface OpenAPISelectProps extends Omit, 'children'> { items: T[]; children: React.ReactNode | ((item: T) => React.ReactNode); - selectedKey?: Key; - onChange?: (key: string | number) => void; placement?: PopoverProps['placement']; + stateKey?: string; + /** + * Icon to display in the select button. + */ + icon?: React.ReactNode; +} + +export function useSelectState(stateKey = 'select-state', initialKey?: Key) { + const store = useStore(getOrCreateStoreByKey(stateKey, initialKey)); + return { + key: store.key, + setKey: useCallback((key: Key | null) => store.setKey(key), [store.setKey]), + }; } export function OpenAPISelect(props: OpenAPISelectProps) { - const { items, children, className, placement } = props; + const { + icon = '▼', + items, + children, + className, + placement, + stateKey, + selectedKey, + onSelectionChange, + } = props; + + const state = useSelectState(stateKey, items[0]?.key); + + const selected = items.find((item) => item.key === state.key) || items[0]; return ( - { + onSelectionChange?.(key); + state.setKey(key); + }} + className={clsx('openapi-select', className)} + > diff --git a/packages/react-openapi/src/OpenAPISpec.tsx b/packages/react-openapi/src/OpenAPISpec.tsx index 599923656..4828288b4 100644 --- a/packages/react-openapi/src/OpenAPISpec.tsx +++ b/packages/react-openapi/src/OpenAPISpec.tsx @@ -42,6 +42,7 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP key="body" requestBody={operation.requestBody} context={context} + data={data} /> ) : null} {operation.responses ? ( diff --git a/packages/react-openapi/src/StaticSection.tsx b/packages/react-openapi/src/StaticSection.tsx index 5124227e7..ffb3a2bba 100644 --- a/packages/react-openapi/src/StaticSection.tsx +++ b/packages/react-openapi/src/StaticSection.tsx @@ -42,18 +42,50 @@ export const SectionBody = forwardRef(function SectionBody( ); }); +export function SectionFooter(props: ComponentPropsWithoutRef<'div'>) { + return ( +
+ ); +} + +export function SectionFooterContent(props: ComponentPropsWithoutRef<'div'>) { + return ( +
+ ); +} + export function StaticSection(props: { className: string; - header: React.ReactNode; + header?: React.ReactNode; children: React.ReactNode; + footer?: React.ReactNode; }) { - const { className, header, children } = props; + const { className, header, children, footer } = props; return (
- - {header} - + {header ? ( + + {header} + + ) : null} {children} + {footer ? ( + + {footer} + + ) : null}
); } diff --git a/packages/react-openapi/src/code-samples.ts b/packages/react-openapi/src/code-samples.ts index 44d29b95b..7bd45e614 100644 --- a/packages/react-openapi/src/code-samples.ts +++ b/packages/react-openapi/src/code-samples.ts @@ -26,6 +26,52 @@ export interface CodeSampleGenerator { } export const codeSampleGenerators: CodeSampleGenerator[] = [ + { + id: 'http', + label: 'HTTP', + syntax: 'bash', + generate: ({ method, url, headers = {}, body }: CodeSampleInput) => { + const { host, path } = parseHostAndPath(url); + + if (body) { + // if we had a body add a content length header + const bodyContent = body ? stringifyOpenAPI(body) : ''; + // handle unicode chars with a text encoder + const encoder = new TextEncoder(); + + const bodyString = BodyGenerators.getHTTPBody(body, headers); + + if (bodyString) { + body = bodyString; + } + + headers = { + ...headers, + 'Content-Length': encoder.encode(bodyContent).length.toString(), + }; + } + + if (!headers.hasOwnProperty('Accept')) { + headers.Accept = '*/*'; + } + + const headerString = headers + ? `${Object.entries(headers) + .map(([key, value]) => + key.toLowerCase() !== 'host' ? `${key}: ${value}` : '' + ) + .join('\n')}\n` + : ''; + + const bodyString = body ? `\n${body}` : ''; + + const httpRequest = `${method.toUpperCase()} ${decodeURI(path)} HTTP/1.1 +Host: ${host} +${headerString}${bodyString}`; + + return httpRequest; + }, + }, { id: 'curl', label: 'cURL', @@ -140,52 +186,6 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [ return code; }, }, - { - id: 'http', - label: 'HTTP', - syntax: 'bash', - generate: ({ method, url, headers = {}, body }: CodeSampleInput) => { - const { host, path } = parseHostAndPath(url); - - if (body) { - // if we had a body add a content length header - const bodyContent = body ? stringifyOpenAPI(body) : ''; - // handle unicode chars with a text encoder - const encoder = new TextEncoder(); - - const bodyString = BodyGenerators.getHTTPBody(body, headers); - - if (bodyString) { - body = bodyString; - } - - headers = { - ...headers, - 'Content-Length': encoder.encode(bodyContent).length.toString(), - }; - } - - if (!headers.hasOwnProperty('Accept')) { - headers.Accept = '*/*'; - } - - const headerString = headers - ? `${Object.entries(headers) - .map(([key, value]) => - key.toLowerCase() !== 'host' ? `${key}: ${value}` : '' - ) - .join('\n')}\n` - : ''; - - const bodyString = body ? `\n${body}` : ''; - - const httpRequest = `${method.toUpperCase()} ${decodeURI(path)} HTTP/1.1 -Host: ${host} -${headerString}${bodyString}`; - - return httpRequest; - }, - }, ]; function indent(code: string, spaces: number) { diff --git a/packages/react-openapi/src/getOrCreateStoreByKey.ts b/packages/react-openapi/src/getOrCreateStoreByKey.ts index 06f4cc44b..c8f0f85ce 100644 --- a/packages/react-openapi/src/getOrCreateStoreByKey.ts +++ b/packages/react-openapi/src/getOrCreateStoreByKey.ts @@ -1,5 +1,3 @@ -'use client'; - import { createStore } from 'zustand'; type Key = string | number; @@ -10,7 +8,7 @@ type State = { type Actions = { setKey: (key: Key | null) => void }; -type Store = State & Actions; +export type Store = State & Actions; const createStateStore = (initial?: Key) => { return createStore()((set) => ({ diff --git a/packages/react-openapi/src/schemas/OpenAPISchemas.tsx b/packages/react-openapi/src/schemas/OpenAPISchemas.tsx index 4449d1d08..f090089c1 100644 --- a/packages/react-openapi/src/schemas/OpenAPISchemas.tsx +++ b/packages/react-openapi/src/schemas/OpenAPISchemas.tsx @@ -1,6 +1,6 @@ import type { OpenAPISchema } from '@gitbook/openapi-parser'; import clsx from 'clsx'; -import { OpenAPIDisclosureGroup } from '../OpenAPIDisclosureGroup'; +import { OpenAPIDisclosure } from '../OpenAPIDisclosure'; import { OpenAPIExample, getExampleFromSchema } from '../OpenAPIExample'; import { OpenAPIRootSchema } from '../OpenAPISchemaServer'; import { Section, SectionBody, StaticSection } from '../StaticSection'; @@ -72,33 +72,22 @@ export function OpenAPISchemas(props: { // If there are multiple schemas, we use a disclosure group to show them all. return (
- ({ - id: name, - label: ( -
- {name} -
- ), - tabs: [ - { - id: 'model', - body: ( -
- - - -
- ), - }, - ], - }))} - /> + {schemas.map(({ name, schema }) => { + return ( + +
+ + + +
+
+ ); + })}
); } diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index b882804fd..e4f5b0d7c 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -149,3 +149,65 @@ function shouldDisplayExample(schema: OpenAPIV3.SchemaObject): boolean { Object.keys(schema.example).length > 0) ); } + +/** + * Get the class name for a status code. + * 1xx: informational + * 2xx: success + * 3xx: redirect + * 4xx, 5xx: error + */ +export function getStatusCodeClassName(statusCode: number | string): string { + const category = getStatusCodeCategory(statusCode); + switch (category) { + case 1: + return 'informational'; + case 2: + return 'success'; + case 3: + return 'redirect'; + case 4: + case 5: + return 'error'; + default: + return 'unknown'; + } +} + +/** + * Get a default label for a status code. + * This is used when there is no label provided in the OpenAPI spec. + * 1xx: Information + * 2xx: Success + * 3xx: Redirect + * 4xx, 5xx: Error + */ +export function getStatusCodeDefaultLabel(statusCode: number | string): string { + const category = getStatusCodeCategory(statusCode); + switch (category) { + case 1: + return 'Information'; + case 2: + return 'Success'; + case 3: + return 'Redirect'; + case 4: + case 5: + return 'Error'; + default: + return ''; + } +} + +function getStatusCodeCategory(statusCode: number | string): number | string { + const code = typeof statusCode === 'string' ? Number.parseInt(statusCode, 10) : statusCode; + + if (Number.isNaN(code) || code < 100 || code >= 600) { + return 'unknown'; + } + + // Determine the category of the status code based on the first digit + const category = Math.floor(code / 100); + + return category; +}