diff --git a/README.md b/README.md index a74ca02..b18cc4e 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,50 @@ export default function App() { ![react-toastify](./assets/sonner.gif) +## Overriding cookie options + +You can override the default cookie options by passing in your own options via the `setToastCookieOptions` function. + +```tsx +import { setToastCookieOptions } from "remix-toast"; + +setToastCookieOptions({ + secrets: + process.env.NODE_ENV === "production" + ? [process.env.SESSION_SECRET] + : ["secret"] +}); +``` + +## Creating utility functions with custom sessions + +`createToastUtilsWithCustomSession` is a function that allows you to create a custom session for your toasts. This is useful if you want to have different types of toasts for different parts of your app. + +```tsx +import { createCookieSessionStorage } from "@remix-run/node"; +import { createToastUtilsWithCustomSession } from "remix-toast"; + +const session = createCookieSessionStorage({ + cookie: { + name: "your-custom-session", + secrets: ["some-secret"], + }, +}); + +export const { + useToast, + redirectWithToast, + redirectWithSuccess, + redirectWithError, + redirectWithInfo, + redirectWithWarning, + jsonWithSuccess, + jsonWithError, + jsonWithInfo, + jsonWithWarning +} = createToastUtilsWithCustomSession(session); +``` + ## Utilities ### redirectWithToast @@ -154,7 +198,7 @@ General function that allows you to redirect to a new route and show a toast mes import { redirectWithToast } from "remix-toast"; export const action = () => { - return redirectWithToast("/login", { message: "You need to login to access this page", type: "error" }); + return redirectWithToast("/login", { message: "You need to login to access this page", description: "description of toast", type: "error" }); } ``` @@ -167,7 +211,9 @@ Redirects to a new route and shows a success toast message. import { redirectWithSuccess } from "remix-toast"; export const action = () => { - return redirectWithSuccess("/login", "You are logged in!"); + return redirectWithSuccess("/login", "You are logged in!"); + //or with description and message (works for all the other utilities as well) + return redirectWithSuccess("/login", { message: "You are logged in!", description: "description of toast" }); } ``` @@ -218,6 +264,8 @@ import { jsonWithSuccess } from "remix-toast"; export const action = () => { return jsonWithSuccess({ result: "Data saved successfully" }, "Operation successful! 🎉"); + //or with description and message (works for all the other utilities as well) + return jsonWithSuccess({ result: "Data saved successfully" }, { message: "Operation successful! 🎉", description: "description of toast" }); }; ``` diff --git a/package.json b/package.json index c60959b..7b5d19f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "remix-toast", - "version": "1.1.0", + "version": "1.2.0", "description": "Utility functions for server-side toast notifications", "type": "module", "main": "./dist/index.cjs", diff --git a/src/index.ts b/src/index.ts index c4ecb46..b1d4679 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,48 +1,168 @@ -import { createCookieSessionStorageFactory, createCookieFactory, redirect, json } from "@remix-run/server-runtime"; +import { + createCookieSessionStorageFactory, + createCookieFactory, + redirect, + json, + SessionStorage, + SessionIdStorageStrategy, +} from "@remix-run/server-runtime"; import { FlashSessionValues, ToastMessage, flashSessionValuesSchema } from "./schema"; import { sign, unsign } from "./crypto"; const FLASH_SESSION = "flash"; const createCookie = createCookieFactory({ sign, unsign }); +type ToastCookieOptions = Partial; + +const toastCookieOptions = { + name: "toast-session", + sameSite: "lax", + path: "/", + httpOnly: true, + secrets: ["s3Cr3t"], +} satisfies ToastCookieOptions; const sessionStorage = createCookieSessionStorageFactory(createCookie)({ - cookie: { - name: "toast-session", - sameSite: "lax", - path: "/", - httpOnly: true, - secrets: ["s3Cr3t"], - }, + cookie: toastCookieOptions, }); -function getSessionFromRequest(request: Request) { +/** + * Sets the cookie options to be used for the toast cookie + * + * @param options Cookie options to be used for the toast cookie + */ +export function setToastCookieOptions(options: ToastCookieOptions) { + Object.assign(toastCookieOptions, options); + Object.assign( + sessionStorage, + createCookieSessionStorageFactory(createCookie)({ + cookie: toastCookieOptions, + }), + ); +} + +function getSessionFromRequest(request: Request, customSession?: SessionStorage) { const cookie = request.headers.get("Cookie"); - return sessionStorage.getSession(cookie); + const sessionToUse = customSession ? customSession : sessionStorage; + return sessionToUse.getSession(cookie); } -async function flashMessage(flash: FlashSessionValues, headers?: ResponseInit["headers"]) { - const session = await sessionStorage.getSession(); +async function flashMessage( + flash: FlashSessionValues, + headers?: ResponseInit["headers"], + customSession?: SessionStorage, +) { + const sessionToUse = customSession ? customSession : sessionStorage; + const session = await sessionToUse.getSession(); session.flash(FLASH_SESSION, flash); - const cookie = await sessionStorage.commitSession(session); + const cookie = await sessionToUse.commitSession(session); const newHeaders = new Headers(headers); newHeaders.append("Set-Cookie", cookie); return newHeaders; } -async function redirectWithFlash(url: string, flash: FlashSessionValues, init?: ResponseInit) { +async function redirectWithFlash( + url: string, + flash: FlashSessionValues, + init?: ResponseInit, + customSession?: SessionStorage, +) { return redirect(url, { ...init, - headers: await flashMessage(flash, init?.headers), + headers: await flashMessage(flash, init?.headers, customSession), }); } -async function jsonWithFlash(data: T, flash: FlashSessionValues, init?: ResponseInit) { +async function jsonWithFlash( + data: T, + flash: FlashSessionValues, + init?: ResponseInit, + customSession?: SessionStorage, +) { return json(data, { ...init, - headers: await flashMessage(flash, init?.headers), + headers: await flashMessage(flash, init?.headers, customSession), + }); +} + +type BaseFactoryType = { + session?: SessionStorage; + type: "info" | "success" | "error" | "warning"; +}; + +const jsonWithToastFactory = ({ type, session }: BaseFactoryType) => { + return ( + data: T, + messageOrToast: string | Omit, + init?: ResponseInit, + customSession?: SessionStorage, + ) => { + const finalInfo = typeof messageOrToast === "string" ? { message: messageOrToast } : messageOrToast; + return jsonWithFlash(data, { toast: { ...finalInfo, type } }, init, customSession ?? session); + }; +}; + +const redirectWithToastFactory = ({ type, session }: BaseFactoryType) => { + return ( + redirectUrl: string, + messageOrToast: string | Omit, + init?: ResponseInit, + customSession?: SessionStorage, + ) => { + const finalInfo = typeof messageOrToast === "string" ? { message: messageOrToast } : messageOrToast; + return redirectWithFlash(redirectUrl, { toast: { ...finalInfo, type } }, init, customSession ?? session); + }; +}; + +/** + * Helper method used to get the toast data from the current request and purge the flash storage from the session + * @param request Current request + * @returns Returns the the toast notification if exists, undefined otherwise and the headers needed to purge it from the session + */ +export async function getToast( + request: Request, + customSession?: SessionStorage, +): Promise<{ toast: ToastMessage | undefined; headers: Headers }> { + const session = await getSessionFromRequest(request, customSession); + const result = flashSessionValuesSchema.safeParse(session.get(FLASH_SESSION)); + const flash = result.success ? result.data : undefined; + const headers = new Headers({ + "Set-Cookie": await sessionStorage.commitSession(session), }); + const toast = flash?.toast; + return { toast, headers }; } +export type { ToastMessage, ToastCookieOptions }; + +/** + * Helper method used to initialize the whole library using a custom session. Returns all the utilities enhanced with the custom session + * you provide. + * + * These utilities will not override the default session, but will use the custom one you provide. So be careful of imports if you plan to + * use both, or only plan to use this one. + * @param session Custom session to be used instead of the default one + * @returns Returns all the utilities you need to display toast notifications and redirect the user or return jsons with toast notifications + */ +export const createToastUtilsWithCustomSession = (session: SessionStorage) => { + return { + jsonWithToast: (data: T, toast: ToastMessage, init?: ResponseInit) => { + return jsonWithFlash(data, { toast }, init, session); + }, + jsonWithSuccess: jsonWithToastFactory({ type: "success", session }), + jsonWithError: jsonWithToastFactory({ type: "error", session }), + jsonWithInfo: jsonWithToastFactory({ type: "info", session }), + jsonWithWarning: jsonWithToastFactory({ type: "warning", session }), + redirectWithToast: (redirectUrl: string, toast: ToastMessage, init?: ResponseInit) => { + return redirectWithFlash(redirectUrl, { toast }, init, session); + }, + redirectWithSuccess: redirectWithToastFactory({ type: "success", session }), + redirectWithError: redirectWithToastFactory({ type: "error", session }), + redirectWithInfo: redirectWithToastFactory({ type: "info", session }), + redirectWithWarning: redirectWithToastFactory({ type: "warning", session }), + getToast: (request: Request) => getToast(request, session), + }; +}; + /** * Helper method used to display a toast notification without redirection * @@ -51,9 +171,9 @@ async function jsonWithFlash(data: T, flash: FlashSessionValues, init?: Respo * @param init Additional response options (status code, additional headers etc) * @returns Returns data with toast cookie set */ -export function jsonWithToast(data: T, toast: ToastMessage, init?: ResponseInit) { - return jsonWithFlash(data, { toast }, init); -} +export const jsonWithToast = (data: T, toast: ToastMessage, init?: ResponseInit, customSession?: SessionStorage) => { + return jsonWithFlash(data, { toast }, init, customSession); +}; /** * Helper method used to generate a JSON response object with a success toast message. @@ -63,9 +183,7 @@ export function jsonWithToast(data: T, toast: ToastMessage, init?: ResponseIn * @param init Additional response options (status code, additional headers etc) * @returns Returns a JSON response object with the specified success toast message. */ -export function jsonWithSuccess(data: T, message: string, init?: ResponseInit) { - return jsonWithToast(data, { message, type: "success" }, init); -} +export const jsonWithSuccess = jsonWithToastFactory({ type: "success" }); /** * Helper method used to generate a JSON response object with an error toast message. @@ -75,10 +193,7 @@ export function jsonWithSuccess(data: T, message: string, init?: ResponseInit * @param init Additional response options (status code, additional headers etc) * @returns Returns a JSON response object with the specified error toast message. */ -export function jsonWithError(data: T, message: string, init?: ResponseInit) { - return jsonWithToast(data, { message, type: "error" }, init); -} - +export const jsonWithError = jsonWithToastFactory({ type: "error" }); /** * Helper method used to generate a JSON response object with an info toast message. * @@ -87,9 +202,7 @@ export function jsonWithError(data: T, message: string, init?: ResponseInit) * @param init Additional response options (status code, additional headers etc) * @returns Returns a JSON response object with the specified info toast message. */ -export function jsonWithInfo(data: T, message: string, init?: ResponseInit) { - return jsonWithToast(data, { message, type: "info" }, init); -} +export const jsonWithInfo = jsonWithToastFactory({ type: "info" }); /** * Helper method used to generate a JSON response object with a warning toast message. @@ -99,9 +212,7 @@ export function jsonWithInfo(data: T, message: string, init?: ResponseInit) { * @param init Additional response options (status code, additional headers etc) * @returns Returns a JSON response object with the specified warning toast message. */ -export function jsonWithWarning(data: T, message: string, init?: ResponseInit) { - return jsonWithToast(data, { message, type: "warning" }, init); -} +export const jsonWithWarning = jsonWithToastFactory({ type: "warning" }); /** * Helper method used to redirect the user to a new page with a toast notification @@ -112,9 +223,15 @@ export function jsonWithWarning(data: T, message: string, init?: ResponseInit * @param init Additional response options (status code, additional headers etc) * @returns Returns redirect response with toast cookie set */ -export function redirectWithToast(url: string, toast: ToastMessage, init?: ResponseInit) { - return redirectWithFlash(url, { toast }, init); -} +export const redirectWithToast = ( + redirectUrl: string, + toast: ToastMessage, + init?: ResponseInit, + customSession?: SessionStorage, +) => { + return redirectWithFlash(redirectUrl, { toast }, init, customSession); +}; + /** * Helper method used to redirect the user to a new page with an error toast notification * @@ -124,9 +241,7 @@ export function redirectWithToast(url: string, toast: ToastMessage, init?: Respo * @param init Additional response options (status code, additional headers etc) * @returns Returns redirect response with toast cookie set */ -export function redirectWithError(redirectUrl: string, message: string, init?: ResponseInit) { - return redirectWithToast(redirectUrl, { message: `${message}`, type: "error" }, init); -} +export const redirectWithError = redirectWithToastFactory({ type: "error" }); /** * Helper method used to redirect the user to a new page with a success toast notification @@ -137,9 +252,7 @@ export function redirectWithError(redirectUrl: string, message: string, init?: R * @param init Additional response options (status code, additional headers etc) * @returns Returns redirect response with toast cookie set */ -export function redirectWithSuccess(redirectUrl: string, message: string, init?: ResponseInit) { - return redirectWithToast(redirectUrl, { message: `${message}`, type: "success" }, init); -} +export const redirectWithSuccess = redirectWithToastFactory({ type: "success" }); /** * Helper method used to redirect the user to a new page with a warning toast notification @@ -150,9 +263,7 @@ export function redirectWithSuccess(redirectUrl: string, message: string, init?: * @param init Additional response options (status code, additional headers etc) * @returns Returns redirect response with toast cookie set */ -export function redirectWithWarning(redirectUrl: string, message: string, init?: ResponseInit) { - return redirectWithToast(redirectUrl, { message: `${message}`, type: "warning" }, init); -} +export const redirectWithWarning = redirectWithToastFactory({ type: "warning" }); /** * Helper method used to redirect the user to a new page with a info toast notification @@ -163,24 +274,4 @@ export function redirectWithWarning(redirectUrl: string, message: string, init?: * @param init Additional response options (status code, additional headers etc) * @returns Returns redirect response with toast cookie set */ -export function redirectWithInfo(redirectUrl: string, message: string, init?: ResponseInit) { - return redirectWithToast(redirectUrl, { message: `${message}`, type: "info" }, init); -} - -/** - * Helper method used to get the toast data from the current request and purge the flash storage from the session - * @param request Current request - * @returns Returns the the toast notification if exists, undefined otherwise and the headers needed to purge it from the session - */ -export async function getToast(request: Request): Promise<{ toast: ToastMessage | undefined; headers: Headers }> { - const session = await getSessionFromRequest(request); - const result = flashSessionValuesSchema.safeParse(session.get(FLASH_SESSION)); - const flash = result.success ? result.data : undefined; - const headers = new Headers({ - "Set-Cookie": await sessionStorage.commitSession(session), - }); - const toast = flash?.toast; - return { toast, headers }; -} - -export type { ToastMessage }; +export const redirectWithInfo = redirectWithToastFactory({ type: "info" }); diff --git a/src/schema.ts b/src/schema.ts index 675a2cc..bc90ecd 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const toastMessageSchema = z.object({ message: z.string(), + description: z.string().optional(), type: z.custom<"info" | "success" | "error" | "warning">(), }); diff --git a/src/test-apps/testing-app/app/root.tsx b/src/test-apps/testing-app/app/root.tsx index b5b9add..4b2bdc4 100644 --- a/src/test-apps/testing-app/app/root.tsx +++ b/src/test-apps/testing-app/app/root.tsx @@ -1,9 +1,9 @@ import { json, type LinksFunction, type LoaderFunctionArgs } from "@remix-run/node"; import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react"; import { useEffect } from "react"; -import { getToast } from "remix-toast"; import { ToastContainer, toast as notify } from "react-toastify"; import toastStyles from "react-toastify/dist/ReactToastify.css"; +import { getToast } from "./toast"; export const links: LinksFunction = () => [{ rel: "stylesheet", href: toastStyles }]; @@ -19,6 +19,7 @@ export default function App() { notify(toast.message, { type: toast.type }); } }, [toast]); + return ( diff --git a/src/test-apps/testing-app/app/routes/_index.tsx b/src/test-apps/testing-app/app/routes/_index.tsx index 32f5d00..afddf1e 100644 --- a/src/test-apps/testing-app/app/routes/_index.tsx +++ b/src/test-apps/testing-app/app/routes/_index.tsx @@ -1,6 +1,6 @@ import type { MetaFunction } from "@remix-run/node"; import { Link, useSubmit } from "@remix-run/react"; -import { redirectWithError } from "remix-toast"; +import { redirectWithError } from "~/toast"; export const meta: MetaFunction = () => { return [{ title: "New Remix App" }, { name: "description", content: "Welcome to Remix!" }]; diff --git a/src/test-apps/testing-app/app/routes/from-library.tsx b/src/test-apps/testing-app/app/routes/from-library.tsx new file mode 100644 index 0000000..43a0675 --- /dev/null +++ b/src/test-apps/testing-app/app/routes/from-library.tsx @@ -0,0 +1,100 @@ +import { ActionFunctionArgs, json, type LinksFunction, type LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useLoaderData } from "@remix-run/react"; +import { useEffect } from "react"; +import { toast as notify } from "react-toastify"; +import toastStyles from "react-toastify/dist/ReactToastify.css"; +import { getToast, redirectWithError, redirectWithInfo, redirectWithSuccess, redirectWithWarning } from "remix-toast"; + +export const links: LinksFunction = () => [{ rel: "stylesheet", href: toastStyles }]; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { toast, headers } = await getToast(request); + return json({ toast }, { headers }); +}; + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const messageType = formData.get("messageType"); + + // The empty object passed as the first argument can be used + // to include additional data to be returned along with the toasters. + switch (messageType) { + case "success": + return redirectWithSuccess("/from-library", "This is a success message"); + case "error": + return redirectWithError("/from-library", "This is an error message"); + case "info": + return redirectWithInfo("/from-library", "This is an info"); + case "warning": + return redirectWithWarning("/from-library", "This is a warning"); + } +} + +export default function App() { + const { toast } = useLoaderData(); + useEffect(() => { + if (toast) { + notify(toast.message, { type: toast.type }); + } + }, [toast]); + return ( +
+
+
+ Test success message with redirection +
+ +
+
+
+ Test error message with redirection +
+ +
+
+
+
+
+ Test warning message with redirection +
+ +
+
+
+ Test info message with redirection +
+ +
+
+
+
+ ); +} diff --git a/src/test-apps/testing-app/app/routes/test.tsx b/src/test-apps/testing-app/app/routes/test.tsx index 32bfcaa..040bbaf 100644 --- a/src/test-apps/testing-app/app/routes/test.tsx +++ b/src/test-apps/testing-app/app/routes/test.tsx @@ -1,11 +1,12 @@ import { cssBundleHref } from "@remix-run/css-bundle"; import { json, type LinksFunction, type LoaderFunctionArgs } from "@remix-run/node"; -import { Link, useSubmit } from "@remix-run/react"; -import { getToast, redirectWithSuccess } from "remix-toast"; +import { useSubmit } from "@remix-run/react"; +import { getToast, redirectWithSuccess } from "~/toast"; export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [])]; export const loader = async ({ request }: LoaderFunctionArgs) => { const { toast, headers } = await getToast(request); + return json({ toast }, { headers }); }; diff --git a/src/test-apps/testing-app/app/routes/without-redirection.tsx b/src/test-apps/testing-app/app/routes/without-redirection.tsx index bd240f2..3927b8b 100644 --- a/src/test-apps/testing-app/app/routes/without-redirection.tsx +++ b/src/test-apps/testing-app/app/routes/without-redirection.tsx @@ -1,7 +1,7 @@ import { cssBundleHref } from "@remix-run/css-bundle"; import { DataFunctionArgs, type LinksFunction } from "@remix-run/node"; import { Form } from "@remix-run/react"; -import { jsonWithError, jsonWithInfo, jsonWithSuccess, jsonWithWarning } from "remix-toast"; +import { jsonWithError, jsonWithInfo, jsonWithSuccess, jsonWithWarning } from "~/toast"; export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [])]; diff --git a/src/test-apps/testing-app/app/toast.ts b/src/test-apps/testing-app/app/toast.ts new file mode 100644 index 0000000..e5d318c --- /dev/null +++ b/src/test-apps/testing-app/app/toast.ts @@ -0,0 +1,20 @@ +import { createCookieSessionStorage } from "@remix-run/node"; +import { createToastUtilsWithCustomSession } from "remix-toast"; + +const session = createCookieSessionStorage({ + cookie: { + name: "toast", + secrets: ["some-secret"], + }, +}); + +export const { + redirectWithError, + redirectWithWarning, + redirectWithSuccess, + getToast, + jsonWithSuccess, + jsonWithError, + jsonWithInfo, + jsonWithWarning, +} = createToastUtilsWithCustomSession(session);