diff --git a/.changeset/fancy-sides-watch.md b/.changeset/fancy-sides-watch.md new file mode 100644 index 000000000..f09c3887b --- /dev/null +++ b/.changeset/fancy-sides-watch.md @@ -0,0 +1,5 @@ +--- +'fumadocs-openapi': patch +--- + +Use internal implementation for form in OpenAPI playground diff --git a/examples/openapi/app/docs/[[...slug]]/page.tsx b/examples/openapi/app/docs/[[...slug]]/page.tsx index b0a171a31..2f539668e 100644 --- a/examples/openapi/app/docs/[[...slug]]/page.tsx +++ b/examples/openapi/app/docs/[[...slug]]/page.tsx @@ -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 ( + + {page.data.title} + {page.data.description} + + + + + ); + } + const MDX = page.data.body; return ( diff --git a/examples/openapi/app/global.css b/examples/openapi/app/global.css index 160113cd9..3c0dab937 100644 --- a/examples/openapi/app/global.css +++ b/examples/openapi/app/global.css @@ -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'; diff --git a/examples/openapi/app/logo.tsx b/examples/openapi/app/logo.tsx deleted file mode 100644 index d782ed64b..000000000 --- a/examples/openapi/app/logo.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; -import { useId } from 'react'; - -export function Logo() { - const id = useId(); - - return ( - - - - - - - - - - ); -} diff --git a/examples/openapi/lib/layout.shared.tsx b/examples/openapi/lib/layout.shared.tsx index 545aabf35..1d4a92d8b 100644 --- a/examples/openapi/lib/layout.shared.tsx +++ b/examples/openapi/lib/layout.shared.tsx @@ -1,10 +1,9 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; -import { Logo } from '@/app/logo'; export function baseOptions(): BaseLayoutProps { return { nav: { - title: , + title: 'OpenAPI Playground', }, }; } diff --git a/examples/openapi/lib/source.ts b/examples/openapi/lib/source.ts index beb7136b2..4fa677636 100644 --- a/examples/openapi/lib/source.ts +++ b/examples/openapi/lib/source.ts @@ -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()], + }, +); diff --git a/packages/openapi/package.json b/packages/openapi/package.json index a705ae382..5c7bdd2c4 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -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", diff --git a/packages/openapi/src/playground/client.tsx b/packages/openapi/src/playground/client.tsx index 7e4958c4a..f5c3cc82d 100644 --- a/packages/openapi/src/playground/client.tsx +++ b/packages/openapi/src/playground/client.tsx @@ -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 { path: Record; query: Record; header: Record; cookie: Record; 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, 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(), []); const { mediaAdapters, serverRef, @@ -174,7 +161,9 @@ export default function PlaygroundClient({ }; }, [examples, exampleId]); - const form = useForm({ + 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); - - if (value) { - localStorage.setItem(storageKeys.AuthField(item), JSON.stringify(value)); - } - } + const timerRef = useRef(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)); + } + } - 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 ( - - + +
{ + testQuery.start(mapInputs(stf.dataEngine.getData() as FormValues)); + e.preventDefault(); + }} >
@@ -305,7 +265,7 @@ export default function PlaygroundClient({ setSecurityId={setSecurityId} > {inputs.map((input) => ( - {input.children} + {input.children} ))} )} @@ -313,7 +273,7 @@ export default function PlaygroundClient({ {testQuery.data ? : null} - + ); } @@ -329,7 +289,7 @@ function SecurityTabs({ children: ReactNode; }) { const [open, setOpen] = useState(false); - const form = useFormContext(); + const engine = useDataEngine(); const result = ( @@ -370,7 +330,7 @@ function SecurityTabs({ setSecurityId(i); } }} - setToken={(token) => form.setValue('header.Authorization', token)} + setToken={(token) => engine.update(['header', 'Authorization'], token)} > {result} @@ -404,7 +364,7 @@ function FormBody({ parameters = [], body }: Pick {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 +
); })} @@ -441,7 +406,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) { const field = useResolvedSchema(_field); const [isJson, setIsJson] = useState(false); - if (field.format === 'binary') return
; + if (field.format === 'binary') return
; if (isJson) return ( @@ -459,14 +424,14 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) { > Close JSON Editor - + ); return (
-