18 KiB
SendouForm - Schema-Based Form System
This document describes the schema-based form system using SendouForm. Forms are defined as Zod schemas that generate both the UI and server-side validation.
Core Concepts
- Forms are defined as Zod schemas using field builders from
~/form/fields - The same schema validates both client-side and server-side
- All translations go in
locales/en/forms.json FormFieldrenders the correct UI based on schema metadata
Schema Definition
Basic Schema Example
export const myFormSchema = z.object({
name: textFieldRequired({
label: "labels.name",
maxLength: 100,
}),
bio: textAreaOptional({
label: "labels.bio",
bottomText: "bottomTexts.bioHelp",
maxLength: 500,
}),
isPublic: toggle({
label: "labels.isPublic",
}),
});
Available Field Builders
| Builder | Description | Required Props |
|---|---|---|
textFieldRequired |
Required text input | label, maxLength |
textFieldOptional |
Optional text input | maxLength |
numberFieldOptional |
Optional number input | - |
textAreaRequired |
Required multiline text | label, maxLength |
textAreaOptional |
Optional multiline text | maxLength |
toggle |
Boolean switch | label |
select |
Required dropdown | label, items |
selectOptional |
Optional dropdown (clearable) | label, items |
selectDynamicOptional |
Dropdown with runtime options | label |
radioGroup |
Radio button group | label, items |
checkboxGroup |
Multiple selection checkboxes | label, items |
datetimeRequired |
Required date/time picker | label |
datetimeOptional |
Optional date/time picker | label |
dayMonthYearRequired |
Date picker (day only) | label |
dualSelectOptional |
Two linked dropdowns | fields |
timeRangeOptional |
Start/end time range | label |
weaponPool |
Weapon selection pool | label, maxCount |
stageSelect |
Stage dropdown | label |
weaponSelectOptional |
Weapon dropdown | label |
userSearch |
User search autocomplete | label |
userSearchOptional |
Optional user search | label |
badges |
Badge selection | label |
array |
Repeatable field | field, max |
fieldset |
Nested object fields | fields |
stringConstant |
Hidden string value | value |
idConstant |
Hidden numeric ID | value (optional) |
customField |
Custom render | initialValue, schema |
Select Items
Select fields require items array:
const typeField = select({
label: "labels.type",
items: [
{ label: "options.type.free", value: "FREE" },
{ label: "options.type.paid", value: "PAID" },
],
});
For dynamic labels (e.g., numbers or translation not needed), use the functional form:
items: [
{ label: () => "1v1", value: "1" },
{ label: () => "2v2", value: "2" },
]
Action Fields
Define action discriminators with stringConstant:
export const myFormSchema = z.object({
_action: stringConstant("CREATE_ITEM"),
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
Constants and Hidden Fields
Use idConstant for IDs that need default values:
export const editFormSchema = z.object({
itemId: idConstant(), // Requires defaultValues
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
When idConstant() is called without a value, the schema requires defaultValues prop in SendouForm.
Text Field Validation
textFieldRequired({
label: "labels.url",
maxLength: 200,
validate: "url", // Built-in URL validation
})
textFieldRequired({
label: "labels.custom",
maxLength: 100,
validate: {
func: (val) => val.startsWith("https://"),
message: "Must start with https://",
},
})
textFieldRequired({
label: "labels.pattern",
maxLength: 10,
regExp: {
pattern: /^\d{1,2}:\d{2}$/,
message: "Invalid format",
},
})
DateTime Validation
datetimeRequired({
label: "labels.date",
min: new Date(),
max: add(new Date(), { days: 30 }),
minMessage: "errors.dateInPast",
maxMessage: "errors.dateTooFarInFuture",
})
Dual Select
dualSelectOptional({
fields: [
{
label: "labels.maxDiv",
items: DIVS.map((d) => ({ label: () => d, value: d })),
},
{
label: "labels.minDiv",
items: DIVS.map((d) => ({ label: () => d, value: d })),
},
],
validate: {
func: ([max, min]) => (max && !min) || (!max && min) ? false : true,
message: "errors.bothOrNeither",
},
})
Arrays and Fieldsets
const itemSchema = z.object({
name: textFieldRequired({ label: "labels.itemName", maxLength: 50 }),
quantity: numberFieldOptional({ label: "labels.quantity" }),
});
export const formSchema = z.object({
items: array({
label: "labels.items",
min: 1,
max: 10,
field: fieldset({ fields: itemSchema }),
}),
});
Union for Shared Field Definitions
Place field inside z.union([]) to reuse across multiple schemas:
const sharedNameField = textFieldRequired({
label: "labels.name",
maxLength: 100,
});
const createSchema = z.object({
_action: stringConstant("CREATE"),
name: sharedNameField,
});
const editSchema = z.object({
_action: stringConstant("EDIT"),
id: idConstant(),
name: sharedNameField,
});
export const actionSchema = z.union([createSchema, editSchema]);
Component Usage
Basic Form
import { SendouForm } from "~/form/SendouForm";
import { myFormSchema } from "./my-schemas";
function MyForm() {
return (
<SendouForm schema={myFormSchema} submitButtonText="Save">
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="bio" />
<FormField name="isPublic" />
</>
)}
</SendouForm>
);
}
With Default Values
Typically default values are loaded via a loader function
const data = useLoaderData<typeof loader>();
<SendouForm
schema={editFormSchema}
defaultValues={{
itemId: data.item.id,
name: data.item.name,
}}
>
{({ FormField }) => (
<FormField name="name" />
)}
</SendouForm>
Auto-Submit Forms
<SendouForm schema={filterSchema} autoSubmit>
{({ FormField }) => (
<FormField name="sortBy" />
)}
</SendouForm>
Client-Side Only (onApply)
<SendouForm
schema={filtersSchema}
defaultValues={currentFilters}
onApply={(values) => {
setSearchParams({ filters: JSON.stringify(values) });
closeDialog();
}}
>
{({ FormField }) => (
<FormField name="category" />
)}
</SendouForm>
Dynamic Select Options
For selectDynamicOptional, pass options via the options prop:
const options = tournaments.map((t) => ({
value: String(t.id),
label: t.name,
}));
<FormField name="tournamentId" options={options} />
Badges Field
const badgeOptions = badges.map((b) => ({
id: b.id,
displayName: b.displayName,
code: b.code,
hue: b.hue,
}));
<FormField name="displayBadges" options={badgeOptions} />
Custom Fields
Use customField for complex UI that doesn't fit standard field types:
Schema
const povSchema = z.union([
z.object({ type: z.literal("USER"), userId: id.optional() }),
z.object({ type: z.literal("NAME"), name: z.string().max(100) }),
]);
export const formSchema = z.object({
pov: customField(
{ initialValue: { type: "USER" as const } },
povSchema.optional()
),
});
Component
<FormField name="pov">
{({ value, onChange, error }) => (
<MyCustomPovSelector
value={value}
onChange={onChange}
error={error}
/>
)}
</FormField>
Custom Field Props
type CustomFieldRenderProps<TValue = unknown> = {
name: string;
error: string | undefined;
value: TValue;
onChange: (value: TValue) => void;
};
Array Field with Custom Items
<FormField name="matches">
{({ index, itemName, values, setItemField, canRemove, remove }) => (
<div>
<FormField name={`${itemName}.startsAt`} />
<FormField name={`${itemName}.mode`} />
{/* Custom element within array item */}
<MyCustomWeaponSelect
value={values.weapon}
onChange={(v) => setItemField("weapon", v)}
/>
{canRemove && (
<button onClick={remove}>Remove</button>
)}
</div>
)}
</FormField>
Server-Side Validation
Basic Action Handler
// 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:
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:
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:
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:
// 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:
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:
{
"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:
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:
// 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
import {
navigate,
submit,
selectStage,
selectWeapon,
selectUser,
} from "~/utils/playwright";
Complete Example
Schema (feature-schemas.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)
import { SendouForm } from "~/form/SendouForm";
import { createItemSchema } from "./feature-schemas";
export default function NewItemPage() {
return (
<SendouForm schema={createItemSchema}>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="description" />
<FormField name="category" />
<FormField name="isActive" />
</>
)}
</SendouForm>
);
}
Action (route.server.ts)
import { redirect, type ActionFunctionArgs } from "react-router";
import { parseFormData } from "~/form/parse.server";
import { createItemSchema } from "./feature-schemas";
import * as ItemRepository from "./ItemRepository.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const result = await parseFormData({
request,
schema: createItemSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
await ItemRepository.create(result.data);
return redirect("/items");
};
E2E Test (feature.spec.ts)
import { createFormHelpers } from "~/utils/playwright-form";
import { createItemSchema } from "~/features/item/feature-schemas";
import { test, navigate, impersonate, seed } from "~/utils/playwright";
test("creates new item", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: "/items/new" });
const form = createFormHelpers(page, createItemSchema);
await form.fill("name", "Test Item");
await form.fill("description", "A test description");
await form.select("category", "SPECIAL");
await form.check("isActive");
await form.submit();
await expect(page).toHaveURL("/items");
});
Translations (locales/en/forms.json)
{
"labels.itemName": "Name",
"labels.description": "Description",
"labels.category": "Category",
"labels.isActive": "Active",
"bottomTexts.descriptionHelp": "Optional description for the item",
"options.category.general": "General",
"options.category.special": "Special"
}