Added docs
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
# Form Submission & Model Binding
|
||||
|
||||
Handle validated form submissions with strongly-typed models — no manual dictionary access required.
|
||||
|
||||
---
|
||||
|
||||
## Basic Submit (No Model Binding)
|
||||
|
||||
The simplest approach uses an `onSuccess` callback with direct `HttpContext` access:
|
||||
|
||||
```csharp
|
||||
app.MapFormValidation<ContactFormValidator>("/api/forms/contact",
|
||||
onSuccess: async ctx =>
|
||||
{
|
||||
var form = ctx.Request.Form;
|
||||
var name = form["name"].ToString();
|
||||
var email = form["email"].ToString();
|
||||
|
||||
// Save to database, send email, etc.
|
||||
await SaveContactAsync(name, email);
|
||||
});
|
||||
```
|
||||
|
||||
The `onSuccess` callback fires only after all validation rules pass. If validation fails, the callback is never invoked.
|
||||
|
||||
---
|
||||
|
||||
## Strongly-Typed Model Binding
|
||||
|
||||
Define a POCO model and let `FormModelBinder` handle the mapping automatically:
|
||||
|
||||
### Step 1: Create a Model
|
||||
|
||||
```csharp
|
||||
public class ContactFormModel
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public int? Age { get; set; }
|
||||
public DateOnly? Birthdate { get; set; }
|
||||
public TimeOnly? Preferredtime { get; set; }
|
||||
public DateTime? Appointment { get; set; }
|
||||
public string Confirmation { get; set; } = "";
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Use the Typed Overload
|
||||
|
||||
```csharp
|
||||
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
|
||||
onSuccess: async model =>
|
||||
{
|
||||
Console.WriteLine($"Name: {model.Name}");
|
||||
Console.WriteLine($"Email: {model.Email}");
|
||||
Console.WriteLine($"Age: {model.Age}");
|
||||
Console.WriteLine($"Birth Date: {model.Birthdate}");
|
||||
Console.WriteLine($"Preferred Time: {model.Preferredtime}");
|
||||
Console.WriteLine($"Appointment: {model.Appointment}");
|
||||
|
||||
await SaveToDbAsync(model);
|
||||
});
|
||||
```
|
||||
|
||||
### Step 3: Customize the Success Message (Optional)
|
||||
|
||||
```csharp
|
||||
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
|
||||
onSuccess: async model => { /* ... */ },
|
||||
successMessage: "Thank you! Your form has been submitted.");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FormModelBinder
|
||||
|
||||
`FormModelBinder.Bind<T>()` maps form fields to model properties using reflection with these rules:
|
||||
|
||||
- **Case-insensitive matching** — form field `name` matches property `Name`
|
||||
- **Automatic type conversion** for all common types
|
||||
- **Nullable support** — empty values become `null` for nullable types
|
||||
|
||||
### Supported Types
|
||||
|
||||
| Type | Format Expected |
|
||||
|---|---|
|
||||
| `string` | Any text |
|
||||
| `int`, `long` | Integer text |
|
||||
| `float`, `double`, `decimal` | Numeric text (invariant culture) |
|
||||
| `bool` | `true`/`false`, `on`, `1` |
|
||||
| `DateTime` | Parseable datetime (e.g. `2025-12-25T10:30`) |
|
||||
| `DateOnly` | Parseable date (e.g. `2025-12-25`) |
|
||||
| `TimeOnly` | Parseable time (e.g. `14:30`) |
|
||||
| `Guid` | Standard GUID format |
|
||||
| `Enum` | Case-insensitive enum member name |
|
||||
|
||||
All types support their `Nullable<T>` equivalents (`int?`, `DateTime?`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### MapFormValidation (without model binding)
|
||||
|
||||
```csharp
|
||||
public static RouteGroupBuilder MapFormValidation<TValidator>(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
string basePath,
|
||||
string successMessage = "✓ Form submitted successfully!",
|
||||
Func<HttpContext, Task>? onSuccess = null)
|
||||
where TValidator : FormValidator, new();
|
||||
```
|
||||
|
||||
### MapFormValidation (with model binding)
|
||||
|
||||
```csharp
|
||||
public static RouteGroupBuilder MapFormValidation<TValidator, TModel>(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
string basePath,
|
||||
Func<TModel, Task> onSuccess,
|
||||
string successMessage = "✓ Form submitted successfully!")
|
||||
where TValidator : FormValidator, new()
|
||||
where TModel : new();
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `basePath` | `string` | URL prefix (e.g. `/api/forms/contact`) |
|
||||
| `successMessage` | `string` | HTML text shown on successful submission |
|
||||
| `onSuccess` | `Func<HttpContext, Task>?` or `Func<TModel, Task>` | Callback invoked after validation passes |
|
||||
|
||||
### Return Value
|
||||
|
||||
Returns a `RouteGroupBuilder` for further endpoint configuration if needed.
|
||||
|
||||
---
|
||||
|
||||
## Registered Endpoints
|
||||
|
||||
Both overloads register the same endpoint structure:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `{basePath}/validate` | Per-field validation (called on input blur) |
|
||||
| `POST` | `{basePath}/submit` | Full form validation and submission |
|
||||
|
||||
Both endpoints have `.DisableAntiforgery()` applied since htmx sends raw form data.
|
||||
|
||||
---
|
||||
|
||||
## Response Format
|
||||
|
||||
### Validation Error Response
|
||||
|
||||
When validation fails, the `/submit` endpoint returns HTML with OOB (out-of-band) swap fragments:
|
||||
|
||||
```html
|
||||
<p data-field-error="email" hx-swap-oob="outerHTML:[data-field-error='email']"
|
||||
class="text-[0.8rem] font-medium text-destructive">
|
||||
Please enter a valid email address.
|
||||
</p>
|
||||
<p data-field-error="name" hx-swap-oob="outerHTML:[data-field-error='name']"
|
||||
class="text-[0.8rem] font-medium text-destructive hidden"></p>
|
||||
<!-- ... one fragment per field ... -->
|
||||
<div id="form-result" class="hidden"></div>
|
||||
```
|
||||
|
||||
htmx processes each OOB fragment, updating every field's error element in a single response.
|
||||
|
||||
### Success Response
|
||||
|
||||
```html
|
||||
<p data-field-error="email" hx-swap-oob="outerHTML:[data-field-error='email']"
|
||||
class="text-[0.8rem] font-medium text-destructive hidden"></p>
|
||||
<!-- ... clears all error elements ... -->
|
||||
<div id="form-result">
|
||||
<div data-testid="success-message"
|
||||
class="rounded-md border border-green-500/30 bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400">
|
||||
✓ Form submitted successfully!
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```csharp
|
||||
// ContactFormValidator.cs
|
||||
using Enciphered.Blazor.UIComponents.Validation;
|
||||
|
||||
public class ContactFormValidator : FormValidator
|
||||
{
|
||||
public ContactFormValidator()
|
||||
{
|
||||
RuleFor("name", required: true, minLength: 2);
|
||||
RuleFor("email", required: true,
|
||||
pattern: @".+@.+\..+",
|
||||
message: "Please enter a valid email address.");
|
||||
}
|
||||
}
|
||||
|
||||
// ContactFormModel.cs
|
||||
public class ContactFormModel
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
|
||||
// Program.cs
|
||||
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
|
||||
onSuccess: async model =>
|
||||
{
|
||||
await db.Contacts.AddAsync(new Contact
|
||||
{
|
||||
Name = model.Name,
|
||||
Email = model.Email
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
```
|
||||
|
||||
```razor
|
||||
@* ContactForm.razor *@
|
||||
@page "/contact"
|
||||
|
||||
<HtmxForm Endpoint="/api/forms/contact">
|
||||
<FormField Label="Name" For="name">
|
||||
<TextInput Id="name" Name="name" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Email" For="email">
|
||||
<TextInput Id="email" Name="email" Type="email" />
|
||||
</FormField>
|
||||
|
||||
<Button Type="submit">Send</Button>
|
||||
</HtmxForm>
|
||||
```
|
||||
@@ -0,0 +1,242 @@
|
||||
# Form Validation
|
||||
|
||||
htmx-powered server-side validation that provides real-time per-field feedback on blur and full-form validation on submit — all without Blazor interactivity.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌─────────────┐ blur ┌──────────────────────────┐ │
|
||||
│ │ <TextInput> │ ──────► │ htmx POST /validate │ │
|
||||
│ └─────────────┘ │ { _field: "email", │ │
|
||||
│ │ email: "bad" } │ │
|
||||
│ └──────────┬───────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────┐ ◄──────────┘ │
|
||||
│ │ <p data-field- │ HTML fragment: │
|
||||
│ │ error="email"> │ <p class="text-destructive"> │
|
||||
│ │ swapped by │ Please enter a valid email. │
|
||||
│ │ htmx │ </p> │
|
||||
│ └─────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Server (Minimal API) │
|
||||
│ │
|
||||
│ FormValidator.ValidateField("email", "bad") │
|
||||
│ → "Please enter a valid email address." │
|
||||
│ │
|
||||
│ HtmxFormValidationRenderer.FieldErrorFragment(...) │
|
||||
│ → HTML <p> element with error text │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create a Validator
|
||||
|
||||
Define your validation rules by extending `FormValidator` and calling `RuleFor()` in the constructor:
|
||||
|
||||
```csharp
|
||||
using Enciphered.Blazor.UIComponents.Validation;
|
||||
|
||||
public class ContactFormValidator : FormValidator
|
||||
{
|
||||
public ContactFormValidator()
|
||||
{
|
||||
RuleFor("name",
|
||||
displayName: "Name",
|
||||
required: true,
|
||||
minLength: 2);
|
||||
|
||||
RuleFor("email",
|
||||
displayName: "Email",
|
||||
required: true,
|
||||
pattern: @".+@.+\..+",
|
||||
message: "Please enter a valid email address.");
|
||||
|
||||
RuleFor("password",
|
||||
displayName: "Password",
|
||||
required: true,
|
||||
minLength: 6);
|
||||
|
||||
RuleFor("age",
|
||||
displayName: "Age",
|
||||
min: 0,
|
||||
max: 150);
|
||||
|
||||
RuleFor("birthdate",
|
||||
displayName: "Birth Date",
|
||||
custom: value => !DateOnly.TryParse(value, out _)
|
||||
? "Please enter a valid date."
|
||||
: null);
|
||||
|
||||
RuleFor("preferredtime",
|
||||
displayName: "Preferred Time",
|
||||
custom: value => !TimeOnly.TryParse(value, out _)
|
||||
? "Please enter a valid time."
|
||||
: null);
|
||||
|
||||
RuleFor("appointment",
|
||||
displayName: "Appointment",
|
||||
custom: value => !DateTime.TryParse(value, out _)
|
||||
? "Please enter a valid date and time."
|
||||
: null);
|
||||
|
||||
RuleFor("confirmation",
|
||||
displayName: "Confirmation",
|
||||
required: true,
|
||||
custom: value => value != "CONFIRM"
|
||||
? "You must type CONFIRM to proceed."
|
||||
: null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Register Validation Endpoints
|
||||
|
||||
In `Program.cs`, call `MapFormValidation<T>()`:
|
||||
|
||||
```csharp
|
||||
app.MapFormValidation<ContactFormValidator>("/api/forms/contact");
|
||||
```
|
||||
|
||||
This registers two endpoints:
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|---|---|---|
|
||||
| `POST /api/forms/contact/validate` | Per-field | Validates a single field on blur |
|
||||
| `POST /api/forms/contact/submit` | Full form | Validates all fields on submit |
|
||||
|
||||
Both endpoints have antiforgery disabled (via `.DisableAntiforgery()`) since htmx sends form data directly.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Build the Form
|
||||
|
||||
Use `HtmxForm`, `FormField`, and input components:
|
||||
|
||||
```razor
|
||||
<HtmxForm Endpoint="/api/forms/contact">
|
||||
<FormField Label="Full Name" For="name">
|
||||
<TextInput Id="name" Name="name" Placeholder="Jane Doe" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Email" For="email">
|
||||
<TextInput Id="email" Name="email" Type="email" Placeholder="jane@example.com" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Password" For="password">
|
||||
<TextInput Id="password" Name="password" Type="password" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Age" For="age">
|
||||
<NumberInput Id="age" Name="age" Min="0" Max="150" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Birth Date" For="birthdate">
|
||||
<DateInput Id="birthdate" Name="birthdate" />
|
||||
</FormField>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button Type="submit">Submit</Button>
|
||||
<Button Type="reset" Variant="@ButtonVariant.Outline">Reset</Button>
|
||||
</div>
|
||||
</HtmxForm>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RuleFor API Reference
|
||||
|
||||
```csharp
|
||||
protected void RuleFor(
|
||||
string field, // Form field name (must match the input's Name)
|
||||
string? displayName, // Human-readable label (auto-generated from field if omitted)
|
||||
bool required, // Whether the field is required
|
||||
int? minLength, // Minimum string length
|
||||
int? maxLength, // Maximum string length
|
||||
string? pattern, // Regex pattern for format validation
|
||||
double? min, // Minimum numeric value
|
||||
double? max, // Maximum numeric value
|
||||
string? message, // Custom error message for pattern failures
|
||||
Func<string, string?>? custom // Custom validation function
|
||||
);
|
||||
```
|
||||
|
||||
### Validation Order
|
||||
|
||||
Rules are evaluated in this order — the first failure stops evaluation:
|
||||
|
||||
1. **Required** — empty/whitespace check
|
||||
2. **Empty skip** — if not required and value is empty, the field passes (skips remaining rules)
|
||||
3. **MinLength** — minimum character count
|
||||
4. **MaxLength** — maximum character count
|
||||
5. **Pattern** — regex match (uses `message` if provided, else default format error)
|
||||
6. **Min/Max** — numeric range (attempts to parse as `double`)
|
||||
7. **Custom** — arbitrary validation function returning an error string or `null`
|
||||
|
||||
### Custom Validators
|
||||
|
||||
The `custom` parameter accepts a `Func<string, string?>` — receive the trimmed value, return an error message or `null`:
|
||||
|
||||
```csharp
|
||||
RuleFor("confirmation",
|
||||
required: true,
|
||||
custom: value => value != "CONFIRM"
|
||||
? "You must type CONFIRM to proceed."
|
||||
: null);
|
||||
```
|
||||
|
||||
For date/time/datetime fields, use `TryParse`:
|
||||
|
||||
```csharp
|
||||
RuleFor("birthdate",
|
||||
custom: value => !DateOnly.TryParse(value, out _)
|
||||
? "Please enter a valid date."
|
||||
: null);
|
||||
```
|
||||
|
||||
> **Note**: Custom validators only run when the value is non-empty. If the field is not required and left blank, the custom function is never called.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### On Blur (Per-Field)
|
||||
|
||||
1. `InputBase<T>` auto-injects htmx attributes when inside `HtmxForm` + `FormField`
|
||||
2. When the user leaves an input, htmx fires `POST /validate` with the field name and value
|
||||
3. The server calls `FormValidator.ValidateField()` and returns an HTML `<p>` fragment
|
||||
4. htmx replaces the existing `<p data-field-error="...">` element with the response
|
||||
|
||||
### On Submit (Full Form)
|
||||
|
||||
1. `HtmxForm` adds `hx-post="/submit"` to the `<form>` element
|
||||
2. htmx sends all form fields
|
||||
3. The server calls `FormValidator.ValidateAll()` and returns:
|
||||
- **If errors**: OOB (out-of-band) swap fragments for each field's error element
|
||||
- **If valid**: Success message + OOB swaps to clear all errors
|
||||
|
||||
### HtmxForm Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `Endpoint` | `string` | **required** | Base path (e.g. `/api/forms/contact`) |
|
||||
| `ResultId` | `string` | `"form-result"` | ID of the result div for success/error messages |
|
||||
| `Class` | `string?` | — | Additional CSS classes on the `<form>` |
|
||||
|
||||
### Form Reset
|
||||
|
||||
Clicking a `<Button Type="reset">` triggers the browser's native form reset. The `forms.js` module listens for the `reset` event and:
|
||||
- Clears all visible input values
|
||||
- Hides all `[data-field-error]` elements
|
||||
- Hides the result div
|
||||
- Resets date/time trigger button text to their placeholders
|
||||
Reference in New Issue
Block a user