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';
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}
+10 -4
View File
@@ -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>
);
+4 -4
View File
@@ -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}
+5 -5
View File
@@ -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>