{({ index, itemName, values, setItemField, canRemove, remove }) => (
{/* Custom element within array item */}
setItemField("weapon", v)}
/>
{canRemove && (
)}
)}
```
## Server-Side Validation
### Basic Action Handler
```ts
// IMPORTANT: import path needs to be this exact one
import { parseFormData } from "~/form/parse.server";
import { myFormSchema } from "./my-schemas";
export const action = async ({ request }: ActionFunctionArgs) => {
const result = await parseFormData({
request,
schema: myFormSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
// data is fully typed based on schema
await doSomething(data);
return redirect("/success");
};
```
### Server-Only Schema Pattern
When you need async validation (database checks, authorization), create a separate server schema file that extends the base schema.
**Base schema (`feature-schemas.ts`)** - used by both client and server:
```ts
import { z } from "zod";
import { textFieldRequired, idConstantOptional } from "~/form/fields";
// Shared sync validation that can be extracted for reuse
function validateGearAllOrNone(data: {
head: number | null;
clothes: number | null;
shoes: number | null;
}) {
const gearFilled = [data.head, data.clothes, data.shoes].filter(
(g) => g !== null,
);
return gearFilled.length === 0 || gearFilled.length === 3;
}
// Export refine config for reuse in server schema
export const gearAllOrNoneRefine = {
fn: validateGearAllOrNone,
opts: { message: "forms:errors.gearAllOrNone", path: ["head"] },
};
// Base schema with form field builders (for UI generation)
export const newBuildBaseSchema = z.object({
buildToEditId: idConstantOptional(),
title: textFieldRequired({ label: "labels.buildTitle", maxLength: 50 }),
// ... other fields
});
// Client schema with sync refinements only
export const newBuildSchema = newBuildBaseSchema.refine(
gearAllOrNoneRefine.fn,
gearAllOrNoneRefine.opts,
);
```
**Server schema (`feature-schemas.server.ts`)** - adds async validation:
```ts
import { requireUser } from "~/features/auth/core/user.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { gearAllOrNoneRefine, newBuildBaseSchema } from "./feature-schemas";
export const newBuildSchemaServer = newBuildBaseSchema
// Reuse sync refinements from base
.refine(gearAllOrNoneRefine.fn, gearAllOrNoneRefine.opts)
// Add async server-only validation
.refine(
async (data) => {
if (!data.buildToEditId) return true;
const user = requireUser();
const ownerId = await BuildRepository.ownerIdById(data.buildToEditId);
return ownerId === user.id;
},
{ message: "Not a build you own", path: ["buildToEditId"] },
);
```
**Action using server schema:**
```ts
import { parseFormData } from "~/form/parse.server";
import { newBuildSchemaServer } from "./feature-schemas.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const result = await parseFormData({
request,
schema: newBuildSchemaServer,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
// ...
};
```
### Uniqueness Validation
Check for duplicates in the database:
```ts
// feature-schemas.server.ts
import { createTeamSchema } from "./feature-schemas";
import * as TeamRepository from "./TeamRepository.server";
export const createTeamSchemaServer = z.object({
...createTeamSchema.shape,
name: createTeamSchema.shape.name.refine(
async (name) => {
const teams = await TeamRepository.findAllUndisbanded();
const customUrl = mySlugify(name);
return !teams.some((team) => team.customUrl === customUrl);
},
{ message: "forms:errors.duplicateName" },
),
});
```
### Cross-Field Validation with superRefine
For complex validation involving multiple fields:
```ts
export const scrimsNewFormSchema = z
.object({
at: datetimeRequired({ label: "labels.start" }),
maps: select({ label: "labels.maps", items: mapsItems }),
mapsTournamentId: customField({ initialValue: null }, id.nullable()),
})
.superRefine((data, ctx) => {
if (data.maps === "TOURNAMENT" && !data.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "errors.tournamentMustBeSelected",
code: z.ZodIssueCode.custom,
});
}
if (data.maps !== "TOURNAMENT" && data.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "errors.tournamentOnlyWhenMapsIsTournament",
code: z.ZodIssueCode.custom,
});
}
});
```
## Translations
All labels and text go in `locales/en/forms.json`:
```json
{
"labels.name": "Name",
"labels.bio": "Bio",
"bottomTexts.bioHelp": "Write a short description",
"errors.required": "This field is required",
"errors.customError": "Custom error message",
"options.type.free": "Free",
"options.type.paid": "Paid"
}
```
NOTE: before adding a new one, verify one does not already exist.
### Translation Key Conventions
- Labels: `labels.fieldName`
- Bottom text: `bottomTexts.fieldName`
- Errors: `errors.errorKey`
- Select options: `options.fieldName.value`
- Mode names: `modes.SZ`, `modes.TC`, etc.
Run `npm run i18n:sync` after adding English translations to initialize other language files.
## E2E Testing
Use `createFormHelpers` for type-safe form interactions:
```ts
import { createFormHelpers } from "~/utils/playwright-form";
import { myFormSchema } from "~/features/my/my-schemas";
test("fills and submits form", async ({ page }) => {
const form = createFormHelpers(page, myFormSchema);
await form.fill("name", "Test Name");
await form.fill("bio", "Test bio text");
await form.check("isPublic");
await form.select("type", "PAID");
await form.setDateTime("date", new Date(2024, 5, 15, 14, 30));
await form.submit();
});
```
### Available Helper Methods
| Method | Usage |
|--------|-------|
| `fill(name, value)` | Fill text input |
| `check(name)` | Check a toggle/checkbox |
| `uncheck(name)` | Uncheck a toggle/checkbox |
| `select(name, optionValue)` | Select dropdown option by value |
| `checkItems(name, values)` | Check specific checkbox group items |
| `selectUser(name, userName)` | Search and select user |
| `selectWeapons(name, weaponNames)` | Select weapons in weapon pool |
| `setDateTime(name, date)` | Set datetime picker |
| `submit()` | Click submit button |
| `getLabel(name)` | Get translated label for field |
| `getItemLabel(name, value)` | Get translated label for select item |
### Custom Fields in Tests
For custom fields without standard form helpers, use Playwright directly:
```ts
// Custom field interactions
await page.getByLabel("Player (Pov)").click();
await page.getByTestId("user-search-input").fill("Sendou");
// Stage/weapon selects with test-id
await selectStage({ page, name: "Museum d'Alfonsino" });
await selectWeapon({ page, name: "Tenta Brella", testId: "match-0-weapon" });
```
### Test Helpers from Playwright Utils
```ts
import {
navigate,
submit,
selectStage,
selectWeapon,
selectUser,
} from "~/utils/playwright";
```
## Complete Example
### Schema (`feature-schemas.ts`)
```ts
import { z } from "zod";
import {
textFieldRequired,
textAreaOptional,
select,
toggle,
stringConstant,
} from "~/form/fields";
export const createItemSchema = z.object({
_action: stringConstant("CREATE"),
name: textFieldRequired({
label: "labels.itemName",
maxLength: 100,
}),
description: textAreaOptional({
label: "labels.description",
bottomText: "bottomTexts.descriptionHelp",
maxLength: 500,
}),
category: select({
label: "labels.category",
items: [
{ label: "options.category.general", value: "GENERAL" },
{ label: "options.category.special", value: "SPECIAL" },
],
}),
isActive: toggle({
label: "labels.isActive",
}),
});
```
### Route Component (`route.tsx`)
```tsx
import { SendouForm } from "~/form/SendouForm";
import { createItemSchema } from "./feature-schemas";
export default function NewItemPage() {
return (