UNPKG

next-action-forge

Version:

A simple, type-safe toolkit for Next.js server actions with Zod validation

458 lines (449 loc) 16.4 kB
"use client"; "use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/hooks/index.ts var hooks_exports = {}; __export(hooks_exports, { ToastRestorer: () => ToastRestorer, useFormAction: () => useFormAction, useOptimisticAction: () => useOptimisticAction, useServerAction: () => useServerAction }); module.exports = __toCommonJS(hooks_exports); // src/hooks/use-server-action.ts var import_react = require("react"); var import_navigation = require("next/navigation"); var import_sonner = require("sonner"); // src/next/errors/redirect.ts var RedirectStatusCode = /* @__PURE__ */ ((RedirectStatusCode2) => { RedirectStatusCode2[RedirectStatusCode2["SeeOther"] = 303] = "SeeOther"; RedirectStatusCode2[RedirectStatusCode2["TemporaryRedirect"] = 307] = "TemporaryRedirect"; RedirectStatusCode2[RedirectStatusCode2["PermanentRedirect"] = 308] = "PermanentRedirect"; return RedirectStatusCode2; })(RedirectStatusCode || {}); var REDIRECT_ERROR_CODE = "NEXT_REDIRECT"; function isRedirectError(error) { if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") { return false; } const digest = error.digest.split(";"); const [errorCode, type] = digest; const destination = digest.slice(2, -2).join(";"); const status = digest.at(-2); const statusCode = Number(status); return errorCode === REDIRECT_ERROR_CODE && (type === "replace" || type === "push") && typeof destination === "string" && !isNaN(statusCode) && statusCode in RedirectStatusCode; } // src/next/errors/http-access-fallback.ts var HTTPAccessErrorStatus = { NOT_FOUND: 404, FORBIDDEN: 403, UNAUTHORIZED: 401 }; var ALLOWED_CODES = new Set(Object.values(HTTPAccessErrorStatus)); var HTTP_ERROR_FALLBACK_ERROR_CODE = "NEXT_HTTP_ERROR_FALLBACK"; function isHTTPAccessFallbackError(error) { if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") { return false; } const [prefix, httpStatus] = error.digest.split(";"); return prefix === HTTP_ERROR_FALLBACK_ERROR_CODE && ALLOWED_CODES.has(Number(httpStatus)); } // src/next/errors/index.ts function isNextNavigationError(error) { return isRedirectError(error) || isHTTPAccessFallbackError(error); } // src/hooks/use-server-action.ts function useServerAction(action, options = {}) { const router = (0, import_navigation.useRouter)(); const [result, setResult] = (0, import_react.useState)(); const [isExecuting, setIsExecuting] = (0, import_react.useState)(false); const [isRedirecting, setIsRedirecting] = (0, import_react.useState)(false); const [, startTransition] = (0, import_react.useTransition)(); const { onSuccess, onError, successMessage, errorMessage, showSuccessToast = false, showErrorToast = true, redirectOnAuthError = true, preventRedirect = false, redirectDelay = 0 } = options; const hasSucceeded = !!result?.data; const hasErrored = !!result?.serverError || !!result?.validationErrors || !!result?.fetchError; const reset = (0, import_react.useCallback)(() => { setResult(void 0); setIsRedirecting(false); }, []); const executeInternal = (0, import_react.useCallback)( async (input) => { try { const response = await (input === void 0 ? action() : action(input)); let actionResult; if (response.success) { actionResult = { data: response.data }; if (process.env.NODE_ENV === "development") { console.log("[useServerAction] Success response:", response); console.log("[useServerAction] Has redirect?", !!response.redirect); } if (showSuccessToast && successMessage) { const message = typeof successMessage === "function" ? successMessage(response.data) : successMessage; import_sonner.toast.success(message); } await onSuccess?.(response.data); if (response.redirect && !preventRedirect) { setIsRedirecting(true); const redirectConfig = typeof response.redirect === "string" ? { url: response.redirect } : response.redirect; const delay = redirectConfig.delay ?? redirectDelay; setTimeout(() => { if (redirectConfig.replace) { router.replace(redirectConfig.url); } else { router.push(redirectConfig.url); } }, delay); } } else if (!response.success && response.error) { actionResult = { serverError: response.error, validationErrors: response.error.fields }; if (process.env.NODE_ENV === "development") { console.log("[useServerAction] Error response:", response.error); console.log("[useServerAction] Should redirect?", response.error.shouldRedirect); console.log("[useServerAction] Redirect to:", response.error.redirectTo); } if (redirectOnAuthError && response.error.shouldRedirect) { if (showErrorToast) { const message = errorMessage ? typeof errorMessage === "function" ? errorMessage(actionResult) : errorMessage : response.error.message || "An error occurred"; if (typeof window !== "undefined") { try { const storageKey = "sonner-toasts"; const existingToasts = JSON.parse( localStorage.getItem(storageKey) || "[]" ); const persistentToast = { id: Date.now(), type: "error", message, persistent: true, createdAt: Date.now() }; existingToasts.push(persistentToast); localStorage.setItem(storageKey, JSON.stringify(existingToasts)); } catch (err) { console.error("Failed to persist toast:", err); } } import_sonner.toast.error(message, { persistent: true }); } setIsRedirecting(true); router.push(response.error.redirectTo || "/login"); return actionResult; } if (showErrorToast) { const message = errorMessage ? typeof errorMessage === "function" ? errorMessage(actionResult) : errorMessage : response.error.message || "An error occurred"; import_sonner.toast.error(message); } await onError?.(actionResult); } else { actionResult = { fetchError: "Unexpected response format" }; } setResult(actionResult); return actionResult; } catch (error) { if (isNextNavigationError(error)) { throw error; } const fetchError = error instanceof Error ? error.message : "Network error"; const actionResult = { fetchError }; setResult(actionResult); if (showErrorToast) { import_sonner.toast.error(fetchError); } await onError?.(actionResult); return actionResult; } }, [action, router, showSuccessToast, successMessage, showErrorToast, errorMessage, redirectOnAuthError, onSuccess, onError] ); const execute = (0, import_react.useCallback)( (...args) => { setIsExecuting(true); return new Promise((resolve) => { startTransition(async () => { try { const result2 = await executeInternal(args[0]); resolve(result2); } catch (error) { const fetchError = error instanceof Error ? error.message : "Unexpected error"; resolve({ fetchError }); } finally { setIsExecuting(false); } }); }); }, [executeInternal] ); return { execute, result, isExecuting, isRedirecting, hasSucceeded, hasErrored, reset }; } // src/hooks/use-optimistic-action.ts var import_react2 = require("react"); function useOptimisticAction(initialState, action, options) { const [optimisticState, addOptimisticUpdate] = (0, import_react2.useOptimistic)( initialState, options.updateFn ); const [, startTransition] = (0, import_react2.useTransition)(); const { updateFn: _, ...serverActionOptions } = options; const { execute: executeAction, isExecuting, hasSucceeded, hasErrored, result, reset } = useServerAction(action, serverActionOptions); const execute = async (input) => { startTransition(() => { if (input !== void 0) { addOptimisticUpdate(input); } else { addOptimisticUpdate(void 0); } }); return executeAction(input); }; return { optimisticState, execute, isExecuting, hasSucceeded, hasErrored, result, reset }; } // src/hooks/use-form-action.ts var import_react3 = require("react"); var import_navigation2 = require("next/navigation"); var import_react_hook_form = require("react-hook-form"); var import_zod = require("@hookform/resolvers/zod"); var import_sonner2 = require("sonner"); function isSuccessResponse(response) { return response.success === true; } function objectToFormData(obj) { const formData = new FormData(); Object.entries(obj).forEach(([key, value]) => { if (value !== void 0 && value !== null) { if (value instanceof File || value instanceof Blob) { formData.append(key, value); } else if (Array.isArray(value)) { value.forEach((item) => formData.append(`${key}[]`, String(item))); } else if (typeof value === "object") { formData.append(key, JSON.stringify(value)); } else { formData.append(key, String(value)); } } }); return formData; } function useFormAction({ action, schema, defaultValues, mode = "onChange", transformData, onSuccess, onError, resetOnSuccess = false, showSuccessToast = false, showErrorToast = true, preventRedirect = false, redirectDelay = 0 }) { const router = (0, import_navigation2.useRouter)(); const [isRedirecting, setIsRedirecting] = (0, import_react3.useState)(false); const onSuccessRef = (0, import_react3.useRef)(onSuccess); const onErrorRef = (0, import_react3.useRef)(onError); const showSuccessToastRef = (0, import_react3.useRef)(showSuccessToast); const showErrorToastRef = (0, import_react3.useRef)(showErrorToast); (0, import_react3.useEffect)(() => { onSuccessRef.current = onSuccess; onErrorRef.current = onError; showSuccessToastRef.current = showSuccessToast; showErrorToastRef.current = showErrorToast; }); const form = (0, import_react_hook_form.useForm)({ resolver: schema ? (0, import_zod.zodResolver)(schema) : void 0, defaultValues, mode }); const initialState = { success: true, data: void 0 }; const [actionState, formAction, isPending] = (0, import_react3.useActionState)(action, initialState); const [isTransitioning, startTransition] = (0, import_react3.useTransition)(); (0, import_react3.useEffect)(() => { if (actionState.success === true && actionState.data === void 0) { return; } setIsRedirecting(false); let timeoutId; if (!isSuccessResponse(actionState) && actionState.error) { const { error } = actionState; if (error.fields) { Object.entries(error.fields).forEach(([field, messages]) => { if (Array.isArray(messages) && messages.length > 0) { form.setError(field, { type: "server", message: messages[0] }); } }); } if (error.field && error.message && !error.fields) { form.setError(error.field, { type: "server", message: error.message }); } if (error.message && !error.field && !error.fields) { const showToast = showErrorToastRef.current; if (showToast) { const message = typeof showToast === "function" ? showToast(error) : typeof showToast === "string" ? showToast : error.message; import_sonner2.toast.error(message); } form.setError("root", { type: "server", message: error.message }); } onErrorRef.current?.(error); } if (isSuccessResponse(actionState) && actionState.data !== void 0) { const showToast = showSuccessToastRef.current; if (showToast) { const message = typeof showToast === "function" ? showToast(actionState.data) : typeof showToast === "string" ? showToast : "Success!"; import_sonner2.toast.success(message); } if (resetOnSuccess) { form.reset(); } onSuccessRef.current?.(actionState.data); if (actionState.redirect && !preventRedirect) { setIsRedirecting(true); const redirectConfig = typeof actionState.redirect === "string" ? { url: actionState.redirect } : actionState.redirect; const delay = redirectConfig.delay ?? redirectDelay; timeoutId = setTimeout(() => { if (redirectConfig.replace) { router.replace(redirectConfig.url); } else { router.push(redirectConfig.url); } }, delay || 100); } } return () => { if (timeoutId) { clearTimeout(timeoutId); } }; }, [actionState]); const handleSubmit = async (data) => { form.clearErrors(); const formData = transformData ? transformData(data) : objectToFormData(data); startTransition(() => { formAction(formData); }); }; const isSubmitting = isPending || isTransitioning; const onSubmit = (e) => { e?.preventDefault(); void form.handleSubmit(handleSubmit)(e); }; return { form, onSubmit, isSubmitting, isRedirecting, actionState, reset: form.reset, handleSubmit }; } // src/hooks/toast-restorer.tsx var import_react4 = require("react"); var import_sonner3 = require("sonner"); function ToastRestorer() { (0, import_react4.useEffect)(() => { if (typeof window === "undefined") return; const storageKey = "sonner-toasts"; try { const storedToasts = localStorage.getItem(storageKey); if (storedToasts) { const persistentToasts = JSON.parse(storedToasts); if (Array.isArray(persistentToasts) && persistentToasts.length > 0) { localStorage.removeItem(storageKey); const recentToasts = persistentToasts.filter( (t) => Date.now() - (t.createdAt || 0) < 3e4 ); recentToasts.forEach((persistedToast, index) => { if (persistedToast.type === "loading") return; setTimeout(() => { const toastFunction = import_sonner3.toast[persistedToast.type]; if (typeof toastFunction === "function") { if (toastFunction.length >= 2) { toastFunction(persistedToast.message, { persistent: true, id: persistedToast.id }); } else { toastFunction(persistedToast.message); } } }, index * 150); }); } } } catch (error) { console.error("Failed to restore toasts:", error); localStorage.removeItem(storageKey); } }, []); return null; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { ToastRestorer, useFormAction, useOptimisticAction, useServerAction }); //# sourceMappingURL=index.js.map