mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-06 19:27:58 -05:00
zod -> valibot (#3364)
This commit is contained in:
@@ -88,7 +88,7 @@ You should aim to colocate code that "changes together" as much as possible. Fea
|
||||
- **FeatureRepository.server.ts**: Database queries & mappers (see `repositories.md`)
|
||||
- **feature-constants.ts**: Constant values
|
||||
- **feature-hooks.ts**: React hooks
|
||||
- **feature-schemas.ts**: Zod schemas for validating form values, params, payloads
|
||||
- **feature-schemas.ts**: valibot schemas for validating form values, params, payloads
|
||||
- **feature-types.ts**: Typescript types
|
||||
- **feature-utils.ts**: Utilities too small to make up for their own modules
|
||||
- **Component.module.css**: CSS module matching the React file of the same root name
|
||||
@@ -170,7 +170,7 @@ TODO (after React server actions in use)
|
||||
|
||||
### Forms
|
||||
|
||||
Forms are defined as Zod schemas built from the field builders in `~/form/fields` and rendered by `SendouForm`. The same schema validates the submission on the server. See `forms.md` for the full documentation.
|
||||
Forms are defined as valibot schemas built from the field builders in `~/form/fields` and rendered by `SendouForm`. The same schema validates the submission on the server. See `forms.md` for the full documentation.
|
||||
|
||||
### Performance
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ SQLite has no boolean type, so booleans are `0`/`1` integers typed as `DBBoolean
|
||||
Converting to one:
|
||||
|
||||
- `toDBBoolean(someBoolean)` from `~/utils/sql` — use this instead of `Number(x)` or `x ? 1 : 0` when writing to the DB.
|
||||
- `dbBoolean` / `checkboxValueToDbBoolean` from `~/utils/zod` for form and payload schemas.
|
||||
- `dbBoolean` / `checkboxValueToDbBoolean` from `~/utils/schema` for form and payload schemas.
|
||||
|
||||
Reading is just truthiness (`if (build.isPrivate)`); convert to a real boolean with `Boolean()` when the value crosses into a domain type.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 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.
|
||||
This document describes the schema-based form system using `SendouForm`. Forms are defined as valibot schemas that generate both the UI and server-side validation.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- Forms are defined as Zod schemas using field builders from `~/form/fields`
|
||||
- Forms are defined as valibot 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`
|
||||
- `FormField` renders the correct UI based on schema metadata
|
||||
@@ -14,7 +14,7 @@ This document describes the schema-based form system using `SendouForm`. Forms a
|
||||
### Basic Schema Example
|
||||
|
||||
```ts
|
||||
export const myFormSchema = z.object({
|
||||
export const myFormSchema = v.object({
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
maxLength: 100,
|
||||
@@ -92,7 +92,7 @@ items: [
|
||||
Define action discriminators with `stringConstant`:
|
||||
|
||||
```ts
|
||||
export const myFormSchema = z.object({
|
||||
export const myFormSchema = v.object({
|
||||
_action: stringConstant("CREATE_ITEM"),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
@@ -103,7 +103,7 @@ export const myFormSchema = z.object({
|
||||
Use `idConstant` for IDs that need default values:
|
||||
|
||||
```ts
|
||||
export const editFormSchema = z.object({
|
||||
export const editFormSchema = v.object({
|
||||
itemId: idConstant(), // Requires defaultValues
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
@@ -175,12 +175,12 @@ dualSelectOptional({
|
||||
### Arrays and Fieldsets
|
||||
|
||||
```ts
|
||||
const itemSchema = z.object({
|
||||
const itemSchema = v.object({
|
||||
name: textField({ label: "labels.itemName", maxLength: 50 }),
|
||||
quantity: numberFieldOptional({ label: "labels.quantity" }),
|
||||
});
|
||||
|
||||
export const formSchema = z.object({
|
||||
export const formSchema = v.object({
|
||||
items: array({
|
||||
label: "labels.items",
|
||||
min: 1,
|
||||
@@ -192,7 +192,7 @@ export const formSchema = z.object({
|
||||
|
||||
### Union for Shared Field Definitions
|
||||
|
||||
Place field inside `z.union([])` to reuse across multiple schemas:
|
||||
Place field inside `v.union([])` to reuse across multiple schemas:
|
||||
|
||||
```ts
|
||||
const sharedNameField = textField({
|
||||
@@ -200,18 +200,18 @@ const sharedNameField = textField({
|
||||
maxLength: 100,
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
const createSchema = v.object({
|
||||
_action: stringConstant("CREATE"),
|
||||
name: sharedNameField,
|
||||
});
|
||||
|
||||
const editSchema = z.object({
|
||||
const editSchema = v.object({
|
||||
_action: stringConstant("EDIT"),
|
||||
id: idConstant(),
|
||||
name: sharedNameField,
|
||||
});
|
||||
|
||||
export const actionSchema = z.union([createSchema, editSchema]);
|
||||
export const actionSchema = v.union([createSchema, editSchema]);
|
||||
```
|
||||
|
||||
## Component Usage
|
||||
@@ -341,7 +341,7 @@ flow, while keeping `SendouForm`'s single-submit `application/json` model unchan
|
||||
```ts
|
||||
import { image } from "~/form/fields";
|
||||
|
||||
export const editTeamSchema = z.object({
|
||||
export const editTeamSchema = v.object({
|
||||
teamId: idConstant(),
|
||||
logo: image({ label: "labels.logo" }), // logo (default)
|
||||
banner: image({ label: "labels.banner", dimensions: "thick-banner" }),
|
||||
@@ -411,15 +411,15 @@ Use `customField` for complex UI that doesn't fit standard field types:
|
||||
### Schema
|
||||
|
||||
```ts
|
||||
const povSchema = z.union([
|
||||
z.object({ type: z.literal("USER"), userId: id.optional() }),
|
||||
z.object({ type: z.literal("NAME"), name: z.string().max(100) }),
|
||||
const povSchema = v.union([
|
||||
v.object({ type: v.literal("USER"), userId: v.optional(id) }),
|
||||
v.object({ type: v.literal("NAME"), name: v.pipe(v.string(), v.maxLength(100)) }),
|
||||
]);
|
||||
|
||||
export const formSchema = z.object({
|
||||
export const formSchema = v.object({
|
||||
pov: customField(
|
||||
{ initialValue: { type: "USER" as const } },
|
||||
povSchema.optional()
|
||||
v.optional(povSchema)
|
||||
),
|
||||
});
|
||||
```
|
||||
@@ -506,7 +506,7 @@ When you need async validation (database checks, authorization), create a separa
|
||||
**Base schema (`feature-schemas.ts`)** - used by both client and server:
|
||||
|
||||
```ts
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { textField, idConstantOptional } from "~/form/fields";
|
||||
|
||||
// Shared sync validation that can be extracted for reuse
|
||||
@@ -524,45 +524,65 @@ function validateGearAllOrNone(data: {
|
||||
// Export refine config for reuse in server schema
|
||||
export const gearAllOrNoneRefine = {
|
||||
fn: validateGearAllOrNone,
|
||||
opts: { message: "forms:errors.gearAllOrNone", path: ["head"] },
|
||||
message: "forms:errors.gearAllOrNone",
|
||||
path: ["head"],
|
||||
};
|
||||
|
||||
// Base schema with form field builders (for UI generation)
|
||||
export const newBuildBaseSchema = z.object({
|
||||
export const newBuildBaseSchema = v.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,
|
||||
export const newBuildSchema = v.pipe(
|
||||
newBuildBaseSchema,
|
||||
superRefine((data, ctx) => {
|
||||
if (!gearAllOrNoneRefine.fn(data)) {
|
||||
ctx.addIssue({
|
||||
message: gearAllOrNoneRefine.message,
|
||||
path: gearAllOrNoneRefine.path,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
**Server schema (`feature-schemas.server.ts`)** - adds async validation:
|
||||
|
||||
```ts
|
||||
import * as v from "valibot";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import { superRefine, superRefineAsync } from "~/utils/schema";
|
||||
import { gearAllOrNoneRefine, newBuildBaseSchema } from "./feature-schemas";
|
||||
|
||||
export const newBuildSchemaServer = newBuildBaseSchema
|
||||
export const newBuildSchemaServer = v.pipeAsync(
|
||||
newBuildBaseSchema,
|
||||
// Reuse sync refinements from base
|
||||
.refine(gearAllOrNoneRefine.fn, gearAllOrNoneRefine.opts)
|
||||
superRefine((data, ctx) => {
|
||||
if (gearAllOrNoneRefine.fn(data)) return;
|
||||
|
||||
ctx.addIssue({
|
||||
message: gearAllOrNoneRefine.message,
|
||||
path: gearAllOrNoneRefine.path,
|
||||
});
|
||||
}),
|
||||
// Add async server-only validation
|
||||
.refine(
|
||||
async (data) => {
|
||||
if (!data.buildToEditId) return true;
|
||||
superRefineAsync(async (data, ctx) => {
|
||||
if (!data.buildToEditId) return;
|
||||
|
||||
const user = requireUser();
|
||||
const ownerId = await BuildRepository.ownerIdById(data.buildToEditId);
|
||||
const user = requireUser();
|
||||
const ownerId = await BuildRepository.ownerIdById(data.buildToEditId);
|
||||
if (ownerId === user.id) return;
|
||||
|
||||
return ownerId === user.id;
|
||||
},
|
||||
{ message: "Not a build you own", path: ["buildToEditId"] },
|
||||
);
|
||||
ctx.addIssue({
|
||||
message: "Not a build you own",
|
||||
path: ["buildToEditId"],
|
||||
});
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
**Action using server schema:**
|
||||
@@ -594,15 +614,15 @@ Check for duplicates in the database:
|
||||
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) => {
|
||||
export const createTeamSchemaServer = v.objectAsync({
|
||||
...createTeamSchema.entries,
|
||||
name: v.pipeAsync(
|
||||
createTeamSchema.entries.name,
|
||||
v.checkAsync(async (name) => {
|
||||
const teams = await TeamRepository.findAllUndisbanded();
|
||||
const customUrl = mySlugify(name);
|
||||
return !teams.some((team) => team.customUrl === customUrl);
|
||||
},
|
||||
{ message: "forms:errors.duplicateName" },
|
||||
}, "forms:errors.duplicateName"),
|
||||
),
|
||||
});
|
||||
```
|
||||
@@ -612,18 +632,17 @@ export const createTeamSchemaServer = z.object({
|
||||
For complex validation involving multiple fields:
|
||||
|
||||
```ts
|
||||
export const scrimsNewFormSchema = z
|
||||
.object({
|
||||
export const scrimsNewFormSchema = v.pipe(
|
||||
v.object({
|
||||
at: datetime({ label: "labels.start" }),
|
||||
maps: select({ label: "labels.maps", items: mapsItems }),
|
||||
mapsTournamentId: customField({ initialValue: null }, id.nullable()),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
mapsTournamentId: customField({ initialValue: null }, v.nullable(id)),
|
||||
}),
|
||||
superRefine((data, ctx) => {
|
||||
if (data.maps === "TOURNAMENT" && !data.mapsTournamentId) {
|
||||
ctx.addIssue({
|
||||
path: ["mapsTournamentId"],
|
||||
message: "errors.tournamentMustBeSelected",
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -631,10 +650,10 @@ export const scrimsNewFormSchema = z
|
||||
ctx.addIssue({
|
||||
path: ["mapsTournamentId"],
|
||||
message: "errors.tournamentOnlyWhenMapsIsTournament",
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
## Translations
|
||||
@@ -733,7 +752,7 @@ import {
|
||||
### Schema (`feature-schemas.ts`)
|
||||
|
||||
```ts
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
textField,
|
||||
textAreaOptional,
|
||||
@@ -742,7 +761,7 @@ import {
|
||||
stringConstant,
|
||||
} from "~/form/fields";
|
||||
|
||||
export const createItemSchema = z.object({
|
||||
export const createItemSchema = v.object({
|
||||
_action: stringConstant("CREATE"),
|
||||
name: textField({
|
||||
label: "labels.itemName",
|
||||
|
||||
@@ -16,15 +16,15 @@ One definition per route (or feature, when several routes share params), in a sh
|
||||
|
||||
```ts
|
||||
// app/features/builds/builds-search-params.ts
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const buildsSearchParams = SearchParams.define({
|
||||
limit: SP.param(z.number().int().min(1).max(100), { default: 24, loader: true }),
|
||||
limit: SP.param(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)), { default: 24, loader: true }),
|
||||
f: SP.json(buildFiltersSchema, { default: [], resets: ["limit"], loader: true }),
|
||||
focused: SP.param(z.enum(["1", "2", "3"]), { default: "1", loader: false }),
|
||||
tournament: SP.param(z.string().max(100).nullable(), { loader: true }),
|
||||
focused: SP.param(v.picklist(["1", "2", "3"]), { default: "1", loader: false }),
|
||||
tournament: SP.param(v.nullable(v.pipe(v.string(), v.maxLength(100))), { loader: true }),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -38,26 +38,26 @@ Options accepted by every declaration:
|
||||
|
||||
### `SP.param` and the derivation table
|
||||
|
||||
`SP.param(valueSchema, opts)` is the canonical declaration. The value schema is plain zod — all validation lives there, and shared schemas from `app/utils/zod.ts` plug in directly. The URL encoding is derived from the schema's type:
|
||||
`SP.param(valueSchema, opts)` is the canonical declaration. The value schema is plain valibot — all validation lives there, and shared schemas from `app/utils/schema.ts` plug in directly. The URL encoding is derived from the schema's type:
|
||||
|
||||
| Schema base type | URL encoding |
|
||||
| --- | --- |
|
||||
| `z.string()`, string enums/literals | as-is |
|
||||
| `z.number()`, number enums/literals (incl. `numericEnum`) | `String(n)` |
|
||||
| `z.boolean()` | `"true"` / `"false"` only |
|
||||
| `z.array(item)` | repeated keys (`?id=1&id=2`); invalid members are dropped, not the whole array |
|
||||
| `.nullable()` wrapper | unwrapped; `null` encodes as param absent. `default` is omitted (it is always `null`; passing anything else throws). `.optional()` is rejected — `.nullable()` is the project-wide convention |
|
||||
| refinements (`.min`, `.max`, `.refine`, …) | validation only; a failing value resolves to the default |
|
||||
| `v.string()`, string picklists/literals | as-is |
|
||||
| `v.number()`, number enums (incl. `numericEnum`) | `String(n)` |
|
||||
| `v.boolean()` | `"true"` / `"false"` only |
|
||||
| `v.array(item)` | repeated keys (`?id=1&id=2`); invalid members are dropped, not the whole array |
|
||||
| `v.nullable()` wrapper | unwrapped; `null` encodes as param absent. `default` is omitted (it is always `null`; passing anything else throws). `v.optional()` is rejected — `v.nullable()` is the project-wide convention |
|
||||
| validations in a pipe (`v.minValue`, `v.maxLength`, `v.check`, …) | validation only; a failing value resolves to the default |
|
||||
|
||||
Derivation is closed, not best-effort: shapes outside this table (objects, mixed-type unions, transforms, `z.preprocess`) are a `define()`-time error. Those use the explicit helpers:
|
||||
Derivation is closed, not best-effort: shapes outside this table (objects, mixed-type unions, transforms, `preprocess`) are a `define()`-time error. Those use the explicit helpers:
|
||||
|
||||
| Helper | Encoding |
|
||||
| --- | --- |
|
||||
| `SP.json(schema, opts)` | `JSON.stringify` in a single value — for objects and whole-array-as-one-param values |
|
||||
| `SP.custom(codec, opts)` | anything — pass a `z.codec(z.string(), valueSchema, { decode, encode })` directly |
|
||||
| `SP.custom(codec, opts)` | anything — pass a `codec(valueSchema, { decode, encode })` (from the search-params module; `decode` returns `undefined` for malformed input, `nullableCodec` widens with `null`) |
|
||||
| `SP.page(opts?)` | the paginated route's `page` param (1-based, `loader: true`, default `1`, `max` overridable) |
|
||||
|
||||
Note: schemas built with `z.preprocess` (like `weaponSplId`, `stageId` in `app/utils/zod.ts`) are pipes and rejected — use the inner schema (`numericEnum(mainWeaponIds)`, `numericEnum(stageIds)`) since string→number conversion is the codec's job.
|
||||
Note: schemas carrying a transform are rejected — those built with `preprocess` (like `weaponSplId`, `stageId` in `app/utils/schema.ts`) and those built with `coerceNumber` (like `id`). Use the inner schema instead (`numericEnum(mainWeaponIds)`, `numericEnum(stageIds)`, `v.pipe(v.number(), v.integer(), v.minValue(1))`) since string→number conversion is the codec's job.
|
||||
|
||||
### Compression
|
||||
|
||||
|
||||
Reference in New Issue
Block a user