Forms
Iridium's Forms allow you to use a DSL to define different form elements, how they appear, interact, hydrate, etc.
Getting Started
Each form and all it's dependent structs like fields, layouts, actions, etc. are typed with the struct you provide when instantiating them.
Essentially, you're letting Iridium know what the underlying model your form will operate over. It will then use reflection to automatically retrieve and set those fields during different phases of your form's lifecycle.
An example explains this best:
// Underlying model/struct for your form is `User`
type User struct {
Name string
Email string
Active bool
}
// A form definition that will act over your `User` model/struct
// allowing you to create, view, and edit users.
Form().
Schema(
FormInput("Name"), // Reads/Writes `Name` field on User struct
FormInput("Email"), // Reads/Writes `Email` field on User struct
FormSwitch("Active"), // Reads/Writes `Active` field on User struct
)Form() is a method created through type generation. It's actually equivalent to form.NewForm[models.User](nil), but this is aliased to reduce visual noise.
Form components
A form's Schema allows you to list form components that will then be displayed. They are split into two categories:
Fields
Layouts
Naming Components
The names components receive, like FormInput("Name"), have special properties and cavets:
Binding
To bind a component name to a struct's field, their names must be exact matches:
type User struct {
Name string
}
FormInput("Name") // <- Correct, this will bind.
FormInput("name") // <- Will not bind.Orphaned Names
Form components do not need a matching struct field to exist. In fact, orphaned components are extremely useful for reactive forms and for use with your form's hooks.
For example orphaned components can:
- Provide additional form data that is not directly saved to a model
- Used to configure other form elements
Example:
While the 'Over25' field is never directly saved to your User model, it can be used to prevent ages under 25 from being entered if it is toggled. You'll want to see the rules & reactivity sections to understand how to do this.
type User struct {
Age int
}
Form().
Schema(
FormSwitch("Over25"), // Temporary - Will not be saved
FormInput("Age") // Will be saved
)Relationships
Names can also represent relationships, which allow you to source data from embedded structs, or from database relationships.
type Person struct {
Name string
Car Car
}
type Car struct {
Make string
}
// A form for the Person struct
Form().
Schema(
FormInput("Name"), // Name of the person
FormInput("Car.Make") // Make of their car
)You can learn more about form relationships here
Data Sources / Drivers
Forms are often registered with Iridium drivers in order to populate and persist data automatically and to different sources. Drivers are bridges between Iridium and a particular data source like a database, in-memory array, file, etc.
See our drivers section for a better understanding of how to create a driver for your form.
Registering a driver
You can register a driver with your form, by passing it directly in the Form() call:
// Get a GORM-based driver for the User struct
userDriver := gormdriver.Get[User]()
// Register that userDriver with your form
Form(userDriver).
Schema()Driverless forms
Forms do not require drivers.
Form() // This form is driverlessHowever, to get much use out of them, you'll still need to provide a Fill or FillModel methods directly on the form in order to fill fields with data. See hooks for more details.
Driverless forms can be useful when you don't need to use a dedicated driver for a particular form. This might be because Iridium's provided drivers, like GORM and Array, don't match your preferred data source, and you don't want to create a custom driver yourself, or because you don't need a data source at all behind a form (say you're just getting values to launch a process).
Common Methods
Schema
The Schema method defines the actual form's schema. It accepts a list of components to populate the form with:
Form().
Schema(
FormSwitch("Active"),
FormTime("DOB"),
FormGrid("my-grid").
Schema(
TextInput("Name")
),
)Fixed Columns
The FixedColumns method defines how many columns the form has.
// static
Form().
FixedColumns(3).
Schema(
// All three will be side-by-side in the form
FormInput("Name"),
FormTime("DOB"),
FormSwitch("Active"),
)
// callback
Form().
FixedColumns(func (ctx *ctxPage.FormSubmit[T]) int {
return 3
})Columns
The Columns method defines how many columns the form has using tailwind's reactive grid-col class. The columns of the grid will react based on the view port.
// static
Form().
Columns(map[string]int{
"xs": 1,
"sm": 2,
"lg": 4,
}).
Schema(
// one top of each other for xs view port
// Name + DOB same line for sm view port
// All three on same line for lg view port
FormInput("Name"),
FormTime("DOB"),
FormSwitch("Active"),
)
// callback
Form().
ColumnsFn(func (ctx *ctxPage.FormSubmit[T]) map[string]int {
return map[string]int{
"xs": 1,
"sm": 2,
"lg": 4,
}
})Driver
The Driver method allows you to set the driver of the form
// get a driver
myDriver := gormDriver.Get[MyModel]()
Form().
Driver(myGormDriver)Alternative
The driver is generally set not with the Driver call, but instead the Form call like so:
myDriver := gormDriver.Get[MyModel]()
Form(myDriver)Disabled
The Disabled method will disable the form and apply disabled to all the form components:
// static
Form().
Disabled()
// callback
Form().
DisabledFn(func (ctx *ctxPage.FormSubmit[T]) bool {
return true
})Form Lifecycle Hooks
Forms have a series of hooks that allow you to further control the form's different lifecycles.
Forms have three core lifecycles:
- Fill - Load the initial form and populate the fields
- Action - Submit the form
- Live - Perform a live request
Different lifecycles run different hooks sequentially:
| Lifecycle | Hooks |
|---|---|
| Load | Fill / FillModel MutateFormDataBeforeFill AfterFill |
| Action | MutateFormDataBeforeValidation BeforeAction Action AfterAction |
| Live | Only runs field level lifecycle hooks |
If any hook returns an error or fails, it short-circuits the execution and returns an error to the client
Data Hooks
Used to define where the form's initial data comes from.
Fill
Prefer FillModel when possible
Using the Fill method allows you to directly provide the url.Values used to populate your form's fields.
Form().
Schema(
FormInput("Name"),
).
Fill(func(ctx *ctxPage.FormSubmit[T]) (url.Values, error) {
// return a map[string][]string a.k.a url.Values here that directly matches your form elements
return map[string][]string{
"Name": []string{"Larry Smith"},
}, nil
})FillModel
Fill model allows you to provide a model you create or source form elsewhere, that is then interpreted by Iridium into url.Values.
It's the preferred way to load data into your form from a struct since it lets Iridium handle the complex struct -> url.Values parsing for you.
Form().
Schema(
FormInput("Name"),
).
FillModel(func(ctx *ctxPage.FormSubmit[T]) (*T, error) {
return &T{
Name: "Hello",
}, nil
})Action Hooks
These hooks only run on a final submission (e.g., clicking Submit) and after your field's validation rules & form's validation hooks have passed.
BeforeAction
BeforeAction is a hook that runs just before the Action callback is called.
Form().
BeforeAction(func(model *T, ctx *ctxPage.FormSubmit[T]) error {
// You could log something out here, or one last change to the model!
})Action
The primary operation to perform once the form is submitted. Usually, you'd call your driver to persist the model here, but you can do anything.
// This action will create a model
Form(myDriver).
Action(func(model *T, ctx *ctxPage.FormSubmit[T]) error {
_, err := ctx.Driver.Create(model)
return err
}).Drivers
Often you'll call directly on your driver from the ctxPage.FormSubmit[T] context here to save/update/upsert your model. The driver you instantiated your form with is passed all the way through to that ctx for that purpose.
AfterAction
Runs after Action, if Action did not return an error.
This is a good place to write a log statement, issue a redirect using the ctx, or send a notification.
Form(myDriver).
AfterAction(func(model *T, ctx *ctxPage.FormSubmit[T]) error {
ctx.HxRedirect("/admin/")
return nil
})Model mutations in Action
Be mindful of model state changes that occur during Action, especially when using a database driver.
The model instance passed to AfterAction references the Go struct as it existed before persistence, not necessarily the version after the database operation.
For example, GORM may mutate the model during save/update hooks (e.g., setting IDs, timestamps, defaults). The GORM driver returns an updated model reference after these operations.
If your AfterAction logic depends on the model’s final persisted state, you should use the model returned by the driver rather than the original struct passed into the hook.
Hydration Hooks
These hooks run relate to filling schema components with data.
MutateFormDataBeforeFill
Modify the raw url.Values before they are applied to the model (e.g., force a value, normalize input, format strings).
Form().
MutateFormDataBeforeFill(func(data url.Values, ctx *ctxPage.FormSubmit[models.User]) error {
})AfterFill
Runs after the model struct has been hydrated with the form values.
Form().
AfterFill(func(ctx *ctxPage.FormSubmit[models.User]) error {
})Validation Hooks
Validation hooks relate to validating each form's fields during submission. Fields contain their own rules to check their validity, but these are additional hooks that allow you to mutate all the fields togther prior to validation.
MutateFormDataBeforeValidation
MutateFormDataBeforeValidation allows you to change the form data before running your fields validation hooks.
Form().
MutateFormDataBeforeValidation(func(data url.Values, ctx *ctxPage.FormSubmit[T]) error {})