diff --git a/zod/.eslintrc.js b/zod/.eslintrc.js new file mode 100644 index 000000000..2061cd226 --- /dev/null +++ b/zod/.eslintrc.js @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], +}; diff --git a/zod/.gitignore b/zod/.gitignore new file mode 100644 index 000000000..3f7bf98da --- /dev/null +++ b/zod/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/zod/README.md b/zod/README.md new file mode 100644 index 000000000..65f3df402 --- /dev/null +++ b/zod/README.md @@ -0,0 +1,49 @@ +# Zod Example + +This example demonstrates how to use [Zod](https://npm.im/zod) for server-side validation and data transformation in a Remix application. It includes a user registration form and a product listing page. + +In the user registration form, Zod is used to validate and transform POST data which is submitted by the form in the action handler. + +In the product listing page, Zod is used to validate and transform GET query parameters which are used for filtering and pagination in the loader. + +Every validation and data transformation is done on the server-side, so the client can use the app without JavaScript enabled. + +Enjoy Remix's progressively enhanced forms 💿 and Zod's type safety 💎! + +## Preview + +Open this example on [CodeSandbox](https://codesandbox.com): + +[![Open in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/remix-run/examples/tree/main/zod) + +## Example + +### `app/root.tsx` + +A simple error boundary component is added to catch the errors and display error messages. + +### `app/routes/index.tsx` + +This file contains the user registration form and its submission handling logic. It leverages Zod for validating and transforming the POST data. + +### `app/routes/products.tsx` + +This file implements the product listing page, including filters and pagination. It leverages Zod for URL query parameter validation and transforming. A cache control header is added to the response to ensure the page is cached also. + +--- + +Following two files are used for mocking and functionality demonstration. They are not directly related to Zod or Remix. + +#### `app/lib/product.server.ts` + +This file defines the schema for the product data and provides a mock product list. It's used to ensure the type safety of the product data. + +#### `app/lib/utils.server.ts` + +This file contains `isDateFormat` utility which is used for date validation for `` and a function for calculating days until the next birthday. + +## Related Links + +- [Remix Documentation](https://remix.run/docs) +- [Zod Documentation](https://github.com/colinhacks/zod/#zod) +- [Zod: Coercion for Primitives](https://github.com/colinhacks/zod/#coercion-for-primitives) diff --git a/zod/app/lib/product.server.ts b/zod/app/lib/product.server.ts new file mode 100644 index 000000000..4b7bc9520 --- /dev/null +++ b/zod/app/lib/product.server.ts @@ -0,0 +1,87 @@ +import * as z from "zod"; + +const ProductSchema = z.object({ + id: z.number().int().positive(), + name: z.string(), + price: z.number(), +}); + +type Product = z.infer; + +export const mockProducts: Product[] = [ + { + id: 1, + name: "Laptop", + price: 900, + }, + { + id: 2, + name: "Smartphone", + price: 700, + }, + { + id: 3, + name: "T-shirt", + price: 20, + }, + { + id: 4, + name: "Jeans", + price: 50, + }, + { + id: 5, + name: "Running Shoes", + price: 90, + }, + { + id: 6, + name: "Bluetooth Speaker", + price: 50, + }, + { + id: 7, + name: "Dress Shirt", + price: 30, + }, + { + id: 8, + name: "Gaming Console", + price: 350, + }, + { + id: 9, + name: "Sneakers", + price: 120, + }, + { + id: 10, + name: "Watch", + price: 200, + }, + { + id: 11, + name: "Hoodie", + price: 40, + }, + { + id: 12, + name: "Guitar", + price: 300, + }, + { + id: 13, + name: "Fitness Tracker", + price: 80, + }, + { + id: 14, + name: "Backpack", + price: 50, + }, + { + id: 15, + name: "Dumbbell Set", + price: 130, + }, +]; diff --git a/zod/app/lib/utils.server.ts b/zod/app/lib/utils.server.ts new file mode 100644 index 000000000..9be23de44 --- /dev/null +++ b/zod/app/lib/utils.server.ts @@ -0,0 +1,16 @@ +export const isDateFormat = (date: string): boolean => { + return /^\d{4}-\d{2}-\d{2}$/.test(date); +}; + +export const calculateDaysUntilNextBirthday = (birthday: Date): number => { + const today = new Date(); + const currentYear = today.getFullYear(); + const birthdayThisYear = new Date( + currentYear, + birthday.getMonth(), + birthday.getDate(), + ); + const diff = birthdayThisYear.getTime() - today.getTime(); + const days = Math.ceil(diff / (1000 * 3600 * 24)); + return days < 0 ? 365 + days : days; +}; diff --git a/zod/app/root.tsx b/zod/app/root.tsx new file mode 100644 index 000000000..34d670368 --- /dev/null +++ b/zod/app/root.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "Remix 💿 + Zod 💎", + viewport: "width=device-width,initial-scale=1", +}); + +export default function App() { + return ( + + + + + + + + + + + + + ); +} + +export const ErrorBoundary = ({ error }: { error: Error }) => { + return ( +
+

App Error

+
{error.message}
+
+ ); +}; diff --git a/zod/app/routes/index.tsx b/zod/app/routes/index.tsx new file mode 100644 index 000000000..0152a9944 --- /dev/null +++ b/zod/app/routes/index.tsx @@ -0,0 +1,170 @@ +import { ActionArgs, json } from "@remix-run/node"; +import { Form, Link, useActionData, useNavigation } from "@remix-run/react"; +import * as z from "zod"; + +import { + calculateDaysUntilNextBirthday, + isDateFormat, +} from "~/lib/utils.server"; + +export const action = async ({ request }: ActionArgs) => { + const formData = await request.formData(); + const payload = Object.fromEntries(formData.entries()); + + const currentDate = new Date(); + + const schema = z.object({ + firstName: z + .string() + .min(2, "Must be at least 2 characters") + .max(50, "Must be less than 50 characters"), + email: z.string().email("Must be a valid email"), + birthday: z.coerce + .date() + .min( + new Date( + currentDate.getFullYear() - 26, + currentDate.getMonth(), + currentDate.getDate(), + ), + "Must be at younger than 25", + ) + .max( + new Date( + currentDate.getFullYear() - 18, + currentDate.getMonth(), + currentDate.getDate(), + ), + "Must be at least 18 years old", + ), + }); + + const parseResult = schema.safeParse(payload); + + if (!parseResult.success) { + const fields = { + firstName: typeof payload.firstName === "string" ? payload.firstName : "", + email: typeof payload.email === "string" ? payload.email : "", + birthday: + typeof payload.birthday === "string" && isDateFormat(payload.birthday) + ? payload.birthday + : "", + }; + + return json( + { + fieldErrors: parseResult.error.flatten().fieldErrors, + fields, + message: null, + }, + { + status: 400, + }, + ); + } + + return json({ + fieldErrors: null, + fields: null, + message: `Hello ${parseResult.data.firstName}! We will send an email to ${ + parseResult.data.email + } for your discount code in ${calculateDaysUntilNextBirthday( + parseResult.data.birthday, + )} days.`, + }); +}; + +const errorTextStyle: React.CSSProperties = { + fontWeight: "bold", + color: "red", + marginInline: 0, + marginBlock: "0.25rem", +}; + +export default function RegisterView() { + const actionData = useActionData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + if (actionData?.message) { + return ( +
+

{actionData.message}

+
+ View Products +
+ ); + } + + return ( +
+

Register for a birthday discount!

+
+
+ + + {actionData?.fieldErrors?.firstName + ? actionData.fieldErrors.firstName.map((error, index) => ( +

+ {error} +

+ )) + : null} +
+ +
+ +
+ + + {actionData?.fieldErrors?.email + ? actionData.fieldErrors.email.map((error, index) => ( +

+ {error} +

+ )) + : null} +
+ +
+ +
+ + + {actionData?.fieldErrors?.birthday + ? actionData.fieldErrors.birthday.map((error, index) => ( +

+ {error} +

+ )) + : null} +
+ +
+ + +
+
+ + View Products + +
+ ); +} diff --git a/zod/app/routes/products.tsx b/zod/app/routes/products.tsx new file mode 100644 index 000000000..f32d8b147 --- /dev/null +++ b/zod/app/routes/products.tsx @@ -0,0 +1,198 @@ +import { LoaderArgs, json, redirect } from "@remix-run/node"; +import { + Form, + Link, + useLoaderData, + useNavigation, + useSubmit, +} from "@remix-run/react"; +import * as z from "zod"; + +import { mockProducts } from "~/lib/product.server"; + +export const loader = ({ request }: LoaderArgs) => { + const URLSearchParams = new URL(request.url).searchParams; + + const schema = z.object({ + name: z.string().optional(), + minPrice: z.coerce.number().min(1).optional(), + maxPrice: z.coerce.number().min(1).optional(), + page: z.coerce.number().min(1).optional(), + size: z.coerce.number().min(5).max(10).step(5).optional(), + }); + + // filter out empty string values from query params + // otherwise zod will throw while coercing them to number + const parseResult = schema.safeParse( + Object.fromEntries( + [...URLSearchParams.entries()].filter(([, v]) => v !== ""), + ), + ); + + if (!parseResult.success) { + console.log(parseResult.error); + throw new Error("Invalid query params"); + } + + const { name, minPrice, maxPrice, page, size } = parseResult.data; + + const products = [...mockProducts].sort((a, b) => a.price - b.price); + let filteredProducts = products; + + if (name) { + filteredProducts = filteredProducts.filter((product) => + product.name.toLowerCase().includes(name.toLowerCase()), + ); + } + + if (minPrice) { + filteredProducts = filteredProducts.filter( + (product) => product.price >= minPrice, + ); + } + + if (maxPrice) { + filteredProducts = filteredProducts.filter( + (product) => product.price <= maxPrice, + ); + } + + const pagination = { + page: page || 1, + size: size || 5, + }; + + const totalPageCount = + Math.ceil(filteredProducts.length / pagination.size) || 1; + + if (pagination.page > totalPageCount) { + // reset page to 1 and redirect on invalid page number + URLSearchParams.set("page", "1"); + return redirect(`?${URLSearchParams.toString()}`, { + status: 303, + }); + } + + const paginatedProducts = filteredProducts.slice( + (pagination.page - 1) * pagination.size, + pagination.page * pagination.size, + ); + + return json( + { + products: paginatedProducts, + searchParams: { + name, + minPrice, + maxPrice, + page: pagination.page, + size: pagination.size, + }, + totalPageCount, + }, + { + headers: { + "Cache-Control": "public, max-age=30, s-maxage=60", + }, + }, + ); +}; + +export default function ProductsView() { + const loaderData = useLoaderData(); + const submit = useSubmit(); // used for select onChange + const navigation = useNavigation(); + const isLoading = navigation.state === "loading"; + + return ( +
+

Products

+ +
+ {/* Filters */} + + {" "} + + {" "} + + {" "} + +
+
    + {loaderData.products.length > 0 ? ( + loaderData.products.map((product) => ( +
  • + + {product.name}{" "} + + + ${product.price} + +
  • + )) + ) : ( +

    No products found

    + )} +
+
+ {/* Pagination */} + + Page {loaderData.searchParams.page} of {loaderData.totalPageCount} + {" "} + {" "} + {" "} + + +
+
+ + Home + +
+ ); +} diff --git a/zod/package.json b/zod/package.json new file mode 100644 index 000000000..14c916822 --- /dev/null +++ b/zod/package.json @@ -0,0 +1,30 @@ +{ + "private": true, + "sideEffects": false, + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/node": "~1.14.2", + "@remix-run/react": "~1.14.2", + "@remix-run/serve": "~1.14.2", + "isbot": "^3.6.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^3.22.2" + }, + "devDependencies": { + "@remix-run/dev": "~1.14.2", + "@remix-run/eslint-config": "~1.14.2", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.8", + "eslint": "^8.27.0", + "typescript": "^4.8.4" + }, + "engines": { + "node": ">=14" + } +} diff --git a/zod/public/favicon.ico b/zod/public/favicon.ico new file mode 100644 index 000000000..8830cf682 Binary files /dev/null and b/zod/public/favicon.ico differ diff --git a/zod/remix.config.js b/zod/remix.config.js new file mode 100644 index 000000000..adf2a0b5d --- /dev/null +++ b/zod/remix.config.js @@ -0,0 +1,8 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + ignoredRouteFiles: ["**/.*"], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", +}; diff --git a/zod/remix.env.d.ts b/zod/remix.env.d.ts new file mode 100644 index 000000000..dcf8c45e1 --- /dev/null +++ b/zod/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/zod/sandbox.config.json b/zod/sandbox.config.json new file mode 100644 index 000000000..f92e0250c --- /dev/null +++ b/zod/sandbox.config.json @@ -0,0 +1,7 @@ +{ + "hardReloadOnChange": true, + "template": "remix", + "container": { + "port": 3000 + } +} diff --git a/zod/tsconfig.json b/zod/tsconfig.json new file mode 100644 index 000000000..20f8a386a --- /dev/null +++ b/zod/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +}