Skip to content

Instantly share code, notes, and snippets.

@bowencool
Last active March 16, 2025 16:07
Show Gist options
  • Select an option

  • Save bowencool/19511f252bed876e5218fadc8e3423e1 to your computer and use it in GitHub Desktop.

Select an option

Save bowencool/19511f252bed876e5218fadc8e3423e1 to your computer and use it in GitHub Desktop.
Create shadcn Dialog
import React, { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { DialogProps } from "@radix-ui/react-dialog";
import { Dialog } from "@/components/ui/dialog";
import { FieldValues, UseFormProps, UseFormReturn, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
export type CreateDialogProps<T extends FieldValues, R = undefined> = Omit<DialogProps, "open"> & {
/**
* @description The dialog content, usually a form.
* @description.zh-CN 弹窗内容,通常是一个表单。
* */
content?: ReactNode /* | Component | FunctionComponent | ExoticComponent */;
// header?: ReactNode;
// footer?: ReactNode;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formSchema?: z.ZodObject<any, any>;
useFormProps?: UseFormProps<T>;
/**
* @description Same as content prop, but higher priority
* @description.zh-CN 同 content ,优先级高于 content
* */
render?: (p: {
confirmLoading: boolean;
handleOk: (values?: T) => void;
form: UseFormReturn<T, unknown, undefined>;
}) => ReactNode;
/**
* @description "Ok" button events that return a Promise can delay closing. Parameter is the value passed by the content, the return value will be passed to the final return Promise.
* @description.zh-CN “确认”按钮事件,返回 promise 可以延迟关闭。参数为弹窗内容传递的值,返回值会被传递给最终返回的 Promise。
* */
onOk?: (values?: T) => Promise<R> | R | undefined;
/**
* @description User-initiated closure.
* @description.zh-CN 用户主动关闭事件。
* */
onCancel?: () => void;
/**
* @description Any closing event, including user-initiated closure and closure after business logic completion.
* @description.zh-CN 任何关闭事件,包括用户主动关闭和业务逻辑完成后的关闭。
* */
onClose?: () => void;
/**
* @description Callback for validation failure and onOk failure, will not cause the modal to close, and will not cause the final returned Promise to reject
* @description.zh-CN 验证失败和 onOk 失败的回调,不会导致弹窗关闭,也不会导致最终返回的 Promise reject
* */
onFailed?: (error: unknown) => void;
};
export type CreateDialogReturn<_T, R> = {
/**
* @description Results only when the modal is closed.
* @description.zh-CN 弹窗关闭时才有的结果
*/
promise: Promise<R>;
destory: () => void;
};
export function TmpComp<T extends FieldValues, R>({
content,
render,
onOk,
onCancel,
onClose,
onFailed,
formSchema = z.object({}),
useFormProps,
...rest
}: CreateDialogProps<T, R>) {
const [visible, setVisible] = useState(false);
const isFirstRender = useRef(true);
const [confirmLoading, setConfirmLoading] = useState(false);
useEffect(() => {
if (isFirstRender.current) return;
if (!visible) {
onClose?.();
}
}, [visible, onClose]);
useEffect(() => {
// for animation
setVisible(true);
isFirstRender.current = false;
}, []);
const handleOk = async (values: T) => {
setConfirmLoading(true);
try {
await onOk?.(values);
setVisible(false);
} catch (error) {
// console.error(error);
onFailed?.(error);
// throw error;
} finally {
setConfirmLoading(false);
}
};
const form = useForm<T>({
resolver: zodResolver(formSchema),
...useFormProps,
});
const renderPropChildren = () => {
if (render) return render({ confirmLoading, handleOk, form });
/* if (isValidElement(content)) */ return content;
};
return (
<Dialog
{...rest}
open={visible}
onOpenChange={(open) => {
if (!open) {
setVisible(false);
onCancel?.();
}
}}
>
{renderPropChildren()}
</Dialog>
);
}
import React from "react";
import { FieldValues } from "react-hook-form";
import { CreateDialogProps, CreateDialogReturn, TmpComp } from "./component";
function usePatchElement(): [React.ReactElement[], (element: React.ReactElement) => () => void] {
const [elements, setElements] = React.useState<React.ReactElement[]>([]);
const patchElement = React.useCallback((element: React.ReactElement) => {
// append a new element to elements (and create a new ref)
setElements((originElements) => [...originElements, element]);
// return a function that removes the new element out of elements (and create a new ref)
// it works a little like useEffect
return () => {
setElements((originElements) => originElements.filter((ele) => ele !== element));
};
}, []);
return [elements, patchElement];
}
interface ElementsHolderRef {
patchElement: ReturnType<typeof usePatchElement>[1];
}
let uuid = 0;
// avoid create multi modal when user click button too fast
let throttleLock = false;
const ElementsHolder = React.memo(
React.forwardRef<ElementsHolderRef>((_props, ref) => {
const [elements, patchElement] = usePatchElement();
React.useImperativeHandle(
ref,
() => ({
patchElement,
}),
[],
);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{elements}</>;
}),
);
export function useDialogCreation<P extends FieldValues, Q = void>(
defaultProps?: Omit<CreateDialogProps<P, Q>, "content" | "render" | "useFormProps" | "formSchema">,
): readonly [
contextHolder: React.ReactElement,
createDialog: <T extends FieldValues, R = void>(params: CreateDialogProps<T, R>) => CreateDialogReturn<T, R>,
] {
const holderRef = React.useRef<ElementsHolderRef>(null);
const createDialog = React.useCallback(
/**
* @description Create a one-off modal dialog dynamically without maintenance loading and visible.
* @description.zh-CN 动态创建一次性的模态框,不需要维护 loading 和 visible。
*/
function createDialog<T extends FieldValues, R = void>(params: CreateDialogProps<T, R>): CreateDialogReturn<T, R> {
if (throttleLock) {
return {
destory: () => {},
promise: Promise.reject("throttled"),
};
}
throttleLock = true;
setTimeout(() => {
throttleLock = false;
}, 500);
let _resolve: (value: R | PromiseLike<R>) => void;
let _reject: (reason?: unknown) => void;
const defered = new Promise<R>((resolve, reject) => {
_reject = reject;
_resolve = resolve;
});
let unmountFunc: (() => void) | undefined;
function destory() {
setTimeout(() => {
unmountFunc?.();
_reject("destory");
console.log("destoryed");
}, 300);
}
setTimeout(() => {
unmountFunc = holderRef.current?.patchElement(
<TmpComp<T, R>
key={`modal-${uuid++}`}
onClose={destory}
{...defaultProps}
{...params}
onOk={(values?: T) => {
const Result = params.onOk?.(values);
_resolve(Result || (values as R));
return Result;
}}
onCancel={() => {
params.onCancel?.();
_reject("cancel");
}}
/>,
);
});
return { destory, promise: defered };
},
[],
);
return [<ElementsHolder key="modal-holder" ref={holderRef} />, createDialog];
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const [dialogHolder, createDialog] = useDialogCreation();
const handleDelete = () => {
createDialog({
render: ({ confirmLoading, handleOk }) => (
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Knowledge File</DialogTitle>
<DialogDescription>This operation is not reversible. Are you sure?</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
<LoadingButton onClick={handleOk} variant="destructive" loading={confirmLoading}>
Confirm
</LoadingButton>
</DialogFooter>
</DialogContent>
),
onOk: async () => {
await request(`/api/knowledge-base/file/${row.original.file_id}`, {
method: "DELETE",
});
toast.success("Knowledge file deleted");
},
});
};
const handleEdit = () => {
createDialog<z.infer<typeof updateFormSchema>>({
formSchema: updateFormSchema,
useFormProps: {
defaultValues: {
scopes: row.original.scopes,
},
},
render: ({ confirmLoading, handleOk, form }) => (
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Update file</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleOk)} className="w-full space-y-6">
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="flex items-center gap-2">
<div>Knowledge Scopes</div>
</FormLabel>
<ScopesSelector
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<LoadingButton type="submit" loading={confirmLoading}>
Update
</LoadingButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
),
onOk: async (values) => {
await request(`/api/knowledge-base/file/${row.original.file_id}`, {
method: "PATCH",
body: values,
});
toast.success("File updated");
},
});
};
return (
<>
{dialogHolder}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex size-8 p-0 data-[state=open]:bg-muted">
<DotsHorizontalIcon className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem onClick={handleEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="text-destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}
@bowencool

bowencool commented Apr 11, 2024 •

Copy link
Copy Markdown
Author

Here is the antd version

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment