mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
add stf (#2906)
* 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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Use internal implementation for form in OpenAPI playground
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
'use client';
|
||||
export * from './lib/data-engine';
|
||||
export * from './lib/stf';
|
||||
export * from './lib/types';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type FieldKey = (string | number)[];
|
||||
@@ -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('.');
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Generated
+28
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user