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, and the struct field must be public:
type User struct {
Name string
age int
}
FormInput("Name") // <- Correct, this will bind.
FormInput("name") // <- Will not bind.
FormInput("age") // <- Will not bind. Private var.Slice/Array Binding
If you have a fixed size amount of form components that you want to autobind to a slice/array contained within a struct, you can do that with Iridium's slice/array bindings.
Undefined size
If your slice is an undefined size, you'll instead want to look at something like our repeater field.
Example:
Imagine you have a configuration screen where users can enabled 3 different services through toggles. Using slice bindings lets Iridium automatically insert the appropriate values into a slice for you when the form submits.
type Config struct {
// EnabledServices is a slice that holds 3 booleans indicating which
// service a user wants enabled in your application
EnabledServices []bool
}
FormInput("EnabledServices[0]") // Binds to the first slice element
FormInput("EnabledServices[1]") // Binds to the second slice element
FormInput("EnabledServices[2]") // Binds to the third slice elementThe following fixed size array will also work
type Config struct {
EnabledServices [3]bool
}
FormInput("EnabledServices[0]") // Binds to the first array element
FormInput("EnabledServices[1]") // Binds to the second array element
FormInput("EnabledServices[2]") // Binds to the third array element
FormInput("EnabledServices[3]") // Silently dropped...HTML standard exception
HTML is designed to allow you to supply multiple of the same form elements with a [] appended to indicate this is an array and should be parsed as such.
For example this should be valid
FormInput("EnabledServices[]") // Should be element 1
FormInput("EnabledServices[]") // ...
FormInput("EnabledServices[]")Iridium does not support this. You are required to provide a index inside of your form name. This is because:
- Our reactive forms need unique form component names otherwise Iridium would not understand which component you are referencing.
- Listing indexes implicity sets a size limit for slice bindings. If we allowed any number of
[]prepended fields, a malicious user could send an enourmous amount of content and potentially DDOS your server (we also include request size limit middleware for this reason).
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()Runtime drivers
Drivers call also be runtime resolved throughout Iridium. This is very useful when you start wanting to display relationships between the current and another model.
Form().
DriverFn(func(ctx *ctxDriver.Resolve) driver.IDriver[T] {
// Use the request to create or find the appropriate driver
// for this form based off the current request.
}).Driverless forms
Forms do not require drivers.
Form() // This form is driverlessDriveless forms are incredible useful if say you need to update a global configuration variable or need more control that a standard driver offers.
Driveless forms often require a Fill or FillModel methods directly on the form in order to fill fields with data. See hooks for more details.
Additionally, driverless forms will never extract your form's model for you into Iridium's contexts for use in configuration and callbacks. Auto-resolving path values (like ids) to legitimate models can only be done by Iridium with the presence of a driver. In other words, if you use a driverless form on a page where you need to get the model from the route like /appointments/1/edit, you'll need to resolve the first appointment model yourself. No driver means Iridium can not talk to your data sources on your behalf.
Driverless Form Example
In this example, we have a basic form that reads and writes to a global singleton Process struct called CurrentProcess.
This allows a user to specify if they want to run a process and how many times they'd like to.
// ProcessStore is some global config the form
// will update
var ProcessStore *Process
// Process is some generic example struct to say
// turn on a server process and run it X times.
type Process struct {
Enabled bool
ExecuteCount int
}
Form().
// Fill model requires you to return a model (in this case Process)
// for Iridium to source the values for your form on its inital load
FillModel(func(ctx *ctxForm.FormSubmit[Process]) (*Process, error) {
// We'll return our global singleton here.
return ProcessStore, nil
}).
Schema(
FormSwitch("Enabled"),
FormInput("ExecuteCount").
Label("Run X times").
Integer(),
).
// Action will run when a form is submitted and all validation has passed.
// It provides a new process struct with the user's data inserted in
// the `model` var as well as the current request context in `ctx`
Action(func(model *Process, ctx *ctxForm.FormSubmit[Process]) error {
// Update the global singleton with the new data
// (required if you want the updated values to show on the next render)
ProcessStore = model
// An example of launching the process
if model.Enabled {
return runProcess(model.ExecuteCount)
}
return nil
})
func init() {
ProcessStore = &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!
})Before action is a great place to do any final clean-up before calling Action. For example, say you have a Time selector on your form, yet your model only accepts a int64 representing unix time not an actual time.Time variable. Here is where you'd write a conversion by accessing the raw data field apart of *ctxForm.FormSubmit and inserting it manually into your 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. If you're using a driverless form, here is where you might pass your model to a processing function, or store it in memory.
// 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.
If you built your create/edit pages off our templates, we've already attach the correct Action for your that will call your driver.
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. You can either write your AfterAction logic then inside of Action as well, or just update the model pointer in Action to pass it through to AfterAction.
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 {})