mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Support OAuth 2.0 in-browser authorize dialog
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Support OAuth 2.0 in-browser authorize dialog
|
||||
@@ -5,7 +5,14 @@ import { baseOptions } from '@/app/layout.config';
|
||||
|
||||
export default function RootDocsLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
<DocsLayout
|
||||
tree={source.pageTree}
|
||||
{...baseOptions}
|
||||
nav={{
|
||||
...baseOptions.nav,
|
||||
mode: 'top',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
|
||||
@@ -62,9 +62,16 @@
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"bearerFormat": "root key",
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"authorizationCode": {
|
||||
"authorizationUrl": "https://discord.com/oauth2/authorize",
|
||||
"tokenUrl": "https://discord.com/api/oauth2/token",
|
||||
"scopes": {
|
||||
"identify": "Get profile information"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fumari/json-schema-to-typescript": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@scalar/openapi-parser": "0.10.8",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/ui/components/dialog';
|
||||
import type { OpenAPIV3_1 } from 'openapi-types';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Input, labelVariants } from '@/ui/components/input';
|
||||
import { useQuery } from '@/utils/use-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from 'fumadocs-ui/components/api';
|
||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||
|
||||
export interface AuthDialogProps {
|
||||
flow: keyof OpenAPIV3_1.OAuth2SecurityScheme['flows'];
|
||||
scheme: OpenAPIV3_1.OAuth2SecurityScheme;
|
||||
|
||||
setToken: (token: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
interface OAuthState {
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export function OauthDialog({
|
||||
flow,
|
||||
scheme,
|
||||
setToken,
|
||||
children,
|
||||
}: AuthDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
},
|
||||
});
|
||||
|
||||
const tokenQuery = useQuery(async (code: string, state: OAuthState) => {
|
||||
const value = scheme.flows.authorizationCode!;
|
||||
|
||||
const res = await fetch(value.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
// note: `state` could be invalid, but server will check it
|
||||
redirect_uri: state.redirect_uri,
|
||||
client_id: state.client_id,
|
||||
client_secret: state.client_secret,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { access_token } = (await res.json()) as {
|
||||
access_token: string;
|
||||
};
|
||||
|
||||
setToken(access_token);
|
||||
setOpen(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
|
||||
if (!code || !state || flow !== 'authorizationCode') return;
|
||||
|
||||
const parsedState = JSON.parse(state) as OAuthState;
|
||||
|
||||
setOpen(true);
|
||||
form.setValue('clientId', parsedState.client_id);
|
||||
form.setValue('clientSecret', parsedState.client_secret);
|
||||
tokenQuery.start(code, parsedState);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- first page load only
|
||||
}, []);
|
||||
|
||||
const authorize = useQuery(async (values: FormValues) => {
|
||||
if (flow === 'authorizationCode') {
|
||||
const value = scheme.flows[flow]!;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('response_type', 'code');
|
||||
params.set('client_id', values.clientId);
|
||||
params.set('redirect_uri', window.location.href);
|
||||
params.set('scope', Object.keys(value.scopes).join('+'));
|
||||
params.set(
|
||||
'state',
|
||||
JSON.stringify({
|
||||
client_id: values.clientId,
|
||||
client_secret: values.clientSecret,
|
||||
redirect_uri: window.location.href,
|
||||
} satisfies OAuthState),
|
||||
);
|
||||
|
||||
window.location.replace(`${value.authorizationUrl}?${params.toString()}`);
|
||||
}
|
||||
|
||||
if (flow === 'clientCredentials') {
|
||||
const value = scheme.flows[flow]!;
|
||||
|
||||
await fetch(value.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
scope: Object.keys(value.scopes).join('+'),
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
return authorize.start(values);
|
||||
});
|
||||
|
||||
const isLoading = authorize.isLoading || tokenQuery.isLoading;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Authorization</DialogTitle>
|
||||
<DialogDescription>
|
||||
Obtain the access token for API.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-6" onSubmit={onSubmit}>
|
||||
{(flow === 'authorizationCode' || flow === 'clientCredentials') && (
|
||||
<>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_id" className={cn(labelVariants())}>
|
||||
Client ID
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
The client ID of your OAuth application.
|
||||
</p>
|
||||
<Input
|
||||
id="client_id"
|
||||
placeholder="Enter value"
|
||||
type="text"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientId')}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_secret" className={cn(labelVariants())}>
|
||||
Client Secret
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
The client secret of your OAuth application.
|
||||
</p>
|
||||
<Input
|
||||
id="client_secret"
|
||||
placeholder="Enter value"
|
||||
type="password"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientSecret')}
|
||||
/>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
{tokenQuery.error ? (
|
||||
<p className="text-red-400 font-medium text-sm">
|
||||
{String(tokenQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'primary',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{tokenQuery.isLoading ? 'Fetching token...' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const OauthDialogTrigger = DialogTrigger;
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
} from 'fumadocs-ui/components/ui/collapsible';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type { Security } from '@/utils/get-security';
|
||||
import {
|
||||
OauthDialog,
|
||||
OauthDialogTrigger,
|
||||
} from '@/playground/auth/oauth-dialog';
|
||||
|
||||
interface FormValues {
|
||||
authorization:
|
||||
@@ -163,34 +167,32 @@ export function Client({
|
||||
const query = { ...input.query };
|
||||
const header = { ...input.header };
|
||||
|
||||
if (input.authorization && authorization) {
|
||||
if (authorization.type === 'apiKey') {
|
||||
if (authorization.in === 'header') {
|
||||
header[authorization.name] = input.authorization;
|
||||
} else if (authorization.in === 'query') {
|
||||
query[authorization.name] = input.authorization;
|
||||
} else {
|
||||
if ('cookie' in header) {
|
||||
header.Cookie = header.cookie;
|
||||
delete header.cookie;
|
||||
}
|
||||
|
||||
header.Cookie = [
|
||||
header.Cookie as string,
|
||||
`${authorization.name}=${input.authorization}`,
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join('; ');
|
||||
}
|
||||
} else if (
|
||||
authorization.type === 'http' &&
|
||||
authorization.scheme === 'basic'
|
||||
) {
|
||||
if (typeof input.authorization === 'object')
|
||||
header.Authorization = `Basic ${btoa(`${input.authorization.username}:${input.authorization.password}`)}`;
|
||||
if (authorization?.type === 'apiKey') {
|
||||
if (authorization.in === 'header') {
|
||||
header[authorization.name] = input.authorization;
|
||||
} else if (authorization.in === 'query') {
|
||||
query[authorization.name] = input.authorization;
|
||||
} else {
|
||||
header.Authorization = input.authorization;
|
||||
if ('cookie' in header) {
|
||||
header.Cookie = header.cookie;
|
||||
delete header.cookie;
|
||||
}
|
||||
|
||||
header.Cookie = [
|
||||
header.Cookie as string,
|
||||
`${authorization.name}=${input.authorization}`,
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join('; ');
|
||||
}
|
||||
} else if (
|
||||
authorization?.type === 'http' &&
|
||||
authorization.scheme === 'basic'
|
||||
) {
|
||||
if (typeof input.authorization === 'object')
|
||||
header.Authorization = `Basic ${btoa(`${input.authorization.username}:${input.authorization.password}`)}`;
|
||||
} else if (authorization) {
|
||||
header.Authorization = input.authorization;
|
||||
}
|
||||
|
||||
const serverUrl = serverRef.current
|
||||
@@ -260,6 +262,144 @@ export function Client({
|
||||
);
|
||||
}
|
||||
|
||||
let authField: ReactNode = null;
|
||||
if (authorization) {
|
||||
authField = (
|
||||
<>
|
||||
{renderCustomField(
|
||||
'authorization',
|
||||
authorization?.type === 'http' && authorization.scheme === 'basic'
|
||||
? {
|
||||
name: 'Authorization',
|
||||
type: 'object',
|
||||
isRequired: true,
|
||||
properties: {
|
||||
username: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
name: 'Authorization',
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
description: 'The Authorization access token',
|
||||
defaultValue: '',
|
||||
},
|
||||
fields.auth,
|
||||
)}
|
||||
{authorization?.type === 'oauth2' && (
|
||||
<OauthDialogTrigger
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Open
|
||||
</OauthDialogTrigger>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let children = (
|
||||
<form
|
||||
{...props}
|
||||
className={cn(
|
||||
'not-prose flex flex-col gap-2 rounded-xl border p-3 shadow-md',
|
||||
props.className,
|
||||
)}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<FormHeader
|
||||
method={method}
|
||||
route={route}
|
||||
isLoading={testQuery.isLoading}
|
||||
/>
|
||||
{servers.length > 1 ? (
|
||||
<CollapsiblePanel title="Server URL">
|
||||
<ServerSelect />
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{header.length > 0 || authorization ? (
|
||||
<CollapsiblePanel title="Headers">
|
||||
{authField}
|
||||
{header.map((field) =>
|
||||
renderCustomField(
|
||||
`header.${field.name}`,
|
||||
field,
|
||||
fields.header,
|
||||
field.name,
|
||||
),
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{path.length > 0 ? (
|
||||
<CollapsiblePanel title="Path">
|
||||
{path.map((field) =>
|
||||
renderCustomField(
|
||||
`path.${field.name}`,
|
||||
field,
|
||||
fields.path,
|
||||
field.name,
|
||||
),
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{query.length > 0 ? (
|
||||
<CollapsiblePanel title="Query">
|
||||
{query.map((field) =>
|
||||
renderCustomField(
|
||||
`query.${field.name}`,
|
||||
field,
|
||||
fields.query,
|
||||
field.name,
|
||||
),
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{body ? (
|
||||
<CollapsiblePanel title="Body">
|
||||
{body.type === 'object' && !fields.body ? (
|
||||
<ObjectInput field={body} fieldName="body" />
|
||||
) : (
|
||||
renderCustomField('body', body, fields.body)
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
|
||||
</form>
|
||||
);
|
||||
|
||||
if (authorization?.type === 'oauth2') {
|
||||
// only the first one, so it looks simpler :)
|
||||
const flow = Object.keys(authorization.flows)[0];
|
||||
|
||||
children = (
|
||||
<OauthDialog
|
||||
flow={flow as keyof typeof authorization.flows}
|
||||
scheme={authorization}
|
||||
setToken={(token) => form.setValue('authorization', `Bearer ${token}`)}
|
||||
>
|
||||
{children}
|
||||
</OauthDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<SchemaContext.Provider
|
||||
@@ -268,112 +408,7 @@ export function Client({
|
||||
[schemas],
|
||||
)}
|
||||
>
|
||||
<form
|
||||
{...props}
|
||||
className={cn(
|
||||
'not-prose flex flex-col gap-2 rounded-xl border p-3 shadow-md',
|
||||
props.className,
|
||||
)}
|
||||
onSubmit={onSubmit as React.FormEventHandler}
|
||||
>
|
||||
<FormHeader
|
||||
method={method}
|
||||
route={route}
|
||||
isLoading={testQuery.isLoading}
|
||||
/>
|
||||
{servers.length > 1 ? (
|
||||
<CollapsiblePanel title="Server URL">
|
||||
<ServerSelect />
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{header.length > 0 || authorization ? (
|
||||
<CollapsiblePanel title="Headers">
|
||||
{authorization?.type === 'http' &&
|
||||
authorization.scheme === 'basic'
|
||||
? renderCustomField(
|
||||
'authorization',
|
||||
{
|
||||
name: 'Authorization',
|
||||
type: 'object',
|
||||
isRequired: true,
|
||||
properties: {
|
||||
username: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
fields.auth,
|
||||
)
|
||||
: authorization
|
||||
? renderCustomField(
|
||||
'authorization',
|
||||
{
|
||||
name: 'Authorization',
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
description: 'The Authorization access token',
|
||||
defaultValue: '',
|
||||
},
|
||||
fields.auth,
|
||||
)
|
||||
: null}
|
||||
{header.map((field) =>
|
||||
renderCustomField(
|
||||
`header.${field.name}`,
|
||||
field,
|
||||
fields.header,
|
||||
field.name,
|
||||
),
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{path.length > 0 ? (
|
||||
<CollapsiblePanel title="Path">
|
||||
{path.map((field) =>
|
||||
renderCustomField(
|
||||
`path.${field.name}`,
|
||||
field,
|
||||
fields.path,
|
||||
field.name,
|
||||
),
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{query.length > 0 ? (
|
||||
<CollapsiblePanel title="Query">
|
||||
{query.map((field) =>
|
||||
renderCustomField(
|
||||
`query.${field.name}`,
|
||||
field,
|
||||
fields.query,
|
||||
field.name,
|
||||
),
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{body ? (
|
||||
<CollapsiblePanel title="Body">
|
||||
{body.type === 'object' && !fields.body ? (
|
||||
<ObjectInput field={body} fieldName="body" />
|
||||
) : (
|
||||
renderCustomField('body', body, fields.body)
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
) : null}
|
||||
|
||||
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
|
||||
</form>
|
||||
{children}
|
||||
</SchemaContext.Provider>
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
@@ -153,9 +153,26 @@ function getAuthorizationField(
|
||||
const singular = security.find(
|
||||
(requirements) => Object.keys(requirements).length === 1,
|
||||
);
|
||||
if (!singular) return;
|
||||
if (!singular) {
|
||||
console.warn(
|
||||
`Cannot find suitable security schema for API Playground from ${JSON.stringify(security, null, 2)}. Only one requirement is allowed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return getSecurities(singular, document)[0];
|
||||
const scheme = getSecurities(singular, document)[0];
|
||||
|
||||
if (scheme.type === 'oauth2') {
|
||||
const flow = Object.keys(scheme.flows).at(0);
|
||||
if (!flow) throw new Error("security scheme's `flows` must not be empty");
|
||||
|
||||
if (flow === 'implicit' || flow === 'password')
|
||||
throw new Error(
|
||||
`OAuth 2.0 flow type: ${flow} is not supported, consider other types like \`authorizationCode\` instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
return scheme;
|
||||
}
|
||||
|
||||
function getIdFromSchema(
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from 'fumadocs-ui/components/api';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-fd-popover p-6 shadow-lg duration-200 data-[state=open]:animate-fd-dialog-in data-[state=closed]:animate-fd-dialog-out sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-fd-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-fd-accent data-[state=open]:text-fd-muted-foreground">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-start',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-fd-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { cva } from 'class-variance-authority';
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const labelVariants = cva(
|
||||
'text-[13px] font-mono text-fd-card-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
'text-[13px] font-medium text-fd-card-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
export function useQuery<I, T>(
|
||||
fn: (input: I) => Promise<T>,
|
||||
export function useQuery<I extends unknown[], T>(
|
||||
fn: (...input: I) => Promise<T>,
|
||||
): {
|
||||
start: (input: I) => void;
|
||||
start: (...input: I) => void;
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<T>();
|
||||
const [error, setError] = useState<T>();
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isLoading: loading,
|
||||
data,
|
||||
start(input) {
|
||||
error,
|
||||
start(...input) {
|
||||
setLoading(true);
|
||||
|
||||
void fn(input)
|
||||
void fnRef
|
||||
.current(...input)
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
setError(undefined);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
setData(undefined);
|
||||
setError(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
}),
|
||||
[data, fn, loading],
|
||||
[error, data, loading],
|
||||
);
|
||||
}
|
||||
|
||||
+14
-7
@@ -7,7 +7,8 @@
|
||||
"name": "name",
|
||||
"description": "",
|
||||
"tags": {},
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "age",
|
||||
@@ -15,7 +16,8 @@
|
||||
"tags": {
|
||||
"defaultValue": "4"
|
||||
},
|
||||
"type": "number"
|
||||
"type": "number",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -27,7 +29,8 @@
|
||||
"name": "name",
|
||||
"description": "",
|
||||
"tags": {},
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "age",
|
||||
@@ -35,13 +38,15 @@
|
||||
"tags": {
|
||||
"defaultValue": "4"
|
||||
},
|
||||
"type": "number"
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "generic",
|
||||
"description": "",
|
||||
"tags": {},
|
||||
"type": "GenericType<string, string, string>"
|
||||
"type": "GenericType<string, string, string>",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -56,7 +61,8 @@
|
||||
"see": "https://fumadocs.vercel.app",
|
||||
"defaultValue": "Henry"
|
||||
},
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "age",
|
||||
@@ -65,7 +71,8 @@
|
||||
"example": "```js\nconsole.log(\"Hello World\")\n```",
|
||||
"remarks": "`timestamp`\nReturned by API"
|
||||
},
|
||||
"type": "timestamp"
|
||||
"type": "timestamp",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+3
@@ -904,6 +904,9 @@ importers:
|
||||
'@fumari/json-schema-to-typescript':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.1.6
|
||||
version: 2.1.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
|
||||
Reference in New Issue
Block a user