OpenAPI: add unset button to non-primitive fields

This commit is contained in:
Fuma Nama
2026-02-24 15:46:32 +08:00
parent fe37b9f5ea
commit ff9e91998c
4 changed files with 70 additions and 46 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
add unset button to non-primitive fields
@@ -17,14 +17,9 @@ import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
import { anyFields, useFieldInfo, useResolvedSchema, useSchemaScope } from '@/playground/schema';
import type { ParsedSchema } from '@/utils/schema';
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
import { cva } from 'class-variance-authority';
function FieldLabel(props: ComponentProps<'label'>) {
return (
<label {...props} className={cn('w-full inline-flex items-center gap-0.5', props.className)}>
{props.children}
</label>
);
}
const fieldLabelVariants = cva('w-full inline-flex items-center gap-0.5');
function FieldLabelType(props: ComponentProps<'code'>) {
return (
@@ -152,30 +147,12 @@ export function FieldInput({
isRequired?: boolean;
fieldName: FieldKey;
}) {
const engine = useDataEngine();
const [value, setValue] = useFieldValue(fieldName);
const id = stringifyFieldKey(fieldName);
if (field.type === 'null') return;
function renderUnset(children: ReactNode) {
return (
<div {...props} className={cn('flex flex-row gap-2', props.className)}>
{children}
{value !== undefined && !isRequired && (
<button
type="button"
onClick={() => engine.delete(fieldName)}
className="text-fd-muted-foreground"
>
<X className="size-4" />
</button>
)}
</div>
);
}
if (field.type === 'string' && field.format === 'binary') {
return renderUnset(
return (
<>
<label
htmlFor={id}
@@ -205,7 +182,7 @@ export function FieldInput({
}}
hidden
/>
</>,
</>
);
}
@@ -248,7 +225,7 @@ export function FieldInput({
}
const isNumber = field.type === 'integer' || field.type === 'number';
return renderUnset(
return (
<Input
id={id}
placeholder="Enter value"
@@ -262,7 +239,7 @@ export function FieldInput({
setValue(e.target.value);
}
}}
/>,
/>
);
}
@@ -293,11 +270,18 @@ export function FieldSet({
const { info, updateInfo } = useFieldInfo(fieldName, field);
const id = stringifyFieldKey(fieldName);
const dataEngine = useDataEngine();
const [isDefined] = useFieldValue(fieldName, {
compute(currentValue) {
return currentValue !== undefined;
},
});
if (_field === false) return;
if (field.readOnly && !readOnly) return;
if (field.writeOnly && !writeOnly) return;
if (collapsible && !isDefined && show) setShow(false);
function renderLabelTrigger(schema = field) {
if (!collapsible) return renderLabelName();
@@ -326,6 +310,18 @@ export function FieldSet({
);
}
function renderUnsetButton() {
return (
<button
type="button"
onClick={() => dataEngine.delete(fieldName)}
className="text-fd-muted-foreground hover:text-fd-accent-foreground"
>
<X className="size-3.5" />
</button>
);
}
if (info.unionField && field[info.unionField]) {
const union = field[info.unionField]!;
const showSelect = union.length > 1;
@@ -417,11 +413,12 @@ export function FieldSet({
{...props}
className={cn('flex flex-col gap-1.5 col-span-full @container', props.className)}
>
<FieldLabel htmlFor={id}>
<div className={fieldLabelVariants()}>
{renderLabelTrigger(schema)}
{slotType ?? <FieldLabelType>{schemaToString(field)}</FieldLabelType>}
{toolbar}
</FieldLabel>
{!isRequired && isDefined && renderUnsetButton()}
</div>
{show && (
<ObjectInput
field={schema}
@@ -436,11 +433,12 @@ export function FieldSet({
if (field.type === 'array') {
return (
<fieldset {...props} className={cn('flex flex-col gap-1.5 col-span-full', props.className)}>
<FieldLabel htmlFor={id}>
<div className={fieldLabelVariants()}>
{renderLabelTrigger()}
{slotType ?? <FieldLabelType>{schemaToString(field)}</FieldLabelType>}
{toolbar}
</FieldLabel>
{!isRequired && isDefined && renderUnsetButton()}
</div>
{show && (
<ArrayInput
fieldName={fieldName}
@@ -451,13 +449,15 @@ export function FieldSet({
</fieldset>
);
}
return (
<fieldset {...props} className={cn('flex flex-col gap-1.5', props.className)}>
<FieldLabel htmlFor={id}>
<label className={fieldLabelVariants()} htmlFor={id}>
{renderLabelName()}
{slotType ?? <FieldLabelType>{schemaToString(field)}</FieldLabelType>}
{toolbar}
</FieldLabel>
{!isRequired && isDefined && renderUnsetButton()}
</label>
<FieldInput field={field} fieldName={fieldName} isRequired={isRequired} />
</fieldset>
);
@@ -166,5 +166,12 @@ function Field({
);
}
return <Input id={fieldName} value={value} onChange={(e) => setValue(e.target.value)} />;
return (
<Input
id={fieldName}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter Value"
/>
);
}
+22 -10
View File
@@ -15,15 +15,17 @@ export interface DataEngineListener {
*/
field?: FieldKey;
/**
* when field value is changed
* when field value is changed.
*/
onUpdate?: (key: FieldKey, ctx: OnUpdateContext) => void;
/**
* when `init(field)` is called
* fired on `init(field)`.
*/
onInit?: (key: FieldKey, ctx: OnInitContext) => void;
/**
* when `delete(field)` is called
* fired on `delete(field)`.
*
* when `field` is specified, this is also fired when parent is deleted.
*/
onDelete?: (key: FieldKey, ctx: OnDeleteContext) => void;
}
@@ -94,9 +96,14 @@ class ListenerManager {
}
onDelete(field: FieldKey, ctx: OnDeleteContext) {
const fieldKey = stringifyFieldKey(field);
for (const v of this.unindexed) v.onDelete?.(field, ctx);
const set = this.indexed.get(stringifyFieldKey(field));
if (set) for (const v of set) v.onDelete?.(field, ctx);
for (const [k, listeners] of this.indexed.entries()) {
if (!fieldKeyStartsWith(k, fieldKey)) continue;
for (const v of listeners) v.onDelete?.(field, ctx);
}
}
}
@@ -169,15 +176,16 @@ export class DataEngine {
if (Array.isArray(parent) && typeof prop === 'number') {
const deleted: unknown[] = parent.splice(prop, 1);
if (deleted.length === 0) return;
this.listeners.onUpdate(parentKey, { swallow: false, ...ctx });
this.listeners.onDelete(key, ctx);
this.listeners.onDelete(key, ctx);
// it will change children's field value when removed at middle
this.listeners.onUpdate(parentKey, { swallow: false, ...ctx });
return deleted[0];
} else if (typeof parent === 'object' && parent !== null) {
const temp = (parent as Record<string, unknown>)[prop];
delete parent[prop as never];
this.listeners.onUpdate(parentKey, { swallow: true, ...ctx });
this.listeners.onDelete(key, ctx);
this.listeners.onUpdate(parentKey, { swallow: true, ...ctx });
return temp;
}
}
@@ -242,13 +250,17 @@ export function useFieldValue<V = unknown>(
useListener({
field: key,
stf: options.stf,
onInit() {
const computed = compute(engine.get(key));
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
onUpdate() {
const computed = compute(engine.get(key));
if (isChanged(value, computed)) setValue(computed);
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
onDelete() {
const computed = compute(undefined);
if (isChanged(value, computed)) setValue(computed);
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
});