mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Improve playground body input
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Improve playground body input
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import {
|
||||
ComponentProps,
|
||||
type HTMLAttributes,
|
||||
type LabelHTMLAttributes,
|
||||
type ReactNode,
|
||||
@@ -36,13 +37,11 @@ import {
|
||||
interface InputHeaderProps {
|
||||
name?: ReactNode;
|
||||
required?: boolean;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function FieldHeader({
|
||||
name,
|
||||
required = false,
|
||||
type,
|
||||
...props
|
||||
}: InputHeaderProps & LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return (
|
||||
@@ -50,22 +49,26 @@ function FieldHeader({
|
||||
{...props}
|
||||
className={cn('w-full inline-flex items-center gap-1', props.className)}
|
||||
>
|
||||
<span className={cn(labelVariants())}>
|
||||
<span className={cn(labelVariants(), 'me-auto')}>
|
||||
{name}
|
||||
{required ? <span className="text-red-400/80 mx-1">*</span> : null}
|
||||
</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
{type ? (
|
||||
<code data-type={true} className="text-xs text-fd-muted-foreground">
|
||||
{type}
|
||||
</code>
|
||||
) : null}
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldHeaderCode(props: ComponentProps<'code'>) {
|
||||
return (
|
||||
<code
|
||||
{...props}
|
||||
className={cn('text-xs text-fd-muted-foreground', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObjectInput({
|
||||
field: _field,
|
||||
fieldName,
|
||||
@@ -73,7 +76,7 @@ export function ObjectInput({
|
||||
}: {
|
||||
field: Exclude<RequestSchema, boolean>;
|
||||
fieldName: string;
|
||||
} & HTMLAttributes<HTMLDivElement>) {
|
||||
} & ComponentProps<'div'>) {
|
||||
const resolved = useResolvedSchema(_field);
|
||||
const field = useMemo(() => combineSchema([resolved]), [resolved]);
|
||||
if (typeof field === 'boolean') return;
|
||||
@@ -180,32 +183,37 @@ function DynamicProperties({
|
||||
|
||||
return (
|
||||
<>
|
||||
{properties.map((item) => (
|
||||
<FieldSet
|
||||
key={item}
|
||||
name={item}
|
||||
field={getType(item)}
|
||||
fieldName={`${fieldName}.${item}`}
|
||||
toolbar={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove Item"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
className: 'p-1',
|
||||
}),
|
||||
)}
|
||||
onClick={() => {
|
||||
setProperties((p) => p.filter((prop) => prop !== item));
|
||||
control.unregister(`${fieldName}.${item}`);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{properties.map((item) => {
|
||||
const type = getType(item);
|
||||
|
||||
return (
|
||||
<FieldSet
|
||||
key={item}
|
||||
name={item}
|
||||
field={type}
|
||||
fieldName={`${fieldName}.${item}`}
|
||||
toolbar={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove Item"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'p-1',
|
||||
}),
|
||||
)}
|
||||
onClick={() => {
|
||||
setProperties((p) => p.filter((prop) => prop !== item));
|
||||
control.unregister(`${fieldName}.${item}`);
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={nextName}
|
||||
@@ -233,7 +241,7 @@ function DynamicProperties({
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldInput({
|
||||
function FieldInput({
|
||||
field,
|
||||
fieldName,
|
||||
isRequired,
|
||||
@@ -245,34 +253,6 @@ export function FieldInput({
|
||||
}) {
|
||||
const { control, register } = useFormContext();
|
||||
|
||||
if (field.type === 'object' || field.anyOf || field.allOf) {
|
||||
return (
|
||||
<ObjectInput
|
||||
field={field}
|
||||
fieldName={fieldName}
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-lg border border-fd-primary/20 bg-fd-background/50 p-3 shadow-sm',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'array') {
|
||||
return (
|
||||
<ArrayInput
|
||||
fieldName={fieldName}
|
||||
items={field.items ?? anyFields}
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-lg border border-fd-primary/20 bg-fd-background/50 p-3 shadow-sm',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'string' && field.format === 'binary') {
|
||||
return (
|
||||
<Controller
|
||||
@@ -352,6 +332,7 @@ export function FieldSet({
|
||||
name,
|
||||
isRequired,
|
||||
depth = 0,
|
||||
slotType,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLElement> & {
|
||||
isRequired?: boolean;
|
||||
@@ -359,29 +340,26 @@ export function FieldSet({
|
||||
field: RequestSchema;
|
||||
fieldName: string;
|
||||
depth?: number;
|
||||
|
||||
slotType?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
}) {
|
||||
const field = useResolvedSchema(_field);
|
||||
const [show, setShow] = useState(
|
||||
() =>
|
||||
typeof _field !== 'object' ||
|
||||
!_field.$ref ||
|
||||
(field.type !== 'object' && !field.allOf && !field.oneOf && !field.anyOf),
|
||||
);
|
||||
const [show, setShow] = useState(false);
|
||||
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
|
||||
|
||||
if (_field === false) return null;
|
||||
if (!show) {
|
||||
|
||||
const recursive =
|
||||
typeof _field !== 'boolean' &&
|
||||
_field.$ref &&
|
||||
(field.oneOf || field.type === 'object' || field.allOf || field.anyOf);
|
||||
|
||||
if (recursive && !show) {
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<FieldHeader
|
||||
{...props}
|
||||
name={name}
|
||||
type={schemaToString(field)}
|
||||
required={isRequired}
|
||||
>
|
||||
{toolbar}
|
||||
</FieldHeader>
|
||||
<FieldHeader {...props} name={name} required={isRequired}>
|
||||
{slotType ?? <FieldHeaderCode>{schemaToString(field)}</FieldHeaderCode>}
|
||||
{toolbar}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -392,39 +370,43 @@ export function FieldSet({
|
||||
)}
|
||||
onClick={() => setShow(true)}
|
||||
>
|
||||
Show
|
||||
Open
|
||||
</button>
|
||||
</fieldset>
|
||||
</FieldHeader>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.oneOf) {
|
||||
const showSelect = field.oneOf.length > 1;
|
||||
|
||||
return (
|
||||
<FieldSet
|
||||
{...props}
|
||||
className={cn(props.className, '[&>label>[data-type]]:hidden')}
|
||||
name={name}
|
||||
fieldName={fieldName}
|
||||
isRequired={isRequired}
|
||||
field={field.oneOf[info.oneOf]}
|
||||
depth={depth + 1}
|
||||
slotType={showSelect ? false : slotType}
|
||||
toolbar={
|
||||
<>
|
||||
<select
|
||||
className="text-xs font-mono"
|
||||
value={info.oneOf}
|
||||
onChange={(e) => {
|
||||
updateInfo({
|
||||
oneOf: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{field.oneOf.map((item, i) => (
|
||||
<option key={i} value={i}>
|
||||
{schemaToString(item)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showSelect && (
|
||||
<select
|
||||
className="text-xs font-mono"
|
||||
value={info.oneOf}
|
||||
onChange={(e) => {
|
||||
updateInfo({
|
||||
oneOf: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{field.oneOf.map((item, i) => (
|
||||
<option key={i} value={i}>
|
||||
{schemaToString(item)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{toolbar}
|
||||
</>
|
||||
}
|
||||
@@ -433,53 +415,102 @@ export function FieldSet({
|
||||
}
|
||||
|
||||
if (Array.isArray(field.type)) {
|
||||
const showSelect = field.type.length > 1;
|
||||
|
||||
return (
|
||||
<FieldSet
|
||||
{...props}
|
||||
name={name}
|
||||
fieldName={fieldName}
|
||||
isRequired={isRequired}
|
||||
field={{
|
||||
...field,
|
||||
type: info.selectedType,
|
||||
}}
|
||||
depth={depth + 1}
|
||||
slotType={showSelect ? false : slotType}
|
||||
toolbar={
|
||||
<>
|
||||
{showSelect && (
|
||||
<select
|
||||
className="text-xs font-mono"
|
||||
value={info.selectedType}
|
||||
onChange={(e) => {
|
||||
updateInfo({
|
||||
selectedType: e.target.value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{field.type.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{toolbar}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'object' || field.anyOf || field.allOf) {
|
||||
return (
|
||||
<fieldset
|
||||
{...props}
|
||||
className={cn('flex flex-col gap-1.5', props.className)}
|
||||
>
|
||||
<FieldHeader name={name} htmlFor={fieldName} required={isRequired}>
|
||||
<select
|
||||
className="text-xs font-mono"
|
||||
value={info.selectedType}
|
||||
onChange={(e) => {
|
||||
updateInfo({
|
||||
selectedType: e.target.value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{field.type.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldHeader htmlFor={fieldName} name={name} required={isRequired}>
|
||||
{slotType ?? (
|
||||
<FieldHeaderCode>{schemaToString(field)}</FieldHeaderCode>
|
||||
)}
|
||||
{toolbar}
|
||||
</FieldHeader>
|
||||
<p className="text-xs text-fd-muted-foreground">{field.description}</p>
|
||||
<FieldInput
|
||||
isRequired={isRequired}
|
||||
field={{
|
||||
...field,
|
||||
type: info.selectedType,
|
||||
}}
|
||||
<ObjectInput
|
||||
field={field}
|
||||
fieldName={fieldName}
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-lg border border-fd-primary/20 bg-fd-background/50 p-3 shadow-sm',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'array') {
|
||||
return (
|
||||
<fieldset
|
||||
{...props}
|
||||
className={cn('flex flex-col gap-1.5', props.className)}
|
||||
>
|
||||
<FieldHeader htmlFor={fieldName} name={name} required={isRequired}>
|
||||
{slotType ?? (
|
||||
<FieldHeaderCode>{schemaToString(field)}</FieldHeaderCode>
|
||||
)}
|
||||
{toolbar}
|
||||
</FieldHeader>
|
||||
<ArrayInput
|
||||
fieldName={fieldName}
|
||||
items={field.items ?? anyFields}
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-lg border border-fd-primary/20 bg-fd-background/50 p-3 shadow-sm',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<fieldset
|
||||
{...props}
|
||||
className={cn('flex flex-col gap-1.5', props.className)}
|
||||
>
|
||||
<FieldHeader
|
||||
htmlFor={fieldName}
|
||||
name={name}
|
||||
required={isRequired}
|
||||
type={field.oneOf ? undefined : schemaToString(field)}
|
||||
>
|
||||
<FieldHeader htmlFor={fieldName} name={name} required={isRequired}>
|
||||
{slotType ?? <FieldHeaderCode>{schemaToString(field)}</FieldHeaderCode>}
|
||||
{toolbar}
|
||||
</FieldHeader>
|
||||
<FieldInput field={field} fieldName={fieldName} isRequired={isRequired} />
|
||||
@@ -519,12 +550,13 @@ function ArrayInput({
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'p-1',
|
||||
}),
|
||||
)}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<Trash2 />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
@@ -533,7 +565,7 @@ function ArrayInput({
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'outline',
|
||||
color: 'secondary',
|
||||
className: 'gap-1.5 py-2',
|
||||
size: 'sm',
|
||||
}),
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
APIExampleProvider,
|
||||
getAPIExamples,
|
||||
} from '@/render/operation/api-example';
|
||||
import { MethodLabel } from '@/ui/components/method-label';
|
||||
import { Badge, MethodLabel } from '@/ui/components/method-label';
|
||||
import { type SampleGenerator } from '@/requests/_shared';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
|
||||
@@ -88,10 +88,16 @@ export function Operation({
|
||||
bodyNode = (
|
||||
<>
|
||||
{heading(headingLevel, 'Request Body', ctx)}
|
||||
<div className="mb-4 p-3 bg-fd-card rounded-xl border flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<code>{type}</code>
|
||||
{!body.required && (
|
||||
<span className="text-xs text-fd-muted-foreground">Optional</span>
|
||||
{body.required ? (
|
||||
<Badge color="red" className="text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="yellow" className="text-xs">
|
||||
Optional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{body.description ? <Markdown text={body.description} /> : null}
|
||||
|
||||
@@ -192,11 +192,17 @@ export function Schema({
|
||||
|
||||
if (fields.length === 0) return;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col border divide-y divide-fd-border bg-fd-muted rounded-lg not-prose">
|
||||
{fields.map((field) => (
|
||||
<span key={field.key}>
|
||||
{field.key}: <code>{field.value}</code>
|
||||
</span>
|
||||
<div
|
||||
key={field.key}
|
||||
className="flex items-center text-[13px] px-3 py-2.5 justify-between gap-2"
|
||||
>
|
||||
<span className="font-medium">{field.key}</span>
|
||||
<code className="texxt-xs text-fd-muted-foreground">
|
||||
{field.value}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,12 +11,12 @@ export function CopyResponseTypeScript({ code }: { code: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-2 bg-fd-card border rounded-xl p-3 not-prose mb-4 last:mb-0">
|
||||
<div className="flex items-start justify-between gap-2 bg-fd-card text-fd-card-foreground border rounded-xl p-3 not-prose mb-4 last:mb-0">
|
||||
<div>
|
||||
<p className="font-medium text-fd-foreground mb-2">
|
||||
TypeScript Definitions
|
||||
<p className="font-medium text-sm mb-2">TypeScript Definitions</p>
|
||||
<p className="text-xs text-fd-muted-foreground">
|
||||
Use the response body type in TypeScript.
|
||||
</p>
|
||||
<p className="text-xs">Use the response body type in TypeScript.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
|
||||
@@ -81,11 +81,11 @@ export function Property({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm prose-no-margin',
|
||||
nested ? 'py-3' : 'p-3 border rounded-xl bg-fd-card',
|
||||
'flex flex-col gap-3 text-sm',
|
||||
!nested && 'p-3 border rounded-xl bg-fd-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||
<div className="flex flex-wrap items-center gap-3 not-prose">
|
||||
<span className="px-1 py-0.5 border rounded-md border-fd-primary/10 bg-fd-primary/10 font-mono text-xs text-fd-primary sm:text-[13px]">
|
||||
{name}
|
||||
{required === false && '?'}
|
||||
@@ -99,7 +99,7 @@ export function Property({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{props.children}
|
||||
<div className="prose-no-margin empty:hidden">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function ObjectCollapsible(props: {
|
||||
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="-me-3">
|
||||
<div className="flex flex-col gap-8 border-s border-y rounded-b-lg px-3">
|
||||
<div className="border-s border-y rounded-b-lg p-3">
|
||||
{props.children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
Reference in New Issue
Block a user