Skip to content

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:

go
// 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:

go
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.

go
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 element

The following fixed size array will also work

go
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

go
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:

  1. Our reactive forms need unique form component names otherwise Iridium would not understand which component you are referencing.
  2. 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.

go
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.

go
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:

go
// 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.

go
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.

go
Form() // This form is driverless

Driveless 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.

go
// 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:

go
Form().
    Schema(
        FormSwitch("Active"),
        FormTime("DOB"),
        FormGrid("my-grid").
           Schema(
                TextInput("Name")
           ),
    )

Fixed Columns

The FixedColumns method defines how many columns the form has.

go
// 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.

go
// 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

go
// 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:

go
myDriver := gormDriver.Get[MyModel]()
Form(myDriver)

Disabled

The Disabled method will disable the form and apply disabled to all the form components:

go
// 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:

  1. Fill - Load the initial form and populate the fields
  2. Action - Submit the form
  3. Live - Perform a live request

Different lifecycles run different hooks sequentially:

LifecycleHooks
LoadFill / FillModel
MutateFormDataBeforeFill
AfterFill
ActionMutateFormDataBeforeValidation
BeforeAction
Action
AfterAction
LiveOnly 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.

go
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.

go
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.

go
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.

go
// 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.

go
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).

go
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.

go
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.

go
Form().
    MutateFormDataBeforeValidation(func(data url.Values, ctx *ctxPage.FormSubmit[T]) error {})

Released under the MIT License.