diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index 90b3be5e29..e1b6b9a63f 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -41,6 +41,7 @@ export function getOpenAPIContext(args: { plus: , copy: , check: , + lock: , }, renderCodeBlock: (codeProps) => , renderDocument: (documentProps) => ( diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index 521a1a6c43..53602c41df 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -334,7 +334,7 @@ } .openapi-securities-scopes ul { - @apply !my-0; + @apply !my-0 ml-4 pl-0; } .openapi-securities-url { @@ -398,15 +398,13 @@ @apply text-left prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit; } -.openapi-disclosure-group-trigger[aria-expanded="false"] { - .openapi-response-description.openapi-markdown { - @apply truncate; - @apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0; - } +.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-description.openapi-markdown { + @apply truncate; + @apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0; +} - .openapi-response-tab-content { - @apply basis-[60%] - } +.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-tab-content { + @apply basis-[60%]; } .openapi-response-body { @@ -528,7 +526,7 @@ .openapi-panel, .openapi-codesample, .openapi-response-examples { - @apply border shrink min-h-40 overflow-hidden rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm; + @apply border shrink min-h-40 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm; } .openapi-response-examples-panel { @@ -851,7 +849,7 @@ body:has(.openapi-select-popover) { .openapi-schema-alternatives .openapi-disclosure, .openapi-schemas-disclosure .openapi-schema.openapi-disclosure ) { - @apply rounded-xl straight-corners:rounded-none; + @apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none; } .openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure { @@ -866,10 +864,10 @@ body:has(.openapi-select-popover) { @apply ring-1 shadow-sm; } -.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:first-child) { +.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:first-child) { @apply mt-2; } -.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:last-child) { +.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:last-child) { @apply mb-2; } @@ -1015,4 +1013,27 @@ body:has(.openapi-select-popover) { .openapi-path-copy-button-icon svg { @apply text-tint size-4; +} + +.openapi-required-scopes { + @apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0; +} + +.openapi-required-scopes .openapi-required-scopes-header { + @apply flex items-center gap-3; +} + +.openapi-required-scopes .openapi-required-scopes-header svg { + @apply size-3.5 text-tint-subtle rotate-none; +} + +.openapi-required-scopes .openapi-disclosure-group-panel { + @apply px-3 pb-3; +} +.openapi-required-scopes .openapi-securities-scopes { + @apply ml-6 font-normal *:!text-[0.8125rem]; +} + +.openapi-required-scopes .openapi-required-scopes-description { + @apply text-xs !text-tint font-normal mb-2; } \ No newline at end of file diff --git a/packages/react-openapi/src/OpenAPICopyButton.tsx b/packages/react-openapi/src/OpenAPICopyButton.tsx index 6cd1c1da1f..baed2e937a 100644 --- a/packages/react-openapi/src/OpenAPICopyButton.tsx +++ b/packages/react-openapi/src/OpenAPICopyButton.tsx @@ -1,5 +1,6 @@ 'use client'; +import clsx from 'classnames'; import { useState } from 'react'; import { Button, type ButtonProps } from 'react-aria-components'; import { OpenAPITooltip } from './OpenAPITooltip'; @@ -45,7 +46,7 @@ export function OpenAPICopyButton( handleCopy(); onPress?.(e); }} - className={`openapi-copy-button ${className}`} + className={clsx('openapi-copy-button', className)} {...props} > {children} diff --git a/packages/react-openapi/src/OpenAPIDisclosure.tsx b/packages/react-openapi/src/OpenAPIDisclosure.tsx index 93d14c2491..41b640ac10 100644 --- a/packages/react-openapi/src/OpenAPIDisclosure.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosure.tsx @@ -13,9 +13,10 @@ export function OpenAPIDisclosure(props: { children: React.ReactNode; label: string | ((isExpanded: boolean) => string); className?: string; + defaultExpanded?: boolean; }): React.JSX.Element { - const { icon, header, label, children, className } = props; - const [isExpanded, setIsExpanded] = useState(false); + const { icon, header, label, children, className, defaultExpanded = false } = props; + const [isExpanded, setIsExpanded] = useState(defaultExpanded); return ( {header}
- {typeof label === 'function' ? label(isExpanded) : label} + {label ? ( + {typeof label === 'function' ? label(isExpanded) : label} + ) : null} {icon}
- - {isExpanded ? children : null} - + {isExpanded ? ( + {children} + ) : null}
); } diff --git a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx index 41cbbfb77a..71413da3e1 100644 --- a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx @@ -1,22 +1,29 @@ 'use client'; +import clsx from 'classnames'; import { createContext, useContext, useRef } from 'react'; import { mergeProps, useButton, useDisclosure, useFocusRing, useId } from 'react-aria'; +import type { Key } from 'react-aria'; import { type DisclosureGroupProps, type DisclosureGroupState, useDisclosureGroupState, useDisclosureState, } from 'react-stately'; +import { useStore } from 'zustand'; import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect'; +import { getOrCreateDisclosureStoreByKey } from './getOrCreateDisclosureStoreByKey'; interface Props { groups: TDisclosureGroup[]; icon?: React.ReactNode; /** State key to use with a store */ selectStateKey?: string; + /** State key to synchronize disclosure groups across the page */ + stateKey?: string; /** Icon to display for the select */ selectIcon?: React.ReactNode; + className?: string; } type TDisclosureGroup = { @@ -31,18 +38,50 @@ type TDisclosureGroup = { const DisclosureGroupStateContext = createContext(null); +function useDisclosureGroupStore(stateKey = 'disclosure-group', initialKeys?: Iterable) { + const store = useStore(getOrCreateDisclosureStoreByKey(stateKey, initialKeys)); + return store; +} + /** * Display an interactive OpenAPI disclosure group. */ export function OpenAPIDisclosureGroup(props: DisclosureGroupProps & Props) { - const { icon, groups, selectStateKey, selectIcon } = props; + const { + icon, + groups, + selectStateKey, + stateKey, + selectIcon, + className, + expandedKeys, + defaultExpandedKeys, + onExpandedChange, + } = props; + + const initialKeys = + expandedKeys || defaultExpandedKeys + ? new Set(expandedKeys || defaultExpandedKeys) + : undefined; + const { expandedKeys: storeExpandedKeys, setExpandedKeys } = useDisclosureGroupStore( + stateKey, + initialKeys + ); - const state = useDisclosureGroupState(props); + const state = useDisclosureGroupState({ + ...props, + expandedKeys: storeExpandedKeys, + onExpandedChange: (keys) => { + setExpandedKeys(keys); + onExpandedChange?.(keys); + }, + }); return ( {groups.map((group) => ( tab.key === store.key) || group.tabs?.[0]; return ( -
+
security.key === selectedKey); + + if (!selectedSecurity) { + return null; + } + + const scopes = selectedSecurity.schemes.flatMap((scheme) => { + if (scheme.type === 'oauth2') { + return Object.entries(scheme.flows ?? {}).flatMap(([_, flow]) => + Object.entries(flow.scopes ?? {}) + ); + } + + return scheme.scopes ?? []; + }); + + if (!scopes.length) { + return null; + } + + return ( + + {context.icons.lock} + {t(context.translation, 'required_scopes')} +
+ ), + tabs: [ + { + key: 'scopes', + label: '', + body: , + }, + ], + }, + ]} + /> + ); +} + +function OpenAPISchemaScopes(props: { + scopes: OpenAPISecurityScope[]; + context: OpenAPIClientContext; +}) { + const { scopes, context } = props; + + return ( +
+
+ {t(context.translation, 'required_scopes_description')} +
+
    + {scopes.map((scope) => ( + + ))} +
+
+ ); +} + +/** + * Display a scope item. Either a key-value pair or a single string. + */ +function OpenAPIScopeItem(props: { + scope: OpenAPISecurityScope; + context: OpenAPIClientContext; +}) { + const { scope, context } = props; + + return ( +
  • + + {scope[1] ? : {scope[1]} : null} +
  • + ); +} + +/** + * Displays the scope name within a copyable button. + */ +function OpenAPIScopeItemKey(props: { + name: string; + context: OpenAPIClientContext; +}) { + const { name, context } = props; + + return ( + + {name} + + ); +} diff --git a/packages/react-openapi/src/OpenAPISecurities.tsx b/packages/react-openapi/src/OpenAPISecurities.tsx index f4808e69c6..d1d00a71e0 100644 --- a/packages/react-openapi/src/OpenAPISecurities.tsx +++ b/packages/react-openapi/src/OpenAPISecurities.tsx @@ -3,10 +3,11 @@ import { Fragment } from 'react'; import { InteractiveSection } from './InteractiveSection'; import { Markdown } from './Markdown'; import { OpenAPICopyButton } from './OpenAPICopyButton'; +import { OpenAPIRequiredScopes } from './OpenAPIRequiredScopes'; import { OpenAPISchemaName } from './OpenAPISchemaName'; import type { OpenAPIClientContext } from './context'; import { t } from './translate'; -import type { OpenAPICustomSecurityScheme, OpenAPISecurityScope } from './types'; +import type { OpenAPICustomSecurityScheme } from './types'; import type { OpenAPIOperationData } from './types'; import { createStateKey, extractOperationSecurityInfo, resolveDescription } from './utils'; @@ -25,48 +26,44 @@ export function OpenAPISecurities(props: { } const tabsData = extractOperationSecurityInfo({ securityRequirement, securities }); + const stateKey = createStateKey('securities', context.blockKey); return ( - ({ - key, - label, - body: ( -
    - {schemes.map((security, index) => { - const description = resolveDescription(security); - return ( -
    - {getLabelForType(security, context)} - {description ? ( - - ) : null} - {security.scopes?.length ? ( - - ) : null} -
    - ); - })} -
    - ), - }))} - /> + <> + + ({ + key, + label, + body: ( +
    + {schemes.map((security, index) => { + const description = resolveDescription(security); + return ( +
    + {getLabelForType(security, context)} + {description ? ( + + ) : null} +
    + ); + })} +
    + ), + }))} + /> + ); } @@ -175,9 +172,6 @@ function OpenAPISchemaOAuth2Item(props: { return null; } - // If the security scheme has scopes, we don't need to display the scopes from the flow - const scopes = !security.scopes?.length && flow.scopes ? Object.entries(flow.scopes) : []; - return (
    ) : null} - {scopes.length ? : null}
    ); } - -/** - * Render a list of available scopes. - */ -function OpenAPISchemaScopes(props: { - scopes: OpenAPISecurityScope[]; - context: OpenAPIClientContext; -}) { - const { scopes, context } = props; - - return ( -
    - {t(context.translation, 'required_scopes')}: -
      - {scopes.map((scope) => ( - - ))} -
    -
    - ); -} - -/** - * Display a scope item. Either a key-value pair or a single string. - */ -function OpenAPIScopeItem(props: { - scope: OpenAPISecurityScope; - context: OpenAPIClientContext; -}) { - const { scope, context } = props; - - return ( -
  • - - {scope[1] ? `: ${scope[1]}` : null} -
  • - ); -} - -/** - * Displays the scope name within a copyable button. - */ -function OpenAPIScopeItemKey(props: { - name: string; - context: OpenAPIClientContext; -}) { - const { name, context } = props; - - return ( - - {name} - - ); -} diff --git a/packages/react-openapi/src/context.ts b/packages/react-openapi/src/context.ts index 76a6293ab6..9283489fc4 100644 --- a/packages/react-openapi/src/context.ts +++ b/packages/react-openapi/src/context.ts @@ -15,6 +15,7 @@ export interface OpenAPIClientContext { plus: React.ReactNode; copy: React.ReactNode; check: React.ReactNode; + lock: React.ReactNode; }; /** diff --git a/packages/react-openapi/src/getOrCreateDisclosureStoreByKey.ts b/packages/react-openapi/src/getOrCreateDisclosureStoreByKey.ts new file mode 100644 index 0000000000..f2c6603a6e --- /dev/null +++ b/packages/react-openapi/src/getOrCreateDisclosureStoreByKey.ts @@ -0,0 +1,51 @@ +import type { Key } from 'react-stately'; +import { createStore } from 'zustand'; + +type DisclosureState = { + expandedKeys: Set; +}; + +type DisclosureActions = { + setExpandedKeys: (keys: Set) => void; + toggleKey: (key: Key) => void; +}; + +export type DisclosureStore = DisclosureState & DisclosureActions; + +const createDisclosureStore = (initialKeys?: Iterable) => { + return createStore()((set) => ({ + expandedKeys: initialKeys ? new Set(initialKeys) : new Set(), + setExpandedKeys: (keys) => { + set(() => ({ expandedKeys: keys })); + }, + toggleKey: (key) => { + set((state) => { + const newKeys = new Set(state.expandedKeys); + if (newKeys.has(key)) { + newKeys.delete(key); + } else { + newKeys.add(key); + } + return { expandedKeys: newKeys }; + }); + }, + })); +}; + +const defaultDisclosureStores = new Map>(); + +const createDisclosureStoreFactory = (stores: typeof defaultDisclosureStores) => { + return (storeKey: string, initialKeys?: Iterable) => { + if (!stores.has(storeKey)) { + stores.set(storeKey, createDisclosureStore(initialKeys)); + } + const store = stores.get(storeKey); + if (!store) { + throw new Error(`Failed to get or create store for key: ${storeKey}`); + } + return stores.get(storeKey)!; + }; +}; + +export const getOrCreateDisclosureStoreByKey = + createDisclosureStoreFactory(defaultDisclosureStores); diff --git a/packages/react-openapi/src/translations/de.ts b/packages/react-openapi/src/translations/de.ts index 3ebbf72e13..3e6c559ee6 100644 --- a/packages/react-openapi/src/translations/de.ts +++ b/packages/react-openapi/src/translations/de.ts @@ -37,6 +37,7 @@ export const de = { hide: 'Verstecke ${1}', available_items: 'Verfügbare Elemente', required_scopes: 'Erforderliche Scopes', + required_scopes_description: 'Dieser Endpunkt erfordert die folgenden Scopes:', properties: 'Eigenschaften', or: 'oder', and: 'und', diff --git a/packages/react-openapi/src/translations/en.ts b/packages/react-openapi/src/translations/en.ts index 648ea6591a..f00ae6df1e 100644 --- a/packages/react-openapi/src/translations/en.ts +++ b/packages/react-openapi/src/translations/en.ts @@ -37,6 +37,7 @@ export const en = { hide: 'Hide ${1}', available_items: 'Available items', required_scopes: 'Required scopes', + required_scopes_description: 'This endpoint requires the following scopes:', possible_values: 'Possible values', properties: 'Properties', or: 'or', diff --git a/packages/react-openapi/src/translations/es.ts b/packages/react-openapi/src/translations/es.ts index b9a1c653ad..d1560dca2c 100644 --- a/packages/react-openapi/src/translations/es.ts +++ b/packages/react-openapi/src/translations/es.ts @@ -37,6 +37,7 @@ export const es = { hide: 'Ocultar ${1}', available_items: 'Elementos disponibles', required_scopes: 'Scopes requeridos', + required_scopes_description: 'Este endpoint requiere los siguientes scopes:', properties: 'Propiedades', or: 'o', and: 'y', diff --git a/packages/react-openapi/src/translations/fr.ts b/packages/react-openapi/src/translations/fr.ts index 194726eb87..cbcb682063 100644 --- a/packages/react-openapi/src/translations/fr.ts +++ b/packages/react-openapi/src/translations/fr.ts @@ -37,6 +37,7 @@ export const fr = { hide: 'Masquer ${1}', available_items: 'Éléments disponibles', required_scopes: 'Scopes requis', + required_scopes_description: 'Cet endpoint nécessite les scopes suivants:', properties: 'Propriétés', or: 'ou', and: 'et', diff --git a/packages/react-openapi/src/translations/ja.ts b/packages/react-openapi/src/translations/ja.ts index 7519517835..70e883c115 100644 --- a/packages/react-openapi/src/translations/ja.ts +++ b/packages/react-openapi/src/translations/ja.ts @@ -37,6 +37,7 @@ export const ja = { hide: '${1}を非表示', available_items: '利用可能なアイテム', required_scopes: '必須スコープ', + required_scopes_description: 'このエンドポイントには次のスコープが必要です:', properties: 'プロパティ', or: 'または', and: 'かつ', diff --git a/packages/react-openapi/src/translations/nl.ts b/packages/react-openapi/src/translations/nl.ts index 4b66ea147e..836eb00d5e 100644 --- a/packages/react-openapi/src/translations/nl.ts +++ b/packages/react-openapi/src/translations/nl.ts @@ -37,6 +37,7 @@ export const nl = { hide: 'Verberg ${1}', available_items: 'Beschikbare items', required_scopes: 'Vereiste scopes', + required_scopes_description: 'Dit endpoint vereist de volgende scopes:', properties: 'Eigenschappen', or: 'of', and: 'en', diff --git a/packages/react-openapi/src/translations/no.ts b/packages/react-openapi/src/translations/no.ts index 3804034211..ef61960553 100644 --- a/packages/react-openapi/src/translations/no.ts +++ b/packages/react-openapi/src/translations/no.ts @@ -37,6 +37,7 @@ export const no = { hide: 'Skjul ${1}', available_items: 'Tilgjengelige elementer', required_scopes: 'Påkrevde scopes', + required_scopes_description: 'Dette endepunktet krever følgende scopes:', properties: 'Egenskaper', or: 'eller', and: 'og', diff --git a/packages/react-openapi/src/translations/pt-br.ts b/packages/react-openapi/src/translations/pt-br.ts index 37ac92cd86..9b434ace85 100644 --- a/packages/react-openapi/src/translations/pt-br.ts +++ b/packages/react-openapi/src/translations/pt-br.ts @@ -37,6 +37,7 @@ export const pt_br = { hide: 'Ocultar ${1}', available_items: 'Itens disponíveis', required_scopes: 'Scopes obrigatórios', + required_scopes_description: 'Este endpoint requer os seguintes scopes:', properties: 'Propriedades', or: 'ou', and: 'e', diff --git a/packages/react-openapi/src/translations/zh.ts b/packages/react-openapi/src/translations/zh.ts index 3d2e20d2d5..0861095c88 100644 --- a/packages/react-openapi/src/translations/zh.ts +++ b/packages/react-openapi/src/translations/zh.ts @@ -37,6 +37,7 @@ export const zh = { hide: '隐藏${1}', available_items: '可用项', required_scopes: '必需范围', + required_scopes_description: '此端点需要以下范围:', properties: '属性', or: '或', and: '和',