add form validation

This commit is contained in:
Michi 2025-04-21 17:30:19 +02:00
parent 7d2dea886c
commit eb75a90936
4 changed files with 93 additions and 14 deletions

View file

@ -55,30 +55,35 @@ Afterwards the site can be accessed in the browser at `http://localhost:3000`.
- The whole app is organzied in multiple folders. THe typescript files for the app are stored in `src/app`. - The whole app is organzied in multiple folders. THe typescript files for the app are stored in `src/app`.
- The code is written in TypeScript. - The code is written in TypeScript.
- TypeScript provides the ability to define types (own structures with strings, numbers, etc.). Therefrom the name `TypeScript` comes. ;) - TypeScript provides the ability to define types (own structures with strings, numbers, etc.). Therefrom the name `TypeScript` comes. ;)
- Types are named with capitalized letters, eg. `User` or `Invoice`.
- A type can be defined with `export type <TYPENAME>`.
```ts
export type User = {
id: string;
name: string;
email: string;
password: string;
};
```
- Variables in `TypeScript` can be optional. Optinal variables are declared with a traling question mark (?) after the variables name. Eg. `x?: number`.
- In `package.json` are the dependencies saved. The Next.js configuration is saved in `next.config.ts`. - In `package.json` are the dependencies saved. The Next.js configuration is saved in `next.config.ts`.
- Each folder in `app` represents a route of the application, but its only accessable when a `page.js` or `route.js` file is contained. - Each folder in `app` represents a route of the application, but its only accessable when a `page.js` or `route.js` file is contained.
- When a folder is named with `_` as prefix, it will be ignored by the routing and not accessable from within the application. - When a folder is named with `_` as prefix, it will be ignored by the routing and not accessable from within the application.
- Folders in parenthesis (Klammern) while not be show in the route. (https://nextjs.org/docs/app/getting-started/project-structure#organize-routes-without-affecting-the-url-path) - Folders in parenthesis (Klammern) while not be show in the route. (https://nextjs.org/docs/app/getting-started/project-structure#organize-routes-without-affecting-the-url-path)
- Slugs can be defined by creating a folder in brackets [] like [slug]. - Slugs can be defined by creating a folder in brackets [] like [slug].
- By default the code is executed on the server. When you want to change this behavior the following has to be added to the top of the file: - By default the code is executed on the server. When you want to change this behavior the following has to be added to the top of the file:
```tsx ```tsx
'use client'; 'use client';
``` ```
- Its possible to render the most parts of an app on the server and some components, like the hover state of the navbar, on the client. Depending on what makes most sense for the application. Thoose components should be move to a separated JS or JSX file. - Its possible to render the most parts of an app on the server and some components, like the hover state of the navbar, on the client. Depending on what makes most sense for the application. Thoose components should be move to a separated JS or JSX file.
- The main function of the app or just a file is defined by `export default`. - The main function of the app or just a file is defined by `export default`.
- The layout of the app is defined in `layout.js`. The content of `layout.js` is shared between all pages. - The layout of the app is defined in `layout.js`. The content of `layout.js` is shared between all pages.
- Variables in `TypeScript` can be optional. Optinal variables are declared with a traling question mark (?) after the variables name. Eg. `x?: number`.
- Inside a React component also empty HTML tags like `<>` and `</>` can be used for logical grouping of elements. This will have no impact on the DOM and is just for organization in the code. - Inside a React component also empty HTML tags like `<>` and `</>` can be used for logical grouping of elements. This will have no impact on the DOM and is just for organization in the code.
## Imports ## Imports
@ -524,6 +529,21 @@ const CreateInvoice = FormSchema.omit({id: true, date: true});
> The `z.corece` does transform the type of the value from `amount` to a `number`. > The `z.corece` does transform the type of the value from `amount` to a `number`.
It's also possible to define error messages in `Zod`, which can be retrieved by a client (the matching function have to be implemented).
```tsx
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.',
}),
amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.'}),,
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(),
});
```
Follwing code does a type validation before further processing the data with the before defined Schema. Follwing code does a type validation before further processing the data with the before defined Schema.
```tsx ```tsx
export async function createInvoice(formData: FormData){ export async function createInvoice(formData: FormData){
@ -535,6 +555,9 @@ export async function createInvoice(formData: FormData){
} }
``` ```
### Form validation
The content of a form should be validated, so a user only can submit a valid form. This can be done on the client side or on the server side. To prohibt manipulation on the client and havin in general more control the server side validation can be used. When the data is validated on the server before processing, a client side form validation for better UX is also okay.
### Revalidating data ### Revalidating data
Next.js does cache the pages on the client side, so the app is overall faster. But when dynamic data is loaded and something has changed, the client has to know this, so the data can be fetched again. You can tell the client to revalidate his data by doing the following: Next.js does cache the pages on the client side, so the app is overall faster. But when dynamic data is loaded and something has changed, the client has to know this, so the data can be fetched again. You can tell the client to revalidate his data by doing the following:

View file

@ -9,21 +9,42 @@ const sql = postgres(process.env.POSTGRES_URL!, {ssl: 'require'});
const FormSchema = z.object({ const FormSchema = z.object({
id: z.string(), id: z.string(),
customerId: z.string(), customerId: z.string({
amount: z.coerce.number(), invalid_type_error: 'Please select a customer.',
status: z.enum(['pending', 'paid']), }),
amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.'}),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(), date: z.string(),
}); });
const CreateInvoice = FormSchema.omit({id: true, date: true}); const CreateInvoice = FormSchema.omit({id: true, date: true});
export async function createInvoice(formData: FormData){ export type State = {
const { customerId, amount, status } = CreateInvoice.parse({ errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData){
const validateFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'), customerId: formData.get('customerId'),
amount: formData.get('amount'), amount: formData.get('amount'),
status: formData.get('status'), status: formData.get('status'),
}); });
if(!validateFields.success){
return{
errors: validateFields.error.flatten().fieldErrors,
message: 'Missing fields. Failed to create invoice.',
}
}
const { customerId, amount, status } = validateFields.data;
const amountInCents = amount * 100; const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0]; const date = new Date().toISOString().split('T')[0];

View file

@ -1,3 +1,5 @@
'use client';
import { CustomerField } from '@/app/lib/definitions'; import { CustomerField } from '@/app/lib/definitions';
import Link from 'next/link'; import Link from 'next/link';
import { import {
@ -7,11 +9,15 @@ import {
UserCircleIcon, UserCircleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button'; import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions'; import { createInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
export default function Form({ customers }: { customers: CustomerField[] }) { export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState: State = { message: null, errors: {}};
const [state, formAction] = useActionState(createInvoice, initialState);
return ( return (
<form action={createInvoice}> <form action={formAction}>
<div className="rounded-md bg-gray-50 p-4 md:p-6"> <div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */} {/* Customer Name */}
<div className="mb-4"> <div className="mb-4">
@ -24,6 +30,7 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
name="customerId" name="customerId"
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500" className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue="" defaultValue=""
aria-describedby="customer-error"
> >
<option value="" disabled> <option value="" disabled>
Select a customer Select a customer
@ -36,6 +43,14 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
</select> </select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" /> <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div> </div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div> </div>
{/* Invoice Amount */} {/* Invoice Amount */}
@ -52,10 +67,20 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
step="0.01" step="0.01"
placeholder="Enter USD amount" placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500" className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
aria-describedby="amount-error"
required
/> />
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" /> <CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div> </div>
</div> </div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div> </div>
{/* Invoice Status */} {/* Invoice Status */}
@ -72,6 +97,7 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
type="radio" type="radio"
value="pending" value="pending"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2" className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
aria-describedby="status-error"
/> />
<label <label
htmlFor="pending" htmlFor="pending"
@ -95,6 +121,14 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
Paid <CheckIcon className="h-4 w-4" /> Paid <CheckIcon className="h-4 w-4" />
</label> </label>
</div> </div>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div> </div>
</div> </div>
</fieldset> </fieldset>

View file

@ -3,7 +3,8 @@
"scripts": { "scripts": {
"build": "next build", "build": "next build",
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"start": "next start" "start": "next start",
"lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",