Skip to content

Instantly share code, notes, and snippets.

@bowencool
Last active April 11, 2024 06:30
Show Gist options
  • Save bowencool/19511f252bed876e5218fadc8e3423e1 to your computer and use it in GitHub Desktop.
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
Copy link
Author

bowencool commented Apr 11, 2024

Here is the antd version

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