241 lines
6.7 KiB
Markdown
241 lines
6.7 KiB
Markdown
# 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>
|
|
```
|