diff --git a/src/lib/action-factory.ts b/src/lib/action-factory.ts new file mode 100644 index 0000000..4af671b --- /dev/null +++ b/src/lib/action-factory.ts @@ -0,0 +1,56 @@ +import { Prisma } from "@prisma/client"; +import * as v from "valibot"; + +export type ActionOkResult = T & { success: true }; +export type ActionErrorResult = { success: false; message: string }; + +function actionOkResult(value: V): ActionOkResult { + return { success: true as const, ...value }; +} + +function actionErrorResult(message: string): ActionErrorResult { + return { success: false as const, message }; +} + +export type ServerActionResult = Promise< + ActionOkResult | ActionErrorResult +>; + +export class CatchableError extends Error {} + +export function buildActionFromSchema< + T extends v.ObjectEntries, + U extends v.ErrorMessage | undefined, + V, +>( + formSchema: v.ObjectSchema, + actionFn: (parsed: v.InferOutput) => Promise, +) { + return async (formData: FormData) => { + const formObj = Object.fromEntries(formData.entries()); + const parseResult = v.safeParse(formSchema, formObj); + if (!parseResult.success) { + console.error(parseResult.issues); + return actionErrorResult( + parseResult.issues.map((i) => i.message).join("\n"), + ); + } + + try { + const result = await actionFn(parseResult.output); + return actionOkResult(result); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + console.error(`Prisma error: ${err.code}, ${err.message}`); + } else { + console.error(err); + } + + if (err instanceof CatchableError) { + return actionErrorResult(err.message); + } + + return actionErrorResult("不明なエラーが発生しました。"); + } + }; +} diff --git a/src/lib/use-previous.ts b/src/lib/use-previous.ts new file mode 100644 index 0000000..ce5b8ae --- /dev/null +++ b/src/lib/use-previous.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +export function usePrevious(state: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = state; + }); + + return ref.current; +} diff --git a/src/lib/use-server-action.ts b/src/lib/use-server-action.ts new file mode 100644 index 0000000..0f313c3 --- /dev/null +++ b/src/lib/use-server-action.ts @@ -0,0 +1,62 @@ +import { useRouter } from "next/navigation"; +import { useCallback, useTransition } from "react"; +// import { useToast } from "@/components/ui/use-toast"; +import type { ActionOkResult, ServerActionResult } from "./action-factory"; + +type Options = { + serverActionFn: (formData: FormData) => ServerActionResult; + /** @default '成功しました' */ + successToastMessage?: string; + onSuccess?: (result: ActionOkResult) => void; + onError?: () => void; + hiddenFields?: { [key: string]: string | undefined }; +}; + +export function useServerAction({ + serverActionFn, + // successToastMessage = "成功しました", + onSuccess, + onError, + hiddenFields = {}, +}: Options) { + const router = useRouter(); + // const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); + + const action = useCallback( + (formData: FormData) => { + for (const [key, value] of Object.entries(hiddenFields ?? {})) { + if (value === undefined) continue; + + formData.set(key, value); + } + + startTransition(async () => { + const result = await serverActionFn(formData); + if (!result.success) { + // toast({ + // title: "エラー", + // description: result.message, + // variant: "destructive", + // }); + onError?.(); + return; + } + + // toast({ title: successToastMessage }); + router.refresh(); + onSuccess?.(result); + }); + }, + [ + onError, + onSuccess, + router, + serverActionFn, + hiddenFields, + // toast + ], + ); + + return [action, isPending] as const; +}