22 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: textField({
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 |
|---|---|---|
textField |
Required text input | label, maxLength |
textFieldOptional |
Optional text input | maxLength |
numberFieldOptional |
Optional number input | - |
textArea |
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 |
datetime |
Required date/time picker | label |
datetimeOptional |
Optional date/time picker | label |
dayMonthYear |
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 |
image |
Small image upload (avatar / logo / banner) | 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: textField({ 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: textField({ label: "labels.name", maxLength: 100 }),
});
When idConstant() is called without a value, the schema requires defaultValues prop in SendouForm.
Text Field Validation
textField({
label: "labels.url",
maxLength: 200,
validate: "url", // Built-in URL validation
})
textField({
label: "labels.custom",
maxLength: 100,
validate: {
func: (val) => val.startsWith("https://"),
message: "Must start with https://",
},
})
textField({
label: "labels.pattern",
maxLength: 10,
regExp: {
pattern: /^\d{1,2}:\d{2}$/,
message: "Invalid format",
},
})
DateTime Validation
datetime({
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: textField({ 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 = textField({
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>
Form Modes
The mode prop controls how submitting works:
"submit"(default): the user submits via the submit button. Values go to the server, or toonApplywhen provided."autoSubmit": no submit button; every change that passes validation is sent to the server."client": no submit button and no<form>element; every change is passed toonApply(required in this mode) and field errors are computed already on mount.
Auto-Submit Forms
<SendouForm schema={filterSchema} mode="autoSubmit">
{({ FormField }) => (
<FormField name="sortBy" />
)}
</SendouForm>
Client-Side Only (onApply)
Submitting hands the validated values to onApply instead of the server:
<SendouForm
schema={filtersSchema}
defaultValues={currentFilters}
onApply={(values) => {
setSearchParams({ filters: JSON.stringify(values) });
closeDialog();
}}
>
{({ FormField }) => (
<FormField name="category" />
)}
</SendouForm>
For applying every change immediately (no submit button), use mode="client" with onApply.
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} />
Image Field
image() is a first-class field for the small "avatar / logo / banner" class of images
(team pfp, team banner, org pfp, calendar/tournament logo). It reuses the existing S3 upload
pipeline and the UnvalidatedUserSubmittedImage admin-validation / supporter auto-validation
flow, while keeping SendouForm's single-submit application/json model unchanged.
Art upload is out of scope. Art (
/art/new) preserves aspect ratio, keeps the original format, produces two derived assets (full + thumbnail) and stores them on its ownArttable rather than as aUserSubmittedImageid. It therefore uses acustomFieldwith its own renderer (ArtImageFormField) and its own server resolver (uploadArtImage), while still submitting as base64 withinSendouForm's single JSON submit. Any future "aspect-preserving / multi-derivative" upload should likewise stay off this field and follow that pattern.
Schema
import { image } from "~/form/fields";
export const editTeamSchema = z.object({
teamId: idConstant(),
logo: image({ label: "labels.logo" }), // logo (default)
banner: image({ label: "labels.banner", dimensions: "thick-banner" }),
cover: image({ label: "labels.cover", dimensions: { width: 800, height: 300 } }),
});
dimensions is optional, defaulting to "logo". It accepts the "logo" / "thick-banner"
presets or explicit { width, height } numbers (passed straight to compressorjs with
resize: "cover").
Value model
The field value is a small JSON-serializable union (ImageFieldValue from ~/form/image-field):
type ImageFieldValue =
| null // none / removed
| { type: "EXISTING"; imgId: number; url: string } // loaded, unchanged (url = preview)
| { type: "NEW"; dataUrl: string } // newly picked, base64 webp
initialValueisnull(the create case).- Edit forms pass an
EXISTINGvalue viaSendouForm'sdefaultValues(urlis only for preview). - The renderer produces a
NEWvalue (client-compressed to webp, base64 data URL) only when the user picks a file, andnullwhen they remove — so removal is owned by the field, with no separate delete action needed. An unchangedEXISTINGvalue never re-sends image bytes.
Server helper
Parse the action with parseFormDataWithImages instead of parseFormData. It resolves every
image() field in the schema to a stored image id (number | null) in place, so the action just
writes each id to its own FK column:
import { parseFormDataWithImages } from "~/form/parse.server";
const result = await parseFormDataWithImages({ request, schema: editTeamSchema });
if (!result.success) return { fieldErrors: result.fieldErrors };
// result.data.logo / result.data.banner are now `number | null`
await TeamRepository.update({
id: data.teamId,
avatarImgId: result.data.logo,
bannerImgId: result.data.banner,
});
Per field it resolves null → null, EXISTING → imgId (no bytes re-sent), NEW → upload + insert → new id. For a NEW value it decodes the base64, validates the bytes are a real webp (magic-byte
check), uploads via uploadStreamToS3, and inserts an unvalidated image row (auto-validated for
supporters). The schema may be a single object or an _action-discriminated union. (The underlying
per-value helper imageFieldValueToImgId from ~/features/img-upload/image-field.server can still
be called directly if needed.)
E2E
await form.setImage("logo", "e2e/fixtures/logo.png");
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 { textField, 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: textField({ 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: datetime({ 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 pnpm run i18n:sync after adding English translations to initialize other language files.
E2E Testing
Use createFormHelpers for type-safe form interactions:
import { createFormHelpers } from "./helpers/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 |
setImage(name, filePath) |
Upload a file into an image field |
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 "./helpers/playwright";
Complete Example
Schema (feature-schemas.ts)
import { z } from "zod";
import {
textField,
textAreaOptional,
select,
toggle,
stringConstant,
} from "~/form/fields";
export const createItemSchema = z.object({
_action: stringConstant("CREATE"),
name: textField({
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 "./helpers/playwright-form";
import { createItemSchema } from "~/features/item/feature-schemas";
import { test, navigate, impersonate, seed } from "./helpers/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"
}