OpenAPI: Improve playground body input

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