* init

* OpenAPI: use stf

* OpenAPI: Use internal implementation for form in OpenAPI playground

* OpenAPI: handle edge case for text media types

* update lock file

* fix test
This commit is contained in:
Fuma Nama
2026-01-15 20:00:06 +08:00
committed by GitHub
parent 3e576a5666
commit cadff1231d
21 changed files with 941 additions and 423 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Use internal implementation for form in OpenAPI playground
+14 -1
View File
@@ -1,8 +1,9 @@
import { source } from '@/lib/source';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/notebook/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
import type { Metadata } from 'next';
import { APIPage } from '@/components/api-page';
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
@@ -12,6 +13,18 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
notFound();
}
if (page.data.type === 'openapi') {
return (
<DocsPage toc={page.data.toc} full>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<APIPage {...page.data.getAPIPageProps()} />
</DocsBody>
</DocsPage>
);
}
const MDX = page.data.body;
return (
+1 -1
View File
@@ -1,5 +1,5 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/solar.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-openapi/css/preset.css';
-35
View File
@@ -1,35 +0,0 @@
'use client';
import { useId } from 'react';
export function Logo() {
const id = useId();
return (
<svg
className="min-w-[50px] ms-2"
xmlns="http://www.w3.org/2000/svg"
width="93"
height="40"
viewBox="0 0 93 40"
id={id}
>
<path
d="M10.8 30.3C4.8 30.3 1.38 27.12 1.38 21.66V9.9H4.59V21.45C4.59 25.5 6.39 27.18 10.8 27.18C15.21 27.18 17.01 25.5 17.01 21.45V9.9H20.25V21.66C20.25 27.12 16.83 30.3 10.8 30.3ZM26.3611 30H23.1211V15.09H26.0911V19.71H26.3011C26.7511 17.19 28.7311 14.79 32.5111 14.79C36.6511 14.79 38.6911 17.58 38.6911 21.03V30H35.4511V21.9C35.4511 19.11 34.1911 17.7 31.1011 17.7C27.8311 17.7 26.3611 19.38 26.3611 22.62V30ZM44.8181 30H41.5781V9.9H44.8181V21H49.0781L53.5481 15.09H57.3281L51.7181 22.26L57.2981 30H53.4881L49.0781 23.91H44.8181V30ZM66.4219 30.3C61.5319 30.3 58.3219 27.54 58.3219 22.56C58.3219 17.91 61.5019 14.79 66.3619 14.79C70.9819 14.79 74.1319 17.34 74.1319 21.87C74.1319 22.41 74.1019 22.83 74.0119 23.28H61.3519C61.4719 26.16 62.8819 27.69 66.3319 27.69C69.4519 27.69 70.7419 26.67 70.7419 24.9V24.66H73.9819V24.93C73.9819 28.11 70.8619 30.3 66.4219 30.3ZM66.3019 17.34C63.0019 17.34 61.5619 18.81 61.3819 21.48H71.0719V21.42C71.0719 18.66 69.4819 17.34 66.3019 17.34ZM78.9586 35.1H76.8286V32.16H79.7386C81.0586 32.16 81.5986 31.8 82.0486 30.78L82.4086 30L75.0586 15.09H78.6886L82.4986 23.01L83.9686 26.58H84.2086L85.6186 22.98L89.1286 15.09H92.6986L84.9286 31.62C83.6986 34.29 82.0186 35.1 78.9586 35.1Z"
fill={`url(#${id}_radial_301_76)`}
/>
<defs>
<radialGradient
id={`${id}_radial_301_76`}
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(23.2729) scale(101.237 101.088)"
>
<stop offset="0.26875" stopColor="currentColor" />
<stop offset="0.904454" stopColor="currentColor" stopOpacity="0.5" />
</radialGradient>
</defs>
</svg>
);
}
+1 -2
View File
@@ -1,10 +1,9 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { Logo } from '@/app/logo';
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: <Logo />,
title: 'OpenAPI Playground',
},
};
}
+15 -7
View File
@@ -1,9 +1,17 @@
import { loader } from 'fumadocs-core/source';
import { openapiPlugin } from 'fumadocs-openapi/server';
import { loader, multiple } from 'fumadocs-core/source';
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
import { docs } from 'fumadocs-mdx:collections/server';
import { openapi } from './openapi';
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
plugins: [openapiPlugin()],
});
export const source = loader(
multiple({
docs: docs.toFumadocsSource(),
openapi: await openapiSource(openapi, {
groupBy: 'tag',
}),
}),
{
baseUrl: '/docs',
plugins: [openapiPlugin()],
},
);
+1
View File
@@ -60,6 +60,7 @@
},
"dependencies": {
"@fumari/json-schema-to-typescript": "^2.0.0",
"@fumari/stf": "workspace:^",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
+93 -120
View File
@@ -7,14 +7,11 @@ import {
useEffect,
useMemo,
useState,
useEffectEvent,
type ComponentProps,
useRef,
} from 'react';
import type { FieldPath, UseControllerProps, UseControllerReturn } from 'react-hook-form';
import { FormProvider, get, set, useController, useForm, useFormContext } from 'react-hook-form';
import { useApiContext } from '@/ui/contexts/api';
import type { FetchResult } from '@/playground/fetcher';
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
import type { ParameterField, SecurityEntry } from '@/playground/index';
import { getStatusInfo } from './status-info';
import { joinURL, resolveRequestData, resolveServerUrl, withBase } from '@/utils/url';
@@ -30,12 +27,7 @@ import { X, ChevronDown, LoaderCircle } from 'lucide-react';
import { encodeRequestData } from '@/requests/media/encode';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from '@/utils/cn';
import {
type FieldInfo,
SchemaProvider,
SchemaScope,
useResolvedSchema,
} from '@/playground/schema';
import { SchemaProvider, SchemaScope, useResolvedSchema } from '@/playground/schema';
import {
Select,
SelectContent,
@@ -45,22 +37,19 @@ import {
} from '@/ui/components/select';
import { labelVariants } from '@/ui/components/input';
import type { ParsedSchema } from '@/utils/schema';
import type { RequestData } from '@/requests/types';
import ServerSelect from './components/server-select';
import { useStorageKey } from '@/ui/client/storage-key';
import { useExampleRequests } from '@/ui/operation/usage-tabs/client';
import { FieldKey, Stf, StfProvider, useDataEngine, useStf } from '@fumari/stf';
import { objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
export interface FormValues {
export interface FormValues extends Record<string, unknown> {
path: Record<string, unknown>;
query: Record<string, unknown>;
header: Record<string, unknown>;
cookie: Record<string, unknown>;
body: unknown;
/**
* Store the cached encoded request data, do not modify it.
*/
_encoded?: RequestData;
}
export interface PlaygroundClientProps extends ComponentProps<'form'>, SchemaScope {
@@ -97,13 +86,12 @@ export interface PlaygroundClientOptions {
/**
* render the paremeter inputs of API endpoint.
*
* It uses `react-hook-form`, you can use either:
* - the library itself, with types from `fumadocs-openapi/playground/client`.
* for updating values, use:
* - the `Custom.useController()` from `fumadocs-openapi/playground/client`.
*
* Recommended types packages: `json-schema-typed`, `openapi-types`.
*/
renderParameterField?: (fieldName: FieldPath<FormValues>, param: ParameterField) => ReactNode;
renderParameterField?: (fieldName: FieldKey, param: ParameterField) => ReactNode;
/**
* render the input for API endpoint body.
@@ -144,7 +132,6 @@ export default function PlaygroundClient({
}: PlaygroundClientProps) {
const { example: exampleId, examples, setExampleData } = useExampleRequests();
const storageKeys = useStorageKey();
const fieldInfoMap = useMemo(() => new Map<string, FieldInfo>(), []);
const {
mediaAdapters,
serverRef,
@@ -174,7 +161,9 @@ export default function PlaygroundClient({
};
}, [examples, exampleId]);
const form = useForm<FormValues>({
const stf = useStf({
// it is fine to modify `defaultValues` in place
// because we already try to persist the form values via `setExampleData`.
defaultValues,
});
@@ -183,107 +172,78 @@ export default function PlaygroundClient({
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(mediaAdapters, requestTimeout),
);
input._encoded ??= encodeRequestData(
const encoded = encodeRequestData(
{ ...mapInputs(input), method, bodyMediaType: body?.mediaType },
mediaAdapters,
parameters,
);
return fetcher.fetch(
joinURL(
withBase(
targetServer ? resolveServerUrl(targetServer.url, targetServer.variables) : '/',
window.location.origin,
),
resolveRequestData(route, input._encoded),
resolveRequestData(route, encoded),
),
{
proxyUrl,
...input._encoded,
...encoded,
},
);
});
const onUpdateDebounced = useEffectEvent((values: FormValues) => {
for (const item of inputs) {
const value = get(values, item.fieldName);
const timerRef = useRef<number | null>(null);
stf.dataEngine.useListener({
onUpdate() {
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(
() => {
const values = stf.dataEngine.getData() as FormValues;
for (const item of inputs) {
const value = stf.dataEngine.get(item.fieldName);
if (value) {
localStorage.setItem(storageKeys.AuthField(item), JSON.stringify(value));
}
}
if (value) {
localStorage.setItem(storageKeys.AuthField(item), JSON.stringify(value));
}
}
const data = {
...mapInputs(values),
method,
bodyMediaType: body?.mediaType,
};
values._encoded ??= encodeRequestData(data, mediaAdapters, parameters);
setExampleData(data, values._encoded);
const data = {
...mapInputs(values),
method,
bodyMediaType: body?.mediaType,
};
setExampleData(data, encodeRequestData(data, mediaAdapters, parameters));
},
timerRef.current ? 400 : 0,
);
},
});
useEffect(() => {
let timer: number | null = null;
const subscription = form.subscribe({
formState: {
values: true,
},
callback({ values }) {
// remove cached encoded request data
delete values._encoded;
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => onUpdateDebounced(values), timer ? 400 : 0);
},
});
return () => subscription();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mounted once only
}, []);
useEffect(() => {
form.reset(initAuthValues(defaultValues));
return () => fieldInfoMap.clear();
return () => {
stf.dataEngine.reset(defaultValues);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore other parts
}, [defaultValues]);
useEffect(() => {
form.reset((values) => initAuthValues(values));
return () => {
form.reset((values) => {
for (const item of inputs) {
set(values, item.fieldName, undefined);
}
return values;
});
};
return initAuthValues(stf);
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore other parts
}, [inputs]);
const onSubmit = form.handleSubmit((value) => {
testQuery.start(mapInputs(value));
});
}, [defaultValues, inputs]);
return (
<FormProvider {...form}>
<SchemaProvider
fieldInfoMap={fieldInfoMap}
references={references}
writeOnly={writeOnly}
readOnly={readOnly}
>
<StfProvider value={stf}>
<SchemaProvider references={references} writeOnly={writeOnly} readOnly={readOnly}>
<form
{...rest}
className={cn(
'not-prose flex flex-col rounded-xl border shadow-md overflow-hidden bg-fd-card text-fd-card-foreground',
rest.className,
)}
onSubmit={onSubmit}
onSubmit={(e) => {
testQuery.start(mapInputs(stf.dataEngine.getData() as FormValues));
e.preventDefault();
}}
>
<ServerSelect />
<div className="flex flex-row items-center gap-2 text-sm p-3 not-last:pb-0">
@@ -305,7 +265,7 @@ export default function PlaygroundClient({
setSecurityId={setSecurityId}
>
{inputs.map((input) => (
<Fragment key={input.fieldName}>{input.children}</Fragment>
<Fragment key={stringifyFieldKey(input.fieldName)}>{input.children}</Fragment>
))}
</SecurityTabs>
)}
@@ -313,7 +273,7 @@ export default function PlaygroundClient({
{testQuery.data ? <ResultDisplay data={testQuery.data} reset={testQuery.reset} /> : null}
</form>
</SchemaProvider>
</FormProvider>
</StfProvider>
);
}
@@ -329,7 +289,7 @@ function SecurityTabs({
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const form = useFormContext();
const engine = useDataEngine();
const result = (
<CollapsiblePanel title="Authorization">
@@ -370,7 +330,7 @@ function SecurityTabs({
setSecurityId(i);
}
}}
setToken={(token) => form.setValue('header.Authorization', token)}
setToken={(token) => engine.update(['header', 'Authorization'], token)}
>
{result}
</OauthDialog>
@@ -404,7 +364,7 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
}
>
{items.map((field) => {
const fieldName = `${type}.${field.name}` as const;
const fieldName: FieldKey = [type, field.name];
if (renderParameterField) {
return renderParameterField(fieldName, field);
}
@@ -417,7 +377,12 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
) as ParsedSchema;
return (
<FieldSet key={fieldName} name={field.name} fieldName={fieldName} field={schema} />
<FieldSet
key={stringifyFieldKey(fieldName)}
name={field.name}
fieldName={fieldName}
field={schema}
/>
);
})}
</CollapsiblePanel>
@@ -441,7 +406,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
const field = useResolvedSchema(_field);
const [isJson, setIsJson] = useState(false);
if (field.format === 'binary') return <FieldSet field={field} fieldName="body" />;
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} />;
if (isJson)
return (
@@ -459,14 +424,14 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
>
Close JSON Editor
</button>
<JsonInput fieldName="body" />
<JsonInput fieldName={['body']} />
</>
);
return (
<FieldSet
field={field}
fieldName="body"
fieldName={['body']}
collapsible={false}
name={
<button
@@ -488,7 +453,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
}
export interface AuthField {
fieldName: string;
fieldName: FieldKey;
defaultValue: unknown;
original?: SecurityEntry;
@@ -508,7 +473,7 @@ function useAuthInputs(
for (const security of securities) {
if (security.type === 'http' && security.scheme === 'basic') {
const fieldName = `header.Authorization`;
const fieldName: FieldKey = ['header', 'Authorization'];
result.push({
fieldName,
@@ -543,15 +508,15 @@ function useAuthInputs(
),
});
} else if (security.type === 'oauth2') {
const fieldName = 'header.Authorization';
const fieldName: FieldKey = ['header', 'Authorization'];
result.push({
fieldName: fieldName,
fieldName,
original: security,
defaultValue: 'Bearer ',
children: (
<fieldset className="flex flex-col gap-2">
<label htmlFor={fieldName} className={cn(labelVariants())}>
<label htmlFor={stringifyFieldKey(fieldName)} className={cn(labelVariants())}>
Access Token
</label>
<div className="flex gap-2">
@@ -580,10 +545,10 @@ function useAuthInputs(
),
});
} else if (security.type === 'http') {
const fieldName = 'header.Authorization';
const fieldName: FieldKey = ['header', 'Authorization'];
result.push({
fieldName: fieldName,
fieldName,
original: security,
defaultValue: 'Bearer ',
children: (
@@ -598,7 +563,7 @@ function useAuthInputs(
),
});
} else if (security.type === 'apiKey') {
const fieldName = `${security.in}.${security.name}`;
const fieldName: FieldKey = [security.in, security.name];
result.push({
fieldName,
@@ -616,7 +581,7 @@ function useAuthInputs(
),
});
} else {
const fieldName = 'header.Authorization';
const fieldName: FieldKey = ['header', 'Authorization'];
result.push({
fieldName,
@@ -650,29 +615,34 @@ function useAuthInputs(
for (const item of inputs) {
if (!item.mapOutput) continue;
set(cloned, item.fieldName, item.mapOutput(get(cloned, item.fieldName)));
objectSet(cloned, item.fieldName, item.mapOutput(objectGet(cloned, item.fieldName)));
}
return cloned;
};
const initAuthValues = (values: FormValues) => {
const initAuthValues = (stf: Stf) => {
const { dataEngine } = stf;
for (const item of inputs) {
const stored = localStorage.getItem(storageKeys.AuthField(item));
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === typeof item.defaultValue) {
set(values, item.fieldName, parsed);
dataEngine.init(item.fieldName, parsed);
continue;
}
}
set(values, item.fieldName, item.defaultValue);
dataEngine.init(item.fieldName, item.defaultValue);
}
return values;
// reset
return () => {
for (const item of inputs) {
stf.dataEngine.delete(item.fieldName);
}
};
};
return { inputs, mapInputs, initAuthValues };
@@ -756,14 +726,17 @@ function CollapsiblePanel({
);
}
// exports for customisations
export const Custom = {
useController<
TName extends FieldPath<FormValues> = FieldPath<FormValues>,
TTransformedValues = FormValues,
>(
props: UseControllerProps<FormValues, TName, TTransformedValues>,
): UseControllerReturn<FormValues, TName> {
return useController<FormValues, TName, TTransformedValues>(props);
useController(
fieldName: FieldKey,
options?: {
defaultValue?: unknown;
},
) {
const [value, setValue] = useDataEngine().useFieldValue(fieldName, options);
return {
value,
setValue,
};
},
};
@@ -1,7 +1,7 @@
'use client';
import { type ComponentProps, type HTMLAttributes, type ReactNode, useState } from 'react';
import { ChevronDown, Plus, Trash2, X } from 'lucide-react';
import { useController, useFieldArray, useFormContext } from 'react-hook-form';
import { ChevronRight, Plus, Trash2, X } from 'lucide-react';
import { FieldKey, useArray, useDataEngine, useObject } from '@fumari/stf';
import {
Select,
SelectContent,
@@ -16,6 +16,7 @@ import { buttonVariants } from 'fumadocs-ui/components/ui/button';
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';
function FieldLabel(props: ComponentProps<'label'>) {
return (
@@ -51,59 +52,97 @@ export function ObjectInput({
...props
}: {
field: Exclude<ParsedSchema, boolean>;
fieldName: string;
fieldName: FieldKey;
} & ComponentProps<'div'>) {
const field = useResolvedSchema(_field);
const [nextName, setNextName] = useState('');
const { properties, onAppend, onDelete } = useObject(fieldName, {
defaultValue: getDefaultValue(field) as object,
properties: field.properties ?? {},
fallback: field.additionalProperties,
patternProperties: field.patternProperties,
});
const isDynamic = field.patternProperties ?? field.additionalProperties;
return (
<div {...props} className={cn('grid grid-cols-1 gap-4 @md:grid-cols-2', props.className)}>
{Object.entries(field.properties ?? {}).map(([key, child]) => (
<FieldSet
key={key}
name={key}
field={child}
fieldName={`${fieldName}.${key}`}
isRequired={field.required?.includes(key)}
/>
))}
{(field.additionalProperties || field.patternProperties) && (
<DynamicProperties
fieldName={fieldName}
filterKey={(v) => !field.properties || !Object.keys(field.properties).includes(v)}
getType={(key) => {
for (const pattern in field.patternProperties) {
if (key.match(RegExp(pattern))) {
return field.patternProperties[pattern];
{properties.map((child) => {
let toolbar: ReactNode = null;
if (child.kind === 'pattern' || child.kind === 'fallback') {
toolbar = (
<button
type="button"
aria-label="Remove Item"
className={cn(
buttonVariants({
color: 'outline',
size: 'icon-xs',
}),
)}
onClick={() => {
onDelete(child.key);
}}
>
<Trash2 />
</button>
);
}
return (
<FieldSet
key={child.key}
name={child.key}
field={child.info}
fieldName={child.field}
isRequired={field.required?.includes(child.key)}
toolbar={toolbar}
/>
);
})}
{isDynamic && (
<div className="flex gap-2 col-span-full">
<Input
value={nextName}
placeholder="Enter Property Name"
onChange={(e) => setNextName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setNextName('');
onAppend(nextName);
e.preventDefault();
}
}
if (field.additionalProperties) return field.additionalProperties;
return anyFields;
}}
/>
}}
/>
<button
type="button"
className={cn(buttonVariants({ color: 'secondary', size: 'sm' }), 'px-4')}
onClick={() => {
onAppend(nextName);
setNextName('');
}}
>
New
</button>
</div>
)}
</div>
);
}
export function JsonInput({ fieldName }: { fieldName: string }) {
const controller = useController({
name: fieldName,
});
export function JsonInput({ fieldName }: { fieldName: FieldKey }) {
const engine = useDataEngine();
const [error, setError] = useState<string | null>(null);
const [value, setValue] = useState(() => JSON.stringify(controller.field.value, null, 2));
const [value, setValue] = useState(() => JSON.stringify(engine.init(fieldName, {}), null, 2));
return (
<div className="flex flex-col bg-fd-secondary text-fd-secondary-foreground overflow-hidden border rounded-lg">
<textarea
{...controller.field}
value={value}
className="p-2 h-[240px] text-sm font-mono resize-none focus-visible:outline-none"
onChange={(v) => {
setValue(v.target.value);
try {
controller.field.onChange(JSON.parse(v.target.value));
engine.update(fieldName, JSON.parse(v.target.value));
setError(null);
} catch (e) {
if (e instanceof Error) setError(e.message);
@@ -115,94 +154,6 @@ export function JsonInput({ fieldName }: { fieldName: string }) {
);
}
function DynamicProperties({
fieldName,
filterKey = () => true,
getType = () => anyFields,
}: {
fieldName: string;
filterKey?: (key: string) => boolean;
getType: (key: string) => ParsedSchema;
}) {
const { control, setValue, getValues } = useFormContext();
const [nextName, setNextName] = useState('');
const [properties, setProperties] = useState<string[]>(() => {
const value = getValues(fieldName);
if (value) return Object.keys(value).filter(filterKey);
return [];
});
const onAppend = () => {
const name = nextName.trim();
if (name.length === 0) return;
setProperties((p) => {
if (p.includes(name) || !filterKey(name)) return p;
const type = getType(name);
setValue(`${fieldName}.${name}`, getDefaultValue(type));
setNextName('');
return [...p, name];
});
};
return (
<>
{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: 'outline',
size: 'icon-xs',
}),
)}
onClick={() => {
setProperties((p) => p.filter((prop) => prop !== item));
control.unregister(`${fieldName}.${item}`);
}}
>
<Trash2 />
</button>
}
/>
);
})}
<div className="flex gap-2 col-span-full">
<Input
value={nextName}
placeholder="Enter Property Name"
onChange={(e) => setNextName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onAppend();
e.preventDefault();
}
}}
/>
<button
type="button"
className={cn(buttonVariants({ color: 'secondary', size: 'sm' }), 'px-4')}
onClick={onAppend}
>
New
</button>
</div>
</>
);
}
export function FieldInput({
field,
fieldName,
@@ -211,24 +162,35 @@ export function FieldInput({
}: HTMLAttributes<HTMLElement> & {
field: Exclude<ParsedSchema, boolean>;
isRequired?: boolean;
fieldName: string;
fieldName: FieldKey;
}) {
const form = useFormContext();
const {
field: { value, onChange, ...restField },
fieldState,
} = useController({
control: form.control,
name: fieldName,
});
const engine = useDataEngine();
const [value, setValue] = engine.useFieldValue(fieldName);
const id = stringifyFieldKey(fieldName);
if (field.type === 'null') return;
if (field.type === 'string' && field.format === 'binary') {
function renderUnset(children: ReactNode) {
return (
<div {...props}>
<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(
<>
<label
htmlFor={fieldName}
htmlFor={id}
className={cn(
buttonVariants({
color: 'secondary',
@@ -246,17 +208,36 @@ export function FieldInput({
)}
</label>
<input
id={fieldName}
id={id}
type="file"
multiple={false}
onChange={(e) => {
if (!e.target.files) return;
onChange(e.target.files.item(0));
if (!e.target.files || e.target.files.length === 0) return;
setValue(e.target.files.item(0));
}}
hidden
{...restField}
/>
</div>
</>,
);
}
if (field.enum && field.enum.length > 0) {
const idx = field.enum.indexOf(value);
return (
<Select value={String(idx)} onValueChange={(v) => setValue(field.enum![Number(v)])}>
<SelectTrigger id={id} {...props}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.enum.map((item, i) => (
<SelectItem key={i} value={String(i)}>
{typeof item === 'string' ? item : JSON.stringify(item, null, 2)}
</SelectItem>
))}
{!isRequired && <SelectItem value="-1">Unset</SelectItem>}
</SelectContent>
</Select>
);
}
@@ -264,10 +245,9 @@ export function FieldInput({
return (
<Select
value={String(value)}
onValueChange={(value) => onChange(value === 'undefined' ? undefined : value === 'true')}
disabled={restField.disabled}
onValueChange={(value) => setValue(value === 'undefined' ? undefined : value === 'true')}
>
<SelectTrigger id={fieldName} className={props.className} {...restField}>
<SelectTrigger id={id} {...props}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -280,38 +260,21 @@ export function FieldInput({
}
const isNumber = field.type === 'integer' || field.type === 'number';
return (
<div {...props} className={cn('flex flex-row gap-2', props.className)}>
<Input
id={fieldName}
placeholder="Enter value"
type={isNumber ? 'number' : 'text'}
step={field.type === 'integer' ? 1 : undefined}
value={value ?? ''}
onChange={(e) => {
if (isNumber && !Number.isNaN(e.target.valueAsNumber)) {
onChange(e.target.valueAsNumber);
} else if (!isNumber) {
onChange(e.target.value);
}
}}
{...restField}
/>
{fieldState.isDirty && (
<button
type="button"
// TODO: `react-hook-form` doesn't support setting a value to `undefined` (aka remove the value), if there's a default value defined.
// the default value is kept by `react-hook-form` internally, we cannot manipulate it.
// hence, we can only support resetting to the default value.
// perhaps when we migrate to Tanstack Form, we can reconsider this.
onClick={() => form.resetField(fieldName)}
className="text-fd-muted-foreground"
>
<X className="size-4" />
</button>
)}
</div>
return renderUnset(
<Input
id={id}
placeholder="Enter value"
type={isNumber ? 'number' : 'text'}
step={field.type === 'integer' ? 1 : undefined}
value={String(value ?? '')}
onChange={(e) => {
if (isNumber) {
setValue(Number.isNaN(e.target.valueAsNumber) ? undefined : e.target.valueAsNumber);
} else if (!isNumber) {
setValue(e.target.value);
}
}}
/>,
);
}
@@ -329,7 +292,7 @@ export function FieldSet({
isRequired?: boolean;
name?: ReactNode;
field: ParsedSchema;
fieldName: string;
fieldName: FieldKey;
depth?: number;
slotType?: ReactNode;
@@ -339,13 +302,15 @@ export function FieldSet({
const { readOnly, writeOnly } = useSchemaScope();
const field = useResolvedSchema(_field);
const [show, setShow] = useState(!collapsible);
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
const { info, updateInfo } = useFieldInfo(fieldName, field);
const id = stringifyFieldKey(fieldName);
const dataEngine = useDataEngine();
if (_field === false) return;
if (field.readOnly && !readOnly) return;
if (field.writeOnly && !writeOnly) return;
if (info.unionField) {
if (info.unionField && field[info.unionField]) {
const union = field[info.unionField]!;
const showSelect = union.length > 1;
@@ -429,37 +394,39 @@ export function FieldSet({
);
}
const showBn = collapsible && (
<button
type="button"
onClick={() => setShow((prev) => !prev)}
className={cn(
buttonVariants({
size: 'icon-xs',
color: 'ghost',
className: 'text-fd-muted-foreground -ms-1',
}),
)}
>
<ChevronDown className={cn(show && 'rotate-180')} />
</button>
);
if (field.type === 'object' || info.intersection) {
const schema = info.intersection?.merged ?? field;
return (
<fieldset
{...props}
className={cn('flex flex-col gap-1.5 col-span-full @container', props.className)}
>
<FieldLabel htmlFor={fieldName}>
{showBn}
<FieldLabel htmlFor={id}>
{collapsible && (
<button
type="button"
onClick={() => {
dataEngine.init(fieldName, getDefaultValue(schema));
setShow((prev) => !prev);
}}
className={cn(
buttonVariants({
size: 'icon-xs',
color: 'ghost',
className: 'text-fd-muted-foreground -ms-1',
}),
)}
>
<ChevronRight className={cn(show && 'rotate-90')} />
</button>
)}
<FieldLabelName required={isRequired}>{name}</FieldLabelName>
{slotType ?? <FieldLabelType>{schemaToString(field)}</FieldLabelType>}
{toolbar}
</FieldLabel>
{show && (
<ObjectInput
field={info.intersection?.merged ?? field}
field={schema}
fieldName={fieldName}
{...props}
className={cn(
@@ -475,8 +442,25 @@ 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={fieldName}>
{showBn}
<FieldLabel htmlFor={id}>
{collapsible && (
<button
type="button"
onClick={() => {
dataEngine.init(fieldName, getDefaultValue(field));
setShow((prev) => !prev);
}}
className={cn(
buttonVariants({
size: 'icon-xs',
color: 'ghost',
className: 'text-fd-muted-foreground -ms-1',
}),
)}
>
<ChevronRight className={cn(show && 'rotate-90')} />
</button>
)}
<FieldLabelName required={isRequired}>{name}</FieldLabelName>
{slotType ?? <FieldLabelType>{schemaToString(field)}</FieldLabelType>}
{toolbar}
@@ -497,7 +481,7 @@ export function FieldSet({
}
return (
<fieldset {...props} className={cn('flex flex-col gap-1.5', props.className)}>
<FieldLabel htmlFor={fieldName}>
<FieldLabel htmlFor={id}>
<FieldLabelName required={isRequired}>{name}</FieldLabelName>
{slotType ?? <FieldLabelType>{schemaToString(field)}</FieldLabelType>}
{toolbar}
@@ -509,30 +493,28 @@ export function FieldSet({
function ArrayInput({
fieldName,
items,
items: itemSchema,
...props
}: {
fieldName: string;
fieldName: FieldKey;
items: ParsedSchema;
} & ComponentProps<'div'>) {
const name = fieldName.split('.').at(-1) ?? '';
const { fields, append, remove } = useFieldArray({
name: fieldName,
});
const name = fieldName.at(-1) ?? '';
const { items, insertItem, removeItem } = useArray(fieldName, []);
return (
<div {...props} className={cn('flex flex-col gap-2', props.className)}>
{fields.map((item, index) => (
{items.map((item) => (
<FieldSet
key={item.id}
key={item.index}
name={
<span className="text-fd-muted-foreground">
{name}[{index}]
{name}[{item.index}]
</span>
}
field={items}
field={itemSchema}
isRequired
fieldName={`${fieldName}.${index}`}
fieldName={item.field}
toolbar={
<button
type="button"
@@ -543,7 +525,7 @@ function ArrayInput({
size: 'icon-xs',
}),
)}
onClick={() => remove(index)}
onClick={() => removeItem(item.index)}
>
<Trash2 />
</button>
@@ -560,7 +542,7 @@ function ArrayInput({
}),
)}
onClick={() => {
append(getDefaultValue(items));
insertItem(getDefaultValue(itemSchema));
}}
>
<Plus className="size-4" />
@@ -8,7 +8,7 @@ import {
SelectValue,
} from '@/ui/components/select';
import { Input, labelVariants } from '@/ui/components/input';
import { type HTMLAttributes, useEffect, useState, useEffectEvent } from 'react';
import { type HTMLAttributes, useEffect, useState, useRef } from 'react';
import { cn } from '@/utils/cn';
import {
Dialog,
@@ -19,9 +19,9 @@ import {
DialogTrigger,
} from '@/ui/components/dialog';
import { resolveServerUrl, withBase } from '@/utils/url';
import { FormProvider, useController, useForm } from 'react-hook-form';
import type { ServerVariableObject } from '@/types';
import type { NoReference } from '@/utils/schema';
import { StfProvider, useDataEngine, useStf } from '@fumari/stf';
export default function ServerSelect(props: HTMLAttributes<HTMLDivElement>) {
const { servers } = useApiContext();
@@ -86,29 +86,23 @@ function ServerSelectContent({
onChange: (values: Record<string, string>) => void;
schema: Record<string, NoReference<ServerVariableObject>>;
}) {
const form = useForm({
defaultValues,
const stf = useStf({
defaultValues: () => structuredClone(defaultValues),
});
const timerRef = useRef<number | null>(null);
stf.dataEngine.useListener({
onUpdate() {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(
() => onChange(stf.dataEngine.getData() as Record<string, string>),
500,
);
},
});
const onChangeDebounced = useEffectEvent(onChange);
useEffect(() => {
let timer: number | null = null;
return form.subscribe({
formState: {
values: true,
},
callback({ values }) {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => onChangeDebounced(values), 500);
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- `form` shouldn't be included
}, []);
return (
<FormProvider {...form}>
<StfProvider value={stf}>
<div className="flex flex-col gap-4">
{Object.entries(schema).map(([key, variable]) => {
return (
@@ -124,7 +118,7 @@ function ServerSelectContent({
);
})}
</div>
</FormProvider>
</StfProvider>
);
}
@@ -135,14 +129,16 @@ function Field({
variable: NoReference<ServerVariableObject>;
fieldName: string;
}) {
const { field } = useController({
name: fieldName,
const [value, setValue] = useDataEngine().useFieldValue([fieldName], {
compute(currentValue) {
return typeof currentValue === 'string' ? currentValue : undefined;
},
});
if (variable.enum) {
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id={fieldName} ref={field.ref}>
<Select value={value} onValueChange={setValue}>
<SelectTrigger id={fieldName}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -156,5 +152,5 @@ function Field({
);
}
return <Input id={fieldName} {...field} />;
return <Input id={fieldName} value={value} onChange={(e) => setValue(e.target.value)} />;
}
+11 -15
View File
@@ -1,13 +1,12 @@
import { Ajv2020 } from 'ajv/dist/2020';
import { createContext, ReactNode, use, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { getDefaultValue } from '@/playground/get-default-values';
import type { ParsedSchema } from '@/utils/schema';
import { mergeAllOf } from '@/utils/merge-schema';
import { FieldKey, useDataEngine } from '@fumari/stf';
interface SchemaContextType extends SchemaScope {
references: Record<string, ParsedSchema>;
fieldInfoMap: Map<string, FieldInfo>;
ajv: Ajv2020;
}
@@ -48,7 +47,6 @@ export const anyFields = {
export function SchemaProvider({
references,
fieldInfoMap,
readOnly,
writeOnly,
children,
@@ -67,8 +65,8 @@ export function SchemaProvider({
return (
<SchemaContext.Provider
value={useMemo(
() => ({ references, fieldInfoMap, ajv, readOnly, writeOnly }),
[references, fieldInfoMap, ajv, readOnly, writeOnly],
() => ({ references, ajv, readOnly, writeOnly }),
[references, ajv, readOnly, writeOnly],
)}
>
{children}
@@ -88,19 +86,18 @@ export function useSchemaScope(): SchemaScope {
* @param depth - The depth to avoid duplicated field name with same schema (e.g. nested `oneOf`).
*/
export function useFieldInfo(
fieldName: string,
fieldName: FieldKey,
schema: Exclude<ParsedSchema, boolean>,
depth: number,
): {
info: FieldInfo;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const { fieldInfoMap, ajv } = use(SchemaContext)!;
const form = useFormContext();
const keyName = `${fieldName}:${depth}`;
const { ajv } = use(SchemaContext)!;
const engine = useDataEngine();
const attachedData = engine.attachedData<FieldInfo>('field-info');
const [info, setInfo] = useState<FieldInfo>(() => {
const value = form.getValues(fieldName as 'body');
const initialInfo = fieldInfoMap.get(keyName);
const value = engine.get(fieldName);
const initialInfo = attachedData.get(fieldName);
if (initialInfo) return initialInfo;
const out: FieldInfo = {
@@ -140,8 +137,7 @@ export function useFieldInfo(
return out;
});
fieldInfoMap.set(keyName, info);
attachedData.set(fieldName, info);
return {
info,
updateInfo: (value) => {
@@ -161,7 +157,7 @@ export function useFieldInfo(
valueSchema = { ...schema, type: updated.selectedType };
}
form.setValue(fieldName, getDefaultValue(valueSchema));
engine.update(fieldName, getDefaultValue(valueSchema));
},
};
}
@@ -30,6 +30,13 @@ export function resolveMediaAdapter(
return adapters[baseType];
}
}
if (mediaType === 'text/plain') {
console.warn(
'there is no defined behaviour for encoding form values into "text/plain", using JSON encoder for now.',
);
return adapters['application/json'];
}
}
/**
@@ -66,6 +66,6 @@ describe('Media Adapter Resolution', () => {
test('isMediaTypeSupported returns false for unsupported types', () => {
expect(isMediaTypeSupported('application/yaml', defaultAdapters)).toBe(false);
expect(isMediaTypeSupported('text/plain', defaultAdapters)).toBe(false);
expect(isMediaTypeSupported('application/unknown', defaultAdapters)).toBe(false);
});
});
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@fumari/stf",
"version": "0.0.0",
"description": "Schema to Form.",
"keywords": [],
"license": "MIT",
"author": "Fuma Nama",
"repository": "github:fuma-nama/fumadocs",
"files": [
"css",
"dist"
],
"type": "module",
"exports": {
".": "./dist/index.js",
"./lib/utils": "./dist/lib/utils.js",
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"clean": "rimraf dist",
"dev": "tsdown --watch",
"lint": "eslint .",
"types:check": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "25.0.5",
"@types/react": "^19.2.8",
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*",
"tsdown": "^0.19.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
}
+4
View File
@@ -0,0 +1,4 @@
'use client';
export * from './lib/data-engine';
export * from './lib/stf';
export * from './lib/types';
+228
View File
@@ -0,0 +1,228 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useEffect, useRef, useState } from 'react';
import { objectGet, arrayStartsWith, objectSet, deepEqual, stringifyFieldKey } from './utils';
import type { FieldKey } from './types';
export interface DataEngineListener {
/**
* when field value is changed
*/
onUpdate?: (
key: FieldKey,
ctx: {
/**
* An update is swallow if the change doesn't affect the values of children fields.
*/
swallow: boolean;
},
) => void;
/**
* when `init(field)` is called
*/
onInit?: (key: FieldKey) => void;
/**
* when `delete(field)` is called
*/
onDelete?: (key: FieldKey) => void;
}
export class DataEngine {
private data: NonNullable<object>;
private attachedDataMap = new Map<string, unknown>();
private readonly listeners: DataEngineListener[] = [];
constructor(defaultValues: NonNullable<object> = {}) {
this.data = defaultValues;
}
listen(listener: DataEngineListener) {
this.listeners.push(listener);
}
unlisten(listener: DataEngineListener) {
const idx = this.listeners.indexOf(listener);
if (idx !== -1) this.listeners.splice(idx, 1);
}
getData() {
return this.data;
}
/**
* init a field
* @param key the key of field
* @param value the initial value, the field is also created for `undefined`
* @returns the value of initialized field, or the current value of field if already initialized
*/
init(key: FieldKey, value?: unknown): unknown {
if (key.length === 0) throw new Error('cannot init for empty key.');
let cur = this.data as Record<string, unknown>;
const currentKey: FieldKey = [];
for (let i = 0; i < key.length; i++) {
const propKey = key[i];
const propValue = cur[propKey];
if (i === key.length - 1) {
if (propValue !== undefined) return propValue;
cur[propKey] = value;
for (const listener of this.listeners) {
listener.onUpdate?.(currentKey, {
swallow: true,
});
listener.onInit?.(key);
}
return value;
} else if (typeof propValue === 'object' && propValue !== null) {
cur = propValue as Record<string, unknown>;
} else {
if (propValue !== undefined)
console.warn(
`the original value of field ${currentKey.join('.')} is overidden, this might be unexpected.`,
);
cur = cur[propKey] = {};
for (const listener of this.listeners)
listener.onUpdate?.(currentKey, {
swallow: true,
});
}
currentKey.push(propKey);
}
}
delete(key: FieldKey): unknown | undefined {
if (key.length === 0) return;
const parentKey = key.slice(0, -1);
const prop = key[key.length - 1];
const parent = this.get(parentKey);
if (Array.isArray(parent) && typeof prop === 'number') {
const [deleted] = parent.splice(prop, 1);
for (const listener of this.listeners) {
listener.onUpdate?.(parentKey, { swallow: false });
listener.onDelete?.(key);
}
return deleted;
} else if (typeof parent === 'object' && parent !== null) {
const temp = (parent as Record<string, unknown>)[prop];
delete parent[prop as never];
for (const listener of this.listeners) {
listener.onUpdate?.(parentKey, { swallow: true });
listener.onDelete?.(key);
}
return temp;
}
}
get(key: FieldKey) {
return objectGet(this.data, key);
}
/**
* update the value of field if it exists
* @returns if the field is updated
*/
update(key: FieldKey, value: unknown): boolean {
try {
this.data = objectSet(this.data, key, value) as NonNullable<object>;
for (const listener of this.listeners)
listener.onUpdate?.(key, {
swallow: false,
});
return true;
} catch {
return false;
}
}
useListener(listener: DataEngineListener) {
const listenerRef = useRef(listener);
listenerRef.current = listener;
useEffect(() => {
const internal: DataEngineListener = {
onDelete(...args) {
return listenerRef.current.onDelete?.(...args);
},
onInit(...args) {
return listenerRef.current.onInit?.(...args);
},
onUpdate(...args) {
return listenerRef.current.onUpdate?.(...args);
},
};
this.listen(internal);
return () => {
this.unlisten(internal);
};
}, []);
}
useFieldValue<V = unknown>(
key: FieldKey,
options: {
defaultValue?: unknown;
/**
* compute value from the actual field value.
*
* to re-compute on in-place updates (may happen on objects, arrays), you should clone the object here.
*/
compute?: (currentValue: unknown) => V;
/** determine whether the value/computed value is changed */
isChanged?: (prev: V, next: V) => boolean;
} = {},
) {
const { compute = (v) => v as V, defaultValue, isChanged = (a, b) => a !== b } = options;
const [value, setValue] = useState<V>(() => compute(this.init(key, defaultValue)));
this.useListener({
onUpdate: (updatedKey, ctx) => {
if (
(ctx.swallow && deepEqual(updatedKey, key)) ||
(!ctx.swallow && arrayStartsWith(key, updatedKey))
) {
const computed = compute(this.get(key));
if (isChanged(value, computed)) setValue(computed);
}
},
onDelete: (updatedKey) => {
if (deepEqual(updatedKey, key)) {
const computed = compute(this.get(key));
if (isChanged(value, computed)) setValue(computed);
}
},
});
return [value, (newValue: unknown) => this.update(key, newValue)] as const;
}
attachedData<T>(namespace: string) {
return {
get: (field: FieldKey): T | undefined => {
return this.attachedDataMap.get(`${namespace}:${stringifyFieldKey(field)}`) as
| T
| undefined;
},
set: (field: FieldKey, value: T) => {
this.attachedDataMap.set(`${namespace}:${stringifyFieldKey(field)}`, value);
},
delete: (field?: FieldKey) => {
if (field) {
this.attachedDataMap.delete(`${namespace}:${stringifyFieldKey(field)}`);
} else {
for (const key of this.attachedDataMap.keys()) {
if (key.startsWith(`${namespace}:`)) this.attachedDataMap.delete(key);
}
}
},
};
}
reset(data: Record<string, unknown>) {
this.update([], data);
this.attachedDataMap.clear();
}
}
+169
View File
@@ -0,0 +1,169 @@
import { createContext, ReactNode, use, useMemo } from 'react';
import { DataEngine } from './data-engine';
import { FieldKey } from './types';
import { deepEqual } from './utils';
const Context = createContext<Stf | null>(null);
export interface Stf {
dataEngine: DataEngine;
}
export function StfProvider({ value, children }: { value: Stf; children: ReactNode }) {
return <Context value={value}>{children}</Context>;
}
export function useStf(options: {
/**
* Note: the passed object will be modified in place, use `structuredClone()` to keep the original object unchanged.
*/
defaultValues?: Record<string, unknown> | (() => Record<string, unknown>);
}): Stf {
const { defaultValues } = options;
const dataEngine = useMemo(
() => new DataEngine(typeof defaultValues === 'function' ? defaultValues() : defaultValues),
// eslint-disable-next-line react-hooks/exhaustive-deps -- assume unchanged
[],
);
return useMemo(
() => ({
dataEngine,
}),
[dataEngine],
);
}
export function useDataEngine() {
return use(Context)!.dataEngine;
}
export interface ArrayItemInfo {
field: FieldKey;
index: number;
}
export function useArray(field: FieldKey, defaultValue?: unknown[]) {
const engine = useDataEngine();
const [items] = engine.useFieldValue(field, {
defaultValue,
compute(value) {
const items: ArrayItemInfo[] = [];
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
items.push({
field: [...field, i],
index: i,
});
}
}
return items;
},
isChanged(prev, next) {
return prev.length !== next.length;
},
});
return {
items,
insertItem(itemValue?: unknown) {
const value = engine.get(field);
engine.update(field, Array.isArray(value) ? [...value, itemValue] : [itemValue]);
},
removeItem(index: number) {
engine.delete([...field, index]);
},
};
}
export type PropertyItemInfo<T> =
| {
kind: 'fixed' | 'fallback';
field: FieldKey;
key: string;
info: T;
}
| {
kind: 'pattern';
field: FieldKey;
key: string;
pattern: string;
info: T;
};
export function useObject<T>(
field: FieldKey,
options: {
defaultValue?: object;
properties: Record<string, T>;
patternProperties?: Record<string, T>;
fallback?: T;
},
) {
const engine = useDataEngine();
const [objectKeys] = engine.useFieldValue(field, {
defaultValue: options.defaultValue,
compute(currentValue) {
return currentValue ? Object.keys(currentValue) : [];
},
isChanged(prev, next) {
return !deepEqual(prev, next);
},
});
const properties = useMemo(() => {
const properties: PropertyItemInfo<T>[] = [];
const unknownKeys = new Set(objectKeys);
for (const [key, prop] of Object.entries(options.properties)) {
unknownKeys.delete(key);
properties.push({
kind: 'fixed',
field: [...field, key],
key,
info: prop,
});
}
for (const [pattern, prop] of Object.entries(options.patternProperties ?? {})) {
const regex = RegExp(pattern);
for (const key of unknownKeys) {
if (!key.match(regex)) continue;
unknownKeys.delete(key);
properties.push({
kind: 'pattern',
info: prop,
key,
pattern,
field: [...field, key],
});
}
}
if (options.fallback) {
for (const key of unknownKeys) {
properties.push({
kind: 'fallback',
field: [...field, key],
key,
info: options.fallback,
});
}
}
return properties;
}, [field, objectKeys, options.fallback, options.patternProperties, options.properties]);
return {
properties,
onAppend(name: string, value?: unknown) {
name = name.trim();
if (name.length === 0) return;
engine.init([...field, name], value);
},
onDelete(name: string) {
return engine.delete([...field, name]);
},
};
}
+1
View File
@@ -0,0 +1 @@
export type FieldKey = (string | number)[];
+87
View File
@@ -0,0 +1,87 @@
import { FieldKey } from './types';
/**
* test if array a starts with array b, only compare values via `===`.
*/
export function arrayStartsWith(a: unknown[], b: unknown[]): boolean {
if (b.length > a.length) return false;
for (let i = 0; i < b.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function objectGet(obj: unknown, key: (string | number)[]): unknown | undefined {
let cur = obj;
for (const prop of key) {
if (typeof cur !== 'object' || cur === null || !(prop in cur)) return;
cur = cur[prop as keyof typeof cur];
}
return cur;
}
/**
* set the value of field if it exists (in place)
*
* @returns updated value, throw error if parent object doesn't exist
*/
export function objectSet(obj: unknown, key: FieldKey, value: unknown): unknown {
if (key.length === 0) {
return value;
}
const parent = objectGet(obj, key.slice(0, -1));
if (typeof parent !== 'object' || parent === null) throw new Error('missing parent object');
(parent as Record<string, unknown>)[key[key.length - 1]] = value;
return obj;
}
/**
* doesn't handle recursive objects
*/
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) {
return true;
}
if (a == null || b == null) {
return false;
}
if (typeof a !== 'object' || typeof b !== 'object') {
return false;
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) {
return false;
}
return a.every((item, index) => deepEqual(item, b[index]));
}
if (Array.isArray(a) || Array.isArray(b)) {
return false;
}
const keysA = Object.keys(a as object);
const keysB = Object.keys(b as object);
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) &&
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
);
}
export function stringifyFieldKey(fieldKey: FieldKey) {
return fieldKey.map((v) => `${typeof v}:${v}`).join('.');
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
format: 'esm',
target: 'es2023',
entry: ['./src/index.{ts,tsx}', './src/lib/utils.ts'],
fixedExtension: false,
dts: true,
exports: true,
});
+28
View File
@@ -2120,6 +2120,9 @@ importers:
'@fumari/json-schema-to-typescript':
specifier: ^2.0.0
version: 2.0.0([email protected])
'@fumari/stf':
specifier: workspace:^
version: link:../stf
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -2428,6 +2431,31 @@ importers:
specifier: ^0.19.0
version: 0.19.0([email protected])([email protected])
packages/stf:
dependencies:
react:
specifier: ^19.2.0
version: 19.2.3
react-dom:
specifier: ^19.2.0
version: 19.2.3([email protected])
devDependencies:
'@types/node':
specifier: 25.0.5
version: 25.0.5
'@types/react':
specifier: ^19.2.8
version: 19.2.8
eslint-config-custom:
specifier: workspace:*
version: link:../eslint-config-custom
tsconfig:
specifier: workspace:*
version: link:../tsconfig
tsdown:
specifier: ^0.19.0
version: 0.19.0([email protected])([email protected])
packages/tsconfig: {}
packages/twoslash: